From e6c4d77e3a9eecb2a85c897a8821022776e03a24 Mon Sep 17 00:00:00 2001 From: eeminionn <109454414+eeminionn@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:43:10 -0400 Subject: [PATCH] Simplify classroom reviews --- .../20260813002436_stable_learning_tools.sql | 39 ++++ supabase/tests/rls.sql | 46 ++++- v2/e2e/mentor.spec.ts | 53 +++++ v2/src/data/demo-classroom.ts | 14 ++ v2/src/pages/MentorPage.tsx | 191 ++++++++++++++++-- v2/src/state/classroom-context.tsx | 182 +++++++++++++++++ v2/src/styles.css | 69 +++++++ v2/src/types.ts | 18 ++ 8 files changed, 591 insertions(+), 21 deletions(-) create mode 100644 supabase/migrations/20260813002436_stable_learning_tools.sql diff --git a/supabase/migrations/20260813002436_stable_learning_tools.sql b/supabase/migrations/20260813002436_stable_learning_tools.sql new file mode 100644 index 0000000..696394f --- /dev/null +++ b/supabase/migrations/20260813002436_stable_learning_tools.sql @@ -0,0 +1,39 @@ +create table public.review_rubrics ( + id uuid primary key default gen_random_uuid(), + class_id uuid not null references public.classes(id) on delete cascade, + title text not null check (char_length(title) between 1 and 80), + criteria jsonb not null default '[]'::jsonb, + created_by uuid not null references public.profiles(id), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint review_rubrics_criteria_check check ( + jsonb_typeof(criteria) = 'array' + and jsonb_array_length(criteria) between 1 and 10 + and octet_length(criteria::text) <= 4000 + ) +); + +alter table public.assignments + add column rubric_id uuid references public.review_rubrics(id) on delete set null; + +create index review_rubrics_class_idx on public.review_rubrics(class_id); +create index assignments_rubric_idx on public.assignments(rubric_id) + where rubric_id is not null; + +alter table public.review_rubrics enable row level security; + +create policy "Staff view review rubrics" +on public.review_rubrics for select +to authenticated +using (public.is_class_staff(class_id)); + +create policy "Staff manage review rubrics" +on public.review_rubrics for all +to authenticated +using (public.is_class_staff(class_id)) +with check ( + public.is_class_staff(class_id) + and created_by = (select auth.uid()) +); + +grant select, insert, update, delete on public.review_rubrics to authenticated; diff --git a/supabase/tests/rls.sql b/supabase/tests/rls.sql index d554ef4..ba81bf6 100644 --- a/supabase/tests/rls.sql +++ b/supabase/tests/rls.sql @@ -1,7 +1,7 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(45); +select plan(49); insert into auth.users ( id, instance_id, aud, role, email, encrypted_password, @@ -308,6 +308,29 @@ select is( 'students cannot list classroom invitation links' ); +select is( + (select count(*) from public.review_rubrics), + 0::bigint, + 'students cannot read classroom review guides' +); + +select throws_ok( + $$ + insert into public.review_rubrics ( + class_id, title, criteria, created_by + ) + values ( + '00000000-0000-0000-0000-000000000201', + 'Forged guide', + '[{"id":"answer","label":"Da la respuesta"}]', + '00000000-0000-0000-0000-000000000102' + ) + $$, + '42501', + 'new row violates row-level security policy for table "review_rubrics"', + 'students cannot create review guides' +); + select throws_ok( $$ select * @@ -471,6 +494,27 @@ select set_config( ); select set_config('request.jwt.claim.role', 'authenticated', true); +select lives_ok( + $$ + insert into public.review_rubrics ( + class_id, title, criteria, created_by + ) + values ( + '00000000-0000-0000-0000-000000000201', + 'Funciones simples', + '[{"id":"answer","label":"Devuelve el valor pedido"}]', + '00000000-0000-0000-0000-000000000101' + ) + $$, + 'class staff can create reusable review guides' +); + +select is( + (select count(*) from public.review_rubrics), + 1::bigint, + 'class staff can read their reusable review guides' +); + select is( ( select count(*) diff --git a/v2/e2e/mentor.spec.ts b/v2/e2e/mentor.spec.ts index a5af011..872e7b4 100644 --- a/v2/e2e/mentor.spec.ts +++ b/v2/e2e/mentor.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { createDemoSnapshot } from "../src/data/demo-classroom"; async function loginAsMentor(page: Page) { await page.goto("./"); @@ -102,6 +103,58 @@ test("mentor creates an assignment for selected students", async ({ page }) => { ).toContainText("Aviso fallido"); }); +test("mentor creates a reusable review guide and assigns it to a task", async ({ + page, +}) => { + await loginAsMentor(page); + await page.getByRole("link", { name: "Tareas", exact: true }).click(); + await page.getByRole("button", { name: "Nueva pauta" }).click(); + await page.getByLabel("Nombre").fill("Funciones simples"); + await page + .getByLabel("Una pregunta por línea") + .fill("Devuelve el valor pedido\nNo imprime la respuesta"); + await page.getByRole("button", { name: "Guardar pauta" }).click(); + await expect(page.getByText("Funciones simples")).toBeVisible(); + + await page.getByRole("button", { name: "Nueva tarea" }).click(); + await page.getByLabel("Misión").selectOption({ index: 1 }); + await page.getByLabel("Título de la tarea").fill("Tarea con pauta"); + await page.getByLabel("Pauta de corrección (opcional)").selectOption({ + label: "Funciones simples", + }); + await page.getByRole("button", { name: "Publicar tarea" }).click(); + await expect(page.getByText("Tarea con pauta")).toBeVisible(); +}); + +test("mentor can approve several available submissions", async ({ page }) => { + await loginAsMentor(page); + await page.evaluate((baseSnapshot) => { + const key = "tomatin.v2.demo-classroom"; + const snapshot = JSON.parse( + window.localStorage.getItem(key) ?? JSON.stringify(baseSnapshot), + ); + const sourceAttempt = snapshot.attempts[0]; + snapshot.progress = snapshot.progress.map((entry: { userId: string; assignmentId: string; status: string }) => + entry.userId === "student-03" && entry.assignmentId === "assignment-once" + ? { ...entry, status: "awaiting_review", submittedAt: new Date().toISOString() } + : entry, + ); + snapshot.attempts.push({ + ...sourceAttempt, + id: "attempt-antonia-once", + userId: "student-03", + createdAt: new Date().toISOString(), + }); + window.localStorage.setItem(key, JSON.stringify(snapshot)); + }, createDemoSnapshot()); + await page.reload(); + await page.getByRole("link", { name: /^Revisiones/ }).click(); + await page.getByLabel("Seleccionar entrega de Camila Rojas").check(); + await page.getByLabel("Seleccionar entrega de Antonia Pérez").check(); + await page.getByRole("button", { name: "Aprobar 2" }).click(); + await expect(page.getByText("Cola al día")).toBeVisible(); +}); + test("mentor creates a reward and fulfills a student redemption", async ({ page, }) => { diff --git a/v2/src/data/demo-classroom.ts b/v2/src/data/demo-classroom.ts index b3a3747..cd126d3 100644 --- a/v2/src/data/demo-classroom.ts +++ b/v2/src/data/demo-classroom.ts @@ -304,6 +304,20 @@ export function createDemoSnapshot(): ClassroomSnapshot { updatedAt: dateAgo(1), }, ], + reviewRubrics: [ + { + id: "rubric-default", + classId: "class-tomatin-2026", + title: "Revisión general", + criteria: [ + { id: "correctness", label: "Da la respuesta correcta" }, + { id: "readability", label: "Se entiende cómo lo resolvió" }, + { id: "edge-cases", label: "Funciona también con otros casos" }, + ], + createdAt: dateAgo(5), + updatedAt: dateAgo(5), + }, + ], }; } diff --git a/v2/src/pages/MentorPage.tsx b/v2/src/pages/MentorPage.tsx index 25fac51..5f2da78 100644 --- a/v2/src/pages/MentorPage.tsx +++ b/v2/src/pages/MentorPage.tsx @@ -772,7 +772,7 @@ function StudentDirectory({ } function ReviewQueue() { - const { profile, snapshot, frontendOnly, reviewAttempt } = useClassroom(); + const { profile, snapshot, frontendOnly, reviewAttempt, approveAttempts } = useClassroom(); const { getMissionById } = useCatalog(); const [selectedId, setSelectedId] = useState(null); const [comment, setComment] = useState(""); @@ -785,9 +785,27 @@ function ReviewQueue() { >([]); const [reviewBusy, setReviewBusy] = useState(false); const [reviewMessage, setReviewMessage] = useState(""); - const [criteria, setCriteria] = useState(() => + const [selectedBatch, setSelectedBatch] = useState([]); + const [batchBusy, setBatchBusy] = useState(false); + const [criteria, setCriteria] = useState>(() => REVIEW_CRITERIA.map((entry) => ({ ...entry, met: false })), ); + useEffect(() => { + if (!snapshot) return; + const queueEntry = + getPendingReviews(snapshot).find( + (entry) => entry.attempt.id === selectedId, + ) ?? getPendingReviews(snapshot)[0]; + const rubric = snapshot.reviewRubrics.find( + (entry) => entry.id === queueEntry?.assignment.rubricId, + ); + setCriteria( + (rubric?.criteria ?? REVIEW_CRITERIA).map((entry) => ({ + ...entry, + met: false, + })), + ); + }, [selectedId, snapshot?.assignments, snapshot?.reviewRubrics]); if (!profile || !snapshot) return null; const queue = getPendingReviews(snapshot); @@ -812,6 +830,19 @@ function ReviewQueue() { selected.progress.missionVersion, ) : undefined; + const selectedRubric = selected + ? snapshot.reviewRubrics.find( + (entry) => entry.id === selected.assignment.rubricId, + ) + : undefined; + + function criteriaFor(rubricId?: string) { + const rubric = snapshot!.reviewRubrics.find((entry) => entry.id === rubricId); + return (rubric?.criteria ?? REVIEW_CRITERIA).map((entry) => ({ + ...entry, + met: false, + })); + } async function decide(decision: "approved" | "changes_requested") { if (frontendOnly) return; @@ -857,7 +888,25 @@ function ReviewQueue() { setInlineComments([]); setInlineBody(""); setInlineLine(1); - setCriteria(REVIEW_CRITERIA.map((entry) => ({ ...entry, met: false }))); + const entry = queue.find((item) => item.attempt.id === attemptId); + setCriteria(criteriaFor(entry?.assignment.rubricId)); + } + + async function approveSelected() { + if (frontendOnly || selectedBatch.length === 0) return; + setBatchBusy(true); + setReviewMessage(""); + try { + const approved = await approveAttempts(selectedBatch); + setSelectedBatch([]); + setReviewMessage(`${approved} ${approved === 1 ? "entrega aprobada" : "entregas aprobadas"}.`); + } catch (error) { + setReviewMessage( + error instanceof Error ? error.message : "Una entrega no pudo procesarse.", + ); + } finally { + setBatchBusy(false); + } } function addInlineComment(event: FormEvent) { @@ -881,6 +930,17 @@ function ReviewQueue() { {visibleQueue.length === 1 ? "entrega" : "entregas"} + {visibleQueue.length > 1 ? ( + + ) : null}
{visibleQueue.map((entry) => ( - +
+ {visibleQueue.length > 1 ? ( + + setSelectedBatch((current) => + event.target.checked + ? [...current, entry.attempt.id] + : current.filter((id) => id !== entry.attempt.id), + ) + } + /> + ) : null} + +
))} {visibleQueue.length === 0 ? (
@@ -1050,7 +1123,9 @@ function ReviewQueue() { ) : null}
- Lista rápida (opcional) + + {selectedRubric ? selectedRubric.title : "Lista rápida"} (opcional) + {criteria.map((entry) => (