diff --git a/.gitignore b/.gitignore index 4b2bd0ae..34552f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,9 +20,7 @@ src/lib/AddAdminRoles # firebase emulator .firebaserc -firebase.json firebase-debug.log -firestore.indexes.json firestore-debug.log storage.rules firebase-storage.rules diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..e7dd0255 --- /dev/null +++ b/firebase.json @@ -0,0 +1,19 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "auth": { + "port": 9099 + }, + "firestore": { + "port": 8080 + }, + "ui": { + "enabled": true, + "port": 4000 + }, + "singleProjectMode": true + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 00000000..e9a6f13e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,28 @@ +{ + "indexes": [ + { + "collectionGroup": "gradableFrqSubmissions", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "submittedAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "gradableFrqSubmissions", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "studentId", "order": "ASCENDING" }, + { "fieldPath": "submittedAt", "order": "DESCENDING" } + ] + }, + { + "collectionGroup": "gradedFrqSubmissions", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "studentId", "order": "ASCENDING" }, + { "fieldPath": "gradedAt", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules index 66aa43c0..1edc8124 100644 --- a/firestore.rules +++ b/firestore.rules @@ -2,12 +2,12 @@ rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { - + // Centralized function to check if the user is authenticated function isAuthenticated() { return request.auth != null; } - + // Centralized function to check if the user is an admin function isAdmin() { return isAuthenticated() && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.access == "admin"; @@ -24,6 +24,12 @@ service cloud.firestore { return isAuthenticated() && (accessLevel == "admin" || accessLevel == "member" || accessLevel == "grader"); } + function isGraderOrAdmin() { + let accessLevel = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.access; + return isAuthenticated() && + (accessLevel == "admin" || accessLevel == "grader"); + } + // Allow read/write access to users for their own user data match /users/{userId} { allow read: if isAuthenticated() && request.auth.uid == userId; @@ -51,7 +57,7 @@ service cloud.firestore { let userDocAfter = getAfter(/databases/$(database)/documents/users/$(request.auth.uid)); let userDataAfter = userDocAfter.data; - return userData.access != "banned" && (isAdmin() || (!("lastFrqResponseAt" in userData) && + return userData.access != "banned" && (isAdmin() || (!("lastFrqResponseAt" in userData) && "lastFrqResponseAt" in userDataAfter ) || ( userData.lastFrqResponseAt < (request.time - duration.value(6, 'h')) @@ -63,12 +69,79 @@ service cloud.firestore { match /{somePath=**}/frqResponses/{frqResponse} { allow read: if isGraderOrMemberOrAdmin(); } - + + // Digital testing FRQs deliberately use separate stores. Templates are + // published prompts, the queue contains only ungraded responses, and a + // grading result is kept separately for the student's dashboard. + + + match /ungraded-frqs/{submissionId} { + // `resource` is null when the document does not exist, and dereferencing it + // raises an evaluation error rather than denying, which surfaces to the + // client as "insufficient permissions" for what is really a missing doc. + allow get, list: if isGraderOrAdmin() || + (isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid); + + allow create: if isAuthenticated() + && request.resource.data.keys().hasOnly([ + 'templateId', + 'subject', + 'unitId', + 'studentId', + 'responses', + 'submittedAt' + ]) + && request.resource.data.templateId is string + && request.resource.data.subject is string + && request.resource.data.unitId is string + && request.resource.data.studentId == request.auth.uid + && request.resource.data.responses is map + && exists( + /databases/$(database)/documents/subjects/$(request.resource.data.subject)/units/$(request.resource.data.unitId)/frqs/$(request.resource.data.templateId) + ); + + allow delete: if isGraderOrAdmin(); +} + + match /graded-frqs/{resultId} { + allow get, list: if isGraderOrAdmin() || + (isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid); + + allow create: if isGraderOrAdmin() + && resultId == request.resource.data.sourceSubmissionId + && request.resource.data.keys().hasOnly([ + 'sourceSubmissionId', + 'templateId', + 'subject', + 'unitId', + 'studentId', + 'responses', + 'submittedAt', + 'score', + 'feedback', + 'grades', + 'graderId', + 'gradedAt' + ]) + && request.resource.data.grades is list + && request.resource.data.sourceSubmissionId is string + && request.resource.data.templateId is string + && request.resource.data.subject is string + && request.resource.data.unitId is string + && request.resource.data.studentId is string + && request.resource.data.responses is map + && request.resource.data.score is string + && request.resource.data.feedback is string + && request.resource.data.graderId == request.auth.uid; + + allow update, delete: if isGraderOrAdmin(); +} + // Admins can read/write all user documents match /users/{document=**} { allow read, write: if isAdmin(); } - + // Subjects and pages can be accessed by members and admins match /subjects/{subject} { allow read: if true; @@ -87,9 +160,18 @@ service cloud.firestore { allow read: if resource.data.isPublic == true || isMemberOrAdmin(); allow write: if isMemberOrAdmin(); } + + // Graders read templates too: the rubric being scored against lives on + // the template, and so does the prompt shown beside a student's answer. + // Restricting reads to members left graders unable to open a private + // FRQ's submission at all. + match /frqs/{frq} { + allow read: if (resource != null && resource.data.isPublic == true) || isGraderOrMemberOrAdmin(); + allow write: if isMemberOrAdmin(); + } } } - + match /pages/{document=**} { allow read: if true; allow write: if isMemberOrAdmin(); diff --git a/package-lock.json b/package-lock.json index b7329a24..7df9d751 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "editorjs-math": "^1.0.2", "editorjs-parser": "^1.5.3", "editorjs-undo": "^2.0.28", - "firebase": "^10.13.0", + "firebase": "^10.14.1", "firebase-admin": "^12.3.1", "highlight.js": "^11.10.0", "katex": "^0.16.47", diff --git a/package.json b/package.json index ba5cb896..88b388ea 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev": "next dev", "lint": "next lint", "start": "next start", - "emulate": "firebase emulators:start --import emulator --export-on-exit" + "emulate": "firebase emulators:start --import emulator --export-on-exit", + "deploy:rules": "firebase deploy --only firestore:rules,firestore:indexes" }, "dependencies": { "@editorjs/attaches": "^1.3.0", @@ -46,7 +47,7 @@ "editorjs-math": "^1.0.2", "editorjs-parser": "^1.5.3", "editorjs-undo": "^2.0.28", - "firebase": "^10.13.0", + "firebase": "^10.14.1", "firebase-admin": "^12.3.1", "highlight.js": "^11.10.0", "katex": "^0.16.47", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 357e818a..98cb2598 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,12 +7,17 @@ import type { User } from "@/types/user"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useUserManagement } from "./useUserManagement"; +import { getUngradedFrqsCollectionRef } from "@/lib/firestore/frqRefs"; import apClassesData from "@/components/apClasses.json"; import { useUser } from "../../components/hooks/UserContext"; import Link from "next/link"; import { cn, formatSlug } from "@/lib/utils"; import { Ban, ClipboardPen, PencilRuler, ShieldUser, X } from "lucide-react"; -import { doc, updateDoc } from "firebase/firestore"; +import { + doc, + getDocs, + updateDoc, +} from "firebase/firestore"; import { db } from "@/lib/firebase"; import { Button } from "@/components/ui/button"; @@ -22,6 +27,28 @@ const Page = () => { const { user } = useUser(); const router = useRouter(); + const [ungradedFrqCount, setUngradedFrqCount] = useState( + null, +); + +useEffect(() => { + if (!user || user.access !== "admin") return; + + const fetchUngradedFrqCount = async () => { + try { + const collectionRef = getUngradedFrqsCollectionRef(); + const snapshot = await getDocs(collectionRef); + + setUngradedFrqCount(snapshot.size); + } catch (error) { + console.error("Failed to fetch ungraded FRQ count:", error); + setUngradedFrqCount(null); + } + }; + + void fetchUngradedFrqCount(); +}, [user]); + if (!user) { return (
@@ -45,10 +72,31 @@ const Page = () => { {user.access === "admin" && ( <> + +
+
+

+ Ungraded FRQs +

+ +
+ + {ungradedFrqCount ?? "—"} + + + currently ungraded +
+
+ + + + +
)} -
)} diff --git a/src/app/admin/subject/[slug]/_components/unitFrqs.tsx b/src/app/admin/subject/[slug]/_components/unitFrqs.tsx new file mode 100644 index 00000000..810edbaf --- /dev/null +++ b/src/app/admin/subject/[slug]/_components/unitFrqs.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { memo, useState } from "react"; +import { Link } from "../../link"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { FRQTemplate } from "@/types/frq"; + +type UnitFrqsProps = { + unitId: string; + subjectSlug: string; + frqs: FRQTemplate[]; + onFrqAdd: (title: string) => void; + onFrqUpdate: (frqId: string, title: string) => void; + onFrqVisibilityChange: (frqId: string, isPublic: boolean) => void; +}; + +function UnitFrqs({ + unitId, + subjectSlug, + frqs, + onFrqAdd, + onFrqUpdate, + onFrqVisibilityChange, +}: UnitFrqsProps) { + const [newFrqTitle, setNewFrqTitle] = useState(""); + + const handleAddFrq = () => { + const title = newFrqTitle.trim(); + + if (!title) { + return; + } + + onFrqAdd(title); + setNewFrqTitle(""); + }; + + const handleRenameFrq = (frqId: string, currentTitle: string) => { + const updatedTitle = window.prompt("Enter new FRQ name", currentTitle)?.trim(); + + if (updatedTitle) { + onFrqUpdate(frqId, updatedTitle); + } + }; + + return ( +
+ {frqs.map((frq, index) => { + if (!frq.id) { + return null; + } + + return ( +
+
+ + + onFrqVisibilityChange(frq.id!, event.target.checked) + } + /> +
+ + + Edit FRQ {index + 1} + + +

+ handleRenameFrq(frq.id!, frq.title || "Untitled FRQ") + } + className="w-full cursor-pointer rounded-sm p-1.5 leading-none hover:bg-accent" + > + {frq.title || "Untitled FRQ"} +

+
+ ); + })} + +
+ setNewFrqTitle(event.target.value)} + placeholder="New FRQ name" + className="w-1/2" + /> + + +
+
+ ); +} + +export default memo(UnitFrqs); \ No newline at end of file diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index a51d4a71..f3ae5cca 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -5,11 +5,15 @@ import { Link } from "@/app/admin/subject/link"; import { ArrowLeft, Save, Plus } from "lucide-react"; import { useUser } from "@/components/hooks/UserContext"; import { db } from "@/lib/firebase"; +import type { FRQTemplate } from "@/types/frq"; import { collection, doc, getDoc, getDocs, + serverTimestamp, + setDoc, + updateDoc, writeBatch, } from "firebase/firestore"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -21,6 +25,10 @@ import short from "short-uuid"; import type { Subject, Unit } from "@/types/firestore"; import UnitComponent from "./_components/unit"; import { DEFAULT_PORTING_SUBJECT } from "@/lib/apPortingDefaults"; +import { + getFrqTemplateDocRef, + getFrqTemplatesCollectionRef, +} from "@/lib/firestore/frqRefs"; const translator = short(short.constants.flickrBase58); @@ -69,6 +77,7 @@ export default function Page({ params }: { params: { slug: string } }) { const { user, error, setError, setLoading } = useUser(); const [subjectTitle, setSubjectTitle] = useState(""); const [units, setUnits] = useState([]); + const [frqTemplates, setFrqTemplates] = useState([]); const [hasUnit0, setHasUnit0] = useState(false); const [resetting, setResetting] = useState(false); @@ -79,41 +88,77 @@ export default function Page({ params }: { params: { slug: string } }) { const [newUnitTitle, setNewUnitTitle] = useState(""); useEffect(() => { - (async () => { + const fetchSubject = async () => { try { - if (user && (user.access === "admin" || user.access === "member")) { - const docRef = doc(db, "subjects", params.slug); - const docSnap = await getDoc(docRef); - if (docSnap.exists()) { - // We have an existing subject - const fetched = docSnap.data() as Subject; - setSubjectTitle(fetched.title); - setUnits(fetched.units || []); - setHasUnit0(fetched.hasUnit0 ?? false); - } else { - // Subject not found -> create new with default template - // Also try to fill in subject title from `apClasses` if found - const foundTitle = - apClasses.find( - (apClass) => - formatSlug(apClass.replace(/AP /g, "")) === params.slug, - ) ?? ""; - const newSubject = structuredClone(emptyData); - newSubject.title = foundTitle; - setSubjectTitle(newSubject.title); - setUnits(newSubject.units); - } - setSubjectLoading(false); + if (!user || (user.access !== "admin" && user.access !== "member")) { + return; } - } catch (err) { - console.error(err); + + const subjectRef = doc(db, "subjects", params.slug); + const subjectSnapshot = await getDoc(subjectRef); + + if (subjectSnapshot.exists()) { + const fetchedSubject = subjectSnapshot.data() as Subject; + const fetchedUnits = fetchedSubject.units || []; + + setSubjectTitle(fetchedSubject.title); + setUnits(fetchedUnits); + setHasUnit0(fetchedSubject.hasUnit0 ?? false); + + const frqSnapshots = await Promise.all( + fetchedUnits.map(async (unit) => { + // One unreadable FRQ subcollection must not blank the whole + // subject editor; the units and chapters loaded fine. + try { + const snapshot = await getDocs( + getFrqTemplatesCollectionRef(params.slug, unit.id), + ); + + return snapshot.docs.map( + (frqDoc): FRQTemplate => ({ + id: frqDoc.id, + ...(frqDoc.data() as Omit), + subject: params.slug, + unitId: unit.id, + }), + ); + } catch (frqError) { + console.error( + `Unable to load FRQs for unit ${unit.id}:`, + frqError, + ); + + return []; + } + }), + ); + + setFrqTemplates(frqSnapshots.flat()); + } else { + const foundTitle = + apClasses.find( + (apClass) => + formatSlug(apClass.replace(/AP /g, "")) === params.slug, + ) ?? ""; + + const newSubject = structuredClone(emptyData); + newSubject.title = foundTitle; + + setSubjectTitle(newSubject.title); + setUnits(newSubject.units); + setFrqTemplates([]); + } + + setSubjectLoading(false); + } catch (error) { + console.error(error); setError("Failed to fetch subject data."); } finally { setLoading(false); } - })().catch((err) => { - console.error("Error fetching subject:", err); - }); + }; + + void fetchSubject(); }, [user, params.slug, setError, setLoading]); /**************************************************** @@ -170,6 +215,151 @@ export default function Page({ params }: { params: { slug: string } }) { setUnsavedChanges(true); }; + /**************************************************** + * FRQ ACTIONS + ****************************************************/ + +const handleAddFrq = async ( + unitId: string, + title: string, +): Promise => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + const frqId = generateShortId(); + + const newFrq: FRQTemplate = { + id: frqId, + subject: params.slug, + unitId, + title: trimmedTitle, + directions: "", + questions: [], + isPublic: false, + }; + + try { + await setDoc( + getFrqTemplateDocRef(params.slug, unitId, frqId), + { + subject: newFrq.subject, + unitId: newFrq.unitId, + title: newFrq.title, + directions: newFrq.directions, + questions: newFrq.questions, + isPublic: newFrq.isPublic, + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }, + ); + + setFrqTemplates((currentFrqs) => [ + ...currentFrqs, + newFrq, + ]); + + setUnsavedChanges(true); + } catch (error) { + // Discarding this error is what made a permission-denied rule look + // identical to a network blip, so the real cause never reached anyone. + console.error("Error adding FRQ:", error); + + alert( + error instanceof Error + ? `Unable to add the FRQ: ${error.message}` + : "Unable to add the FRQ. Please try again.", + ); + } +}; + +const handleRenameFrq = async ( + frqId: string, + title: string, +): Promise => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + const frq = frqTemplates.find((item) => item.id === frqId); + + if (!frq) { + alert("Unable to find the FRQ."); + return; + } + + try { + await updateDoc( + getFrqTemplateDocRef(params.slug, frq.unitId, frqId), + { + title: trimmedTitle, + updatedAt: serverTimestamp(), + }, + ); + + setFrqTemplates((currentFrqs) => + currentFrqs.map((item) => + item.id === frqId + ? { ...item, title: trimmedTitle } + : item, + ), + ); + + setUnsavedChanges(true); + } catch (error) { + console.error("Error renaming FRQ:", error); + + alert( + error instanceof Error + ? `Unable to rename the FRQ: ${error.message}` + : "Unable to rename the FRQ. Please try again.", + ); + } +}; + +const handleFrqVisibilityChange = async ( + frqId: string, + isPublic: boolean, +): Promise => { + const frq = frqTemplates.find((item) => item.id === frqId); + + if (!frq) { + alert("Unable to find the FRQ."); + return; + } + + try { + await updateDoc( + getFrqTemplateDocRef(params.slug, frq.unitId, frqId), + { + isPublic, + updatedAt: serverTimestamp(), + }, + ); + + setFrqTemplates((currentFrqs) => + currentFrqs.map((item) => + item.id === frqId + ? { ...item, isPublic } + : item, + ), + ); + + setUnsavedChanges(true); + } catch (error) { + console.error("Error updating FRQ visibility:", error); + + alert( + error instanceof Error + ? `Unable to update the FRQ visibility: ${error.message}` + : "Unable to update the FRQ visibility. Please try again.", + ); + } +}; /**************************************************** * SAVE ACTION * This function will force delete anything in the db that isnt in the local to keep db clean @@ -270,6 +460,38 @@ export default function Page({ params }: { params: { slug: string } }) { // 1. Save the main subject doc batch.set(doc(db, "subjects", params.slug), subjectToSave); + // 1b. Remove units that were deleted locally. The loop below only visits + // units that still exist, so a deleted unit previously kept its document + // and every chapter, test, and FRQ underneath it. The orphaned FRQs are + // the visible symptom: they stay readable at their old URL and keep + // appearing in the grading queue's template lookups. + const unitsCollectionRef = collection( + db, + "subjects", + params.slug, + "units", + ); + const existingUnitsSnap = await getDocs(unitsCollectionRef); + const localUnitIds = new Set(subjectToSave.units.map((u) => u.id)); + + for (const unitDoc of existingUnitsSnap.docs) { + if (localUnitIds.has(unitDoc.id)) { + continue; + } + + for (const subcollection of ["chapters", "tests", "frqs"]) { + const staleDocs = await getDocs( + collection(unitDoc.ref, subcollection), + ); + + staleDocs.forEach((staleDoc) => { + batch.delete(staleDoc.ref); + }); + } + + batch.delete(unitDoc.ref); + } + // 2. For each Unit, update or create the unit doc, then manage sub-collections for (const unit of subjectToSave.units) { // Set (upsert) the Unit itself @@ -483,7 +705,7 @@ export default function Page({ params }: { params: { slug: string } }) { {/* Render each Unit */}
- {units.map((unit, index) => ( + {units.map((unit, index) => ( frq.unitId === unit.id, + )} + onFrqAdd={handleAddFrq} + onFrqRename={handleRenameFrq} + onFrqVisibilityChange={handleFrqVisibilityChange} /> ))}
diff --git a/src/app/frq-feedback/[id]/page.tsx b/src/app/frq-feedback/[id]/page.tsx new file mode 100644 index 00000000..6d8cf3a4 --- /dev/null +++ b/src/app/frq-feedback/[id]/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import usePathname from "@/components/client/pathname"; +import FRQFeedbackRenderer from "@/components/frq/feedbackRenderer"; +import type { FRQFeedbackDocument } from "@/components/frq/feedback/types"; +import { + getFrqTemplateDocRef, + getGradedFrqDocRef, +} from "@/lib/firestore/frqRefs"; +import { buildFeedbackDocument } from "@/lib/frq/feedbackDocument"; +import { normalizeFrqTemplate } from "@/lib/frq/template"; +import type { GradedFRQSubmission } from "@/types/frq"; +import { getDoc } from "firebase/firestore"; +import { useEffect, useState } from "react"; + +const Page = () => { + const pathname = usePathname() ?? ""; + const frqId = pathname.split("/").at(-1) ?? ""; + + const [feedbackData, setFeedbackData] = useState( + null, + ); + const [overallFeedback, setOverallFeedback] = useState(""); + const [storedScore, setStoredScore] = useState(""); + const [hasPerPartGrades, setHasPerPartGrades] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + useEffect(() => { + const fetchFeedback = async () => { + setIsLoading(true); + setLoadError(null); + + if (!frqId) { + setLoadError("Feedback not found."); + setIsLoading(false); + return; + } + + try { + const gradedSnapshot = await getDoc(getGradedFrqDocRef(frqId)); + + if (!gradedSnapshot.exists()) { + setLoadError("Feedback not found."); + return; + } + + const graded = { + id: gradedSnapshot.id, + ...(gradedSnapshot.data() as Omit), + }; + + // The rubric tree lives on the template. Without it there is nothing to + // show the awarded points against, which is exactly why this page used + // to fall back to canned AP Human Geography content. + const templateSnapshot = await getDoc( + getFrqTemplateDocRef( + graded.subject, + graded.unitId, + graded.templateId, + ), + ); + + if (!templateSnapshot.exists()) { + setLoadError( + "The FRQ this grade belongs to no longer exists, so its feedback cannot be shown.", + ); + return; + } + + const template = normalizeFrqTemplate(templateSnapshot.data(), { + id: templateSnapshot.id, + subject: graded.subject, + unitId: graded.unitId, + }); + + setFeedbackData(buildFeedbackDocument(graded, template)); + setOverallFeedback(graded.feedback ?? ""); + setStoredScore(graded.score ?? ""); + setHasPerPartGrades( + Array.isArray(graded.grades) && graded.grades.length > 0, + ); + } catch (error: unknown) { + console.error("Error fetching FRQ feedback:", error); + + // An ownership-scoped rule denies reads of a document that is missing + // *or* belongs to someone else, and the client cannot tell which. Both + // are reported as not-found so the page never confirms that another + // student's grade exists. + const isDenied = + typeof error === "object" && + error !== null && + (error as { code?: string }).code === "permission-denied"; + + if (isDenied) { + setLoadError("Feedback not found."); + return; + } + + setLoadError( + error instanceof Error + ? `Could not load this feedback: ${error.message}` + : "Could not load this feedback.", + ); + } finally { + setIsLoading(false); + } + }; + + void fetchFeedback(); + }, [frqId]); + + if (isLoading) { + return
Loading...
; + } + + if (loadError !== null || feedbackData === null) { + return
{loadError ?? "Feedback not found."}
; + } + + return ( +
+ {!hasPerPartGrades && ( +
+ This submission was graded before per-question scores were recorded, + so the rubric below shows zero for every line. The grader's + recorded score was{" "} + + {storedScore.trim().length > 0 ? storedScore : "not recorded"} + + . +
+ )} + + +
+ ); +}; + +export default Page; diff --git a/src/app/frq-grading/[id]/page.tsx b/src/app/frq-grading/[id]/page.tsx new file mode 100644 index 00000000..c0be8e77 --- /dev/null +++ b/src/app/frq-grading/[id]/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import FRQGradingRenderer from "@/components/frq/gradingRenderer"; +import { useUser } from "@/components/hooks/UserContext"; +import { + getFrqTemplateDocRef, + getUngradedFrqDocRef, +} from "@/lib/firestore/frqRefs"; +import { normalizeFrqTemplate } from "@/lib/frq/template"; +import type { FRQTemplate, GradableFRQSubmission } from "@/types/frq"; +import { getDoc } from "firebase/firestore"; +import { useEffect, useState } from "react"; + +type PageProps = { + params: { + id: string; + }; +}; + +const Page = ({ params }: PageProps) => { + const { user, loading: userLoading } = useUser(); + + const [submission, setSubmission] = useState( + null, + ); + const [template, setTemplate] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const canGrade = user?.access === "admin" || user?.access === "grader"; + + useEffect(() => { + if (userLoading || !canGrade) { + if (!userLoading) { + setIsLoading(false); + } + return; + } + + const fetchSubmissionAndTemplate = async () => { + setIsLoading(true); + setLoadError(null); + + try { + const submissionSnapshot = await getDoc( + getUngradedFrqDocRef(params.id), + ); + + if (!submissionSnapshot.exists()) { + setLoadError( + "This submission is no longer in the queue. It may already have been graded.", + ); + return; + } + + const loadedSubmission = { + id: submissionSnapshot.id, + ...(submissionSnapshot.data() as Omit), + }; + + setSubmission(loadedSubmission); + + // The rubric being graded against lives on the template, not on the + // submission, so both have to be in hand before grading can start. + const templateSnapshot = await getDoc( + getFrqTemplateDocRef( + loadedSubmission.subject, + loadedSubmission.unitId, + loadedSubmission.templateId, + ), + ); + + setTemplate( + templateSnapshot.exists() + ? normalizeFrqTemplate(templateSnapshot.data(), { + id: templateSnapshot.id, + subject: loadedSubmission.subject, + unitId: loadedSubmission.unitId, + }) + : null, + ); + } catch (error) { + console.error("Error loading FRQ submission:", error); + + setLoadError( + error instanceof Error + ? `Could not load this submission: ${error.message}` + : "Could not load this submission.", + ); + } finally { + setIsLoading(false); + } + }; + + void fetchSubmissionAndTemplate(); + }, [params.id, userLoading, canGrade]); + + if (userLoading || isLoading) { + return
Loading...
; + } + + if (!canGrade) { + return ( +
+ You need grader or admin access to grade FRQ submissions. +
+ ); + } + + if (loadError) { + return
{loadError}
; + } + + return ; +}; + +export default Page; diff --git a/src/app/frq-grading/page.tsx b/src/app/frq-grading/page.tsx new file mode 100644 index 00000000..287cc688 --- /dev/null +++ b/src/app/frq-grading/page.tsx @@ -0,0 +1,480 @@ +"use client"; + +import Navbar from "@/components/global/navbar"; +import Footer from "@/components/global/footer"; +import Link from "next/link"; +import { useUser } from "@/components/hooks/UserContext"; +import { + getFrqTemplateDocRef, + getUngradedFrqDocRef, + getUngradedFrqsCollectionRef, +} from "@/lib/firestore/frqRefs"; +import { deleteDoc, getDoc, getCountFromServer, getDocs, limit, orderBy, query, startAfter, type DocumentData, type QueryDocumentSnapshot, Timestamp } from "firebase/firestore"; +import { useCallback, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Trash2, ChevronLeft, ChevronRight } from "lucide-react"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog" + +type UngradedFrqRow = { + id: string; + templateId: string; + studentId: string; + submittedAt: Timestamp | null; + responses: Record; + frqTitle: string; + subject: string; + unitId: string; + isMalformed: boolean; +}; + +const PAGE_SIZE = 60; + +type PageCursor = + QueryDocumentSnapshot | null; + +const Page = () => { + const { user, loading: userLoading } = useUser(); + + // Firestore rules already reject a non-grader's reads, but without this check + // the page renders a full, permanently empty queue and gives no hint why. + const canGrade = user?.access === "admin" || user?.access === "grader"; + + const [frqs, setFrqs] = useState(null); + + const [frqToDelete, setFrqToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const [totalCount, setTotalCount] = useState( + null, +); + + const [pageIndex, setPageIndex] = useState(0); + + const [pageCursors, setPageCursors] = useState([ + null, + ]); + + const [pageEndCursor, setPageEndCursor] = + useState(null); + + const [hasNextPage, setHasNextPage] = useState(false); + + const [isPageLoading, setIsPageLoading] = useState(false); + + + const fetchFrqs = useCallback(async (cursor: PageCursor) => { + setIsPageLoading(true); + try { + // `ungraded-frqs` is the collection the student test actually writes to. + // This page previously read `gradableFrqSubmissions`, a name from an + // earlier schema pass that nothing has written to since, so the queue + // read as permanently empty no matter how many tests were submitted. + const collectionRef = getUngradedFrqsCollectionRef(); + + const pageQuery = cursor + ? query( + collectionRef, + orderBy("submittedAt", "desc"), + startAfter(cursor), + limit(PAGE_SIZE + 1), + ) + : query( + collectionRef, + orderBy("submittedAt", "desc"), + limit(PAGE_SIZE + 1), + ); + + const snapshot = await getDocs(pageQuery); + const pageDocuments = snapshot.docs.slice(0, PAGE_SIZE); + + setHasNextPage(snapshot.docs.length > PAGE_SIZE); + setPageEndCursor(pageDocuments.at(-1) ?? null); + + const rows = await Promise.all( + pageDocuments.map(async (submissionDoc) => { + const rawData = submissionDoc.data(); + + const templateId = + typeof rawData.templateId === "string" ? rawData.templateId : ""; + + const studentId = + typeof rawData.studentId === "string" ? rawData.studentId : ""; + + const submittedAt = + rawData.submittedAt instanceof Timestamp + ? rawData.submittedAt + : null; + + const rawResponses: unknown = rawData.responses; + const hasValidResponses = + typeof rawResponses === "object" && + rawResponses !== null && + !Array.isArray(rawResponses); + + const responses: Record = hasValidResponses + ? Object.fromEntries( + Object.entries(rawResponses).filter( + (entry): entry is [string, string] => typeof entry[1]=="string" + ), + ) + : {}; + + // A submission records where its template lives, so the location no + // longer has to be recovered from a flat template collection. + const subject = + typeof rawData.subject === "string" ? rawData.subject : ""; + const unitId = + typeof rawData.unitId === "string" ? rawData.unitId : ""; + + let isMalformed = + templateId === "" || + studentId === "" || + subject === "" || + unitId === "" || + submittedAt === null || + !hasValidResponses; + + let frqTitle = "Unknown FRQ"; + + if (!isMalformed) { + try { + const templateSnapshot = await getDoc( + getFrqTemplateDocRef(subject, unitId, templateId), + ); + + if (templateSnapshot.exists()) { + const templateData = templateSnapshot.data(); + + if (typeof templateData.title === "string") { + frqTitle = templateData.title; + } else { + isMalformed = true; + } + } else { + isMalformed = true; + } + } catch (error) { + console.error( + `Unable to load template for submission ${submissionDoc.id}:`, + error, + ); + isMalformed = true; + } + } + + return { + id: submissionDoc.id, + templateId: templateId || "Missing", + studentId: studentId || "Unknown", + submittedAt, + responses, + isMalformed, + frqTitle, + subject: subject || "Unknown subject", + unitId: unitId || "Unknown unit", + }; + }), + ); + setFrqs(rows); + } catch (error) { + console.error("Error fetching ungraded FRQs:", error); + setFrqs([]); + } finally { + setIsPageLoading(false); + } + }, []); + + + useEffect(() => { + if (userLoading || !canGrade) { + return; + } + + const initializePage = async () => { + const countSnapshot = await getCountFromServer( + getUngradedFrqsCollectionRef(), + ); + + setTotalCount(countSnapshot.data().count); + await fetchFrqs(null); + }; + + initializePage().catch((error) => { + console.error( + "Error initializing ungraded FRQs:", + error, + ); + setFrqs([]); + }); + }, [fetchFrqs, userLoading, canGrade]); + + +const handleNextPage = async () => { + if ( + !hasNextPage || + !pageEndCursor || + isPageLoading + ) { + return; + } + + const nextPageIndex = pageIndex + 1; + + setPageCursors((currentCursors) => { + const updatedCursors = [...currentCursors]; + updatedCursors[nextPageIndex] = pageEndCursor; + return updatedCursors; + }); + + setPageIndex(nextPageIndex); + await fetchFrqs(pageEndCursor); +}; + +const handlePreviousPage = async () => { + if (pageIndex === 0 || isPageLoading) { + return; + } + + const previousPageIndex = pageIndex - 1; + const previousPageCursor = + pageCursors[previousPageIndex] ?? null; + + setPageIndex(previousPageIndex); + await fetchFrqs(previousPageCursor); +}; + + + const handleDelete = async () => { + if (!frqToDelete) return; + + setIsDeleting(true); + + try { + await deleteDoc(getUngradedFrqDocRef(frqToDelete.id)); + + setFrqs((currentFrqs) => + currentFrqs + ? currentFrqs.filter( + (frq) => frq.id !== frqToDelete.id, + ) + : currentFrqs, + ); + + setFrqToDelete(null); + setTotalCount((currentCount) => + currentCount === null + ? null + : Math.max(currentCount - 1, 0), + ); + + } catch (error) { + console.error("Error deleting ungraded FRQ:", + error, + ); + + window.alert( + "Unable to delete this FRQ. Please try again.", + ); + } finally { + setIsDeleting(false); + } + }; + + if (userLoading) { + return
Loading...
; + } + + if (!canGrade) { + return ( +
+ +
+

Grader access required

+

+ Ask an admin to grant your account grader access to review FRQ + submissions. +

+
+
+
+ ); + } + + if (frqs === null) { + return
Loading...
; + } + + return ( +
+ +
+ + +
+

+ Ungraded FRQs +

+ +

+ ({totalCount?.toLocaleString() ?? "—"}) +

+
+ + {frqs.length === 0 ? ( +
+

No ungraded FRQs found.

+
+ ) : ( +
+
+ + + + + + + + + + + + + + + {frqs.map((frq) => ( + + + + + + + + + + + ))} + +
FRQSubmission IDTest TakerSubmittedResponsesActions
+

{frq.frqTitle}

+ {frq.isMalformed && ( +

+ Malformed submission +

+ )} +

+ FRQ ID: {frq.templateId} +

+ +

+ {frq.subject} · {frq.unitId} +

+
+ {frq.id} + + {frq.studentId} + + {frq.submittedAt ? frq.submittedAt.toDate().toLocaleString() : "Unknown"} + + {Object.keys(frq.responses).length} + +
+ {frq.isMalformed ? ( + + ) : ( + + )} + +
+
+
+
+ )} + +
+ + +

+ Page {pageIndex + 1} +

+ + +
+
+ + { + if (!open && !isDeleting) { + setFrqToDelete(null); + } + }} + > + + + Are you sure? + + + This will permanently delete the ungraded submission + {frqToDelete ? ` "${frqToDelete.frqTitle}" from ${frqToDelete.studentId}` : ""}. This action cannot be undone. + + + + + + + + + + + +
+
+); +}; + +export default Page; diff --git a/src/app/subject/[slug]/(no-sidebar)/[unit]/frq/[id]/page.tsx b/src/app/subject/[slug]/(no-sidebar)/[unit]/frq/[id]/page.tsx new file mode 100644 index 00000000..b0112f10 --- /dev/null +++ b/src/app/subject/[slug]/(no-sidebar)/[unit]/frq/[id]/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import usePathname from "@/components/client/pathname"; +import FRQTestRenderer from "@/components/frq/testRenderer"; +import { getFrqTemplateDocRef } from "@/lib/firestore/frqRefs"; +import { normalizeFrqTemplate } from "@/lib/frq/template"; +import type { FRQTemplate } from "@/types/frq"; +import { getDoc } from "firebase/firestore"; +import { useEffect, useState } from "react"; + +/** + * The unit segment is built as `unit-{displayNumber}-{unitId}`, so the id is + * everything after the second dash rather than the last dash-delimited chunk — + * that keeps working if a unit id ever contains a dash. + */ +const parseUnitId = (unitSegment: string | undefined) => { + const parts = (unitSegment ?? "").split("-"); + + return parts.length > 2 ? parts.slice(2).join("-") : ""; +}; + +const Page = () => { + const pathname = usePathname(); + + const pathParts = pathname.split("/").filter(Boolean).slice(-4); + const subject = pathParts[0] ?? ""; + const unitId = parseUnitId(pathParts[1]); + const frqId = pathParts[3] ?? ""; + + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchFRQ = async () => { + setLoading(true); + setError(null); + setTemplate(null); + + if (!subject || !unitId || !frqId) { + setError("This FRQ address is not valid."); + setLoading(false); + return; + } + + try { + const docSnap = await getDoc( + getFrqTemplateDocRef(subject, unitId, frqId), + ); + + if (!docSnap.exists()) { + setError("FRQ not found."); + return; + } + + setTemplate( + normalizeFrqTemplate(docSnap.data(), { + id: docSnap.id, + subject, + unitId, + }), + ); + } catch (fetchError: unknown) { + console.error("Error fetching FRQ data:", fetchError); + + setError( + fetchError instanceof Error + ? `Error fetching FRQ data: ${fetchError.message}` + : "Error fetching FRQ data.", + ); + } finally { + setLoading(false); + } + }; + + void fetchFRQ(); + }, [subject, unitId, frqId]); + + return ( + + ); +}; + +export default Page; diff --git a/src/app/subject/[slug]/(sidebar)/layout.tsx b/src/app/subject/[slug]/(sidebar)/layout.tsx index dd37cfa0..3767358b 100644 --- a/src/app/subject/[slug]/(sidebar)/layout.tsx +++ b/src/app/subject/[slug]/(sidebar)/layout.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import { type Subject } from "@/types/firestore"; import { db } from "@/lib/firebase"; -import { doc, getDoc } from "firebase/firestore"; +import { collection, doc, getDoc, getDocs, query, where } from "firebase/firestore"; import { useUser } from "@/components/hooks/UserContext"; export default function Layout({ @@ -35,7 +35,51 @@ export default function Layout({ const docRef = doc(db, "subjects", params.slug); const docSnap = await getDoc(docRef); if (docSnap.exists()) { - setSubject(docSnap.data() as Subject); + const subjectData = docSnap.data() as Subject; + + const canPreview = + user?.access === "admin" || user?.access === "member"; + + // The sidebar is the only navigation on chapter and test pages, so it + // needs the unit's FRQs too — otherwise an FRQ is reachable only from + // the subject landing page. + const unitsWithFrqs = await Promise.all( + subjectData.units.map(async (unit) => { + try { + const frqsCollectionRef = collection( + db, + "subjects", + params.slug, + "units", + unit.id, + "frqs", + ); + + const frqsSnapshot = await getDocs( + canPreview + ? frqsCollectionRef + : query(frqsCollectionRef, where("isPublic", "==", true)), + ); + + return { + ...unit, + frqs: frqsSnapshot.docs.map((frqDoc) => ({ + ...frqDoc.data(), + id: frqDoc.id, + })), + }; + } catch (frqError) { + console.error( + `Unable to load FRQs for unit ${unit.id}:`, + frqError, + ); + + return { ...unit, frqs: [] }; + } + }), + ); + + setSubject({ ...subjectData, units: unitsWithFrqs }); } } catch (error) { console.error("Error fetching subject data:", error); diff --git a/src/app/subject/[slug]/(sidebar)/page.tsx b/src/app/subject/[slug]/(sidebar)/page.tsx index a317dd00..94717080 100644 --- a/src/app/subject/[slug]/(sidebar)/page.tsx +++ b/src/app/subject/[slug]/(sidebar)/page.tsx @@ -8,9 +8,16 @@ import SubjectBreadcrumb from "@/components/subject/subject-breadcrumb"; import TableOfContents from "@/components/subject/table-of-contents"; import UnitAccordion from "@/components/subject/unit-accordion"; import { useEffect, useState } from "react"; -import { doc, getDoc } from "firebase/firestore"; import usePathname from "@/components/client/pathname"; import { useUser } from "@/components/hooks/UserContext"; +import { + collection, + doc, + getDoc, + getDocs, + query, + where, +} from "firebase/firestore"; const Page = ({ params }: { params: { slug: string } }) => { const pathname = usePathname(); @@ -23,7 +30,11 @@ const Page = ({ params }: { params: { slug: string } }) => { useEffect(() => { const fetchSubject = async () => { try { - const isAuthorized = user && (user.access === "admin" || user.access === "member" || user.access === "grader"); + const isAuthorized = + user && + (user.access === "admin" || + user.access === "member" || + user.access === "grader"); if (params.slug === "porting" && !isAuthorized) { setError("Subject not found. That's probably us, not you."); setLoading(false); @@ -32,7 +43,60 @@ const Page = ({ params }: { params: { slug: string } }) => { const docRef = doc(db, "subjects", params.slug); const docSnap = await getDoc(docRef); if (docSnap.exists()) { - setSubject(docSnap.data() as Subject); + const subjectData = docSnap.data() as Subject; + + const canPreview = + user?.access === "admin" || user?.access === "member"; + + const unitsWithFrqs = await Promise.all( + subjectData.units.map(async (unit) => { + // FRQs are supplementary to the curriculum. A failure here — a + // rules change that has not been deployed, an offline read — + // must not take down the whole subject page, which is what an + // unguarded rejection inside Promise.all did for every visitor. + try { + const frqsCollectionRef = collection( + db, + "subjects", + params.slug, + "units", + unit.id, + "frqs", + ); + + const frqsQuery = canPreview + ? frqsCollectionRef + : query(frqsCollectionRef, where("isPublic", "==", true)); + + const frqsSnapshot = await getDocs(frqsQuery); + + const frqs = frqsSnapshot.docs.map((frqDoc) => ({ + ...frqDoc.data(), + id: frqDoc.id, + })); + + return { + ...unit, + frqs, + }; + } catch (frqError) { + console.error( + `Unable to load FRQs for unit ${unit.id}:`, + frqError, + ); + + return { + ...unit, + frqs: [], + }; + } + }), + ); + + setSubject({ + ...subjectData, + units: unitsWithFrqs, + }); } else { setError("Subject not found. That's probably us, not you."); } diff --git a/src/components/client/pathname.ts b/src/components/client/pathname.ts index 2d880776..e796c507 100644 --- a/src/components/client/pathname.ts +++ b/src/components/client/pathname.ts @@ -1,34 +1,7 @@ -import { useState } from "react"; +"use client"; -function usePathname() { - const [pathname, setPathname] = useState(window.location.pathname); +import { usePathname as useNextPathname } from "next/navigation"; - // Add event listeners directly - const handlePathChange = () => { - setPathname(window.location.pathname); - }; - - // Initial path setup and event listeners for navigation events - if (typeof window !== "undefined") { - window.addEventListener("popstate", handlePathChange); - window.addEventListener("pushstate", handlePathChange); - - // Clean up by overriding these methods if the hook will be used across multiple components - const originalPushState = history.pushState.bind(history); - const originalReplaceState = history.replaceState.bind(history); - - history.pushState = function (...args) { - originalPushState.apply(this, args); - handlePathChange(); - }; - - history.replaceState = function (...args) { - originalReplaceState.apply(this, args); - handlePathChange(); - }; - } - - return pathname; -} - -export default usePathname; +export default function usePathname() { + return useNextPathname(); +} \ No newline at end of file diff --git a/src/components/frq/FRQDropdown.tsx b/src/components/frq/FRQDropdown.tsx new file mode 100644 index 00000000..53929c25 --- /dev/null +++ b/src/components/frq/FRQDropdown.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { ChevronUp, MapPin, X } from "lucide-react"; +import { useState } from "react"; +import type { ReactNode } from "react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +export interface FRQDropdownProps { + testName: string; + currentFrqIndex: number; + totalFrqs: number; + onJumpToFrq: (index: number) => void; + leftWidget?: ReactNode; +} + +export default function FRQDropdown({ + testName, + currentFrqIndex, + totalFrqs, + onJumpToFrq, + leftWidget, +}: FRQDropdownProps) { + const [navigationOpen, setNavigationOpen] = useState(false); + + const jumpToFrq = (index: number) => { + onJumpToFrq(index); + setNavigationOpen(false); + }; + + return ( +
+ {leftWidget} + + + + Question {currentFrqIndex + 1} of {totalFrqs} + + + + +
+
+

+ {testName} Questions +

+ + +
+ +
+ +
+
+ {Array.from({ length: totalFrqs }).map((_, index) => ( + + ))} +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/frq/FRQFooter.tsx b/src/components/frq/FRQFooter.tsx new file mode 100644 index 00000000..9e8fff50 --- /dev/null +++ b/src/components/frq/FRQFooter.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import FRQDropdown from "@/components/frq/FRQDropdown"; + +interface FooterProps { + testName: string; + currentFrqIndex: number; + totalFrqs: number; + onPrevious: () => void; + onNext: () => void; + onJumpToFrq: (index: number) => void; +} + +export default function Footer({ + testName, + currentFrqIndex, + totalFrqs, + onPrevious, + onNext, + onJumpToFrq, +}: FooterProps) { + return ( +
+

{testName}

+ +
+ + + + + +
+ +
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/frq/editorFooter.tsx b/src/components/frq/editorFooter.tsx new file mode 100644 index 00000000..cbd2f077 --- /dev/null +++ b/src/components/frq/editorFooter.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ChevronUp } from "lucide-react"; +import { useState } from "react"; + +type FRQVisibility = "public" | "private"; + +interface FRQPartSummary { + id: string; + label: string; +} + +interface FRQEditorFooterProps { + parts: FRQPartSummary[]; + frqName: string; + visibility: FRQVisibility; + hasUnsavedChanges: boolean; +} + +/** + * Status bar for the FRQ editor. Each FRQ is its own Firestore document, so + * this navigates the parts of the open FRQ rather than a batch of FRQs; adding + * and removing whole FRQs belongs to the admin subject page that owns the list. + */ +const FRQEditorFooter = ({ + parts, + frqName, + visibility, + hasUnsavedChanges, +}: FRQEditorFooterProps) => { + // Controlled so picking a part closes the panel instead of leaving it parked + // over the footer, matching frq/FRQFooter.tsx on the other FRQ pages. + const [navigationOpen, setNavigationOpen] = useState(false); + + const scrollToPart = (partId: string) => { + setNavigationOpen(false); + document + .querySelector(`[data-frq-part="${partId}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + return ( +
+
+

+ {frqName} +

+ +

+ Visibility:{" "} + {visibility} +

+
+ +
+ + + {parts.length} {parts.length === 1 ? "part" : "parts"} + + + + +

Jump to a part

+ + {parts.length === 0 ? ( +

+ This FRQ has no parts yet. +

+ ) : ( +
+ {parts.map((part) => ( + + ))} +
+ )} +
+
+
+ +
+ {hasUnsavedChanges ? ( + Unsaved changes + ) : ( + All changes saved + )} +
+
+ ); +}; + +export default FRQEditorFooter; diff --git a/src/components/frq/editorRenderer.tsx b/src/components/frq/editorRenderer.tsx index 0b93bd48..534b9ca7 100644 --- a/src/components/frq/editorRenderer.tsx +++ b/src/components/frq/editorRenderer.tsx @@ -1,15 +1,763 @@ +"use client"; + +import AdvancedTextbox from "@/components/article-creator/custom_questions/AdvancedTextbox"; +import FRQEditorFooter from "@/components/frq/editorFooter"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { getFrqTemplateDocRef } from "@/lib/firestore/frqRefs"; +import { + DEFAULT_TIME_LIMIT_MINUTES, + getPartLabel, + getQuestionPoints, + toQuestionInput, +} from "@/lib/frq/template"; +import type { QuestionFormat, QuestionInput } from "@/types/questions"; +import { serverTimestamp, updateDoc } from "firebase/firestore"; +import { Clock3, Info, Plus, Save, Trash2 } from "lucide-react"; +import type { + FRQAnswerType, + FRQGradingCriterion, + FRQQuestionStatus, + FRQTemplate, + FRQTemplateQuestion, +} from "@/types/frq"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +interface EditorQuestion { + id: string; + questionData: QuestionFormat; + status: FRQQuestionStatus; + answerType: FRQAnswerType; + criteria: FRQGradingCriterion[]; +} + interface FRQEditorRendererProps { frqFound: boolean; + frqTemplate: FRQTemplate | null; } +type SaveState = "idle" | "saving" | "saved" | "error"; + +const createQuestionInput = (value = ""): QuestionInput => ({ + value, + files: [], +}); + +/** + * Unique, immutable ID built from the current time plus a short random suffix. + * The random half is what makes it collision-safe: a timestamp alone repeats + * when several IDs are minted in the same millisecond. + */ +const makeId = (prefix: string) => + `${prefix}-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 8)}`; + +const formatPoints = (points: number) => + `${points} ${points === 1 ? "point" : "points"}`; + +const createQuestionData = ( + question = createQuestionInput(), +): QuestionFormat => ({ + question, + type: "frq", + options: [], + answers: [], + explanation: createQuestionInput(), + content: createQuestionInput(), + topic: "", +}); + +const createEditorQuestion = (): EditorQuestion => ({ + id: makeId("question"), + questionData: createQuestionData(), + status: "public", + answerType: "text", + criteria: [], +}); + +const createEditorQuestionFromTemplate = ( + templateQuestion: FRQTemplateQuestion, +): EditorQuestion => ({ + id: templateQuestion.id, + questionData: createQuestionData( + toQuestionInput(templateQuestion.prompt, templateQuestion.promptFiles), + ), + status: templateQuestion.status ?? "public", + answerType: templateQuestion.answerType ?? "text", + criteria: templateQuestion.criteria ?? [], +}); + +interface EditorState { + title: string; + description: QuestionInput; + questions: EditorQuestion[]; + timeLimitMinutes: number; + isPublic: boolean; +} + +const buildInitialState = (template: FRQTemplate | null): EditorState => ({ + title: template?.title ?? "", + description: toQuestionInput( + template?.directions, + template?.directionsFiles, + ), + questions: (template?.questions ?? []).map(createEditorQuestionFromTemplate), + timeLimitMinutes: template?.timeLimitMinutes ?? DEFAULT_TIME_LIMIT_MINUTES, + isPublic: template?.isPublic === true, +}); + +/** + * The exact document body a save writes, minus the server timestamp. Unsaved + * state is detected by comparing this against the last persisted version rather + * than by watching for state updates: React StrictMode double-invokes effects + * in development, and the rich-text children re-emit equal values on mount, so + * a "something changed" listener reported unsaved work before any edit. + */ +const buildTemplatePayload = (state: EditorState) => ({ + title: state.title.trim() || "Untitled FRQ", + directions: state.description.value, + directionsFiles: state.description.files, + timeLimitMinutes: state.timeLimitMinutes, + isPublic: state.isPublic, + questions: state.questions.map((question, index) => ({ + id: question.id, + title: getPartLabel(index), + prompt: question.questionData.question.value, + promptFiles: question.questionData.question.files, + answerType: question.answerType, + status: question.status, + criteria: question.criteria, + })), +}); + const FRQEditorRenderer = ({ frqFound, + frqTemplate, }: FRQEditorRendererProps) => { + const initialState = useRef(buildInitialState(frqTemplate)).current; + + const [title, setTitle] = useState(initialState.title); + const [description, setDescription] = useState( + initialState.description, + ); + const [questions, setQuestions] = useState( + initialState.questions, + ); + const [timeLimitMinutes, setTimeLimitMinutes] = useState( + initialState.timeLimitMinutes, + ); + const [isPublic, setIsPublic] = useState(initialState.isPublic); + + const [saveState, setSaveState] = useState("idle"); + const [saveError, setSaveError] = useState(null); + const [savedSignature, setSavedSignature] = useState(() => + JSON.stringify(buildTemplatePayload(initialState)), + ); + + const currentPayload = useMemo( + () => + buildTemplatePayload({ + title, + description, + questions, + timeLimitMinutes, + isPublic, + }), + [title, description, questions, timeLimitMinutes, isPublic], + ); + + const hasUnsavedChanges = JSON.stringify(currentPayload) !== savedSignature; + + useEffect(() => { + if (!hasUnsavedChanges) { + return; + } + + const warnBeforeLeaving = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", warnBeforeLeaving); + + return () => { + window.removeEventListener("beforeunload", warnBeforeLeaving); + }; + }, [hasUnsavedChanges]); + + const updateQuestion = ( + questionId: string, + updater: (question: EditorQuestion) => EditorQuestion, + ) => { + setQuestions((currentQuestions) => + currentQuestions.map((question) => + question.id === questionId ? updater(question) : question, + ), + ); + }; + + const addQuestion = () => { + setQuestions((currentQuestions) => [ + ...currentQuestions, + createEditorQuestion(), + ]); + }; + + const deleteQuestion = (questionId: string) => { + setQuestions((currentQuestions) => + currentQuestions.filter((question) => question.id !== questionId), + ); + }; + + const addCriterion = (questionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: [ + ...question.criteria, + { id: makeId("criterion"), description: "", points: 1 }, + ], + })); + }; + + const updateCriterion = ( + questionId: string, + criterionId: string, + changes: Partial>, + ) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.map((criterion) => + criterion.id === criterionId ? { ...criterion, ...changes } : criterion, + ), + })); + }; + + const deleteCriterion = (questionId: string, criterionId: string) => { + updateQuestion(questionId, (question) => ({ + ...question, + criteria: question.criteria.filter( + (criterion) => criterion.id !== criterionId, + ), + })); + }; + + const saveTemplate = useCallback(async () => { + if (!frqTemplate?.id) { + setSaveState("error"); + setSaveError("This FRQ has no document to save to."); + return; + } + + setSaveState("saving"); + setSaveError(null); + + try { + await updateDoc( + getFrqTemplateDocRef( + frqTemplate.subject, + frqTemplate.unitId, + frqTemplate.id, + ), + { ...currentPayload, updatedAt: serverTimestamp() }, + ); + + // Re-baseline against exactly what was written, so an edit made while the + // save was in flight still registers as unsaved. + setSavedSignature(JSON.stringify(currentPayload)); + setSaveState("saved"); + } catch (error) { + // The previous version swallowed this entirely, which is why a + // permission-denied rule looked identical to a successful save. + console.error("Error saving FRQ template:", error); + + setSaveState("error"); + setSaveError( + error instanceof Error + ? error.message + : "Unknown error while saving this FRQ.", + ); + } + }, [currentPayload, frqTemplate]); + + // AdvancedTextbox treats its `questions[qIndex]` entry as a stable reference, + // so these must keep identity across unrelated re-renders instead of being + // rebuilt fresh. Both memos sit above the early return so hooks stay + // unconditional. + const descriptionQuestions = useMemo( + () => [createQuestionData(description)], + [description], + ); + + const questionFormats = useMemo( + () => questions.map((question) => question.questionData), + [questions], + ); + + const totalPoints = questions.reduce( + (total, question) => + total + + getQuestionPoints({ + id: question.id, + title: "", + criteria: question.criteria, + }), + 0, + ); + + if (!frqFound || !frqTemplate) { + return
Failed to load FRQ.
; + } + return ( -
- {frqFound - ? "FRQ loaded successfully." - : "Failed to load FRQ."} +
+
+
+ + {formatPoints(totalPoints)} total + +
+ +
+ + + + setTimeLimitMinutes(Math.max(1, Number(event.target.value) || 1)) + } + className="h-9 w-20" + /> + minutes +
+ +
+ {saveState === "saved" && !hasUnsavedChanges && ( + Saved + )} + + {saveState === "error" && ( + + {saveError} + + )} + + +
+
+ +
+
+
+
+ + setTitle(event.target.value)} + className="mt-2 max-w-md text-base font-semibold" + /> + +
+ setIsPublic(event.target.checked)} + /> + +
+
+ +
+

FRQ Description

+

+ Add the source material and directions students need for this + FRQ. This is what students see beside the response box. +

+ +
+ { + const updatedDescription = updatedQuestions[0]?.question; + + if (!updatedDescription) { + return; + } + + setDescription(updatedDescription); + }} + origin="question" + qIndex={0} + placeholder="Enter the FRQ description here." + /> +
+
+
+ +
+
+
+

Questions

+

+ {questions.length} questions in this FRQ +

+
+ + +
+ + question.id)} + className="space-y-4" + > + {questions.map((question, questionIndex) => ( + + +
+ + + {getPartLabel(questionIndex)} + + + {question.status === "legacy" && ( + + Legacy + + )} + + + {formatPoints( + getQuestionPoints({ + id: question.id, + title: "", + criteria: question.criteria, + }), + )} + +
+
+ + {/* ui/accordion.tsx hardcodes opacity-70 on the content root + and routes className to an inner div, so there is no + class-based override. Without this the editor's inputs all + render washed out. Remove once accordion.tsx exposes it. */} + +
+ +
+ + updateQuestion(question.id, (currentQuestion) => ({ + ...currentQuestion, + questionData: + updatedQuestions[questionIndex] ?? + currentQuestion.questionData, + })) + } + origin="question" + qIndex={questionIndex} + placeholder="Enter the question prompt here." + /> +
+
+ + {/* No question-level points field: the total is derived from + the grading criteria below, which is what the grader + actually awards against. */} +
+
+ + + + + + + { + if (value !== "text" && value !== "equation") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + answerType: value, + }), + ); + }} + > + + Text + + + Equation + + + + +
+ +
+ + + + + + + { + if (value !== "public" && value !== "legacy") { + return; + } + + updateQuestion( + question.id, + (currentQuestion) => ({ + ...currentQuestion, + status: value, + }), + ); + }} + > + + Public + + + Legacy + + + + +
+
+ +
+ + + + + + + + + A question's point total is calculated from its + grading criteria. Graders award points against these + exact lines. + + + + +
+ +
+

+ Grading Criteria +

+ + {question.criteria.length === 0 ? ( +

+ No grading criteria have been added. A question with + no criteria is worth zero points. +

+ ) : ( + question.criteria.map((criterion, criterionIndex) => ( +
+
+ +