diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/App.css b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css index 19a444c17..8a820632d 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css @@ -9,6 +9,14 @@ html, body, #root { margin: auto; } +.appGradientBg { + background: + radial-gradient(1200px 500px at 10% -10%, rgba(99, 102, 241, 0.18), transparent 60%), + radial-gradient(1000px 400px at 110% 20%, rgba(236, 72, 153, 0.14), transparent 60%), + linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%); + background-attachment: local; +} + .App-logo { height: 40vmin; pointer-events: none; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx index 114f9eda6..94ec49094 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx @@ -63,10 +63,10 @@ function OldAssignmentBuilder() { ); } -function AssignmentGrader() { +function AssignmentGraderOld() { return (
-

Assignment Grader

+

Assignment Grader (legacy)

@@ -89,6 +89,7 @@ function AppContent() { >
+ async lazy() { + const { Grader } = await import("@components/routes/Grader"); + return { Component: Grader }; + }, + children: [ + { + index: true, + async lazy() { + const { GraderAssignmentsPage } = await import( + "@components/routes/Grader" + ); + return { Component: GraderAssignmentsPage }; + } + }, + { + path: ":assignmentId", + async lazy() { + const { GraderQuestionsPage } = await import( + "@components/routes/Grader" + ); + return { Component: GraderQuestionsPage }; + } + }, + { + path: ":assignmentId/questions/:questionId", + async lazy() { + const { GraderQuestionPage } = await import( + "@components/routes/Grader" + ); + return { Component: GraderQuestionPage }; + } + }, + { + path: ":assignmentId/questions/:questionId/students/:sid", + async lazy() { + const { GraderQuestionPage } = await import( + "@components/routes/Grader" + ); + return { Component: GraderQuestionPage }; + } + }, + { + path: ":assignmentId/questions/:questionId/:sid", + async lazy() { + const { GraderQuestionPage } = await import( + "@components/routes/Grader" + ); + return { Component: GraderQuestionPage }; + } + } + ] + }, + { + path: "graderOld", + element: }, { path: "admin", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js b/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js index 2c62de185..c2ce98665 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js @@ -99,43 +99,49 @@ export async function renderRunestoneComponent( window.componentMap[res.divid] = res; } } - // add a button to the preview to allow the user to flag this compenent for review - let flagButton = document.createElement("button"); - console.log("res", res); - flagButton.classList.add("flag-for-review"); - flagButton.textContent = "Flag for Review"; - flagButton.addEventListener("click", async function () { - let data = { - question_name: res.divid || res.selector_id || res.origOpts.orig.id - }; - if (!data.question_name) { - alert("Error: Cannot determine question name to flag for review"); - return; - } - console.log("Flagging question for review", data.question_name); - // Send a POST request to the server to flag the question - let response = await fetch("/admin/instructor/flag_question", { - method: "POST", - body: JSON.stringify(data), - headers: { - "Content-Type": "application/json" + if (moreOpts.graderMode) { + window.componentMap = window.componentMap || {}; + let key = moreOpts.gradingContainer + ? `${moreOpts.gradingContainer} ${res.divid}` + : res.divid; + window.componentMap[key] = res; + } + if (!moreOpts.suppressFlagForReview && !moreOpts.graderMode) { + let flagButton = document.createElement("button"); + flagButton.classList.add("flag-for-review"); + flagButton.textContent = "Flag for Review"; + flagButton.addEventListener("click", async function () { + let data = { + question_name: res.divid || res.selector_id || res.origOpts.orig.id + }; + if (!data.question_name) { + alert("Error: Cannot determine question name to flag for review"); + return; } - }); - if (response.ok) { - let resp = await response.json(); - if (resp.success === true) { - flagButton.textContent = "Question Flagged"; - flagButton.style.backgroundColor = "#22c55e"; - flagButton.style.color = "white"; - flagButton.disabled = true; + console.log("Flagging question for review", data.question_name); + let response = await fetch("/admin/instructor/flag_question", { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json" + } + }); + if (response.ok) { + let resp = await response.json(); + if (resp.success === true) { + flagButton.textContent = "Question Flagged"; + flagButton.style.backgroundColor = "#22c55e"; + flagButton.style.color = "white"; + flagButton.disabled = true; + } else { + alert("Error: " + resp.message); + } } else { - alert("Error: " + resp.message); + alert("Error: " + response.statusText); } - } else { - alert("Error: " + response.statusText); - } - }); - previewRef.current.appendChild(flagButton); + }); + previewRef.current.appendChild(flagButton); + } } catch (e) { console.log(e); previewRef.current.innerHTML = `

An error occurred while trying to render a ${componentKind}

`; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css new file mode 100644 index 000000000..0a6a40dc0 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css @@ -0,0 +1,487 @@ + + +.graderShell { + min-height: 100%; + padding: 1.25rem 1.5rem 2.5rem; + +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.65rem; + font-weight: 700; + color: #3730a3; + letter-spacing: -0.02em; +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 0.5rem; + color: #475569; + font-size: 0.9rem; + margin-bottom: 1rem; +} +.breadcrumb a { + color: #4f46e5; + text-decoration: none; +} +.breadcrumb a:hover { text-decoration: underline; } + +@keyframes shimmer { + 0% { background-position: 0 50%; } + 100% { background-position: 200% 50%; } +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.card { + position: relative; + padding: 1rem 1.1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(8px); + border: 1px solid rgba(148, 163, 184, 0.22); + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06); + cursor: pointer; + transition: + transform 0.25s cubic-bezier(.2,.8,.2,1), + box-shadow 0.25s ease, + border-color 0.25s ease; + overflow: hidden; + animation: cardIn 0.45s cubic-bezier(.2,.8,.2,1) both; +} +.card::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(140deg, rgba(99,102,241,0.08), rgba(236,72,153,0.06)); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} +.card:hover { + transform: translateY(-4px); + border-color: rgba(99, 102, 241, 0.45); + box-shadow: 0 14px 30px rgba(79, 70, 229, 0.15); +} +.card:hover::before { opacity: 1; } + +@keyframes cardIn { + from { opacity: 0; transform: translateY(8px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.cardTitle { + font-weight: 650; + font-size: 1.05rem; + color: #0f172a; + margin-bottom: 0.35rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.qTypeChip { + display: inline-flex; + align-items: center; + padding: 0.08rem 0.4rem; + border-radius: 999px; + font-size: 0.6rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: white; + background: linear-gradient(95deg, #6366f1, #8b5cf6); + box-shadow: 0 2px 8px rgba(99,102,241,0.25); +} +.qTypeChip.mchoice { background: linear-gradient(95deg,#2563eb,#06b6d4); } +.qTypeChip.fillintheblank { background: linear-gradient(95deg,#10b981,#059669); } +.qTypeChip.parsonsprob { background: linear-gradient(95deg,#f97316,#ef4444); } +.qTypeChip.activecode { background: linear-gradient(95deg,#8b5cf6,#ec4899); } +.qTypeChip.shortanswer { background: linear-gradient(95deg,#0ea5e9,#6366f1); } +.qTypeChip.clickablearea { background: linear-gradient(95deg,#f59e0b,#d97706); } +.qTypeChip.dragndrop { background: linear-gradient(95deg,#14b8a6,#0d9488); } +.qTypeChip.matching { background: linear-gradient(95deg,#a855f7,#7c3aed); } +.qTypeChip.webwork { background: linear-gradient(95deg,#db2777,#be185d); } + +.metaRow { + display: flex; + justify-content: space-between; + gap: 0.4rem; + font-size: 0.83rem; + color: #475569; + margin-top: 0.4rem; +} +.metaRow strong { color: #0f172a; font-weight: 600; } + +.progress { + height: 6px; + border-radius: 999px; + background: #e2e8f0; + overflow: hidden; + margin-top: 0.75rem; +} +.progressBar { + height: 100%; + background: linear-gradient(95deg, #6366f1, #ec4899); + transition: width 0.6s cubic-bezier(.2,.8,.2,1); +} + +.emptyState { + padding: 3rem 1rem; + text-align: center; + color: #64748b; + border: 1px dashed #cbd5e1; + border-radius: 16px; + background: rgba(255,255,255,0.6); +} + +.tableWrap { + background: rgba(255,255,255,0.85); + border: 1px solid rgba(148,163,184,0.22); + border-radius: 18px; + overflow: hidden; + box-shadow: 0 6px 22px rgba(15,23,42,0.06); + animation: cardIn 0.4s both; +} +.tableWrap :global(.p-datatable-thead > tr > th) { + background: linear-gradient(180deg, #eef2ff, #f8fafc); + font-weight: 700; + color: #3730a3; +} + +.compactToggle :global(.p-selectbutton .p-button) { + padding: 0.25rem 0.6rem; + font-size: 0.78rem; + line-height: 1.1; +} +.compactToggle :global(.p-selectbutton .p-button .pi) { + font-size: 0.78rem; +} + +.tableWrap :global(.p-datatable-thead > tr > th) { + padding: 0.4rem 0.6rem; + font-size: 0.78rem; + line-height: 1.15; +} +.tableWrap :global(.p-datatable-thead .p-sortable-column-icon) { + font-size: 0.75rem; + margin-left: 0.25rem; +} + +.tableWrap :global(.p-datatable-thead > tr.p-filter-row > th) { + padding: 0.25rem 0.5rem; +} +.tableWrap :global(.p-column-filter-element .p-inputtext), +.tableWrap :global(.p-column-filter-element .p-dropdown), +.tableWrap :global(.p-column-filter .p-inputtext) { + padding: 0.2rem 0.45rem; + font-size: 0.75rem; + min-height: 0; + height: 26px; +} +.tableWrap :global(.p-column-filter-element .p-dropdown .p-dropdown-label) { + padding: 0.2rem 0.45rem; + font-size: 0.75rem; + line-height: 1.1; +} +.tableWrap :global(.p-column-filter-element .p-dropdown .p-dropdown-trigger) { + width: 1.75rem; +} +.tableWrap :global(.p-column-filter-clear-button), +.tableWrap :global(.p-column-filter-menu-button) { + width: 1.5rem; + height: 1.5rem; +} + +.tableWrap :global(.p-datatable-tbody > tr > td) { + padding: 0.45rem 0.6rem; +} + +.dateFilterWrapper { + width: 100%; + display: block; +} +.dateFilterWrapper :global(.react-datepicker__input-container) { + display: block; + width: 100%; +} +.dateFilterInput { + width: 100%; + height: 26px; + padding: 0.2rem 1.4rem 0.2rem 0.45rem; + font-size: 0.75rem; + line-height: 1.1; + color: #0f172a; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 6px; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} +.dateFilterInput:focus { + border-color: #6366f1; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); +} +.dateFilterInput::placeholder { color: #94a3b8; } + +.dateFilterWrapper :global(.react-datepicker__close-icon) { + padding: 0 6px 0 0; +} +.dateFilterWrapper :global(.react-datepicker__close-icon::after) { + background: #94a3b8; + font-size: 11px; + height: 14px; + width: 14px; + line-height: 14px; + padding: 0; +} + +.dateFilterPopper { + z-index: 9999; +} + +.row :global(tr):hover { background: rgba(99,102,241,0.06) !important; } + +.chip { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} +.chipCorrect { background: #dcfce7; color: #166534; } +.chipWrong { background: #fee2e2; color: #991b1b; } +.chipPartial { background: #fef3c7; color: #92400e; } +.chipUnknown { background: #e2e8f0; color: #475569; } + +.floatBtn { + position: fixed; + right: 1.25rem; + bottom: 1.25rem; + z-index: 900; +} + +.dialogBody { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); + gap: 1rem; +} +@media (max-width: 900px) { + .dialogBody { grid-template-columns: 1fr; } +} + +.previewPane { + background: #fff; + border-radius: 14px; + border: 1px solid #e2e8f0; + padding: 1rem; + max-height: 70vh; + overflow: auto; +} +.gradePane { + background: linear-gradient(180deg, #f8fafc, #eef2ff); + border-radius: 14px; + border: 1px solid #e2e8f0; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + max-height: 100%; + overflow-y: auto; +} + +.historyItem { + padding: 0.55rem 0.75rem; + border-radius: 10px; + border: 1px solid #e2e8f0; + margin-bottom: 0.4rem; + background: #fff; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-word; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.historyItem:hover { border-color: #818cf8; background: #eef2ff; } +.historyItem.active { border-color: #4f46e5; background: #e0e7ff; } + +.splitPane { + display: flex; + gap: 1rem; + align-items: stretch; + animation: cardIn 0.4s both; + + height: calc(100vh - 200px); + min-height: 520px; +} + +.splitMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.splitGrid { + flex: 1; + display: grid; + + grid-template-columns: minmax(0, 1fr) 280px; + gap: 1rem; + align-items: stretch; + min-height: 0; +} +@media (max-width: 1100px) { + .splitPane { + flex-direction: column; + height: auto; + } + .splitGrid { grid-template-columns: 1fr; } +} + +.submissionPane { + display: flex; + flex-direction: column; + background: #fff; + border-radius: 14px; + border: 1px solid #e2e8f0; + padding: 0.85rem 1rem 1rem; + min-width: 0; + min-height: 0; + overflow: hidden; +} +.submissionRenderer { + flex: 1; + min-height: 0; + overflow: auto; + padding-top: 0.5rem; +} + +.attemptBar { + flex: 0 0 auto; + border-bottom: 1px solid #e2e8f0; + padding-bottom: 0.55rem; +} +.attemptBarRow { + display: flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} +.attemptLabel { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #334155; +} +.attemptLabel strong { color: #0f172a; } +.attemptTimestamp { + font-size: 11px; + color: #94a3b8; + font-variant-numeric: tabular-nums; +} +.latestBadge { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 700; + padding: 0.05rem 0.45rem; + border-radius: 999px; + background: #eef2ff; + color: #4338ca; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.attemptStepBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.attemptStepBtn:hover:not(:disabled) { + border-color: #818cf8; + color: #4338ca; + background: #eef2ff; +} +.attemptStepBtn:disabled { + opacity: 0.4; + cursor: default; +} + +.attemptDetails { + flex: 0 0 auto; + border-top: 1px solid #e2e8f0; + padding-top: 0.55rem; + margin-top: 0.5rem; +} +.attemptSummary { + cursor: pointer; + user-select: none; + font-size: 12px; + color: #475569; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.2rem 0; +} +.attemptSummary:hover { color: #4338ca; } +.attemptList { + margin-top: 0.5rem; + max-height: 240px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.historyMeta { + display: flex; + gap: 8px; + align-items: center; + font-size: 11px; + color: #475569; + margin-bottom: 4px; +} +.historyAnswer { + font-size: 11px; + color: #0f172a; + font-family: ui-monospace, SFMono-Regular, monospace; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.4; +} + +.splitPane .previewPane { + max-height: none; + height: 100%; +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx new file mode 100644 index 000000000..861009be4 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx @@ -0,0 +1,146 @@ +import { Button } from "primereact/button"; +import React, { useState } from "react"; +import { Link, Outlet, useLocation, useParams } from "react-router-dom"; + +import { useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api"; +import { + useGetGraderAnswersQuery, + useGetGraderQuestionsQuery +} from "@store/grader/grader.logic.api"; + +import { ShortcutsHelpDialog } from "./components/ShortcutsHelpDialog"; +import styles from "./Grader.module.css"; +import { useEnsureEbookConfigForGrader } from "./hooks/useEnsureEbookConfigForGrader"; +import { useGraderTour } from "./hooks/useGraderTour"; +import { usePlatform } from "./hooks/usePlatform"; +import { getQuestionProgress } from "./state/graderSelectors"; +import { + DEMO_ASSIGNMENTS, + getDemoAnswersFor, + getDemoQuestionsFor +} from "./tour/graderDemoData"; +import { + GraderTourProvider, + useGraderTourContext +} from "./tour/GraderTourContext"; + +export const Grader: React.FC = () => { + return ( + + + + ); +}; + +const GraderShell: React.FC = () => { + useEnsureEbookConfigForGrader(); + const { startTour } = useGraderTour(); + const params = useParams(); + const location = useLocation(); + const platform = usePlatform(); + const { data: realAssignments } = useGetAssignmentsQuery(); + const { isDemo } = useGraderTourContext(); + const assignments = isDemo ? DEMO_ASSIGNMENTS : realAssignments; + + const assignmentId = params.assignmentId ? Number(params.assignmentId) : undefined; + const assignment = assignments?.find((a) => a.id === assignmentId); + const questionId = params.questionId ? Number(params.questionId) : undefined; + + const { data: qRealData } = useGetGraderQuestionsQuery(assignmentId ?? 0, { + skip: !assignmentId || isDemo + }); + const { data: aRealData } = useGetGraderAnswersQuery( + { assignmentId: assignmentId ?? 0, questionId: questionId ?? 0 }, + { skip: !assignmentId || !questionId || isDemo } + ); + const qData = isDemo && assignmentId ? getDemoQuestionsFor(assignmentId) : qRealData; + const aData = + isDemo && assignmentId && questionId + ? getDemoAnswersFor(assignmentId, questionId) + : aRealData; + const questionMeta = qData?.questions.find((q) => q.id === questionId); + const progress = aData ? getQuestionProgress(aData.answers, questionMeta) : null; + + const [helpOpen, setHelpOpen] = useState(false); + + return ( +
+
+
+ Assignment Grader +
+
+
+
+ + + + + + setHelpOpen(false)} + platform={platform} + /> +
+ ); +}; + +export default Grader; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx new file mode 100644 index 000000000..c488d20d1 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx @@ -0,0 +1,344 @@ +import { Button } from "primereact/button"; +import { Dialog } from "primereact/dialog"; +import { InputNumber } from "primereact/inputnumber"; +import { InputTextarea } from "primereact/inputtextarea"; +import { Slider } from "primereact/slider"; +import React, { useEffect, useMemo, useState } from "react"; + +import { + GraderStudentAnswer, + useGetGraderHistoryQuery, + useSaveGradeMutation +} from "@store/grader/grader.logic.api"; + +import styles from "../Grader.module.css"; +import { getDemoHistoryFor } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; +import { AnswerRenderer } from "./questionTypes/AnswerRenderer"; + +interface Props { + visible: boolean; + onHide: () => void; + assignmentId: number; + questionId: number; + questionName: string; + questionType: string; + htmlsrc?: string; + maxPoints: number; + student: GraderStudentAnswer | null; + answers?: GraderStudentAnswer[]; + onSelectStudent?: (student: GraderStudentAnswer) => void; +} + +export const AnswerDetailDialog: React.FC = ({ + visible, + onHide, + assignmentId, + questionId, + questionName, + questionType, + htmlsrc, + maxPoints, + student, + answers, + onSelectStudent +}) => { + const skip = !visible || !student; + const { isDemo } = useGraderTourContext(); + const { data: historyData } = useGetGraderHistoryQuery( + skip + ? { assignmentId: 0, questionId: 0, sid: "" } + : { assignmentId, questionId, sid: student!.sid }, + { skip: skip || isDemo } + ); + const [saveGrade, { isLoading: saving }] = useSaveGradeMutation(); + + const history = isDemo && student ? getDemoHistoryFor(student.sid).history : historyData?.history ?? []; + const [activeAttempt, setActiveAttempt] = useState(-1); + const [points, setPoints] = useState(0); + const [comment, setComment] = useState(""); + + const liveStudent = useMemo(() => { + if (!student) return null; + return answers?.find((a) => a.sid === student.sid) ?? student; + }, [answers, student]); + + const currentIndex = useMemo(() => { + if (!liveStudent || !answers?.length) return -1; + return answers.findIndex((a) => a.sid === liveStudent.sid); + }, [answers, liveStudent]); + const hasPrev = currentIndex > 0; + const hasNext = currentIndex >= 0 && !!answers && currentIndex < answers.length - 1; + const goPrev = () => { + if (hasPrev && answers && onSelectStudent) onSelectStudent(answers[currentIndex - 1]); + }; + const goNext = () => { + if (hasNext && answers && onSelectStudent) onSelectStudent(answers[currentIndex + 1]); + }; + + useEffect(() => { + if (!student) return; + setPoints(student.score ?? 0); + setComment(student.comment ?? ""); + setActiveAttempt(history.length ? history.length - 1 : -1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [student?.sid, history.length]); + + const currentAnswer = useMemo(() => { + if (activeAttempt >= 0 && history[activeAttempt]) { + return history[activeAttempt]; + } + return null; + }, [activeAttempt, history]); + + const displayedAnswer = (() => { + const raw = currentAnswer?.answer ?? liveStudent?.answer ?? ""; + if (typeof raw === "string") return raw; + try { + return JSON.stringify(raw); + } catch { + return String(raw); + } + })(); + + const handleSave = async () => { + if (!student) return; + await saveGrade({ + sid: student.sid, + div_id: questionName, + score: points, + comment, + questionId, + assignmentId + }); + }; + + if (!student || !liveStudent) return null; + + return ( + +
+ } + style={{ width: "min(1080px, 95vw)" }} + maximizable + draggable={false} + modal + > +
+
+ +
+ +
+
+

+ Attempt history +

+ {history.length > 1 && ( + setActiveAttempt(e.value as number)} + min={0} + max={history.length - 1} + step={1} + style={{ margin: "0.5rem 0 0.75rem" }} + /> + )} +
+ {history.length === 0 && ( + No prior attempts recorded. + )} + {history.map((h, idx) => ( +
setActiveAttempt(idx)} + title={h.timestamp || ""} + > +
+ #{idx + 1} + {h.timestamp && {new Date(h.timestamp).toLocaleString()}} + {h.correct != null && ( + + {h.correct ? "correct" : "wrong"} + + )} +
+
+ {(() => { + const a = h.answer; + const s = + typeof a === "string" + ? a + : a == null + ? "" + : (() => { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + })(); + return s.slice(0, 160) || "(empty)"; + })()} +
+
+ ))} +
+
+ +
+ + +
+ setPoints(Math.max(0, Math.min(maxPoints, e.value ?? 0)))} + min={0} + max={maxPoints} + showButtons + buttonLayout="horizontal" + style={{ flex: 1 }} + inputStyle={{ width: "100%", textAlign: "center" }} + decrementButtonClassName="p-button-secondary" + incrementButtonClassName="p-button-secondary" + decrementButtonIcon="pi pi-minus" + incrementButtonIcon="pi pi-plus" + /> +
+ + + setComment(e.target.value)} + rows={4} + autoResize + placeholder="Leave feedback the student will see on their results page" + /> + +
+
+
+
+ + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx new file mode 100644 index 000000000..40d8fcda9 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx @@ -0,0 +1,232 @@ +import { Button } from "primereact/button"; +import { InputNumber } from "primereact/inputnumber"; +import { InputSwitch } from "primereact/inputswitch"; +import { InputTextarea } from "primereact/inputtextarea"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; + +import { GraderStudentAnswer } from "@store/grader/grader.logic.api"; + +import { AutoSaveStatus } from "../hooks/useAutoSaveGrade"; +import styles from "../Grader.module.css"; +import { SaveStatusPill } from "./SaveStatusPill"; + +interface Props { + student?: GraderStudentAnswer | null; + maxPoints: number; + score: number; + comment: string; + status: AutoSaveStatus; + errorMessage?: string; + hasPrev: boolean; + hasNext: boolean; + positionLabel?: string; + + disabled?: boolean; + onScoreChange: (n: number) => void; + onCommentChange: (s: string) => void; + onCommentBlur: () => void; + onScoreBlur: () => void; + onPrev: () => void; + onNext: () => void; + onRetry: () => void; + + autoAdvance?: boolean; + onAutoAdvanceChange?: (value: boolean) => void; +} + +export interface GradePanelHandle { + focusGrade: () => void; + focusComment: () => void; +} + +export const GradePanel = forwardRef(function GradePanel( + props, + ref +) { + const { + student, + maxPoints, + score, + comment, + status, + errorMessage, + hasPrev, + hasNext, + positionLabel, + disabled, + onScoreChange, + onCommentChange, + onCommentBlur, + onScoreBlur, + onPrev, + onNext, + onRetry, + autoAdvance, + onAutoAdvanceChange + } = props; + + const scoreInputRef = useRef(null); + const commentRef = useRef(null); + + useImperativeHandle(ref, () => ({ + focusGrade: () => scoreInputRef.current?.focus(), + focusComment: () => commentRef.current?.focus() + })); + + const isDisabled = disabled || !student; + const studentName = student + ? `${student.first_name || ""} ${student.last_name || ""}`.trim() || student.sid + : "No student selected"; + + return ( +
+
+ +
+
+ {studentName} +
+
+ {student ? ( + <> + + {student.sid} + + + {" "} + · {student.attempts} attempt{student.attempts === 1 ? "" : "s"} + + {positionLabel && · {positionLabel}} + + ) : ( + No submissions for this question + )} +
+
+ +
+ + {onAutoAdvanceChange && ( + + )} + +
+
+ +
+ +
+ { + scoreInputRef.current = el ?? null; + }} + value={score} + onValueChange={(e) => onScoreChange(e.value ?? 0)} + onBlur={onScoreBlur} + min={0} + max={maxPoints} + showButtons + buttonLayout="horizontal" + disabled={isDisabled} + style={{ flex: 1 }} + inputStyle={{ width: "100%", textAlign: "center" }} + decrementButtonClassName="p-button-secondary" + incrementButtonClassName="p-button-secondary" + decrementButtonIcon="pi pi-minus" + incrementButtonIcon="pi pi-plus" + /> +
+
+ +
+ + onCommentChange(e.target.value)} + onBlur={onCommentBlur} + rows={4} + autoResize + disabled={isDisabled} + placeholder={ + isDisabled + ? "No submissions to comment on yet" + : "Leave feedback the student will see on their results page" + } + style={{ width: "100%" }} + /> +
+
+ ); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx new file mode 100644 index 000000000..7719ccad2 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx @@ -0,0 +1,80 @@ +import React from "react"; + +import { + AutoSaveStatus +} from "../hooks/useAutoSaveGrade"; + +const STYLES: Record = { + idle: { display: "none" }, + dirty: { background: "#f1f5f9", color: "#475569", borderColor: "#cbd5e1" }, + saving: { background: "#eef2ff", color: "#4338ca", borderColor: "#c7d2fe" }, + saved: { background: "#dcfce7", color: "#166534", borderColor: "#bbf7d0" }, + error: { background: "#fee2e2", color: "#991b1b", borderColor: "#fecaca" } +}; + +const ICONS: Record = { + idle: "", + dirty: "pi pi-circle", + saving: "pi pi-spin pi-spinner", + saved: "pi pi-check", + error: "pi pi-exclamation-triangle" +}; + +const LABELS: Record = { + idle: "", + dirty: "Unsaved", + saving: "Saving…", + saved: "Saved", + error: "Save failed" +}; + +interface Props { + status: AutoSaveStatus; + onRetry?: () => void; + errorMessage?: string; +} + +export const SaveStatusPill: React.FC = ({ status, onRetry, errorMessage }) => { + if (status === "idle") return null; + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + gap: 6, + padding: "0.18rem 0.55rem", + borderRadius: 999, + fontSize: 12, + fontWeight: 600, + border: "1px solid", + transition: "background 0.2s, color 0.2s", + ...STYLES[status] + }; + return ( + + + {LABELS[status]} + {status === "error" && onRetry && ( + + )} + + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx new file mode 100644 index 000000000..d656bbcd9 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx @@ -0,0 +1,144 @@ +import { Dialog } from "primereact/dialog"; +import React from "react"; + +import { Platform } from "../hooks/usePlatform"; + +interface Props { + visible: boolean; + onHide: () => void; + platform: Platform; +} + +interface Row { + keys: string[]; + label: string; +} + +const SECTIONS: Array<{ title: string; rows: Row[] }> = [ + { + title: "Navigation", + rows: [ + { keys: ["J"], label: "Next student (rolls over to next question)" }, + { keys: ["K"], label: "Previous student (rolls over to previous question)" }, + { keys: ["↓"], label: "Next student" }, + { keys: ["↑"], label: "Previous student" }, + { keys: ["→"], label: "Next attempt" }, + { keys: ["←"], label: "Previous attempt" } + ] + }, + { + title: "Focus & input", + rows: [ + { keys: ["G"], label: "Focus grade input" }, + { keys: ["C"], label: "Focus comment box" }, + { keys: ["H"], label: "Toggle 'hide graded' filter" } + ] + }, + { + title: "Help", + rows: [{ keys: ["?"], label: "Open this dialog" }] + } +]; + +const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +export const ShortcutsHelpDialog: React.FC = ({ visible, onHide }) => { + return ( + + + Keyboard shortcuts + + } + style={{ width: "min(560px, 95vw)" }} + draggable={false} + modal + > +
+ {SECTIONS.map((s) => ( +
+

+ {s.title} +

+ + + {s.rows.map((r, i) => ( + + + + + ))} + +
+ {r.keys.map((k, j) => ( + + {k === "–" ? ( + + ) : ( + {k} + )} + {j < r.keys.length - 1 && k !== "–" && r.keys[j + 1] !== "–" && ( + + + )} + + ))} + + {r.label} +
+
+ ))} +
+ Letter shortcuts are ignored while a text field has focus — finish typing + first, or click outside the field to leave it. +
+ Tip: enable Auto-advance after save in the grade panel to + jump to the next ungraded student automatically. You can always undo from + the toast that appears. +
+
+
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css new file mode 100644 index 000000000..34c9b60ae --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css @@ -0,0 +1,146 @@ +.sidebar { + display: flex; + flex-direction: column; + width: 280px; + flex: 0 0 280px; + background: rgba(255, 255, 255, 0.85); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 16px; + overflow: hidden; + max-height: calc(100vh - 220px); + min-height: 420px; + box-shadow: 0 6px 22px rgba(15, 23, 42, 0.06); +} + +.header { + padding: 0.75rem 0.85rem; + border-bottom: 1px solid #e2e8f0; + background: linear-gradient(180deg, #eef2ff, #f8fafc); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.headerRow { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + color: #3730a3; +} +.counter { + font-variant-numeric: tabular-nums; + font-size: 12px; + color: #64748b; + font-weight: 600; +} + +.legendRow { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + font-size: 11px; + color: #475569; +} +.legend { + display: inline-flex; + align-items: center; + gap: 4px; + font-variant-numeric: tabular-nums; +} + +.controls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.4rem; +} +.controls > * { + width: 100%; +} +.controls :global(.p-inputtext) { + padding: 0.35rem 0.5rem; + width: 100%; +} +.controls :global(.p-togglebutton) { + width: 100%; + justify-content: center; + min-height: 0; + padding: 0.2rem 0; + font-size: 12px; + line-height: 1.1; +} +.controls :global(.p-togglebutton .p-button-label) { + font-size: 12px; + line-height: 1.1; + padding: 0; +} +.controls :global(.p-togglebutton .p-button-icon) { + font-size: 12px; +} + +.list { + list-style: none; + margin: 0; + padding: 0.4rem; + overflow-y: auto; + flex: 1; +} + +.item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.6rem; + border-radius: 10px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + border: 1px solid transparent; +} +.item:hover { + background: rgba(99, 102, 241, 0.08); +} +.item.active { + background: #eef2ff; + border-color: #818cf8; +} + +.itemBody { + flex: 1; + min-width: 0; +} +.itemName { + font-weight: 600; + color: #0f172a; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.itemMeta { + display: flex; + gap: 4px; + align-items: center; + font-size: 11px; + color: #64748b; + margin-top: 2px; + font-variant-numeric: tabular-nums; +} +.itemSid { + font-family: ui-monospace, SFMono-Regular, monospace; +} + +.dot { + font-size: 14px; + width: 16px; + display: inline-flex; + justify-content: center; +} + +.empty { + padding: 1.2rem 0.75rem; + color: #94a3b8; + font-size: 12px; + text-align: center; +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx new file mode 100644 index 000000000..ced9d74b0 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx @@ -0,0 +1,182 @@ +import { InputText } from "primereact/inputtext"; +import { ProgressBar } from "primereact/progressbar"; +import { ToggleButton } from "primereact/togglebutton"; +import React, { useMemo, useRef } from "react"; + +import { GraderStudentAnswer } from "@store/grader/grader.logic.api"; + +import { + getQuestionProgress, + getStudentStatus, + statusColor, + statusIcon, + statusLabel, + StudentGradingStatus +} from "../state/graderSelectors"; +import styles from "./StudentListSidebar.module.css"; + +interface Props { + answers: ReadonlyArray; + question?: { autograde?: string }; + activeSid?: string; + dirtySids?: ReadonlySet; + onSelect: (sid: string) => void; + hideGraded: boolean; + onToggleHideGraded: (v: boolean) => void; +} + +const studentName = (s: GraderStudentAnswer) => + `${s.first_name || ""} ${s.last_name || ""}`.trim() || s.sid; + +export const StudentListSidebar: React.FC = ({ + answers, + question, + activeSid, + dirtySids, + onSelect, + hideGraded, + onToggleHideGraded +}) => { + const [filter, setFilter] = React.useState(""); + const progress = useMemo( + () => getQuestionProgress(answers, question, { dirtySids }), + [answers, question, dirtySids] + ); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + return answers.filter((a) => { + const status = getStudentStatus(a, question, { dirtySids }); + if (hideGraded && (status === "graded" || status === "autograded")) return false; + if (!q) return true; + return ( + studentName(a).toLowerCase().includes(q) || + a.sid.toLowerCase().includes(q) + ); + }); + }, [answers, filter, hideGraded, question, dirtySids]); + + const listRef = useRef(null); + + return ( + + ); +}; + +const StatusDot: React.FC<{ status: StudentGradingStatus }> = ({ status }) => ( + + + +); + +const Legend: React.FC<{ status: StudentGradingStatus; count: number }> = ({ + status, + count +}) => { + if (count === 0) return null; + return ( + + + {count} + + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx new file mode 100644 index 000000000..8e4642c52 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx @@ -0,0 +1,217 @@ +import { Slider } from "primereact/slider"; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useState +} from "react"; + +import { + GraderAnswerHistoryItem, + GraderStudentAnswer, + useGetGraderHistoryQuery +} from "@store/grader/grader.logic.api"; + +import styles from "../Grader.module.css"; +import { getDemoHistoryFor } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; +import { AnswerRenderer } from "./questionTypes/AnswerRenderer"; + +interface Props { + assignmentId: number; + questionId: number; + questionName: string; + questionType: string; + htmlsrc?: string; + student: GraderStudentAnswer; +} + +export interface SubmissionPaneHandle { + + prevAttempt: () => void; + + nextAttempt: () => void; +} + +const formatAnswer = (a: GraderAnswerHistoryItem["answer"]) => { + if (typeof a === "string") return a; + if (a == null) return ""; + try { + return JSON.stringify(a); + } catch { + return String(a); + } +}; + +const correctChip = ( + h: Pick +): { label: string; cls: string } | null => { + if (h.correct === true) return { label: "correct", cls: styles.chipCorrect }; + if (h.correct === false && (h.percent ?? 0) > 0) + return { label: "partial", cls: styles.chipPartial }; + if (h.correct === false) return { label: "wrong", cls: styles.chipWrong }; + return null; +}; + +export const SubmissionPane = forwardRef( + function SubmissionPane( + { assignmentId, questionId, questionName, questionType, htmlsrc, student }, + ref + ) { + const { isDemo } = useGraderTourContext(); + const { data: historyData } = useGetGraderHistoryQuery( + { assignmentId, questionId, sid: student.sid }, + { skip: isDemo } + ); + + const history: GraderAnswerHistoryItem[] = isDemo + ? getDemoHistoryFor(student.sid).history + : historyData?.history ?? []; + + const [activeAttempt, setActiveAttempt] = useState(-1); + + useEffect(() => { + setActiveAttempt(history.length ? history.length - 1 : -1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [student.sid, history.length]); + + const currentAnswer = useMemo( + () => (activeAttempt >= 0 && history[activeAttempt]) || null, + [activeAttempt, history] + ); + + const displayedAnswer = formatAnswer(currentAnswer?.answer ?? student.answer ?? ""); + const chip = correctChip(currentAnswer ?? student); + const totalAttempts = history.length; + const isLatest = activeAttempt === totalAttempts - 1; + + const goPrevAttempt = () => setActiveAttempt((i) => Math.max(0, i - 1)); + const goNextAttempt = () => + setActiveAttempt((i) => Math.min(totalAttempts - 1, i + 1)); + + useImperativeHandle( + ref, + () => ({ + prevAttempt: goPrevAttempt, + nextAttempt: goNextAttempt + }), + + // eslint-disable-next-line react-hooks/exhaustive-deps + [totalAttempts] + ); + + return ( +
+ +
+
+ + + + {totalAttempts > 0 + ? `Attempt ${activeAttempt + 1} of ${totalAttempts}` + : "No attempts"} + + {isLatest && totalAttempts > 1 && ( + latest + )} + + {chip && {chip.label}} + {currentAnswer?.timestamp && ( + + {new Date(currentAnswer.timestamp).toLocaleString()} + + )} + + + +
+ {totalAttempts > 1 && ( + setActiveAttempt(e.value as number)} + min={0} + max={totalAttempts - 1} + step={1} + style={{ marginTop: 4 }} + /> + )} +
+ +
+ +
+ + {totalAttempts > 0 && ( +
+ + All attempts ({totalAttempts}) + +
+ {history.map((h, idx) => { + const c = correctChip(h); + const active = idx === activeAttempt; + const raw = formatAnswer(h.answer); + return ( + + ); + })} +
+
+ )} +
+ ); + } +); + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx new file mode 100644 index 000000000..d71a70b5e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx @@ -0,0 +1,58 @@ +import { SelectButton } from "primereact/selectbutton"; +import React from "react"; + +import styles from "../Grader.module.css"; + +export type GraderViewMode = "cards" | "table"; + +const VIEW_OPTIONS: { label: string; value: GraderViewMode; icon: string }[] = [ + { label: "Cards", value: "cards", icon: "pi pi-th-large" }, + { label: "Table", value: "table", icon: "pi pi-table" } +]; + +interface ViewModeToggleProps { + value: GraderViewMode; + onChange: (mode: GraderViewMode) => void; + ariaLabel?: string; + tourId?: string; +} + +export const ViewModeToggle: React.FC = ({ + value, + onChange, + ariaLabel = "Toggle view", + tourId +}) => ( +
+ { + if (e.value) onChange(e.value as GraderViewMode); + }} + options={VIEW_OPTIONS} + optionLabel="label" + optionValue="value" + allowEmpty={false} + itemTemplate={(option: { + label: string; + value: GraderViewMode; + icon: string; + }) => ( + + + {option.label} + + )} + aria-label={ariaLabel} + /> +
+); + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx new file mode 100644 index 000000000..fcc9fcf78 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { QuestionPreviewHeader } from "./RunestonePreview"; +import { AnswerRendererProps } from "./types"; + +export const ActiveCodeAnswerView: React.FC = (props) => { + const { answer } = props; + + return ( +
+ +

Submitted source

+
+        {answer || "(empty)"}
+      
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx new file mode 100644 index 000000000..dcebde5fa --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { ActiveCodeAnswerView } from "./ActiveCodeAnswerView"; +import { DefaultAnswerView } from "./DefaultAnswerView"; +import { FitbAnswerView } from "./FitbAnswerView"; +import { McqAnswerView } from "./McqAnswerView"; +import { ParsonsAnswerView } from "./ParsonsAnswerView"; +import { RunestoneGraderPreview } from "./RunestoneGraderPreview"; +import { ShortAnswerView } from "./ShortAnswerView"; +import { AnswerRendererProps } from "./types"; + +const RUNESTONE_GRADER_TYPES = new Set([ + "mchoice", + "clickablearea", + "dragndrop", + "fillintheblank", + "shortanswer", + "parsonsprob", + "matching", + "activecode", + "actex", + "codelens", + "hparsons", + "lp", + "webwork", + "selectquestion" +]); + +export const AnswerRenderer: React.FC< + AnswerRendererProps & { questionType: string } +> = (props) => { + const { + questionType, + htmlsrc, + questionName, + sid, + history, + activeAttemptIndex + } = props; + + const hasIndex = + typeof activeAttemptIndex === "number" && activeAttemptIndex >= 0; + const isLatestAttempt = + hasIndex && activeAttemptIndex === history.length - 1; + + const attempt = + hasIndex && !isLatestAttempt ? history[activeAttemptIndex!] : null; + + const interactive = + htmlsrc && RUNESTONE_GRADER_TYPES.has(questionType) ? ( +
+

+ Question: {questionName} +

+ +
+ ) : null; + + if (interactive) return <>{interactive}; + + switch (questionType) { + case "mchoice": + case "clickablearea": + case "dragndrop": + return ; + case "fillintheblank": + return ; + case "shortanswer": + return ; + case "parsonsprob": + return ; + case "activecode": + case "codelens": + case "actex": + return ; + default: + return ; + } +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx new file mode 100644 index 000000000..78699a969 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { AnswerRendererProps } from "./types"; + +export const DefaultAnswerView: React.FC = ({ answer }) => { + return ( +
+

Student answer

+
+        {answer || "(empty)"}
+      
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx new file mode 100644 index 000000000..c2e1e439e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import { QuestionPreviewHeader } from "./RunestonePreview"; +import { AnswerRendererProps } from "./types"; + +export const FitbAnswerView: React.FC = (props) => { + const { answer } = props; + let values: string[] = []; + try { + const parsed = JSON.parse(answer); + values = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; + } catch { + values = answer ? answer.split(",") : []; + } + + return ( +
+ +

Blanks

+
    + {values.length === 0 &&
  1. (empty)
  2. } + {values.map((v, i) => ( +
  3. + + {v || "(empty)"} + +
  4. + ))} +
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx new file mode 100644 index 000000000..b0878a0ef --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx @@ -0,0 +1,40 @@ +import React from "react"; + +import { QuestionPreviewHeader } from "./RunestonePreview"; +import { AnswerRendererProps } from "./types"; + +export const McqAnswerView: React.FC = (props) => { + const { answer, correct } = props; + const selected = (answer || "").split(",").filter(Boolean); + + return ( +
+ +

+ Selected option{selected.length > 1 ? "s" : ""} +

+
+ {selected.length === 0 ? ( + (no selection) + ) : ( + selected.map((s) => ( + + Option {s} + + )) + )} +
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx new file mode 100644 index 000000000..35e33bf03 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +import { QuestionPreviewHeader } from "./RunestonePreview"; +import { AnswerRendererProps } from "./types"; + +export const ParsonsAnswerView: React.FC = (props) => { + const { answer } = props; + const blocks = (answer || "") + .split("-") + .map((s) => s.trim()) + .filter(Boolean); + + return ( +
+ +

+ Reconstructed block order ({blocks.length}) +

+
+ {blocks.length === 0 && ( + (no blocks submitted) + )} + {blocks.map((b, i) => ( +
+ + {i + 1} + + {b} +
+ ))} +
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx new file mode 100644 index 000000000..f696d9124 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx @@ -0,0 +1,205 @@ +import { MathJaxWrapper } from "@components/routes/AssignmentBuilder/MathJaxWrapper"; +import { MathJax } from "better-react-mathjax"; +import React, { useEffect, useReducer, useRef } from "react"; + +import { renderRunestoneComponent } from "@/componentFuncs"; +import { GraderAnswerHistoryItem } from "@store/grader/grader.logic.api"; + +interface Props { + htmlsrc?: string; + divId: string; + sid: string; + + attempt?: GraderAnswerHistoryItem | null; + + attemptId?: number | string; + + deadline?: string; +} + +export const RunestoneGraderPreview: React.FC = ({ + htmlsrc, + divId, + sid, + attempt, + deadline +}) => { + const ref = useRef(null); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + useEffect(() => { + if (!ref.current || !htmlsrc) return; + + try { + const cfg = (window as any).eBookConfig || {}; + if (cfg.email && cfg.course) { + localStorage.removeItem(`${cfg.email}:${cfg.course}:${divId}-given`); + } + } catch { + + } + + ref.current.innerHTML = htmlsrc; + + const useFetch = !attempt; + const opts: Record = { + graderactive: true, + graderMode: true, + suppressFlagForReview: true, + sid, + + assessmentTaken: useFetch, + useRunestoneServices: true, + gradingContainer: ref.current.id || undefined + }; + if (deadline) { + opts.deadline = deadline; + opts.enforceDeadline = true; + opts.rawdeadline = deadline; + opts.tzoff = new Date().getTimezoneOffset() / 60; + } + + let cancelled = false; + renderRunestoneComponent(ref, opts) + .then(async () => { + if (cancelled) return; + forceUpdate(); + if (useFetch) return; + + const cmKey = (opts.gradingContainer ? `${opts.gradingContainer} ` : "") + divId; + const componentMap = (window as any).componentMap || {}; + const inst = componentMap[cmKey] || componentMap[divId]; + if (!inst) return; + try { + if (typeof inst.checkServerComplete?.then === "function") { + await inst.checkServerComplete; + } + + if (inst.addingScrubber) { + for (let i = 0; i < 50 && inst.addingScrubber; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + } + if (cancelled) return; + + const rawAnswer = attempt!.answer; + const codeString = + typeof rawAnswer === "string" + ? rawAnswer + : rawAnswer == null + ? "" + : (() => { + try { + return JSON.stringify(rawAnswer); + } catch { + return String(rawAnswer); + } + })(); + + const isCodeEditor = + inst.editor && typeof inst.editor.setValue === "function"; + + if (isCodeEditor && (!inst.restoreAnswers || attempt!.source === "code_table")) { + + inst.editor.acEditEvent = false; + inst.editor.setValue(codeString); + if (typeof inst.setLockedRegions === "function") { + try { inst.setLockedRegions(); } catch {} + } + if (typeof inst.setHighlightLines === "function") { + try { inst.setHighlightLines(); } catch {} + } + + try { + if (Array.isArray(inst.history) && inst.history.length) { + const idx = inst.history.findIndex((h: string) => h === codeString); + const $ = (window as any).$; + if (idx >= 0 && $ && inst.scrubber) { + $(inst.scrubber).slider("value", idx); + if (typeof inst.slideit === "function") { + try { inst.slideit(null); } catch {} + } + } + } + } catch { /* noop */ } + inst.attempted = true; + } else if (typeof inst.restoreAnswers === "function") { + inst.restoreAnswers({ + answer: rawAnswer ?? "", + correct: attempt!.correct ?? null, + percent: attempt!.percent ?? null, + timestamp: attempt!.timestamp, + sid + }); + inst.attempted = true; + if (typeof inst.decorateStatus === "function") { + inst.decorateStatus(); + } + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn("[RunestoneGraderPreview] restore failed", err); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [htmlsrc, sid, divId, attempt?.id]); + + if (!htmlsrc) { + return ( +
+ No rendered question preview available. +
+ ); + } + + const hostId = `grader-preview-${divId}-${sid}-${attempt?.id ?? "latest"}`; + + return ( + + + +
+
+
+
+
+ + + ); +}; + +export default RunestoneGraderPreview; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx new file mode 100644 index 000000000..c83da8229 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx @@ -0,0 +1,85 @@ +import { MathJaxWrapper } from "@components/routes/AssignmentBuilder/MathJaxWrapper"; +import { MathJax } from "better-react-mathjax"; +import React, { useEffect, useReducer, useRef } from "react"; + +import { renderRunestoneComponent } from "@/componentFuncs"; + +import { AnswerRendererProps } from "./types"; + +export const RunestonePreview: React.FC<{ htmlsrc?: string; divId: string }> = ({ + htmlsrc, + divId +}) => { + const ref = useRef(null); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + useEffect(() => { + if (!ref.current || !htmlsrc) return; + ref.current.innerHTML = htmlsrc; + renderRunestoneComponent(ref, { isCalledFromBuilder: true, graderactive: false }) + .then(forceUpdate) + .catch(() => undefined); + + }, [htmlsrc, divId]); + + if (!htmlsrc) { + return ( +
+ No rendered question preview available. +
+ ); + } + + return ( + + + +
+
+
+
+
+ + + ); +}; + +export const QuestionPreviewHeader: React.FC< + Pick +> = ({ questionName }) => ( + +
+

+ Question: {questionName} +

+
+); + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx new file mode 100644 index 000000000..bd663bc3e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +import { QuestionPreviewHeader } from "./RunestonePreview"; +import { AnswerRendererProps } from "./types"; + +export const ShortAnswerView: React.FC = (props) => { + const { answer } = props; + + return ( +
+ +

Student response

+
+ {answer || (empty response)} +
+
+ ); +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts new file mode 100644 index 000000000..77d1a2367 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts @@ -0,0 +1,15 @@ +import { GraderAnswerHistoryItem } from "@store/grader/grader.logic.api"; + +export interface AnswerRendererProps { + htmlsrc?: string; + answer: string; + correct?: boolean | null; + percent?: number | null; + history: GraderAnswerHistoryItem[]; + questionName: string; + questionId: number; + sid: string; + + activeAttemptIndex?: number; +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts new file mode 100644 index 000000000..cfb0f527e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts @@ -0,0 +1,226 @@ +import debounce from "lodash/debounce"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useSaveGradeMutation } from "@store/grader/grader.logic.api"; + +import { useGraderTourContext } from "../tour/GraderTourContext"; + +export type AutoSaveStatus = "idle" | "dirty" | "saving" | "saved" | "error"; + +export interface AutoSavedInfo { + sid: string; + questionId: number; + questionName: string; + + previous: { score: number; comment: string }; + + next: { score: number; comment: string }; +} + +interface Args { + sid?: string; + + assignmentId: number; + questionId: number; + questionName: string; + + maxPoints: number; + initialScore: number; + initialComment: string; + + onSaved?: (info: AutoSavedInfo) => void; +} + +interface Result { + status: AutoSaveStatus; + score: number; + comment: string; + setScore: (n: number) => void; + setComment: (s: string) => void; + + flush: () => Promise; + + saveNow: () => Promise; + + revertTo: (prev: { score: number; comment: string }) => void; + + isDirty: boolean; + lastSavedAt?: number; + errorMessage?: string; +} + +const SAVED_VISIBLE_MS = 1500; +const DEBOUNCE_MS = 700; + +export const useAutoSaveGrade = (args: Args): Result => { + const { + sid, + assignmentId, + questionId, + questionName, + maxPoints, + initialScore, + initialComment, + onSaved + } = args; + const { isDemo } = useGraderTourContext(); + const [saveGrade] = useSaveGradeMutation(); + + const [score, setScoreState] = useState(initialScore); + const [comment, setCommentState] = useState(initialComment); + const [status, setStatus] = useState("idle"); + const [errorMessage, setErrorMessage] = useState(); + const [lastSavedAt, setLastSavedAt] = useState(); + + const sidRef = useRef(sid); + + const lastSavedRef = useRef<{ score: number; comment: string }>({ + score: initialScore, + comment: initialComment + }); + useEffect(() => { + sidRef.current = sid; + setScoreState(initialScore); + setCommentState(initialComment); + setStatus("idle"); + setErrorMessage(undefined); + lastSavedRef.current = { score: initialScore, comment: initialComment }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sid]); + + const valuesRef = useRef({ score, comment }); + useEffect(() => { + valuesRef.current = { score, comment }; + }, [score, comment]); + + const onSavedRef = useRef(onSaved); + useEffect(() => { + onSavedRef.current = onSaved; + }, [onSaved]); + + const persist = useCallback(async () => { + const targetSid = sidRef.current; + if (!targetSid) return; + const { score: s, comment: c } = valuesRef.current; + const prev = lastSavedRef.current; + + if (s === prev.score && c === prev.comment) { + setStatus("idle"); + return; + } + setStatus("saving"); + try { + if (isDemo) { + await new Promise((r) => setTimeout(r, 250)); + } else { + await saveGrade({ + sid: targetSid, + div_id: questionName, + score: s, + comment: c, + questionId, + assignmentId + }).unwrap(); + } + setStatus("saved"); + setLastSavedAt(Date.now()); + setErrorMessage(undefined); + const previous = { ...prev }; + lastSavedRef.current = { score: s, comment: c }; + onSavedRef.current?.({ + sid: targetSid, + questionId, + questionName, + previous, + next: { score: s, comment: c } + }); + } catch (e: any) { + setStatus("error"); + setErrorMessage(e?.data?.detail || e?.message || "Save failed"); + } + }, [isDemo, saveGrade, questionName, questionId, assignmentId]); + + const debounced = useMemo( + () => debounce(persist, DEBOUNCE_MS, { leading: false, trailing: true }), + [persist] + ); + + useEffect(() => { + if (status !== "saved") return; + const t = setTimeout(() => { + setStatus((s) => (s === "saved" ? "idle" : s)); + }, SAVED_VISIBLE_MS); + return () => clearTimeout(t); + }, [status, lastSavedAt]); + + useEffect(() => () => debounced.cancel(), [debounced]); + + const setScore = useCallback( + (n: number) => { + const clamped = Math.max(0, Math.min(maxPoints, isFinite(n) ? n : 0)); + setScoreState(clamped); + setStatus("dirty"); + debounced(); + }, + [debounced, maxPoints] + ); + + const setComment = useCallback( + (s: string) => { + setCommentState(s); + setStatus("dirty"); + debounced(); + }, + [debounced] + ); + + const flush = useCallback(async () => { + if (status === "dirty") { + debounced.cancel(); + await persist(); + } + }, [status, debounced, persist]); + + const saveNow = useCallback(async () => { + debounced.cancel(); + await persist(); + }, [debounced, persist]); + + const revertTo = useCallback( + (prev: { score: number; comment: string }) => { + debounced.cancel(); + setScoreState(prev.score); + setCommentState(prev.comment); + lastSavedRef.current = { score: prev.score, comment: prev.comment }; + valuesRef.current = { score: prev.score, comment: prev.comment }; + setStatus("idle"); + setErrorMessage(undefined); + }, + [debounced] + ); + + useEffect(() => { + const handler = (e: BeforeUnloadEvent) => { + if (status === "dirty" || status === "saving") { + e.preventDefault(); + e.returnValue = ""; + } + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [status]); + + return { + status, + score, + comment, + setScore, + setComment, + flush, + saveNow, + revertTo, + isDirty: status === "dirty" || status === "saving", + lastSavedAt, + errorMessage + }; +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts new file mode 100644 index 000000000..2bebe29ae --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; + +export function useEnsureEbookConfigForGrader() { + useEffect(() => { + const w = window as any; + if (!w.eBookConfig) w.eBookConfig = {}; + const cfg = w.eBookConfig; + cfg.useRunestoneServices = cfg.useRunestoneServices !== false; + cfg.isLoggedIn = cfg.isLoggedIn !== false; + cfg.new_server_prefix = cfg.new_server_prefix ?? "/ns"; + cfg.app = cfg.app ?? "/runestone"; + cfg.python3 = cfg.python3 !== false; + if (!cfg.course) cfg.course = ""; + if (!cfg.basecourse) cfg.basecourse = ""; + if (!cfg.email) cfg.email = cfg.username ?? "instructor"; + }, []); +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts new file mode 100644 index 000000000..fa0d4488c --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts @@ -0,0 +1,101 @@ +import { useEffect } from "react"; + +import { detectPlatform } from "./usePlatform"; + +export interface GraderHotkeyHandlers { + + next?: () => void; + + prev?: () => void; + + nextAttempt?: () => void; + + prevAttempt?: () => void; + + focusGrade?: () => void; + + focusComment?: () => void; + + toggleHideGraded?: () => void; + + openHelp?: () => void; +} + +interface Options { + enabled?: boolean; +} + +const isInputTarget = (t: EventTarget | null): boolean => { + if (!(t instanceof HTMLElement)) return false; + const tag = t.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return t.isContentEditable; +}; + +export const useGraderHotkeys = ( + handlers: GraderHotkeyHandlers, + { enabled = true }: Options = {} +) => { + useEffect(() => { + if (!enabled) return; + const platform = detectPlatform(); + const isMod = (e: KeyboardEvent) => + platform === "mac" ? e.metaKey : e.ctrlKey; + + const onKeyDown = (e: KeyboardEvent) => { + + if (e.key === "?" && !isMod(e)) { + if (!isInputTarget(e.target)) { + e.preventDefault(); + handlers.openHelp?.(); + } + return; + } + + if (isInputTarget(e.target) || isMod(e) || e.altKey) return; + + switch (e.key) { + case "j": + case "ArrowDown": + if (handlers.next) { + e.preventDefault(); + handlers.next(); + } + break; + case "k": + case "ArrowUp": + if (handlers.prev) { + e.preventDefault(); + handlers.prev(); + } + break; + case "ArrowRight": + if (handlers.nextAttempt) { + e.preventDefault(); + handlers.nextAttempt(); + } + break; + case "ArrowLeft": + if (handlers.prevAttempt) { + e.preventDefault(); + handlers.prevAttempt(); + } + break; + case "g": + handlers.focusGrade?.(); + e.preventDefault(); + break; + case "c": + handlers.focusComment?.(); + e.preventDefault(); + break; + case "h": + handlers.toggleHideGraded?.(); + break; + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [enabled, handlers]); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts new file mode 100644 index 000000000..fdfb3ab71 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from "react"; + +export interface GraderPrefs { + + autoAdvance: boolean; +} + +const STORAGE_KEY = "rs-grader-prefs-v1"; + +const DEFAULT_PREFS: GraderPrefs = { + autoAdvance: false +}; + +const read = (): GraderPrefs => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_PREFS }; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_PREFS, ...parsed }; + } catch { + return { ...DEFAULT_PREFS }; + } +}; + +const write = (prefs: GraderPrefs) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + + } +}; + +export const useGraderPrefs = () => { + const [prefs, setPrefsState] = useState(() => read()); + + const updatePrefs = useCallback((patch: Partial) => { + setPrefsState((prev) => { + const next = { ...prev, ...patch }; + write(next); + return next; + }); + }, []); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) setPrefsState(read()); + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + return { prefs, updatePrefs }; +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts new file mode 100644 index 000000000..0ae16ec8a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts @@ -0,0 +1,135 @@ +import { driver, Driver } from "driver.js"; +import "driver.js/dist/driver.css"; +import { useCallback, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; + +import { + DEMO_ANSWERS, + DEMO_ASSIGNMENT_ID, + DEMO_QUESTION_ID, + DEMO_STUDENT_SID +} from "../tour/graderDemoData"; +import { + GRADER_TOUR_STEPS, + TourRoute, + TourStepConfig +} from "../tour/graderTourConfig"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +const TOUR_ROUTES: Record = { + assignments: "/grader", + questions: `/grader/${DEMO_ASSIGNMENT_ID}`, + + answers: `/grader/${DEMO_ASSIGNMENT_ID}/questions/${DEMO_QUESTION_ID}`, + student: `/grader/${DEMO_ASSIGNMENT_ID}/questions/${DEMO_QUESTION_ID}/students/${DEMO_STUDENT_SID}` +}; + +const waitForElement = ( + selector: string, + timeout = 4000 +): Promise => + new Promise((resolve) => { + const existing = document.querySelector(selector); + if (existing) return resolve(existing); + + const started = Date.now(); + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + observer.disconnect(); + resolve(el); + } else if (Date.now() - started > timeout) { + observer.disconnect(); + resolve(null); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => { + observer.disconnect(); + resolve(document.querySelector(selector)); + }, timeout); + }); + +export const useGraderTour = () => { + const navigate = useNavigate(); + const { setIsDemo, setDemoSelected } = useGraderTourContext(); + const driverRef = useRef(null); + + const prepareStep = useCallback( + async (step: TourStepConfig) => { + const targetPath = TOUR_ROUTES[step.route]; + if (window.location.pathname !== targetPath) { + navigate(targetPath); + } + if (step.openDialog) { + setDemoSelected(DEMO_ANSWERS.answers[0]); + } else if (step.route !== "student") { + setDemoSelected(null); + } + await waitForElement(step.element); + }, + [navigate, setDemoSelected] + ); + + const startTour = useCallback(async () => { + + driverRef.current?.destroy(); + + setIsDemo(true); + setDemoSelected(null); + navigate("/grader"); + + await waitForElement(GRADER_TOUR_STEPS[0].element); + + const cleanup = () => { + setIsDemo(false); + setDemoSelected(null); + navigate("/grader"); + }; + + const d = driver({ + showProgress: true, + animate: true, + allowClose: true, + smoothScroll: true, + popoverClass: "grader-tour-popover", + onDestroyed: cleanup, + steps: GRADER_TOUR_STEPS.map((step, idx) => ({ + element: step.element, + popover: { + title: step.title, + description: step.description, + side: step.side, + align: step.align, + onNextClick: async (_el, _step, opts) => { + const next = GRADER_TOUR_STEPS[idx + 1]; + if (next) await prepareStep(next); + opts.driver.moveNext(); + }, + onPrevClick: async (_el, _step, opts) => { + const prev = GRADER_TOUR_STEPS[idx - 1]; + if (prev) await prepareStep(prev); + opts.driver.movePrevious(); + } + } + })) + }); + + driverRef.current = d; + d.drive(); + }, [navigate, prepareStep, setDemoSelected, setIsDemo]); + + useEffect( + () => () => { + + driverRef.current?.destroy(); + const overlays = document.querySelectorAll( + ".driver-overlay, .driver-popover" + ); + overlays.forEach((e) => e.remove()); + }, + [] + ); + + return { startTour }; +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts new file mode 100644 index 000000000..eb4519b18 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts @@ -0,0 +1,19 @@ +import { useMemo } from "react"; + +export type Platform = "mac" | "other"; + +export const detectPlatform = (): Platform => { + if (typeof navigator === "undefined") return "other"; + const ua = navigator.userAgent || ""; + const platform = + // @ts-expect-error - userAgentData not in lib.dom yet + navigator.userAgentData?.platform || navigator.platform || ""; + if (/Mac|iPhone|iPad|iPod/i.test(`${platform} ${ua}`)) return "mac"; + return "other"; +}; + +export const usePlatform = (): Platform => useMemo(detectPlatform, []); + +export const modKeyLabel = (p: Platform) => (p === "mac" ? "⌘" : "Ctrl"); +export const altKeyLabel = (p: Platform) => (p === "mac" ? "⌥" : "Alt"); +export const shiftKeyLabel = "⇧"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts new file mode 100644 index 000000000..8a04d148d --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts @@ -0,0 +1,158 @@ +import { useCallback, useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { + GraderQuestionStats, + GraderStudentAnswer +} from "@store/grader/grader.logic.api"; + +import { + findFirstUngradedSid, + findNextUngradedSid +} from "../state/graderSelectors"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +export interface StudentNavigation { + current?: GraderStudentAnswer; + currentIndex: number; + total: number; + hasPrev: boolean; + hasNext: boolean; + prev: () => void; + next: () => void; + goTo: (sid: string) => void; + + goNextUngraded: () => boolean; + firstUngraded?: string; +} + +interface Options { + answers: ReadonlyArray; + question?: { autograde?: string }; + + dirtySids?: ReadonlySet; + + questions?: ReadonlyArray; +} + +export const useStudentNavigation = (opts: Options): StudentNavigation => { + const { answers, question, dirtySids, questions } = opts; + const params = useParams(); + const navigate = useNavigate(); + const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext(); + + const aid = Number(params.assignmentId); + const qid = Number(params.questionId); + const sidParam = params.sid; + const activeSid = isDemo ? demoSelected?.sid : sidParam; + + const currentIndex = useMemo(() => { + if (!activeSid) return -1; + return answers.findIndex((a) => a.sid === activeSid); + }, [activeSid, answers]); + + const current = currentIndex >= 0 ? answers[currentIndex] : undefined; + const total = answers.length; + const hasPrevStudent = currentIndex > 0; + const hasNextStudent = currentIndex >= 0 && currentIndex < total - 1; + + const questionIndex = useMemo(() => { + if (!questions || !qid) return -1; + return questions.findIndex((q) => q.id === qid); + }, [questions, qid]); + const prevQuestion = + questionIndex > 0 ? questions![questionIndex - 1] : undefined; + const nextQuestion = + questionIndex >= 0 && questions && questionIndex < questions.length - 1 + ? questions[questionIndex + 1] + : undefined; + + const hasPrev = hasPrevStudent || !!prevQuestion; + const hasNext = hasNextStudent || !!nextQuestion; + + const goTo = useCallback( + (sid: string) => { + if (isDemo) { + const row = answers.find((a) => a.sid === sid) ?? null; + setDemoSelected(row); + return; + } + navigate(`/grader/${aid}/questions/${qid}/students/${encodeURIComponent(sid)}`); + }, + [aid, qid, answers, isDemo, navigate, setDemoSelected] + ); + + const goToQuestion = useCallback( + (targetQid: number, opts?: { selectLast?: boolean }) => { + if (isDemo) return; + navigate(`/grader/${aid}/questions/${targetQid}`, { + + state: opts?.selectLast ? { selectLast: true } : undefined + }); + }, + [aid, isDemo, navigate] + ); + + const prev = useCallback(() => { + if (hasPrevStudent) { + goTo(answers[currentIndex - 1].sid); + } else if (prevQuestion) { + + goToQuestion(prevQuestion.id, { selectLast: true }); + } + }, [hasPrevStudent, currentIndex, answers, goTo, prevQuestion, goToQuestion]); + + const next = useCallback(() => { + if (hasNextStudent) { + goTo(answers[currentIndex + 1].sid); + } else if (nextQuestion) { + goToQuestion(nextQuestion.id); + } + }, [hasNextStudent, currentIndex, answers, goTo, nextQuestion, goToQuestion]); + + const goNextUngraded = useCallback((): boolean => { + const nextSid = findNextUngradedSid(answers, currentIndex, question, { + dirtySids + }); + if (nextSid) { + goTo(nextSid); + return true; + } + if (hasNextStudent) { + goTo(answers[currentIndex + 1].sid); + return true; + } + if (nextQuestion) { + goToQuestion(nextQuestion.id); + return true; + } + return false; + }, [ + answers, + currentIndex, + question, + dirtySids, + goTo, + hasNextStudent, + nextQuestion, + goToQuestion + ]); + + const firstUngraded = useMemo( + () => findFirstUngradedSid(answers, question, { dirtySids }) ?? undefined, + [answers, question, dirtySids] + ); + + return { + current, + currentIndex, + total, + hasPrev, + hasNext, + prev, + next, + goTo, + goNextUngraded, + firstUngraded + }; +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts new file mode 100644 index 000000000..fc5e7090a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts @@ -0,0 +1,31 @@ +import { useCallback, useEffect, useState } from "react"; + +export function useViewModeStorage( + storageKey: string, + allowedModes: readonly TMode[], + defaultMode: TMode +): [TMode, (mode: TMode) => void] { + const readInitial = useCallback((): TMode => { + if (typeof window === "undefined") return defaultMode; + const stored = window.localStorage.getItem(storageKey); + return stored && (allowedModes as readonly string[]).includes(stored) + ? (stored as TMode) + : defaultMode; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + const [mode, setModeState] = useState(readInitial); + + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(storageKey, mode); + }, [storageKey, mode]); + + const setMode = useCallback((next: TMode) => { + setModeState(next); + }, []); + + return [mode, setMode]; +} + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts new file mode 100644 index 000000000..5109a2d64 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts @@ -0,0 +1,6 @@ +export { Grader } from "./Grader"; +export { default as GraderAssignmentsPage } from "./pages/GraderAssignmentsPage"; +export { default as GraderQuestionsPage } from "./pages/GraderQuestionsPage"; +export { default as GraderAnswersPage } from "./pages/GraderAnswersPage"; +export { default as GraderQuestionPage } from "./pages/GraderQuestionPage"; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx new file mode 100644 index 000000000..bdba4f8df --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx @@ -0,0 +1,210 @@ +import { Column } from "primereact/column"; +import { DataTable } from "primereact/datatable"; +import { ProgressSpinner } from "primereact/progressspinner"; +import React, { useState } from "react"; +import { useParams } from "react-router-dom"; + +import { + GraderStudentAnswer, + useGetGraderAnswersQuery, + useGetGraderQuestionsQuery +} from "@store/grader/grader.logic.api"; + +import { AnswerDetailDialog } from "../components/AnswerDetailDialog"; +import styles from "../Grader.module.css"; +import { getDemoAnswersFor, getDemoQuestionsFor } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +export const GraderAnswersPage: React.FC = () => { + const { assignmentId, questionId, sid } = useParams(); + const aid = Number(assignmentId); + const qid = Number(questionId); + + const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext(); + + const { data: qRealData } = useGetGraderQuestionsQuery(aid, { + skip: !aid || isDemo + }); + const { data: realData, isLoading } = useGetGraderAnswersQuery( + { assignmentId: aid, questionId: qid }, + { skip: !aid || !qid || isDemo } + ); + + const data = isDemo ? getDemoAnswersFor(aid, qid) ?? undefined : realData; + const qData = isDemo ? getDemoQuestionsFor(aid) ?? undefined : qRealData; + + const [selected, setSelected] = useState(null); + + const effectiveSelected = isDemo ? demoSelected : selected; + + const questionMeta = qData?.questions.find((q) => q.id === qid); + + const didAutoOpenRef = React.useRef(false); + React.useEffect(() => { + if (isDemo) return; + if (didAutoOpenRef.current) return; + if (sid && data?.answers.length) { + const match = data.answers.find((a) => a.sid === sid) || null; + if (match) { + setSelected(match); + didAutoOpenRef.current = true; + } + } + }, [sid, data, isDemo]); + + if (!data && isLoading) { + return ( +
+ +
+ ); + } + + if (!data) { + return
Could not load student answers.
; + } + + const closeDialog = () => { + if (isDemo) setDemoSelected(null); + else setSelected(null); + }; + + return ( + <> +
+ { + const row = e.data as GraderStudentAnswer; + if (isDemo) setDemoSelected(row); + else setSelected(row); + }} + rowHover + className={styles.row} + emptyMessage="No student answers yet." + > + ( +
+ + {row.first_name || ""} {row.last_name || ""} + + + {row.sid} + +
+ )} + /> + ( + + {row.answer || (empty)} + + )} + /> + ( + {row.attempts} + )} + /> + { + if (row.correct == null) { + return n/a; + } + if (row.correct) { + return correct; + } + if ((row.percent ?? 0) > 0) { + return partial; + } + return wrong; + }} + /> + ( + + {row.score != null ? row.score : "—"} + + {" "} + / {row.max_points} + + + )} + /> + + row.comment ? ( + + {row.comment.slice(0, 80)} + {row.comment.length > 80 ? "…" : ""} + + ) : ( + + ) + } + /> + ( + + )} + /> +
+
+ + { + if (isDemo) setDemoSelected(row); + else setSelected(row); + }} + /> + + ); +}; + +export default GraderAnswersPage; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx new file mode 100644 index 000000000..a66ce29c7 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx @@ -0,0 +1,336 @@ +import { FilterMatchMode } from "primereact/api"; +import { Column } from "primereact/column"; +import { DataTable, DataTableFilterMeta } from "primereact/datatable"; +import { ProgressSpinner } from "primereact/progressspinner"; +import React, { useState } from "react"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; +import { useNavigate } from "react-router-dom"; + +import { useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api"; + +import { + GraderViewMode, + ViewModeToggle +} from "../components/ViewModeToggle"; +import styles from "../Grader.module.css"; +import { useViewModeStorage } from "../hooks/useViewModeStorage"; +import { DEMO_ASSIGNMENTS } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +const VIEW_MODES = ["cards", "table"] as const satisfies readonly GraderViewMode[]; +const VIEW_MODE_STORAGE_KEY = "grader.assignmentsViewMode"; + +const formatDate = (iso?: string | null) => { + if (!iso) return "No due date"; + try { + return new Date(iso).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric" + }); + } catch { + return iso; + } +}; + +const buildInitialFilters = (): DataTableFilterMeta => ({ + name: { value: null, matchMode: FilterMatchMode.CONTAINS }, + description: { value: null, matchMode: FilterMatchMode.CONTAINS }, + points: { value: null, matchMode: FilterMatchMode.EQUALS } +}); + +const matchesDateRange = ( + dueDate: Date | null, + range: [Date | null, Date | null] | null +): boolean => { + if (!range) return true; + const [from, to] = range; + if (!from && !to) return true; + if (!dueDate) return false; + const t = dueDate.getTime(); + if (from) { + const start = new Date(from); + start.setHours(0, 0, 0, 0); + if (t < start.getTime()) return false; + } + if (to) { + const end = new Date(to); + end.setHours(23, 59, 59, 999); + if (t > end.getTime()) return false; + } + return true; +}; + +export const GraderAssignmentsPage: React.FC = () => { + const navigate = useNavigate(); + const { data: realAssignments, isLoading } = useGetAssignmentsQuery(); + const { isDemo } = useGraderTourContext(); + const assignments = isDemo ? DEMO_ASSIGNMENTS : realAssignments; + + const [viewMode, setViewMode] = useViewModeStorage( + VIEW_MODE_STORAGE_KEY, + VIEW_MODES, + "cards" + ); + + const [filters, setFilters] = useState(buildInitialFilters); + + const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>( + null + ); + + const [first, setFirst] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [sortField, setSortField] = useState("duedateDate"); + const [sortOrder, setSortOrder] = useState<1 | -1 | 0 | null | undefined>(1); + + if (!assignments && isLoading) { + return ( +
+ +
+ ); + } + + if (!assignments?.length) { + return ( +
+ +

No assignments yet

+

Create an assignment in the Assignment Builder to start grading.

+
+ ); + } + + const allRows = assignments.map((a) => ({ + ...a, + duedateDate: a.duedate ? new Date(a.duedate) : null, + duedateDisplay: formatDate(a.duedate) + })); + + const rows = dateRange + ? allRows.filter((r) => matchesDateRange(r.duedateDate, dateRange)) + : allRows; + + const viewToggle = ( + + ); + + if (viewMode === "table") { + return ( + <> + {viewToggle} +
+ { + setFirst(e.first); + setRowsPerPage(e.rows); + }} + rowsPerPageOptions={[10, 25, 50, 100]} + sortField={sortField} + sortOrder={sortOrder} + onSort={(e) => { + setSortField(e.sortField); + setSortOrder(e.sortOrder as 1 | -1 | 0 | null | undefined); + setFirst(0); + }} + selectionMode="single" + onRowClick={(e) => { + const row = e.data as (typeof rows)[number]; + navigate(`/grader/${row.id}`); + }} + rowHover + className={styles.row} + emptyMessage="No assignments match the current filters." + filters={filters} + onFilter={(e) => { + setFilters(e.filters); + setFirst(0); + }} + filterDisplay="row" + > + ( +
+ + {row.name} +
+ )} + /> + + row.description ? ( + + {row.description} + + ) : ( + + ) + } + /> + { + const [startDate, endDate] = dateRange ?? [null, null]; + return ( + { + const [from, to] = + (dates as [Date | null, Date | null]) ?? [null, null]; + setDateRange(!from && !to ? null : [from, to]); + setFirst(0); + }} + isClearable + dateFormat="MMM d, yyyy" + placeholderText="Pick range" + className={styles.dateFilterInput} + wrapperClassName={styles.dateFilterWrapper} + popperClassName={styles.dateFilterPopper} + portalId="root" + /> + ); + }} + style={{ width: 240 }} + body={(row) => ( + + + {row.duedateDisplay} + + )} + /> + ( + + {row.points ?? 0} + pts + + )} + /> + ( + + )} + /> +
+
+ + ); + } + + return ( + <> + {viewToggle} +
+ {assignments.map((a, idx) => ( + + ))} +
+ + ); +}; + +export default GraderAssignmentsPage; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx new file mode 100644 index 000000000..c0b40a425 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx @@ -0,0 +1,387 @@ +import { Button } from "primereact/button"; +import { ProgressSpinner } from "primereact/progressspinner"; +import { Toast } from "primereact/toast"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { + GraderStudentAnswer, + useGetGraderAnswersQuery, + useGetGraderQuestionsQuery, + useSaveGradeMutation +} from "@store/grader/grader.logic.api"; + +import { GradePanel, GradePanelHandle } from "../components/GradePanel"; +import { ShortcutsHelpDialog } from "../components/ShortcutsHelpDialog"; +import { StudentListSidebar } from "../components/StudentListSidebar"; +import { SubmissionPane, SubmissionPaneHandle } from "../components/SubmissionPane"; +import { AutoSavedInfo, useAutoSaveGrade } from "../hooks/useAutoSaveGrade"; +import { useGraderHotkeys } from "../hooks/useGraderHotkeys"; +import { useGraderPrefs } from "../hooks/useGraderPrefs"; +import { usePlatform } from "../hooks/usePlatform"; +import { useStudentNavigation } from "../hooks/useStudentNavigation"; +import styles from "../Grader.module.css"; +import { getDemoAnswersFor, getDemoQuestionsFor } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +export const GraderQuestionPage: React.FC = () => { + const { assignmentId, questionId, sid } = useParams(); + const aid = Number(assignmentId); + const qid = Number(questionId); + const navigate = useNavigate(); + const location = useLocation(); + const platform = usePlatform(); + + const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext(); + + const { data: qRealData } = useGetGraderQuestionsQuery(aid, { + skip: !aid || isDemo + }); + const { data: realData, isLoading } = useGetGraderAnswersQuery( + { assignmentId: aid, questionId: qid }, + { skip: !aid || !qid || isDemo } + ); + const data = isDemo ? getDemoAnswersFor(aid, qid) ?? undefined : realData; + const qData = isDemo ? getDemoQuestionsFor(aid) ?? undefined : qRealData; + const questionMeta = qData?.questions.find((q) => q.id === qid); + + const answers: ReadonlyArray = data?.answers ?? []; + const activeSid = isDemo ? demoSelected?.sid : sid; + + const [dirtySids, setDirtySids] = useState>(new Set()); + + const nav = useStudentNavigation({ + answers, + question: questionMeta, + dirtySids, + questions: qData?.questions + }); + + useEffect(() => { + if (isDemo) return; + if (sid || !data) return; + const selectLast = (location.state as { selectLast?: boolean } | null)?.selectLast === true; + let targetSid: string | undefined; + if (selectLast && answers.length) { + targetSid = answers[answers.length - 1].sid; + } else if (nav.firstUngraded) { + targetSid = nav.firstUngraded; + } else if (answers.length) { + targetSid = answers[0].sid; + } + if (targetSid) { + navigate( + `/grader/${aid}/questions/${qid}/students/${encodeURIComponent(targetSid)}`, + + { replace: true, state: null } + ); + } + }, [isDemo, sid, data, nav.firstUngraded, answers, aid, qid, navigate, location.state]); + + const student = nav.current; + + const { prefs, updatePrefs } = useGraderPrefs(); + const toastRef = useRef(null); + const [revertGrade] = useSaveGradeMutation(); + + const advanceTimerRef = useRef(null); + const cancelPendingAdvance = useCallback(() => { + if (advanceTimerRef.current != null) { + window.clearTimeout(advanceTimerRef.current); + advanceTimerRef.current = null; + } + }, []); + + const answersRef = useRef(answers); + useEffect(() => { + answersRef.current = answers; + }, [answers]); + const navRef = useRef(nav); + useEffect(() => { + navRef.current = nav; + }, [nav]); + const prefsRef = useRef(prefs); + useEffect(() => { + prefsRef.current = prefs; + }, [prefs]); + const questionNameRef = useRef(data?.question.name ?? ""); + useEffect(() => { + questionNameRef.current = data?.question.name ?? ""; + }, [data?.question.name]); + + const autoSaveRef = useRef | null>(null); + + const undoSave = useCallback( + async (info: AutoSavedInfo) => { + cancelPendingAdvance(); + toastRef.current?.clear(); + try { + if (!isDemo) { + await revertGrade({ + sid: info.sid, + div_id: info.questionName, + score: info.previous.score, + comment: info.previous.comment, + questionId: info.questionId, + assignmentId: aid + }).unwrap(); + } + } catch { + + } + + navRef.current.goTo(info.sid); + + autoSaveRef.current?.revertTo(info.previous); + }, + [cancelPendingAdvance, isDemo, revertGrade, aid] + ); + + const handleSaved = useCallback( + (info: AutoSavedInfo) => { + const row = answersRef.current.find((a) => a.sid === info.sid); + const displayName = + row && (row.first_name || row.last_name) + ? `${row.first_name ?? ""} ${row.last_name ?? ""}`.trim() + : info.sid; + + toastRef.current?.replace({ + severity: "success", + content: ( +
+ +
+
Saved {displayName}
+
+ {info.next.score} pt{info.next.score === 1 ? "" : "s"} + {info.next.comment ? " · with comment" : ""} +
+
+
+ ), + life: 5000 + }); + + if (prefsRef.current.autoAdvance && navRef.current.hasNext) { + cancelPendingAdvance(); + advanceTimerRef.current = window.setTimeout(() => { + advanceTimerRef.current = null; + + navRef.current.goNextUngraded(); + }, 650); + } + }, + [cancelPendingAdvance, undoSave] + ); + + useEffect(() => () => cancelPendingAdvance(), [cancelPendingAdvance, qid]); + + const autoSave = useAutoSaveGrade({ + sid: student?.sid, + assignmentId: aid, + questionId: qid, + questionName: data?.question.name ?? "", + maxPoints: data?.question.max_points ?? 0, + initialScore: student?.score ?? 0, + initialComment: student?.comment ?? "", + onSaved: handleSaved + }); + + useEffect(() => { + autoSaveRef.current = autoSave; + }, [autoSave]); + + useEffect(() => { + if (!student?.sid) return; + const id = student.sid; + if (autoSave.status === "dirty" || autoSave.status === "saving") { + + cancelPendingAdvance(); + setDirtySids((prev) => { + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + return next; + }); + } else { + setDirtySids((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, [autoSave.status, student?.sid]); + + const [help, setHelp] = useState(false); + const [hideGraded, setHideGraded] = useState(false); + const gradePanelRef = useRef(null); + const submissionRef = useRef(null); + + useGraderHotkeys({ + next: async () => { + cancelPendingAdvance(); + await autoSave.flush(); + nav.next(); + }, + prev: async () => { + cancelPendingAdvance(); + await autoSave.flush(); + nav.prev(); + }, + nextAttempt: () => submissionRef.current?.nextAttempt(), + prevAttempt: () => submissionRef.current?.prevAttempt(), + focusGrade: () => gradePanelRef.current?.focusGrade(), + focusComment: () => gradePanelRef.current?.focusComment(), + toggleHideGraded: () => setHideGraded((v) => !v), + openHelp: () => setHelp(true) + }); + + const selectSid = async (nextSid: string) => { + if (nextSid === activeSid) return; + cancelPendingAdvance(); + + await autoSave.flush(); + if (isDemo) { + const row = answers.find((a) => a.sid === nextSid); + setDemoSelected(row ?? null); + } else { + navigate( + `/grader/${aid}/questions/${qid}/students/${encodeURIComponent(nextSid)}` + ); + } + }; + + const positionLabel = useMemo(() => { + if (nav.currentIndex < 0) return undefined; + return `${nav.currentIndex + 1} / ${nav.total}`; + }, [nav.currentIndex, nav.total]); + + if (!data && isLoading) { + return ( +
+ +
+ ); + } + if (!data) { + return
Could not load student answers.
; + } + + return ( + <> +
+ + +
+ {!student ? ( +
+
+ {answers.length === 0 ? ( +

No student answers yet.

+ ) : ( +

Select a student from the list to start grading.

+ )} +
+ {}} + onCommentChange={() => {}} + onScoreBlur={() => {}} + onCommentBlur={() => {}} + onPrev={() => nav.prev()} + onNext={() => nav.next()} + onRetry={() => {}} + /> +
+ ) : ( +
+ + { + cancelPendingAdvance(); + await autoSave.flush(); + nav.prev(); + }} + onNext={async () => { + cancelPendingAdvance(); + await autoSave.flush(); + nav.next(); + }} + onRetry={autoSave.saveNow} + autoAdvance={prefs.autoAdvance} + onAutoAdvanceChange={(v) => updatePrefs({ autoAdvance: v })} + /> +
+ )} +
+
+ + + + setHelp(false)} + platform={platform} + /> + + ); +}; + +export default GraderQuestionPage; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx new file mode 100644 index 000000000..9ca8ab029 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx @@ -0,0 +1,453 @@ +import { FilterMatchMode } from "primereact/api"; +import { Column } from "primereact/column"; +import { DataTable, DataTableFilterMeta } from "primereact/datatable"; +import { Dropdown } from "primereact/dropdown"; +import { ProgressSpinner } from "primereact/progressspinner"; +import React, { useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; + +import { useGetGraderQuestionsQuery } from "@store/grader/grader.logic.api"; + +import { + GraderViewMode, + ViewModeToggle +} from "../components/ViewModeToggle"; +import styles from "../Grader.module.css"; +import { useViewModeStorage } from "../hooks/useViewModeStorage"; +import { getDemoQuestionsFor } from "../tour/graderDemoData"; +import { useGraderTourContext } from "../tour/GraderTourContext"; + +const friendlyType = (t: string) => { + const map: Record = { + mchoice: "Multiple choice", + fillintheblank: "Fill in the blank", + parsonsprob: "Parsons", + activecode: "Active code", + shortanswer: "Short answer", + clickablearea: "Clickable", + dragndrop: "Drag & drop", + codelens: "Codelens", + matching: "Matching", + webwork: "WeBWorK" + }; + return map[t] || t; +}; + +const MANUALLY_SCORED_TYPES = new Set(["shortanswer"]); + +const PARTIAL_CREDIT_TYPES = new Set([ + "dragndrop", + "clickablearea", + "matching", + "fillintheblank", + "parsonsprob", + "microparsons", + "hparsons" +]); + +const VIEW_MODES = ["cards", "table"] as const satisfies readonly GraderViewMode[]; +const VIEW_MODE_STORAGE_KEY = "grader.questionsViewMode"; + +const buildInitialFilters = (): DataTableFilterMeta => ({ + name: { value: null, matchMode: FilterMatchMode.CONTAINS }, + question_type: { value: null, matchMode: FilterMatchMode.EQUALS }, + answered_count: { value: null, matchMode: FilterMatchMode.EQUALS }, + correct_count: { value: null, matchMode: FilterMatchMode.EQUALS }, + points: { value: null, matchMode: FilterMatchMode.EQUALS } +}); + +type QuestionRow = ReturnType extends infer T + ? T extends { questions: (infer Q)[] | undefined } + ? Q + : never + : never; + +interface QuestionStats { + correctPct: number; + pointsPct: number; + isManual: boolean; + usePartial: boolean; + correctLabel: string; + correctTooltip: string; + avgTooltip: string; +} + +const computeStats = (q: QuestionRow): QuestionStats => { + const isManual = MANUALLY_SCORED_TYPES.has(q.question_type); + const usePartial = + PARTIAL_CREDIT_TYPES.has(q.question_type) && q.avg_percent != null; + + const correctPct = usePartial + ? (q.avg_percent ?? 0) * 100 + : q.answered_count > 0 + ? (q.correct_count / q.answered_count) * 100 + : 0; + + const pointsPct = q.points > 0 ? (q.average_score / q.points) * 100 : 0; + const avgDenominator = q.graded_count ?? 0; + + const correctLabel = usePartial + ? "avg. credit" + : isManual + ? "fully scored" + : "fully correct"; + const correctTooltip = usePartial + ? `Mean partial credit across all answers (${q.question_type})` + : isManual + ? "Students whose graded score equals the question's max points" + : "Students who submitted a fully correct answer at least once"; + const avgTooltip = avgDenominator + ? `Average across ${avgDenominator} graded student${ + avgDenominator === 1 ? "" : "s" + }` + : "No graded submissions yet"; + + return { + correctPct, + pointsPct, + isManual, + usePartial, + correctLabel, + correctTooltip, + avgTooltip + }; +}; + +export const GraderQuestionsPage: React.FC = () => { + const { assignmentId } = useParams(); + const id = Number(assignmentId); + const navigate = useNavigate(); + const { isDemo } = useGraderTourContext(); + const { data: realData, isLoading, isError } = useGetGraderQuestionsQuery(id, { + skip: !id || isDemo, + refetchOnMountOrArgChange: true + }); + const data = isDemo ? getDemoQuestionsFor(id) ?? undefined : realData; + + const [viewMode, setViewMode] = useViewModeStorage( + VIEW_MODE_STORAGE_KEY, + VIEW_MODES, + "cards" + ); + + const [filters, setFilters] = useState(buildInitialFilters); + + const [first, setFirst] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [sortField, setSortField] = useState(undefined); + const [sortOrder, setSortOrder] = useState<1 | -1 | 0 | null | undefined>(null); + + const typeOptions = useMemo(() => { + if (!data) return [] as { label: string; value: string }[]; + const unique = Array.from(new Set(data.questions.map((q) => q.question_type))); + return unique + .map((t) => ({ label: friendlyType(t), value: t })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [data]); + + if (!data && isLoading) { + return ( +
+ +
+ ); + } + + if (!data) { + return ( +
+ +

Could not load questions

+ {isError &&

The server returned an error.

} +
+ ); + } + + if (!data.questions.length) { + return ( +
+

No gradable questions in this assignment.

+
+ ); + } + + const viewToggle = ( + + ); + + if (viewMode === "table") { + return ( + <> + {viewToggle} +
+ { + setFirst(e.first); + setRowsPerPage(e.rows); + }} + rowsPerPageOptions={[10, 25, 50, 100]} + sortField={sortField} + sortOrder={sortOrder} + onSort={(e) => { + setSortField(e.sortField); + setSortOrder(e.sortOrder as 1 | -1 | 0 | null | undefined); + setFirst(0); + }} + selectionMode="single" + onRowClick={(e) => { + const row = e.data as QuestionRow; + navigate(`/grader/${id}/questions/${row.id}`); + }} + rowHover + className={styles.row} + emptyMessage="No questions match the current filters." + filters={filters} + onFilter={(e) => { + setFilters(e.filters); + setFirst(0); + }} + filterDisplay="row" + > + ( + + {row.name} + + )} + /> + ( + options.filterApplyCallback(e.value)} + placeholder="Any" + showClear + style={{ width: "100%", minWidth: 0 }} + /> + )} + body={(row: QuestionRow) => ( + + {friendlyType(row.question_type)} + + )} + /> + ( + + + {row.answered_count} + + )} + /> + { + const stats = computeStats(row); + return ( + + {row.correct_count} + + {" "} + {stats.correctLabel} + + + ); + }} + /> + { + const stats = computeStats(row); + return ( + + {row.average_score} + / {row.points} + + ); + }} + /> + ( + + {row.points} + + )} + /> + { + const stats = computeStats(row); + return ( +
+ + {Math.round(stats.correctPct)}%{" "} + {stats.usePartial ? "credit" : "correct"} + +
+
+
+
+ ); + }} + /> + ( + + )} + /> + +
+ + ); + } + + return ( + <> + {viewToggle} +
+ {data.questions.map((q) => { + const stats = computeStats(q); + return ( + + ); + })} +
+ + ); +}; + +export default GraderQuestionsPage; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts new file mode 100644 index 000000000..899916e29 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts @@ -0,0 +1,119 @@ +import { + GraderQuestionStats, + GraderStudentAnswer +} from "@store/grader/grader.logic.api"; + +export type StudentGradingStatus = + | "graded" + | "autograded" + | "in_progress" + | "pending" + | "no_submission"; + +const isAutogradeManual = (autograde?: string) => + !autograde || autograde === "manual"; + +export const getStudentStatus = ( + s: GraderStudentAnswer, + q?: { autograde?: string }, + opts: { dirtySids?: ReadonlySet } = {} +): StudentGradingStatus => { + if (opts.dirtySids?.has(s.sid)) return "in_progress"; + + if (s.attempts === 0 && s.score == null) return "no_submission"; + + if (isAutogradeManual(q?.autograde)) { + if (s.score != null || (s.comment && s.comment.length > 0)) return "graded"; + return "pending"; + } + + if (s.score != null) { + + if (s.comment && s.comment.length > 0) return "graded"; + return "autograded"; + } + return "pending"; +}; + +export interface QuestionProgress { + total: number; + graded: number; + autograded: number; + pending: number; + noSubmission: number; + inProgress: number; + + donePct: number; +} + +export const getQuestionProgress = ( + answers: ReadonlyArray, + q?: { autograde?: string }, + opts: { dirtySids?: ReadonlySet } = {} +): QuestionProgress => { + const out: QuestionProgress = { + total: answers.length, + graded: 0, + autograded: 0, + pending: 0, + noSubmission: 0, + inProgress: 0, + donePct: 0 + }; + for (const a of answers) { + const st = getStudentStatus(a, q, opts); + if (st === "graded") out.graded++; + else if (st === "autograded") out.autograded++; + else if (st === "pending") out.pending++; + else if (st === "no_submission") out.noSubmission++; + else if (st === "in_progress") out.inProgress++; + } + const done = out.graded + out.autograded; + out.donePct = out.total ? (done / out.total) * 100 : 0; + return out; +}; + +export const findNextUngradedSid = ( + answers: ReadonlyArray, + fromIndex: number, + q?: { autograde?: string }, + opts: { dirtySids?: ReadonlySet } = {} +): string | null => { + for (let i = fromIndex + 1; i < answers.length; i++) { + const st = getStudentStatus(answers[i], q, opts); + if (st === "pending" || st === "in_progress") return answers[i].sid; + } + return null; +}; + +export const findFirstUngradedSid = ( + answers: ReadonlyArray, + q?: { autograde?: string }, + opts: { dirtySids?: ReadonlySet } = {} +): string | null => findNextUngradedSid(answers, -1, q, opts); + +export const statusLabel: Record = { + graded: "Graded", + autograded: "Auto-graded", + in_progress: "In progress", + pending: "Pending", + no_submission: "No submission" +}; + +export const statusIcon: Record = { + graded: "pi pi-check-circle", + autograded: "pi pi-bolt", + in_progress: "pi pi-spin pi-sync", + pending: "pi pi-circle", + no_submission: "pi pi-minus-circle" +}; + +export const statusColor: Record = { + graded: "#16a34a", + autograded: "#0ea5e9", + in_progress: "#f59e0b", + pending: "#94a3b8", + no_submission: "#cbd5e1" +}; + +export type { GraderQuestionStats }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx new file mode 100644 index 000000000..0c0bfd131 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx @@ -0,0 +1,41 @@ +import React, { createContext, useContext, useMemo, useState } from "react"; + +import type { GraderStudentAnswer } from "@store/grader/grader.logic.api"; + +interface GraderTourContextValue { + isDemo: boolean; + demoSelected: GraderStudentAnswer | null; + setDemoSelected: (s: GraderStudentAnswer | null) => void; + setIsDemo: (v: boolean) => void; +} + +const Ctx = createContext(null); + +export const GraderTourProvider: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const [isDemo, setIsDemo] = useState(false); + const [demoSelected, setDemoSelected] = + useState(null); + + const value = useMemo( + () => ({ isDemo, setIsDemo, demoSelected, setDemoSelected }), + [isDemo, demoSelected] + ); + + return {children}; +}; + +export const useGraderTourContext = (): GraderTourContextValue => { + const v = useContext(Ctx); + if (!v) { + return { + isDemo: false, + demoSelected: null, + setDemoSelected: () => {}, + setIsDemo: () => {} + }; + } + return v; +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts new file mode 100644 index 000000000..3cf2a61c6 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts @@ -0,0 +1,495 @@ +import type { Assignment } from "@/types/assignment"; +import type { + GraderAnswersResponse, + GraderHistoryResponse, + GraderQuestionsResponse, + GraderStudentAnswer +} from "@store/grader/grader.logic.api"; + +export const DEMO_ASSIGNMENT_ID = 900001; +export const DEMO_ALT_ASSIGNMENT_ID = 900002; +export const DEMO_ALT2_ASSIGNMENT_ID = 900003; + +export const DEMO_QUESTION_ID = 910001; +export const DEMO_ACTIVECODE_QID = 910002; +export const DEMO_PARSONS_QID = 910003; +export const DEMO_FITB_QID = 910004; +export const DEMO_SHORT_QID = 910005; +export const DEMO_CLICKABLE_QID = 910006; +export const DEMO_DND_QID = 910007; +export const DEMO_CODELENS_QID = 910008; +export const DEMO_MATCHING_QID = 910009; +export const DEMO_WEBWORK_QID = 910010; + +export const DEMO_STUDENT_SID = "demo-student-1"; + +export const DEMO_MCHOICE_HTMLSRC = ` +
+
+

