diff --git a/api/src/database.types.ts b/api/src/database.types.ts index 804b538d..ecf4f968 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -1471,6 +1471,15 @@ export type Database = { title: string }[] } + manage_subjects: { + Args: { + p_name: string + p_subject_ids: string[] + p_summary: string + p_target_id: string + } + Returns: undefined + } objective_closure: { Args: { p_targets: string[] } Returns: { @@ -1533,7 +1542,13 @@ export type Database = { } } Enums: { - app_role: "verifier" | "moderator" | "curator" | "admin" | "official" + app_role: + | "verifier" + | "moderator" + | "curator" + | "admin" + | "official" + | "lead-verifier" case_status: "pending" | "in_review" | "approved" | "rejected" case_type: | "guide_publish" @@ -1697,7 +1712,14 @@ export const Constants = { }, public: { Enums: { - app_role: ["verifier", "moderator", "curator", "admin", "official"], + app_role: [ + "verifier", + "moderator", + "curator", + "admin", + "official", + "lead-verifier", + ], case_status: ["pending", "in_review", "approved", "rejected"], case_type: [ "guide_publish", diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts index 85529bac..f98ef10f 100644 --- a/api/src/routes/dashboard.ts +++ b/api/src/routes/dashboard.ts @@ -2,6 +2,12 @@ import { Hono } from "hono"; import { describeRoute } from "hono-openapi"; import type { HonoEnv } from "../types"; import { requireUser } from "../middleware/auth.middleware"; +import { rateLimitMiddleware } from "../middleware/rate-limit.middleware"; +import { MODERATION } from "../middleware/rateLimits"; +import { + scheduleSearchSync, + syncGuidesForSubject, +} from "../services/search.service"; import { getUserStatus, markUserStatus, @@ -13,6 +19,8 @@ import { fetchMembersTable, fetchAssignmentsTable, reassignPanelMember, + fetchSubjectsTable, + manageSubjects, } from "../services/dashboard.service"; import { jsonContent, errorResponses, validate } from "../lib/openapi"; import { @@ -29,9 +37,58 @@ import { rolesTableQuerySchema, membersTableQuerySchema, assignmentsTableQuerySchema, + subjectsTableQuerySchema, + subjectsTableResponseSchema, + manageSubjectsSchema, } from "@bluelearn/schemas"; export const dashboardRouter = new Hono() + .get( + "/subjects", + describeRoute({ + tags: ["dashboard"], + summary: "List subjects for moderation", + security: [{ bearerAuth: [] }], + responses: { + 200: jsonContent(subjectsTableResponseSchema, "Subjects table"), + ...errorResponses(400, 401, 403), + }, + }), + requireUser, + validate("query", subjectsTableQuerySchema), + async (c) => + c.json( + await fetchSubjectsTable(c.get("supabase"), c.req.valid("query")), + 200 + ) + ) + .post( + "/subjects/manage", + describeRoute({ + tags: ["dashboard"], + summary: "Edit a subject or merge selected subjects", + security: [{ bearerAuth: [] }], + responses: { + 200: jsonContent(successResponseSchema, "Subjects saved"), + ...errorResponses(400, 401, 403, 409, 429), + }, + }), + requireUser, + rateLimitMiddleware({ ...MODERATION, bucket: "subject-manage" }), + validate("json", manageSubjectsSchema), + async (c) => { + await manageSubjects(c.get("supabase"), c.req.valid("json")); + scheduleSearchSync( + c, + syncGuidesForSubject( + c.env, + c.get("supabase"), + c.req.valid("json").target_id + ) + ); + return c.json({ success: true }, 200); + } + ) // Get user status (Active, Inactive, Suspended) .get( "/:id/status", diff --git a/api/src/services/dashboard.service.ts b/api/src/services/dashboard.service.ts index 3bf5bc34..ec2dd999 100644 --- a/api/src/services/dashboard.service.ts +++ b/api/src/services/dashboard.service.ts @@ -3,6 +3,8 @@ import type { AssignmentsTableQuery, MembersTableQuery, RolesTableQuery, + SubjectsTableQuery, + ManageSubjectsInput, } from "@bluelearn/schemas"; import type { Database } from "../database.types"; import type { ProfileActivityRow } from "./identity.service"; @@ -129,7 +131,11 @@ const assignmentColumns = { date_updated: { column: "updated_at", kind: "range" }, } satisfies TableColumns; -type TableQuery = MembersTableQuery | RolesTableQuery | AssignmentsTableQuery; +type TableQuery = + | MembersTableQuery + | RolesTableQuery + | AssignmentsTableQuery + | SubjectsTableQuery; type TableRequest = PromiseLike<{ data: unknown; @@ -245,6 +251,78 @@ async function fetchTablePage>( return { rows: data as Awaited["data"], total: count ?? 0 }; } +export async function requireSubjectManager(supabase: DB) { + const roles = await Promise.all([ + supabase.rpc("has_role", { check_role: "admin" }), + supabase.rpc("has_role", { check_role: "lead-verifier" }), + ]); + if (roles.some(({ error }) => error)) + throw new ServiceError("Unable to check subject permissions", 500); + if (!roles.some(({ data }) => data)) + throw new ServiceError( + "Only admins and lead verifiers can manage subjects", + 403 + ); +} + +export async function fetchSubjectsTable( + supabase: DB, + query: SubjectsTableQuery +) { + await requireSubjectManager(supabase); + const { rows, total } = await fetchTablePage( + ({ head }) => + supabase + .from("subjects") + .select("id, name, summary, slug, status, created_at", { + count: "exact", + head, + }), + { + name: { column: "name", kind: "text" }, + summary: { column: "summary", kind: "text" }, + date_created: { column: "created_at", kind: "range" }, + }, + query, + ["id"], + "Failed to load subjects" + ); + return { + data: (rows ?? []).map(({ created_at, ...row }) => ({ + ...row, + date_created: created_at, + })), + total, + }; +} + +export async function manageSubjects(supabase: DB, input: ManageSubjectsInput) { + await requireSubjectManager(supabase); + const { error } = await supabase.rpc("manage_subjects", { + p_subject_ids: input.subject_ids, + p_target_id: input.target_id, + p_name: input.name, + p_summary: input.summary ?? "", + }); + if (!error) return; + if (error.code === "42501") + throw new ServiceError( + "Only admins and lead verifiers can manage subjects", + 403 + ); + if (error.code === "P0002") + throw new ServiceError( + "A selected subject is no longer available. Refresh the table and try again.", + 409 + ); + if (error.code === "23514") throw new ServiceError(error.message, 400); + console.error(error); + throw new ServiceError( + "Unable to save subjects. Refresh the table before trying again.", + 500 + ); +} + // select one page of data from the dashboard view for the roles table export async function fetchRolesTable(supabase: DB, query: RolesTableQuery) { const { rows, total } = await fetchTablePage( diff --git a/api/src/services/search.service.ts b/api/src/services/search.service.ts index dc16ef24..e3034f50 100644 --- a/api/src/services/search.service.ts +++ b/api/src/services/search.service.ts @@ -267,6 +267,38 @@ export async function syncGuideDocument( } } +// Subject edits affect the tags cached on guide search cards. Read after the +// transaction so merges and renames both use the retained subject's new tags. +export async function syncGuidesForSubject( + env: Bindings, + supabase: DB, + subjectId: string +) { + try { + const pageSize = 100; + for (let offset = 0; ; offset += pageSize) { + const { data, error } = await supabase + .from("published_guides") + .select("id") + .contains("subject_ids", [subjectId]) + .order("id") + .range(offset, offset + pageSize - 1); + if (error) throw new Error(error.message); + const ids = (data ?? []).flatMap(({ id }) => (id ? [id] : [])); + for (let start = 0; start < ids.length; start += 5) { + await Promise.all( + ids + .slice(start, start + 5) + .map((id) => syncGuideDocument(env, supabase, id)) + ); + } + if ((data?.length ?? 0) < pageSize) break; + } + } catch (error) { + console.error(`Search sync failed for subject ${subjectId}:`, error); + } +} + // Same contract as syncGuideDocument, for one objective. export async function syncObjectiveDocument( env: Bindings, diff --git a/api/tests/dashboard-subjects.test.ts b/api/tests/dashboard-subjects.test.ts new file mode 100644 index 00000000..d4864b73 --- /dev/null +++ b/api/tests/dashboard-subjects.test.ts @@ -0,0 +1,386 @@ +import { createClient } from "@supabase/supabase-js"; +import { describe, expect, it, vi } from "vitest"; +import { subjectsTableResponseSchema } from "@bluelearn/schemas"; +import app from "../src/index"; +import { admin, auth, env, jsonAuth, makeUser } from "./helpers"; +import { grantRole } from "./factories/identity"; +import { + createSubject, + tagGuideRevision, + tagObjectiveRevision, +} from "./factories/subjects"; +import { createGuideRevision, createPublishedGuide } from "./factories/guides"; +import { + createObjectiveRevision, + createPublishedObjective, +} from "./factories/objectives"; +import { expectToMatchSpec } from "./openapi"; + +const path = "/dashboard/subjects"; +const manage = ( + token: string, + ids: string[], + target = ids[0], + name = "Merged subject", + summary: string | null = "Updated summary" +) => + app.request( + `${path}/manage`, + jsonAuth(token, "POST", { + subject_ids: ids, + target_id: target, + name, + summary, + }), + env + ); +async function manager(role: "admin" | "lead-verifier" = "admin") { + const user = await makeUser(); + await grantRole(user.userId, role); + return user; +} +const subject = (id: string) => + admin.from("subjects").select("*").eq("id", id).maybeSingle().throwOnError(); + +describe("dashboard subject management", () => { + it("refreshes the retained subject's guide search cards after an edit", async () => { + const author = await manager(); + const row = await createSubject({ name: "Original search subject" }); + const guide = await createPublishedGuide({ authorId: author.userId }); + await tagGuideRevision(guide.revision.id, row.id); + const backgroundTasks: Array> = []; + const originalFetch = globalThis.fetch; + const documents: Array<{ tags?: Array<{ name: string; slug: string }> }> = + []; + const fakeFetch = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input, init) => { + if ( + typeof input === "string" && + input.startsWith("http://subject-search.test:80/") + ) { + documents.push(JSON.parse(String(init?.body))); + return new Response("{}", { status: 200 }); + } + return originalFetch(input, init); + }); + try { + const response = await app.request( + `${path}/manage`, + jsonAuth(author.token, "POST", { + subject_ids: [row.id], + target_id: row.id, + name: "Updated search subject", + summary: "", + }), + { + ...env, + TYPESENSE_HOST: "subject-search.test", + TYPESENSE_PORT: "80", + TYPESENSE_PROTOCOL: "http", + }, + { + waitUntil: (task) => { + backgroundTasks.push(task); + }, + passThroughOnException: () => {}, + props: {}, + } + ); + expect(response.status).toBe(200); + expect(backgroundTasks).toHaveLength(1); + await Promise.all(backgroundTasks); + expect(documents).toHaveLength(1); + expect(documents[0].tags).toContainEqual({ + name: "Updated search subject", + slug: row.slug, + }); + } finally { + fakeFetch.mockRestore(); + } + }); + it("requires authentication for listing and mutations", async () => { + expect((await app.request(path, {}, env)).status).toBe(401); + expect( + (await app.request(`${path}/manage`, { method: "POST" }, env)).status + ).toBe(401); + }); + + it.each([null, "verifier", "moderator", "curator", "official"] as const)( + "denies non-managers with role %s", + async (role) => { + const user = await makeUser(); + if (role) await grantRole(user.userId, role); + const row = await createSubject(); + const list = await app.request(path, auth(user.token), env); + expect(list.status).toBe(403); + expect((await manage(user.token, [row.id])).status).toBe(403); + expect((await subject(row.id)).data?.name).toBe(row.name); + const client = createClient( + env.SUPABASE_URL, + env.SUPABASE_PUBLISHABLE_KEY, + { + global: { headers: { Authorization: `Bearer ${user.token}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + } + ); + const { error } = await client.rpc("manage_subjects", { + p_subject_ids: [row.id], + p_target_id: row.id, + p_name: "Bypass attempt", + p_summary: "", + }); + expect(error?.code).toBe("42501"); + } + ); + + it.each(["admin", "lead-verifier"] as const)( + "lets %s list other authors' draft subjects and edit one without changing its URL", + async (role) => { + const user = await manager(role); + const name = `Draft ${crypto.randomUUID().slice(0, 8)}`; + const row = await createSubject({ name, status: "draft", slug: null }); + const list = await app.request( + `${path}?name=${encodeURIComponent(name)}`, + auth(user.token), + env + ); + expect(list.status).toBe(200); + await expectToMatchSpec(list.clone(), "GET", path); + expect( + subjectsTableResponseSchema + .parse(await list.json()) + .data.map((item) => item.id) + ).toContain(row.id); + const result = await manage( + user.token, + [row.id], + row.id, + " Corrected name ", + " Better summary " + ); + expect(result.status).toBe(200); + await expectToMatchSpec(result, "POST", `${path}/manage`); + expect((await subject(row.id)).data).toMatchObject({ + name: "Corrected name", + summary: "Better summary", + slug: null, + status: "draft", + created_at: row.created_at, + }); + } + ); + + it("merges three subjects across guide and objective revisions without losing content or duplicate tags", async () => { + const user = await manager("lead-verifier"); + const author = await makeUser(); + const keep = await createSubject(); + const first = await createSubject(); + const second = await createSubject({ slug: null, status: "draft" }); + const unrelated = await createSubject(); + const guide = await createPublishedGuide({ authorId: author.userId }); + const guideDraft = await createGuideRevision(guide.guide.id, { + author_id: author.userId, + status: "draft", + }); + const objective = await createPublishedObjective(author.userId, guide); + const objectiveDraft = await createObjectiveRevision( + objective.objective.id, + { author_id: author.userId, status: "draft" } + ); + for (const row of [keep, first, second, unrelated]) { + await tagGuideRevision(guideDraft.id, row.id); + await tagObjectiveRevision(objectiveDraft.id, row.id); + } + await tagGuideRevision(guide.revision.id, first.id); + await tagObjectiveRevision(objective.revision.id, second.id); + expect( + (await manage(user.token, [keep.id, first.id, second.id])).status + ).toBe(200); + expect((await subject(first.id)).data).toBeNull(); + expect((await subject(second.id)).data).toBeNull(); + expect((await subject(keep.id)).data).toMatchObject({ + name: "Merged subject", + slug: keep.slug, + status: "published", + created_at: keep.created_at, + }); + for (const request of [ + admin + .from("guide_revision_subjects") + .select("subject_id") + .eq("guide_revision_id", guideDraft.id), + admin + .from("objective_revision_subjects") + .select("subject_id") + .eq("objective_revision_id", objectiveDraft.id), + ]) { + const { data } = await request.throwOnError(); + expect(data.map((tag) => tag.subject_id).sort()).toEqual( + [keep.id, unrelated.id].sort() + ); + } + for (const request of [ + admin + .from("guide_revision_subjects") + .select("subject_id") + .eq("guide_revision_id", guide.revision.id), + admin + .from("objective_revision_subjects") + .select("subject_id") + .eq("objective_revision_id", objective.revision.id), + ]) { + const { data } = await request.throwOnError(); + expect(data.map((tag) => tag.subject_id)).toContain(keep.id); + } + const live = await admin + .from("guides") + .select("current_revision_id") + .eq("id", guide.guide.id) + .single() + .throwOnError(); + expect(live.data.current_revision_id).toBe(guide.revision.id); + const liveObjective = await admin + .from("objectives") + .select("current_revision_id") + .eq("id", objective.objective.id) + .single() + .throwOnError(); + expect(liveObjective.data.current_revision_id).toBe(objective.revision.id); + }); + + it("rolls back stale selections and doesn't hide published subjects in a draft destination", async () => { + const user = await manager(); + const published = await createSubject(); + const draft = await createSubject({ status: "draft", slug: null }); + expect( + (await manage(user.token, [published.id, crypto.randomUUID()])).status + ).toBe(409); + expect((await manage(user.token, [draft.id, published.id])).status).toBe( + 400 + ); + expect((await subject(published.id)).data?.name).toBe(published.name); + expect((await subject(draft.id)).data?.name).toBe(draft.name); + }); + + it("serializes overlapping merges without losing revision tags", async () => { + const user = await manager(); + const first = await createSubject(); + const second = await createSubject(); + const shared = await createSubject(); + const guide = await createPublishedGuide({ authorId: user.userId }); + await tagGuideRevision(guide.revision.id, shared.id); + + const results = await Promise.all([ + manage(user.token, [first.id, shared.id], first.id, "First destination"), + manage( + user.token, + [shared.id, second.id], + second.id, + "Second destination" + ), + ]); + expect(results.map(({ status }) => status).sort()).toEqual([200, 409]); + const retained = results[0].status === 200 ? first : second; + const untouched = results[0].status === 200 ? second : first; + expect((await subject(shared.id)).data).toBeNull(); + expect((await subject(untouched.id)).data?.name).toBe(untouched.name); + const { data } = await admin + .from("guide_revision_subjects") + .select("subject_id") + .eq("guide_revision_id", guide.revision.id) + .throwOnError(); + expect(data).toEqual([{ subject_id: retained.id }]); + }); + + it("keeps all-draft merges unpublished and accepts clearing a summary", async () => { + const user = await manager(); + const first = await createSubject({ status: "draft", slug: null }); + const second = await createSubject({ status: "draft", slug: null }); + expect( + ( + await manage( + user.token, + [first.id, second.id], + first.id, + "Draft merged", + null + ) + ).status + ).toBe(200); + expect((await subject(first.id)).data).toMatchObject({ + status: "draft", + slug: null, + summary: null, + }); + }); + + it("rejects malformed selections and invalid details without changing subjects", async () => { + const user = await manager(); + const row = await createSubject(); + const base = { + subject_ids: [row.id], + target_id: row.id, + name: "Valid name", + summary: "", + }; + for (const change of [ + { subject_ids: [] }, + { subject_ids: [row.id, row.id] }, + { target_id: crypto.randomUUID() }, + { subject_ids: ["bad-id"] }, + { name: " " }, + { name: "a".repeat(51) }, + { summary: "a".repeat(501) }, + { subject_ids: Array.from({ length: 101 }, () => crypto.randomUUID()) }, + ]) { + expect( + ( + await app.request( + `${path}/manage`, + jsonAuth(user.token, "POST", { ...base, ...change }), + env + ) + ).status + ).toBe(400); + } + expect((await subject(row.id)).data?.name).toBe(row.name); + }); + + it("filters literal text and creation dates, sorts before pagination, and returns filtered totals", async () => { + const user = await manager(); + const prefix = crypto.randomUUID().slice(0, 8); + for (const [suffix, date] of [ + ["B", "2026-10-02"], + ["A", "2026-10-01"], + ["C", "2026-10-03"], + ]) { + await createSubject({ + name: `${prefix} ${suffix}`, + summary: "100% match_", + created_at: `${date}T12:00:00Z`, + }); + } + const query = `name=${prefix}&sortBy=name&sortDirection=asc&limit=1&page=2`; + const page = await app.request(`${path}?${query}`, auth(user.token), env); + expect(await page.json()).toMatchObject({ + total: 3, + data: [{ name: `${prefix} B` }], + }); + const filtered = await app.request( + `${path}?name=${prefix}&summary=${encodeURIComponent("% match_")}&date_created_from=2026-10-02T00:00:00Z&date_created_to=2026-10-03T00:00:00Z`, + auth(user.token), + env + ); + expect(await filtered.json()).toMatchObject({ + total: 1, + data: [{ name: `${prefix} B` }], + }); + const beyond = await app.request( + `${path}?name=${prefix}&page=10&limit=1`, + auth(user.token), + env + ); + expect(await beyond.json()).toEqual({ total: 3, data: [] }); + }); +}); diff --git a/app/src/components/__tests__/dashboardSubjects.test.tsx b/app/src/components/__tests__/dashboardSubjects.test.tsx new file mode 100644 index 00000000..c1828a9f --- /dev/null +++ b/app/src/components/__tests__/dashboardSubjects.test.tsx @@ -0,0 +1,302 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentType } from "react"; +import type { DashboardSubject } from "@bluelearn/schemas"; +import { Route } from "@/routes/dashboard/subjects"; +import { ManageSubjectsDialog } from "@/components/subjects/ManageSubjectsDialog"; +import { + DashboardSidebar, + DashboardTabs, +} from "@/components/sidebar/DashboardSidebar"; + +const state = vi.hoisted(() => ({ + manage: vi.fn(), + invalidate: vi.fn(), + fetch: vi.fn(), + toast: { success: vi.fn(), error: vi.fn() }, + roles: [] as Array, +})); +const subjects: Array = [ + { + id: "52100000-0000-4000-8000-000000000001", + name: "Mathematics", + summary: "Numbers and patterns", + slug: "mathematics", + status: "published", + date_created: "2026-10-23T00:00:00Z", + }, + { + id: "52100000-0000-4000-8000-000000000002", + name: "Maths", + summary: null, + slug: "maths", + status: "published", + date_created: "2026-10-23T00:00:00Z", + }, + { + id: "52100000-0000-4000-8000-000000000003", + name: "Math draft", + summary: null, + slug: null, + status: "draft", + date_created: "2026-10-23T00:00:00Z", + }, +]; +const page = { data: subjects, total: subjects.length }; +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (options: unknown) => ({ + options, + useLoaderData: () => page, + useSearch: () => ({}), + useNavigate: () => vi.fn(), + }), + useRouter: () => ({ invalidate: state.invalidate }), + useRouterState: () => "/dashboard/subjects", + notFound: vi.fn(), + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})); +vi.mock("@/lib/authContext", () => ({ + useAuth: () => ({ roles: state.roles }), +})); +vi.mock("@/lib/api/dashboard", () => ({ + manageSubjects: state.manage, + fetchSubjectsTable: state.fetch, +})); +vi.mock("sonner", () => ({ toast: state.toast })); +const SubjectsPage = Route.options.component as ComponentType; +beforeEach(() => { + vi.clearAllMocks(); + state.roles = []; + state.manage.mockResolvedValue(undefined); + state.invalidate.mockResolvedValue(undefined); +}); +afterEach(cleanup); + +describe("dashboard subjects", () => { + it("requires one subject for editing and multiple subjects for merging", () => { + render(); + expect( + screen + .getByRole("button", { name: "Edit subject" }) + .hasAttribute("disabled") + ).toBe(true); + expect( + screen + .getByRole("button", { name: "Merge subjects" }) + .hasAttribute("disabled") + ).toBe(true); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select Mathematics" }) + ); + expect( + screen + .getByRole("button", { name: "Edit subject" }) + .hasAttribute("disabled") + ).toBe(false); + fireEvent.click(screen.getByRole("checkbox", { name: "Select Maths" })); + expect( + screen + .getByRole("button", { name: "Edit subject" }) + .hasAttribute("disabled") + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Merge subjects (2)" })); + expect( + screen.getByRole("dialog", { name: "Merge 2 subjects" }) + ).toBeDefined(); + expect(state.manage).not.toHaveBeenCalled(); + }); + + it("selects and clears the current page without offering a destructive delete action", () => { + render(); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select all subjects on this page" }) + ); + expect( + screen + .getByRole("button", { name: "Merge subjects (3)" }) + .hasAttribute("disabled") + ).toBe(false); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select all subjects on this page" }) + ); + expect( + screen + .getByRole("button", { name: "Merge subjects" }) + .hasAttribute("disabled") + ).toBe(true); + expect(screen.queryByRole("button", { name: /delete/i })).toBeNull(); + }); + + it("edits name and summary and refreshes only after saving", async () => { + render(); + fireEvent.click( + screen.getByRole("checkbox", { name: "Select Mathematics" }) + ); + fireEvent.click(screen.getByRole("button", { name: "Edit subject" })); + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Pure mathematics" }, + }); + fireEvent.change(screen.getByLabelText("Summary"), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + await waitFor(() => expect(state.invalidate).toHaveBeenCalledOnce()); + expect(state.manage).toHaveBeenCalledWith({ + subject_ids: [subjects[0].id], + target_id: subjects[0].id, + name: "Pure mathematics", + summary: "", + }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("allows changing the retained subject and sends one atomic merge", async () => { + const close = vi.fn(); + const saved = vi.fn().mockResolvedValue(undefined); + render( + + ); + const target = screen.getByLabelText("Subject to keep"); + expect(screen.queryByRole("option", { name: "Math draft" })).toBeNull(); + fireEvent.change(target, { target: { value: subjects[1].id } }); + expect(screen.getByLabelText("Name").getAttribute("value")).toBe("Maths"); + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Mathematical sciences" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(state.manage).toHaveBeenCalledOnce(); + expect(state.manage).toHaveBeenCalledWith({ + subject_ids: subjects.map(({ id }) => id), + target_id: subjects[1].id, + name: "Mathematical sciences", + summary: "", + }); + expect(saved).toHaveBeenCalledOnce(); + }); + + it("cancels without changing subjects", () => { + const close = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(state.manage).not.toHaveBeenCalled(); + }); + + it("rejects a whitespace-only name without sending a request", () => { + render( + + ); + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: " " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + expect(screen.getByRole("alert")).toBeDefined(); + expect(state.manage).not.toHaveBeenCalled(); + }); + + it("keeps edits after a failure and supports retry", async () => { + state.manage.mockRejectedValueOnce( + new Error("Selection changed. Refresh and try again.") + ); + const saved = vi.fn().mockResolvedValue(undefined); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + expect(await screen.findByRole("alert")).toHaveProperty( + "textContent", + "Selection changed. Refresh and try again." + ); + expect(saved).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + await waitFor(() => expect(saved).toHaveBeenCalledOnce()); + }); + + it("disables resubmission and cancellation while a merge is pending", async () => { + let finish!: () => void; + state.manage.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }) + ); + const close = vi.fn(); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + expect( + screen.getByRole("button", { name: "Saving..." }).hasAttribute("disabled") + ).toBe(true); + expect( + screen.getByRole("button", { name: "Cancel" }).hasAttribute("disabled") + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Saving..." })); + expect(state.manage).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + }); + + it("doesn't report a successful merge as failed when refreshing the table fails", async () => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Confirm merge" })); + await waitFor(() => + expect(state.toast.error).toHaveBeenCalledWith( + expect.stringContaining("Subjects were saved") + ) + ); + }); + + it.each(["admin", "lead-verifier", "verifier"])( + "shows desktop and mobile subject navigation only to managers: %s", + (role) => { + state.roles = [role]; + render( + <> + + + + ); + expect(screen.queryAllByRole("link", { name: "Subjects" })).toHaveLength( + role === "verifier" ? 0 : 2 + ); + } + ); +}); diff --git a/app/src/components/sidebar/DashboardSidebar.tsx b/app/src/components/sidebar/DashboardSidebar.tsx index adc3ef6b..4899b5c6 100644 --- a/app/src/components/sidebar/DashboardSidebar.tsx +++ b/app/src/components/sidebar/DashboardSidebar.tsx @@ -1,5 +1,5 @@ import { Link, useRouterState } from "@tanstack/react-router"; -import { FileCheckCorner, Shield, Users } from "lucide-react"; +import { BookOpen, FileCheckCorner, Shield, Users } from "lucide-react"; import type { LucideProps } from "lucide-react"; import type { ForwardRefExoticComponent, RefAttributes } from "react"; import { cn } from "@/lib/utils"; @@ -14,6 +14,12 @@ const items: Array<{ >; roles?: Array; }> = [ + { + label: "Subjects", + to: "/dashboard/subjects", + icon: BookOpen, + roles: ["lead-verifier", "admin"], + }, { label: "Members", to: "/dashboard/members", @@ -82,6 +88,7 @@ export const DashboardSidebar = () => { }; export const DashboardTabs = () => { + const { roles } = useAuth(); const pathname = useRouterState({ select: (state) => state.location.pathname, }); @@ -89,24 +96,29 @@ export const DashboardTabs = () => { return ( ); diff --git a/app/src/components/subjects/ManageSubjectsDialog.tsx b/app/src/components/subjects/ManageSubjectsDialog.tsx new file mode 100644 index 00000000..b8d076ee --- /dev/null +++ b/app/src/components/subjects/ManageSubjectsDialog.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { manageSubjectsSchema } from "@bluelearn/schemas"; +import type { DashboardSubject } from "@bluelearn/schemas"; +import { manageSubjects } from "@/lib/api/dashboard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +// Mount with a snapshot of the selection so filtering can't change a pending merge. +export function ManageSubjectsDialog({ + subjects, + onClose, + onSaved, +}: { + subjects: Array; + onClose: () => void; + onSaved: () => Promise; +}) { + const merging = subjects.length > 1; + const destinations = subjects.some(({ status }) => status === "published") + ? subjects.filter(({ status }) => status === "published") + : subjects; + const [targetId, setTargetId] = useState(destinations[0].id); + const [name, setName] = useState(destinations[0].name); + const [summary, setSummary] = useState(destinations[0].summary ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const target = destinations.find(({ id }) => id === targetId)!; + + async function save(event: React.FormEvent) { + event.preventDefault(); + if (saving) return; + const parsed = manageSubjectsSchema.safeParse({ + subject_ids: subjects.map(({ id }) => id), + target_id: targetId, + name, + summary, + }); + if (!parsed.success) { + setError(parsed.error.issues[0].message); + return; + } + setSaving(true); + setError(null); + try { + await manageSubjects(parsed.data); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to save subjects."); + setSaving(false); + return; + } + toast.success(merging ? "Subjects merged" : "Subject updated"); + try { + await onSaved(); + } catch { + toast.error( + "Subjects were saved, but the table couldn't refresh. Reload the page." + ); + } + onClose(); + } + + return ( + { + if (!open && !saving) onClose(); + }} + > + + + + {merging ? `Merge ${subjects.length} subjects` : "Edit subject"} + + + {merging + ? "Choose the subject to keep. All guide and objective tags will move to it, including saved and past revisions. The other subjects will be removed. This can't be undone." + : "Update the name and summary without changing the subject's URL."} + + +
+
+ {merging && ( + <> +
    + {subjects.map((subject) => ( +
  • + {subject.name} +
  • + ))} +
+
+ + +

+ {target.slug + ? `Keeps /subjects/${target.slug}. Removed subjects' URLs will no longer work.` + : "The subject stays a draft until it is published."} +

+
+ + )} +
+ + setName(event.target.value)} + required + minLength={3} + maxLength={50} + /> +
+
+ +