Question: Which loop iterates exactly five times and prints the numbers 0 through 4?

+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + +
+ Feedback: Review how range(n) produces the sequence 0 .. n-1. +
+
+
+
+`; + +const iso = (d: Date) => d.toISOString(); +const daysFromNow = (n: number) => { + const d = new Date(); + d.setDate(d.getDate() + n); + return iso(d); +}; + +const baseAssignment = { + updated_date: null, + visible_on: null, + hidden_on: null, + visible: true, + is_peer: false, + is_timed: false, + nofeedback: false, + nopause: false, + time_limit: null, + peer_async_visible: false, + exercises: [] as [], + all_assignments: [] as [], + search_results: [] as [], + isAuthorized: true, + released: true, + selectedAssignments: [] as [], + course: 0, + threshold_pct: null, + allow_self_autograde: null, + from_source: false, + current_index: 0, + enforce_due: false +}; + +export const DEMO_ASSIGNMENTS: Assignment[] = [ + { + ...baseAssignment, + id: DEMO_ASSIGNMENT_ID, + name: "Demo – Week 3: Loops & Functions", + description: + "Sample assignment used by the tour — covers every question type supported by the grader.", + duedate: daysFromNow(4), + points: 40, + kind: "Regular", + question_count: 10 + }, + { + ...baseAssignment, + id: DEMO_ALT_ASSIGNMENT_ID, + name: "Demo – Week 4: Lists & Dictionaries", + description: + "Second demo assignment, shown so the picker grid is not a lone card.", + duedate: daysFromNow(11), + points: 30, + kind: "Regular", + question_count: 6 + }, + { + ...baseAssignment, + id: DEMO_ALT2_ASSIGNMENT_ID, + name: "Demo – Peer review: Algorithms", + description: "A third demo so the grid renders at least one row comfortably.", + duedate: daysFromNow(18), + points: 20, + is_peer: true, + peer_async_visible: true, + kind: "Peer", + question_count: 4 + } +]; + +export const DEMO_QUESTIONS: GraderQuestionsResponse = { + assignment: { + id: DEMO_ASSIGNMENT_ID, + name: DEMO_ASSIGNMENTS[0].name, + description: DEMO_ASSIGNMENTS[0].description, + duedate: DEMO_ASSIGNMENTS[0].duedate, + points: DEMO_ASSIGNMENTS[0].points + }, + questions: [ + { + id: DEMO_QUESTION_ID, + name: "q_loops_mchoice", + question_type: "mchoice", + points: 5, + autograde: "all_or_nothing", + which_to_grade: "best_answer", + answered_count: 22, + correct_count: 17, + average_score: 3.9 + }, + { + id: DEMO_ACTIVECODE_QID, + name: "q_reverse_list_ac", + question_type: "activecode", + points: 8, + autograde: "unittest", + which_to_grade: "last_answer", + answered_count: 20, + correct_count: 11, + average_score: 5.2 + }, + { + id: DEMO_PARSONS_QID, + name: "q_swap_parsons", + question_type: "parsonsprob", + points: 4, + autograde: "manual", + which_to_grade: "best_answer", + answered_count: 18, + correct_count: 14, + average_score: 3.4 + }, + { + id: DEMO_FITB_QID, + name: "q_syntax_fitb", + question_type: "fillintheblank", + points: 3, + autograde: "all_or_nothing", + which_to_grade: "first_answer", + answered_count: 24, + correct_count: 19, + average_score: 2.6 + }, + { + id: DEMO_SHORT_QID, + name: "q_explain_shortanswer", + question_type: "shortanswer", + points: 6, + autograde: "manual", + which_to_grade: "manual", + answered_count: 15, + correct_count: 0, + average_score: 3.1 + }, + { + id: DEMO_CLICKABLE_QID, + name: "q_find_bug_click", + question_type: "clickablearea", + points: 3, + autograde: "all_or_nothing", + which_to_grade: "best_answer", + answered_count: 19, + correct_count: 12, + average_score: 2.0 + }, + { + id: DEMO_DND_QID, + name: "q_order_steps_dnd", + question_type: "dragndrop", + points: 4, + autograde: "all_or_nothing", + which_to_grade: "best_answer", + answered_count: 17, + correct_count: 10, + average_score: 2.6 + }, + { + id: DEMO_CODELENS_QID, + name: "q_trace_codelens", + question_type: "codelens", + points: 3, + autograde: "interact", + which_to_grade: "last_answer", + answered_count: 13, + correct_count: 13, + average_score: 3.0 + }, + { + id: DEMO_MATCHING_QID, + name: "q_match_concepts", + question_type: "matching", + points: 2, + autograde: "all_or_nothing", + which_to_grade: "best_answer", + answered_count: 16, + correct_count: 9, + average_score: 1.3 + }, + { + id: DEMO_WEBWORK_QID, + name: "q_calc_webwork", + question_type: "webwork", + points: 2, + autograde: "all_or_nothing", + which_to_grade: "best_answer", + answered_count: 8, + correct_count: 5, + average_score: 1.4 + } + ] +}; + +const firstNames = [ + "Alex", "Bob", "Carmen", "Dana", "Elena", "Felix", "Grace", "Hassan", + "Ivy", "Jin", "Kai", "Lola", "Maxim", "Nina", "Oleg", "Pia", + "Quentin", "Rita", "Sergey", "Tara" +]; +const lastNames = [ + "Kowalski", "Müller", "Rossi", "Dupont", "Svensson", "Jones", "Reyes", "Khan", + "Singh", "ONeil", "Ng", "Petrov", "Tanaka", "Hernández", "Lind", "Ahmadi", + "Wójcik", "Silva", "Haddad", "Park" +]; + +const mchoiceAnswer = (i: number) => { + const choices = ["0", "1", "2", "3"]; + return choices[i % choices.length]; +}; + +export const DEMO_ANSWERS: GraderAnswersResponse = { + question: { + id: DEMO_QUESTION_ID, + name: "q_loops_mchoice", + question_type: "mchoice", + htmlsrc: DEMO_MCHOICE_HTMLSRC, + max_points: 5 + }, + answers: Array.from({ length: 20 }, (_, i): GraderStudentAnswer => { + const correct = i % 3 !== 2; + const partial = !correct && i % 5 === 0; + const attempts = (i % 4) + 1; + return { + sid: i === 0 ? DEMO_STUDENT_SID : `demo-student-${i + 1}`, + first_name: firstNames[i], + last_name: lastNames[i], + email: `${firstNames[i].toLowerCase()}@demo.edu`, + answer: mchoiceAnswer(i), + correct: correct, + percent: correct ? 1 : partial ? 0.5 : 0, + timestamp: iso(new Date(Date.now() - i * 3600_000)), + attempts, + score: correct ? 5 : partial ? 3 : i % 2 === 0 ? null : 1, + comment: + i === 0 + ? "Nice reasoning! Consider off-by-one cases next time." + : i === 1 + ? "Partial — rechecked after lab." + : i % 5 === 0 + ? "Please show your work next submission." + : null, + max_points: 5 + }; + }) +}; + +export const DEMO_HISTORY: GraderHistoryResponse = { + history: [ + { + id: 1, + answer: "2", + correct: false, + percent: 0, + timestamp: iso(new Date(Date.now() - 26 * 3600_000)), + source: "mchoice" + }, + { + id: 2, + answer: "3", + correct: false, + percent: 0, + timestamp: iso(new Date(Date.now() - 20 * 3600_000)), + source: "mchoice" + }, + { + id: 3, + answer: "1", + correct: false, + percent: 0.5, + timestamp: iso(new Date(Date.now() - 8 * 3600_000)), + source: "mchoice" + }, + { + id: 4, + answer: "1", + correct: false, + percent: 0.5, + timestamp: iso(new Date(Date.now() - 4 * 3600_000)), + source: "mchoice" + }, + { + id: 5, + answer: "0", + correct: true, + percent: 1, + timestamp: iso(new Date(Date.now() - 3600_000)), + source: "mchoice" + } + ], + useinfo: [ + { + id: 1, + timestamp: iso(new Date(Date.now() - 26.1 * 3600_000)), + event: "mChoice", + act: "answer:2:no" + }, + { + id: 2, + timestamp: iso(new Date(Date.now() - 20.1 * 3600_000)), + event: "mChoice", + act: "answer:3:no" + }, + { + id: 3, + timestamp: iso(new Date(Date.now() - 8.1 * 3600_000)), + event: "mChoice", + act: "answer:1:partial" + }, + { + id: 4, + timestamp: iso(new Date(Date.now() - 4.1 * 3600_000)), + event: "mChoice", + act: "answer:1:partial" + }, + { + id: 5, + timestamp: iso(new Date(Date.now() - 1.1 * 3600_000)), + event: "mChoice", + act: "answer:0:correct" + } + ] +}; + +export const getDemoHistoryFor = (sid: string): GraderHistoryResponse => { + const student = DEMO_ANSWERS.answers.find((a) => a.sid === sid); + if (!student) return { history: [], useinfo: [] }; + if (sid === DEMO_STUDENT_SID) return DEMO_HISTORY; + + const total = Math.max(1, student.attempts); + const baseTs = Date.now(); + const distractors = ["1", "2", "3"].filter((v) => v !== student.answer); + const finalAnswer = student.answer ?? "0"; + const finalCorrect = student.correct ?? false; + const finalPercent = student.percent ?? (finalCorrect ? 1 : 0); + + const history = Array.from({ length: total }, (_, i) => { + const isLast = i === total - 1; + const attemptAnswer = isLast + ? finalAnswer + : distractors[i % distractors.length] ?? "1"; + const attemptCorrect = isLast ? finalCorrect : false; + const attemptPercent = isLast ? finalPercent : i === total - 2 ? 0.5 : 0; + return { + id: i + 1, + answer: attemptAnswer, + correct: attemptCorrect, + percent: attemptPercent, + timestamp: iso(new Date(baseTs - (total - i) * 3 * 3600_000)), + source: "mchoice" + }; + }); + + const useinfo = history.map((h, i) => ({ + id: i + 1, + timestamp: h.timestamp, + event: "mChoice", + act: `answer:${h.answer}:${ + h.correct ? "correct" : (h.percent ?? 0) > 0 ? "partial" : "no" + }` + })); + + return { history, useinfo }; +}; + +export const getDemoQuestionsFor = ( + aid: number +): GraderQuestionsResponse | null => { + if (aid === DEMO_ASSIGNMENT_ID) return DEMO_QUESTIONS; + if (aid === DEMO_ALT_ASSIGNMENT_ID) { + return { + assignment: { + id: DEMO_ALT_ASSIGNMENT_ID, + name: DEMO_ASSIGNMENTS[1].name, + description: DEMO_ASSIGNMENTS[1].description, + duedate: DEMO_ASSIGNMENTS[1].duedate, + points: DEMO_ASSIGNMENTS[1].points + }, + questions: DEMO_QUESTIONS.questions.slice(0, 6).map((q, i) => ({ + ...q, + id: q.id + 10_000, + name: `alt_${q.name}`, + answered_count: Math.max(0, q.answered_count - 4 - i) + })) + }; + } + if (aid === DEMO_ALT2_ASSIGNMENT_ID) { + return { + assignment: { + id: DEMO_ALT2_ASSIGNMENT_ID, + name: DEMO_ASSIGNMENTS[2].name, + description: DEMO_ASSIGNMENTS[2].description, + duedate: DEMO_ASSIGNMENTS[2].duedate, + points: DEMO_ASSIGNMENTS[2].points + }, + questions: DEMO_QUESTIONS.questions.slice(0, 4).map((q) => ({ + ...q, + id: q.id + 20_000, + name: `peer_${q.name}` + })) + }; + } + return null; +}; + +export const getDemoAnswersFor = ( + aid: number, + qid: number +): GraderAnswersResponse | null => { + if (aid === DEMO_ASSIGNMENT_ID && qid === DEMO_QUESTION_ID) return DEMO_ANSWERS; + const qMeta = getDemoQuestionsFor(aid)?.questions.find((q) => q.id === qid); + if (!qMeta) return null; + return { + question: { + id: qMeta.id, + name: qMeta.name, + question_type: qMeta.question_type, + htmlsrc: qMeta.question_type === "mchoice" ? DEMO_MCHOICE_HTMLSRC : undefined, + max_points: qMeta.points + }, + answers: DEMO_ANSWERS.answers.map((a) => ({ ...a, max_points: qMeta.points })) + }; +}; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts new file mode 100644 index 000000000..1e3fe9678 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts @@ -0,0 +1,226 @@ +import { Alignment, Side } from "driver.js"; + +export type TourRoute = "assignments" | "questions" | "answers" | "student"; + +export interface TourStepConfig { + + element: string; + title: string; + description: string; + side: Side; + align: Alignment; + + route: TourRoute; + + openDialog?: boolean; +} + +export const GRADER_TOUR_STEPS: TourStepConfig[] = [ + { + route: "assignments", + element: '[data-tour="grader-title"]', + title: "Welcome to the Grader", + description: + "This quick tour walks through every feature. We've loaded a demo course so you can see each view — even if your real course is empty.", + side: "bottom", + align: "start" + }, + { + route: "assignments", + element: '[data-tour="grader-shortcuts-btn"]', + title: "Keyboard shortcuts", + description: + "Power-users: open the cheatsheet at any time, or just hit ? on the keyboard. J / K (or ↑ / ↓) walk through students and roll over to the neighbouring question when you hit the end of the list.", + side: "left", + align: "start" + }, + { + route: "assignments", + element: '[data-tour="grader-take-tour-btn"]', + title: "Tour button", + description: + "You can re-open this tour at any time with the 'Take tour' button in the top-right corner.", + side: "left", + align: "start" + }, + { + route: "assignments", + element: '[data-tour="grader-breadcrumb"]', + title: "Breadcrumbs", + description: + "The breadcrumb always reflects where you are. Inside a question it also shows a graded/total chip so you can see your progress at a glance.", + side: "bottom", + align: "start" + }, + { + route: "assignments", + element: '[data-tour="grader-assignment-picker"]', + title: "Pick an assignment", + description: + "Every assignment in the course is listed as a card with its due date and total points. Click a card to drill in.", + side: "top", + align: "start" + }, + { + route: "assignments", + element: '[data-tour="grader-assignment-card"]', + title: "Assignment card", + description: + "Each card shows the name, a short description, due date and total points. Clicking opens that assignment's question list.", + side: "right", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-question-grid"]', + title: "Questions overview", + description: + "A grid of every gradable question in the assignment. The demo course includes one of every supported type so you can see the colour coding.", + side: "bottom", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-question-card"]', + title: "Question card", + description: + "Each card shows the question name, a colour-coded type tag, and four aggregated metrics.", + side: "right", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-q-answered"]', + title: "Answered — number of students", + description: + "How many students submitted at least one attempt. For ActiveCode this also includes students who only ran the code, so the card matches what you'll see on the per-question screen.", + side: "right", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-q-correct"]', + title: "Fully correct / fully scored — depends on type", + description: + "Students whose LAST attempt is a complete solution. The label changes with the question type:\n• Auto-graded (mchoice, activecode, codelens, webwork, splice, …): `correct = TRUE` (or `percent ≥ 1`) on the latest answer → shown as 'fully correct'.\n• Manually graded (shortanswer, parsons with `autograde=manual`, …): the instructor's score in `question_grades` equals the question's max points → shown as 'fully scored'.\n• Partial-credit types (dragndrop, clickablearea, matching, fillintheblank, parsons): we instead show 'avg. credit' — see the next step.", + side: "right", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-q-average"]', + title: "Average score — based on the gradebook", + description: + "Mean of `question_grades.score` across students who already have a graded row.", + side: "right", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-q-percent"]', + title: "% correct or % credit — depends on type", + description: + "For binary types this is `correct / answered` — the share of students whose last attempt is fully correct.\nFor partial-credit types (dragndrop, clickablearea, matching, fillintheblank, parsons) we switch to '% credit', which is the average `percent` (0–100%) over the LATEST attempt of every student. This is honest about partially-correct answers — '50% credit' on Drag-and-drop means the class on average got half the items right, not zero.", + side: "left", + align: "start" + }, + { + route: "questions", + element: '[data-tour="grader-q-progress"]', + title: "Progress bar — average / max points", + description: + "Visual ratio of `average score / max points` for the question. Empty bar means nobody is graded yet; a full bar means the average submission already earned the maximum.", + side: "bottom", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-split-pane"]', + title: "Unified grading view", + description: + "Everything you need on one screen: student list on the left, the submission preview in the middle, the grade panel on the right.", + side: "top", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-student-sidebar"]', + title: "Student sidebar", + description: + "All students who attempted the question. Coloured icons show progress: green ✓ already graded, blue ⚡ auto-graded, grey ◯ pending. Filter, search, or hit H to hide already-graded students.", + side: "right", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-preview-pane"]', + title: "Answer preview", + description: + "The question is rendered exactly as the student saw it. Different question types get specialised renderers (MCQ, Parsons, ActiveCode, Fill-in-the-blank, etc.).", + side: "right", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-history"]', + title: "Attempt history", + description: + "Every submission is recorded chronologically. Drag the slider to replay an earlier attempt right inside the preview pane.", + side: "bottom", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-grade-panel"]', + title: "Grade panel", + description: + "Score and comment for the active student. Changes are auto-saved when you tab out of a field — watch the small status pill in the top-right.", + side: "left", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-points-input"]', + title: "Award points", + description: + "Enter the score (clamped to the maximum). Press G anywhere to jump back to this field.", + side: "left", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-comment-input"]', + title: "Leave feedback", + description: + "Add a short comment the student will see on their results page.", + side: "left", + align: "start" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-next"]', + title: "Manual prev / next", + description: + "Walk through students with the Prev / Next buttons (or J / K on the keyboard). When you reach the last student in a question, Next rolls over to the first ungraded student of the next question; Prev does the same backwards. Changes are auto-saved as you type — there is no save button.", + side: "left", + align: "end" + }, + { + route: "student", + openDialog: true, + element: '[data-tour="grader-auto-advance"]', + title: "Auto-advance after save", + description: + "Flip this switch to keep your hands on the keyboard. Once a student's grade is auto-saved (and you stop editing for a moment), the Grader jumps to the next ungraded student automatically — perfect for long batches. A short Undo toast lets you roll back the move and the save if you change your mind.", + side: "left", + align: "end" + } +]; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts index c819da3b9..cc727037a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts @@ -56,6 +56,11 @@ export const buildNavBar = (eBookConfig: EBookConfig, navigate?: (path: string) // icon: "pi pi-gauge", // command: () => navigateToPath("grader") // }, + { + label: "Grader", + icon: "pi pi-check-square", + command: () => navigateToPath("grader") + }, { label: "Assignment Builder", icon: "pi pi-file-edit", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts index 0abaf111d..6baf75426 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts @@ -9,6 +9,7 @@ import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesSlice } from "@store/exercises/exercises.logic"; import { exercisesApi } from "@store/exercises/exercises.logic.api"; +import { graderApi } from "@store/grader/grader.logic.api"; import { readingsSlice } from "@store/readings/readings.logic"; import { readingsApi } from "@store/readings/readings.logic.api"; import { searchExercisesSlice } from "@store/searchExercises/searchExercises.logic"; @@ -48,7 +49,8 @@ const reducersMap = { [readingsApi.reducerPath]: readingsApi.reducer, [exercisesApi.reducerPath]: exercisesApi.reducer, [datasetApi.reducerPath]: datasetApi.reducer, - [datafileApi.reducerPath]: datafileApi.reducer + [datafileApi.reducerPath]: datafileApi.reducer, + [graderApi.reducerPath]: graderApi.reducer }; export type RootState = StateType; @@ -64,7 +66,8 @@ export const setupStore = (preloadedState?: Partial) => { readingsApi.middleware, exercisesApi.middleware, datasetApi.middleware, - datafileApi.middleware + datafileApi.middleware, + graderApi.middleware ); } }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts new file mode 100644 index 000000000..6aebddcb7 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts @@ -0,0 +1,196 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; + +import { baseQuery } from "@/store/baseQuery"; +import { DetailResponse } from "@/types/api"; + +export interface GraderAssignment { + id: number; + name: string; + description?: string; + duedate?: string; + points?: number; +} + +export interface GraderQuestionStats { + id: number; + name: string; + question_type: string; + htmlsrc?: string; + points: number; + autograde?: string; + which_to_grade?: string; + + answered_count: number; + + correct_count: number; + + graded_count?: number; + + average_score: number; + + avg_percent?: number | null; +} + +export interface GraderStudentAnswer { + sid: string; + first_name?: string; + last_name?: string; + email?: string; + answer: string; + correct?: boolean | null; + percent?: number | null; + timestamp?: string; + attempts: number; + score?: number | null; + comment?: string | null; + max_points: number; +} + +export interface GraderAnswerHistoryItem { + id: number; + + answer: string | Record | any[] | null; + correct?: boolean | null; + percent?: number | null; + timestamp?: string; + source?: string | null; +} + +export interface GraderUseinfoItem { + id: number; + timestamp?: string; + event: string; + act: string; +} + +export interface GraderQuestionsResponse { + assignment: GraderAssignment; + questions: GraderQuestionStats[]; +} + +export interface GraderAnswersResponse { + question: { + id: number; + name: string; + question_type: string; + htmlsrc?: string; + max_points: number; + }; + answers: GraderStudentAnswer[]; +} + +export interface GraderHistoryResponse { + history: GraderAnswerHistoryItem[]; + useinfo: GraderUseinfoItem[]; +} + +export interface GraderSavePayload { + sid: string; + div_id: string; + score: number; + comment?: string; + + questionId: number; + + assignmentId: number; +} + +export const graderApi = createApi({ + reducerPath: "graderApi", + baseQuery, + keepUnusedDataFor: 30, + tagTypes: ["GraderQuestions", "GraderAnswers"], + endpoints: (build) => ({ + getGraderQuestions: build.query({ + query: (assignmentId) => ({ + method: "GET", + url: `/assignment/instructor/grader/assignments/${assignmentId}/questions` + }), + providesTags: (_res, _err, id) => [{ type: "GraderQuestions", id }], + transformResponse: (r: DetailResponse) => r.detail + }), + getGraderAnswers: build.query< + GraderAnswersResponse, + { assignmentId: number; questionId: number } + >({ + query: ({ assignmentId, questionId }) => ({ + method: "GET", + url: `/assignment/instructor/grader/questions/answers?assignment_id=${assignmentId}&question_id=${questionId}` + }), + providesTags: (_res, _err, { questionId }) => [ + { type: "GraderAnswers", id: questionId } + ], + transformResponse: (r: DetailResponse) => r.detail + }), + getGraderHistory: build.query< + GraderHistoryResponse, + { assignmentId: number; questionId: number; sid: string } + >({ + query: ({ assignmentId, questionId, sid }) => ({ + method: "GET", + url: `/assignment/instructor/grader/questions/history?assignment_id=${assignmentId}&question_id=${questionId}&sid=${encodeURIComponent( + sid + )}` + }), + transformResponse: (r: DetailResponse) => r.detail + }), + saveGrade: build.mutation({ + query: ({ questionId: _questionId, assignmentId: _assignmentId, ...body }) => ({ + method: "POST", + url: "/assignment/instructor/grader/grade", + body + }), + + onQueryStarted: async ( + { sid, score, comment, questionId, assignmentId }, + { dispatch, queryFulfilled, getState } + ) => { + const state: any = getState(); + const cacheEntries = Object.values( + state[graderApi.reducerPath]?.queries ?? {} + ) as Array<{ endpointName?: string; originalArgs?: any }>; + const matching = cacheEntries.filter( + (e) => + e.endpointName === "getGraderAnswers" && + e.originalArgs?.questionId === questionId + ); + const patches = matching.map((entry) => + dispatch( + graderApi.util.updateQueryData( + "getGraderAnswers", + entry.originalArgs as { assignmentId: number; questionId: number }, + (draft) => { + const row = draft.answers.find((a) => a.sid === sid); + if (row) { + row.score = score; + if (comment !== undefined) row.comment = comment; + } + } + ) + ) + ); + try { + await queryFulfilled; + + if (assignmentId) { + dispatch( + graderApi.util.invalidateTags([ + { type: "GraderQuestions", id: assignmentId } + ]) + ); + } + } catch { + patches.forEach((p) => p.undo()); + } + } + }) + }) +}); + +export const { + useGetGraderQuestionsQuery, + useGetGraderAnswersQuery, + useGetGraderHistoryQuery, + useSaveGradeMutation +} = graderApi; + diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts index 69157bc45..1e9b7ff42 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts @@ -9,6 +9,7 @@ import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesSlice } from "@store/exercises/exercises.logic"; import { exercisesApi } from "@store/exercises/exercises.logic.api"; +import { graderApi } from "@store/grader/grader.logic.api"; import { readingsSlice } from "@store/readings/readings.logic"; import { readingsApi } from "@store/readings/readings.logic.api"; import { searchExercisesSlice } from "@store/searchExercises/searchExercises.logic"; @@ -29,7 +30,8 @@ const reducersMap = { [readingsApi.reducerPath]: readingsApi.reducer, [exercisesApi.reducerPath]: exercisesApi.reducer, [datasetApi.reducerPath]: datasetApi.reducer, - [datafileApi.reducerPath]: datafileApi.reducer + [datafileApi.reducerPath]: datafileApi.reducer, + [graderApi.reducerPath]: graderApi.reducer }; export const rootReducer = combineReducers(reducersMap); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts index da0800904..51b696f4d 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts @@ -4,6 +4,7 @@ import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExerc import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesApi } from "@store/exercises/exercises.logic.api"; +import { graderApi } from "@store/grader/grader.logic.api"; import { readingsApi } from "@store/readings/readings.logic.api"; import { rootReducer, RootState } from "@store/rootReducer"; @@ -18,7 +19,8 @@ export const setupStore = (preloadedState?: Partial) => { readingsApi.middleware, exercisesApi.middleware, datasetApi.middleware, - datafileApi.middleware + datafileApi.middleware, + graderApi.middleware ); } }); diff --git a/bases/rsptx/assignment_server_api/core.py b/bases/rsptx/assignment_server_api/core.py index ad3cb6186..a985e612f 100644 --- a/bases/rsptx/assignment_server_api/core.py +++ b/bases/rsptx/assignment_server_api/core.py @@ -22,6 +22,7 @@ from .routers import student from .routers import instructor from .routers import peer +from .routers import grader from rsptx.exceptions.core import add_exception_handlers from rsptx.logging import rslogger from rsptx.templates import template_folder @@ -57,6 +58,7 @@ auth_manager.attach_middleware(app) app.include_router(student.router) +app.include_router(grader.router) app.include_router(instructor.router) app.include_router(peer.router) diff --git a/bases/rsptx/assignment_server_api/routers/assignment_summary.py b/bases/rsptx/assignment_server_api/routers/assignment_summary.py index f030c0c45..e4747efba 100644 --- a/bases/rsptx/assignment_server_api/routers/assignment_summary.py +++ b/bases/rsptx/assignment_server_api/routers/assignment_summary.py @@ -143,7 +143,7 @@ def create_assignment_summary(assignment_id, course, dburl): merged.iloc[0, 3:] = merged.iloc[0, 3:].apply( lambda x: ( "{:.2f}".format(float(x.split("(")[0])) - if type(x) is str and "(" in x + if isinstance(x, str) and "(" in x else "{:.2f}".format(float(x)) ) ) diff --git a/bases/rsptx/assignment_server_api/routers/grader.py b/bases/rsptx/assignment_server_api/routers/grader.py new file mode 100644 index 000000000..350c9b9eb --- /dev/null +++ b/bases/rsptx/assignment_server_api/routers/grader.py @@ -0,0 +1,622 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, Query, Request, status +from pydantic import BaseModel +from sqlalchemy import select, func, and_, or_ + +from rsptx.auth.session import auth_manager +from rsptx.db.async_session import async_session +from rsptx.db.crud import ( + fetch_assignment_questions, + fetch_course, + fetch_course_instructors, + fetch_one_assignment, + fetch_question_grade, + fetch_users_for_course, + create_question_grade_entry, + update_question_grade_entry, +) +from rsptx.db.models import ( + Code, + QuestionGrade, + Useinfo, + runestone_component_dict, +) +from rsptx.endpoint_validators import instructor_role_required +from rsptx.logging import rslogger +from rsptx.response_helpers.core import make_json_response + + +router = APIRouter( + prefix="/instructor/grader", + tags=["grader"], +) + + +QTYPE_TO_TABLE = { + "mchoice": "mchoice_answers", + "fillintheblank": "fitb_answers", + "parsonsprob": "parsons_answers", + "activecode": "unittest_answers", + "actex": "unittest_answers", + "shortanswer": "shortanswer_answers", + "clickablearea": "clickablearea_answers", + "dragndrop": "dragndrop_answers", + "codelens": "codelens_answers", + "matching": "matching_answers", + "webwork": "webwork_answers", + "hparsons": "microparsons_answers", + "microparsons": "microparsons_answers", + "splice": "splice_answers", +} + + +CODE_TABLE_TYPES = {"activecode", "actex", "codelens"} + + +def _answer_table_for(question_type: str): + table_name = QTYPE_TO_TABLE.get(question_type) + if not table_name: + return None + rcd = runestone_component_dict.get(table_name) + if not rcd: + return None + return rcd.model + + +class GraderQuestionStats(BaseModel): + id: int + name: str + question_type: str + htmlsrc: Optional[str] = None + points: int + autograde: Optional[str] = None + which_to_grade: Optional[str] = None + + answered_count: int + + correct_count: int + + graded_count: int + + average_score: float + + avg_percent: Optional[float] = None + + +class GraderStudentAnswer(BaseModel): + sid: str + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + answer: Optional[str] = None + correct: Optional[bool] = None + percent: Optional[float] = None + timestamp: Optional[str] = None + attempts: int = 0 + score: Optional[float] = None + comment: Optional[str] = None + max_points: int = 0 + + +class GraderAnswerHistoryItem(BaseModel): + id: int + + answer: Optional[Any] = None + correct: Optional[bool] = None + percent: Optional[float] = None + timestamp: Optional[str] = None + source: Optional[str] = None + + +class GradeUpdatePayload(BaseModel): + sid: str + div_id: str + score: float + comment: Optional[str] = "" + + +@router.get("/assignments/{assignment_id}/questions") +@instructor_role_required() +async def list_assignment_questions( + assignment_id: int, request: Request, user=Depends(auth_manager) +): + """Return the list of questions in the assignment along with aggregate + student answer statistics. Only non-instructor answers are counted. + """ + course = await fetch_course(user.course_name) + assignment = await fetch_one_assignment(assignment_id) + if not assignment or assignment.course != course.id: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, detail="Assignment not found" + ) + + instructor_ids = { + u.username for u in await fetch_course_instructors(course.course_name) + } + + rows = await fetch_assignment_questions(assignment_id) + + row_list = [r for r in rows] + questions: List[GraderQuestionStats] = [] + instructor_list = list(instructor_ids) if instructor_ids else [] + for row in row_list: + q = row.Question + aq = row.AssignmentQuestion + tbl = _answer_table_for(q.question_type) + + answered_count = 0 + correct_count = 0 + graded_count = 0 + average_score = 0.0 + avg_percent: Optional[float] = None + max_points = aq.points or 0 + + try: + async with async_session() as session: + + base_clauses = [] + latest_ids_q = None + if tbl is not None: + base_clauses = [ + tbl.div_id == q.name, + tbl.course_name == course.course_name, + ] + if instructor_list: + base_clauses.append(tbl.sid.notin_(instructor_list)) + latest_ids_q = ( + select(func.max(tbl.id)) + .where(and_(*base_clauses)) + .group_by(tbl.sid) + ) + + answered_sids: set = set() + if tbl is not None: + res = await session.execute( + select(func.distinct(tbl.sid)).where(and_(*base_clauses)) + ) + answered_sids.update(s for (s,) in res.all() if s) + + if q.question_type in CODE_TABLE_TYPES: + code_clauses = [ + Code.acid == q.name, + Code.course_id == course.id, + ] + if instructor_list: + code_clauses.append(Code.sid.notin_(instructor_list)) + res = await session.execute( + select(func.distinct(Code.sid)).where(and_(*code_clauses)) + ) + answered_sids.update(s for (s,) in res.all() if s) + + answered_count = len(answered_sids) + + if ( + tbl is not None + and hasattr(tbl, "correct") + and latest_ids_q is not None + ): + last_correct_clauses = [tbl.id.in_(latest_ids_q)] + if hasattr(tbl, "percent"): + last_correct_clauses.append( + or_( + tbl.correct == True, # noqa: E712 + tbl.percent >= 1.0, + ) + ) + else: + last_correct_clauses.append(tbl.correct == True) # noqa: E712 + res = await session.execute( + select(func.count(func.distinct(tbl.sid))).where( + and_(*last_correct_clauses) + ) + ) + correct_count = int(res.scalar() or 0) + else: + + if max_points > 0: + qg_clauses = [ + QuestionGrade.div_id == q.name, + QuestionGrade.course_name == course.course_name, + QuestionGrade.score != None, # noqa: E711 + QuestionGrade.score >= float(max_points), + ] + if instructor_list: + qg_clauses.append(QuestionGrade.sid.notin_(instructor_list)) + res = await session.execute( + select(func.count(func.distinct(QuestionGrade.sid))).where( + and_(*qg_clauses) + ) + ) + correct_count = int(res.scalar() or 0) + + if ( + tbl is not None + and hasattr(tbl, "percent") + and latest_ids_q is not None + ): + res = await session.execute( + select(func.avg(tbl.percent)).where( + and_( + tbl.id.in_(latest_ids_q), + tbl.percent != None, # noqa: E711 + ) + ) + ) + val = res.scalar() + if val is not None: + avg_percent = round(float(val), 4) + + qg_base = [ + QuestionGrade.div_id == q.name, + QuestionGrade.course_name == course.course_name, + QuestionGrade.score != None, # noqa: E711 + ] + if instructor_list: + qg_base.append(QuestionGrade.sid.notin_(instructor_list)) + + res = await session.execute( + select( + func.avg(QuestionGrade.score), + func.count(func.distinct(QuestionGrade.sid)), + ).where(and_(*qg_base)) + ) + row_avg = res.first() + if row_avg is not None: + avg_val, gcount = row_avg + average_score = float(avg_val) if avg_val is not None else 0.0 + graded_count = int(gcount or 0) + except Exception as e: # pragma: no cover - defensive + rslogger.error(f"Grader stats failed for question {q.name}: {e}") + + questions.append( + GraderQuestionStats( + id=q.id, + name=q.name, + question_type=q.question_type, + htmlsrc=q.htmlsrc, + points=max_points, + autograde=aq.autograde, + which_to_grade=aq.which_to_grade, + answered_count=answered_count, + correct_count=correct_count, + graded_count=graded_count, + average_score=round(average_score, 2), + avg_percent=avg_percent, + ) + ) + + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "assignment": { + "id": assignment.id, + "name": assignment.name, + "description": assignment.description, + "duedate": ( + assignment.duedate.isoformat() if assignment.duedate else None + ), + "points": assignment.points, + }, + "questions": [q.dict() for q in questions], + }, + ) + + +@router.get("/questions/answers") +@instructor_role_required() +async def list_question_answers( + request: Request, + assignment_id: int = Query(...), + question_id: int = Query(...), + user=Depends(auth_manager), +): + """Return the most-recent answer for every student (excluding instructors) + that submitted something for this question. + """ + course = await fetch_course(user.course_name) + assignment = await fetch_one_assignment(assignment_id) + if not assignment or assignment.course != course.id: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, detail="Assignment not found" + ) + + rows = await fetch_assignment_questions(assignment_id) + question = None + max_points = 0 + for row in rows: + if row.Question.id == question_id: + question = row.Question + max_points = row.AssignmentQuestion.points or 0 + break + if question is None: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, detail="Question not in assignment" + ) + + tbl = _answer_table_for(question.question_type) + students = await fetch_users_for_course(course.course_name) + instructor_ids = { + u.username for u in await fetch_course_instructors(course.course_name) + } + student_map = {s.username: s for s in students if s.username not in instructor_ids} + + answers: List[GraderStudentAnswer] = [] + latest_by_sid: dict = {} + attempt_counts: dict = {} + answer_source: dict = {} + + if tbl is not None: + async with async_session() as session: + q = ( + select(tbl) + .where( + and_( + tbl.div_id == question.name, + tbl.course_name == course.course_name, + ) + ) + .order_by(tbl.sid, tbl.timestamp.desc()) + ) + res = await session.execute(q) + for a in res.scalars(): + if a.sid not in student_map: + continue + attempt_counts[a.sid] = attempt_counts.get(a.sid, 0) + 1 + if a.sid not in latest_by_sid: + latest_by_sid[a.sid] = a + answer_source[a.sid] = "answer_table" + + if question.question_type in CODE_TABLE_TYPES: + async with async_session() as session: + q = ( + select(Code) + .where( + and_( + Code.acid == question.name, + Code.course_id == course.id, + ) + ) + .order_by(Code.sid, Code.timestamp.desc()) + ) + res = await session.execute(q) + for c in res.scalars(): + if c.sid not in student_map: + continue + attempt_counts[c.sid] = attempt_counts.get(c.sid, 0) + 1 + if c.sid not in latest_by_sid: + latest_by_sid[c.sid] = c + answer_source[c.sid] = "code_table" + + for sid, row in latest_by_sid.items(): + stu = student_map.get(sid) + grade = await fetch_question_grade(sid, course.course_name, question.name) + + if answer_source.get(sid) == "code_table": + answer_text = row.code or "" + correct_val = None + percent_val = None + else: + answer_text = str(getattr(row, "answer", "") or "") + correct_val = bool(row.correct) if hasattr(row, "correct") else None + percent_val = ( + float(row.percent) + if hasattr(row, "percent") and row.percent is not None + else None + ) + + answers.append( + GraderStudentAnswer( + sid=sid, + first_name=getattr(stu, "first_name", None), + last_name=getattr(stu, "last_name", None), + email=getattr(stu, "email", None), + answer=answer_text, + correct=correct_val, + percent=percent_val, + timestamp=row.timestamp.isoformat() if row.timestamp else None, + attempts=attempt_counts.get(sid, 1), + score=float(grade.score) if grade and grade.score is not None else None, + comment=grade.comment if grade else None, + max_points=max_points, + ) + ) + + answers.sort(key=lambda x: ((x.last_name or ""), (x.first_name or ""), x.sid)) + + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "question": { + "id": question.id, + "name": question.name, + "question_type": question.question_type, + "htmlsrc": question.htmlsrc, + "max_points": max_points, + }, + "answers": [a.dict() for a in answers], + }, + ) + + +@router.get("/questions/history") +@instructor_role_required() +async def get_student_answer_history( + request: Request, + assignment_id: int = Query(...), + question_id: int = Query(...), + sid: str = Query(...), + user=Depends(auth_manager), +): + """Return every attempt a student made on a particular question so the + instructor can detect brute-force behaviour. + """ + course = await fetch_course(user.course_name) + + rows = await fetch_assignment_questions(assignment_id) + question = None + for row in rows: + if row.Question.id == question_id: + question = row.Question + break + if question is None: + return make_json_response( + status=status.HTTP_404_NOT_FOUND, detail="Question not in assignment" + ) + + tbl = _answer_table_for(question.question_type) + history: List[GraderAnswerHistoryItem] = [] + + if tbl is not None: + async with async_session() as session: + q = ( + select(tbl) + .where( + and_( + tbl.div_id == question.name, + tbl.course_name == course.course_name, + tbl.sid == sid, + ) + ) + .order_by(tbl.timestamp.asc()) + ) + res = await session.execute(q) + for a in res.scalars(): + raw_answer = getattr(a, "answer", None) + + if isinstance(raw_answer, (dict, list)): + answer_val: Any = raw_answer + else: + answer_val = "" if raw_answer is None else str(raw_answer) + history.append( + GraderAnswerHistoryItem( + id=a.id, + answer=answer_val, + correct=bool(a.correct) if hasattr(a, "correct") else None, + percent=( + float(a.percent) + if hasattr(a, "percent") and a.percent is not None + else None + ), + timestamp=a.timestamp.isoformat() if a.timestamp else None, + source=( + str(getattr(a, "source", "") or "") + if hasattr(a, "source") + else None + ), + ) + ) + + if question.question_type in CODE_TABLE_TYPES: + async with async_session() as session: + q = ( + select(Code) + .where( + and_( + Code.acid == question.name, + Code.course_id == course.id, + Code.sid == sid, + ) + ) + .order_by(Code.timestamp.asc()) + ) + res = await session.execute(q) + for c in res.scalars(): + history.append( + GraderAnswerHistoryItem( + id=c.id, + answer=c.code or "", + correct=None, + percent=None, + timestamp=c.timestamp.isoformat() if c.timestamp else None, + source="code_table", + ) + ) + + history.sort(key=lambda h: h.timestamp or "") + + async with async_session() as session: + q = ( + select(Useinfo) + .where( + and_( + Useinfo.div_id == question.name, + Useinfo.course_id == course.course_name, + Useinfo.sid == sid, + ) + ) + .order_by(Useinfo.timestamp.asc()) + ) + res = await session.execute(q) + useinfo_rows = [ + { + "id": u.id, + "timestamp": u.timestamp.isoformat() if u.timestamp else None, + "event": u.event, + "act": u.act, + } + for u in res.scalars() + ] + + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "history": [h.dict() for h in history], + "useinfo": useinfo_rows, + }, + ) + + +@router.post("/grade") +@instructor_role_required() +async def upsert_grade( + payload: GradeUpdatePayload, request: Request, user=Depends(auth_manager) +): + """Create or update a QuestionGrade row for a student on a given question. + The comment is stored alongside the score. + """ + course = await fetch_course(user.course_name) + existing = await fetch_question_grade( + payload.sid, course.course_name, payload.div_id + ) + + if existing is None: + await create_question_grade_entry( + payload.sid, course.course_name, payload.div_id, int(payload.score) + ) + else: + await update_question_grade_entry( + payload.sid, + course.course_name, + payload.div_id, + int(payload.score), + qge_id=existing.id, + ) + + async with async_session() as session: + q = select(QuestionGrade).where( + and_( + QuestionGrade.sid == payload.sid, + QuestionGrade.course_name == course.course_name, + QuestionGrade.div_id == payload.div_id, + ) + ) + res = await session.execute(q) + row = res.scalars().first() + if row is not None: + row.score = payload.score + row.comment = payload.comment or "" + await session.commit() + + rslogger.info( + f"Grader saved grade sid={payload.sid} div={payload.div_id} score={payload.score}" + ) + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "score": payload.score, + "comment": payload.comment or "", + "sid": payload.sid, + "div_id": payload.div_id, + }, + ) diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 82230c292..cf1c4d93f 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -970,6 +970,16 @@ async def get_grader( return await get_builder(request, "/grader", user, response_class) +@router.get("/grader/{subpath:path}") +async def get_grader_subpath( + request: Request, + subpath: str, + user=Depends(auth_manager), + response_class=HTMLResponse, +): + return await get_builder(request, "/grader", user, response_class) + + @router.get("/except") async def get_except( request: Request, user=Depends(auth_manager), response_class=HTMLResponse diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py index d84a13ee2..000e47b42 100644 --- a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py +++ b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py @@ -223,7 +223,7 @@ def generate_partial_Parsons( + distractor_tuple_dict[fixed_line_key][2].strip() + " #paired", ) - elif type(distractor_tuple_dict[fixed_line_key]) is str: + elif isinstance(distractor_tuple_dict[fixed_line_key], str): distractor_tuple_dict[fixed_line_key] = ( fixed_line_key[0] + 0.5, fixed_line_key[0], diff --git a/components/rsptx/configuration/core.py b/components/rsptx/configuration/core.py index 3d481337b..72eb0bfdb 100644 --- a/components/rsptx/configuration/core.py +++ b/components/rsptx/configuration/core.py @@ -176,7 +176,7 @@ def read_key(): rslogger.warning( "No Key file OR WEB2PY_PRIVATE_KEY - will default to settings.jwt_secret" ) - if type(self.jwt_secret) is bytes: + if isinstance(self.jwt_secret, bytes): return self.jwt_secret.decode("utf-8") return self.jwt_secret diff --git a/components/rsptx/lti1p3/caches.py b/components/rsptx/lti1p3/caches.py index 3913ffb42..6ce274279 100644 --- a/components/rsptx/lti1p3/caches.py +++ b/components/rsptx/lti1p3/caches.py @@ -52,8 +52,8 @@ def get(self, key): def set(self, key, value, expiration=600): # pylti1p3 library tries to store dicts and bools # make sure we don't give redis those - if type(value) is bool: + if isinstance(value, bool): value = str(value) - elif type(value) is dict: + elif isinstance(value, dict): value = json.dumps(value) self.redis.set(key, value, ex=expiration)