Skip to content

Commit cc0e13a

Browse files
authored
Improve test insights ux (#592)
Regrade workflow: launch regrade from error explorer, preselect submissions on the autograder page, and choose commits or manual SHA in a regrade dialog (with auto-promote). View and copy affected students' emails from error groups. Conflict detection and clearer validation for solo office-hours requests.
1 parent ba65cbc commit cc0e13a

File tree

7 files changed

+824
-56
lines changed

7 files changed

+824
-56
lines changed

app/course/[course_id]/manage/assignments/[assignment_id]/rerun-autograder/page.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Input,
2424
List,
2525
Skeleton,
26+
Spinner,
2627
Table,
2728
Text,
2829
VStack
@@ -32,7 +33,7 @@ import { useOne } from "@refinedev/core";
3233
import * as Sentry from "@sentry/nextjs";
3334
import { CellContext, ColumnDef, flexRender } from "@tanstack/react-table";
3435
import { useParams } from "next/navigation";
35-
import { useCallback, useEffect, useMemo, useState } from "react";
36+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3637
import { FaSort, FaSortDown, FaSortUp } from "react-icons/fa";
3738
import { Select as ReactSelect } from "chakra-react-select";
3839

@@ -461,7 +462,14 @@ function SubmissionGraderTable({ autograder_repo }: { autograder_repo: string })
461462
tc.close();
462463
};
463464
}, [supabase, assignment_id, classRealTimeController]);
464-
const { getHeaderGroups, getRowModel, getRowCount, getCoreRowModel, data } = useTableControllerTable({
465+
const {
466+
getHeaderGroups,
467+
getRowModel,
468+
getRowCount,
469+
getCoreRowModel,
470+
data,
471+
isLoading: isTableLoading
472+
} = useTableControllerTable({
465473
columns,
466474
tableController,
467475
initialState: {
@@ -496,6 +504,49 @@ function SubmissionGraderTable({ autograder_repo }: { autograder_repo: string })
496504
}, [data, showDevColumns]);
497505
const [selectedRows, setSelectedRows] = useState<number[]>([]);
498506
const [regrading, setRegrading] = useState<boolean>(false);
507+
const hasProcessedPreselection = useRef(false);
508+
509+
// Check for pre-selected submission IDs from sessionStorage (e.g., from test insights)
510+
useEffect(() => {
511+
// Only process once, and only when we have actual data with submissions
512+
if (hasProcessedPreselection.current) return;
513+
514+
const stored = sessionStorage.getItem("preselect_submission_ids");
515+
if (!stored) return;
516+
517+
// Wait until we have data with actual submission IDs
518+
if (!data || data.length === 0) return;
519+
const hasActiveSubmissions = data.some((row) => row.activesubmissionid !== null);
520+
if (!hasActiveSubmissions) return;
521+
522+
try {
523+
const preselectedIds = JSON.parse(stored) as number[];
524+
// Filter to only include IDs that exist in the current data
525+
const validIds = preselectedIds.filter((id) => data.some((row) => row.activesubmissionid === id));
526+
527+
// Mark as processed before clearing storage
528+
hasProcessedPreselection.current = true;
529+
sessionStorage.removeItem("preselect_submission_ids");
530+
531+
if (validIds.length > 0) {
532+
setSelectedRows(validIds);
533+
toaster.info({
534+
title: "Submissions Pre-selected",
535+
description: `${validIds.length} submission${validIds.length !== 1 ? "s" : ""} selected from Test Insights.`
536+
});
537+
} else if (preselectedIds.length > 0) {
538+
// We had IDs but none matched - inform the user
539+
toaster.warning({
540+
title: "Could not pre-select submissions",
541+
description: `${preselectedIds.length} submission ID${preselectedIds.length !== 1 ? "s were" : " was"} provided but ${preselectedIds.length !== 1 ? "they don't" : "it doesn't"} match any active submissions.`
542+
});
543+
}
544+
} catch {
545+
// Ignore parsing errors
546+
hasProcessedPreselection.current = true;
547+
sessionStorage.removeItem("preselect_submission_ids");
548+
}
549+
}, [data]);
499550

500551
// Compute unique values for each column from ALL rows (before filtering)
501552
// Depends on 'data' so it recalculates when async data loads
@@ -622,7 +673,18 @@ function SubmissionGraderTable({ autograder_repo }: { autograder_repo: string })
622673
</Checkbox.Root>
623674
</HStack>
624675
</Box>
625-
<Table.Root interactive>
676+
677+
{/* Loading indicator */}
678+
{isTableLoading && (
679+
<Box p={8} textAlign="center">
680+
<Spinner size="xl" />
681+
<Text mt={4} color="fg.muted">
682+
Loading submissions...
683+
</Text>
684+
</Box>
685+
)}
686+
687+
<Table.Root interactive display={isTableLoading ? "none" : undefined}>
626688
<Table.Header>
627689
{getHeaderGroups().map((headerGroup) => (
628690
<Table.Row bg="bg.subtle" key={headerGroup.id}>
@@ -767,7 +829,7 @@ function SubmissionGraderTable({ autograder_repo }: { autograder_repo: string })
767829
if (checked.checked) {
768830
setSelectedRows((prev) => [...prev, id]);
769831
} else {
770-
setSelectedRows((prev) => prev.filter((id) => id !== id));
832+
setSelectedRows((prev) => prev.filter((existingId) => existingId !== id));
771833
}
772834
}}
773835
>
@@ -801,7 +863,12 @@ function SubmissionGraderTable({ autograder_repo }: { autograder_repo: string })
801863
})}
802864
</Table.Body>
803865
</Table.Root>
804-
<div>{getRowCount()} Rows</div>
866+
{!isTableLoading && (
867+
<Text fontSize="sm" color="fg.muted" py={2}>
868+
{getRowCount()} submission{getRowCount() !== 1 ? "s" : ""} loaded
869+
{selectedRows.length > 0 && ` (${selectedRows.length} selected)`}
870+
</Text>
871+
)}
805872
</VStack>
806873
<Box
807874
p="2"

app/course/[course_id]/manage/assignments/[assignment_id]/test-insights/page.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,28 @@ import {
55
TestInsightsOverview,
66
CommonErrorsExplorer,
77
ErrorPinIntegration,
8+
RegradeSubmissionsDialog,
89
DEFAULT_ERROR_FILTERS,
910
type ErrorExplorerFilters,
1011
type CommonErrorGroup
1112
} from "@/lib/test-insights";
13+
import { toaster } from "@/components/ui/toaster";
1214
import { Box, Heading, HStack, Icon, Tabs, Text, VStack } from "@chakra-ui/react";
13-
import { useParams } from "next/navigation";
15+
import { useParams, useRouter } from "next/navigation";
1416
import { useCallback, useState } from "react";
1517
import { FaBug, FaChartBar, FaExclamationTriangle } from "react-icons/fa";
1618

1719
export default function TestInsightsPage() {
1820
const { course_id, assignment_id } = useParams();
21+
const router = useRouter();
1922
const assignmentId = Number(assignment_id);
2023
const courseId = Number(course_id);
2124

2225
// State for filters - must be called unconditionally
2326
const [filters, setFilters] = useState<ErrorExplorerFilters>(DEFAULT_ERROR_FILTERS);
2427
const [activeTab, setActiveTab] = useState<string>("overview");
2528
const [selectedErrorForPin, setSelectedErrorForPin] = useState<CommonErrorGroup | null>(null);
29+
const [selectedErrorForRegrade, setSelectedErrorForRegrade] = useState<CommonErrorGroup | null>(null);
2630

2731
// Validate route params - pass null to hooks if invalid
2832
const validAssignmentId = Number.isFinite(assignmentId) ? assignmentId : null;
@@ -46,21 +50,38 @@ export default function TestInsightsPage() {
4650
setSelectedErrorForPin(errorGroup);
4751
}, []);
4852

49-
// Handle viewing submissions for a common error - placeholder for future implementation
53+
// Handle viewing submissions for a common error
5054
const handleViewSubmissions = useCallback(
51-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5255
(submissionIds: number[]) => {
53-
// TODO: Navigate to the assignments table with a filter for these submissions
54-
// Could use router.push with query params to filter the assignments table
56+
// Store submission IDs in sessionStorage so the rerun-autograder page can pre-select them
57+
sessionStorage.setItem("preselect_submission_ids", JSON.stringify(submissionIds));
58+
59+
// Navigate to the rerun-autograder page
60+
router.push(`/course/${course_id}/manage/assignments/${assignment_id}/rerun-autograder`);
61+
62+
toaster.info({
63+
title: "Navigating to Rerun Autograder",
64+
description: `${submissionIds.length} submissions will be pre-selected for review.`
65+
});
5566
},
56-
[]
67+
[router, course_id, assignment_id]
5768
);
5869

5970
// Handle closing the error pin modal
6071
const handleCloseErrorPinModal = useCallback(() => {
6172
setSelectedErrorForPin(null);
6273
}, []);
6374

75+
// Handle regrading submissions for a common error
76+
const handleRegradeSubmissions = useCallback((errorGroup: CommonErrorGroup) => {
77+
setSelectedErrorForRegrade(errorGroup);
78+
}, []);
79+
80+
// Handle closing the regrade modal
81+
const handleCloseRegradeModal = useCallback(() => {
82+
setSelectedErrorForRegrade(null);
83+
}, []);
84+
6485
// Render error UI if params are invalid
6586
if (!validAssignmentId || !validCourseId) {
6687
return (
@@ -133,6 +154,7 @@ export default function TestInsightsPage() {
133154
onFiltersChange={setFilters}
134155
onCreateErrorPin={handleCreateErrorPin}
135156
onViewSubmissions={handleViewSubmissions}
157+
onRegradeSubmissions={handleRegradeSubmissions}
136158
/>
137159
</Box>
138160
</Tabs.Content>
@@ -148,6 +170,17 @@ export default function TestInsightsPage() {
148170
onClose={handleCloseErrorPinModal}
149171
/>
150172
)}
173+
174+
{/* Regrade Submissions Modal */}
175+
{selectedErrorForRegrade && (
176+
<RegradeSubmissionsDialog
177+
assignmentId={validAssignmentId}
178+
courseId={validCourseId}
179+
errorGroup={selectedErrorForRegrade}
180+
isOpen={true}
181+
onClose={handleCloseRegradeModal}
182+
/>
183+
)}
151184
</VStack>
152185
);
153186
}

app/course/[course_id]/office-hours/[queue_id]/new/newRequestForm.tsx

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,31 @@ export default function HelpRequestForm() {
322322
).length;
323323
}, [selectedHelpQueue, allHelpRequests]);
324324

325+
// Watch is_private for conflict detection
326+
const is_private = watch("is_private");
327+
328+
// Check if the selected queue would conflict with current requests (moved up for use in effects)
329+
const isCreatingSoloRequest = selectedStudents.length === 1 && selectedStudents[0] === private_profile_id;
330+
const wouldConflict = useMemo(() => {
331+
return Boolean(
332+
selectedHelpQueue &&
333+
isCreatingSoloRequest &&
334+
userActiveRequests.some(
335+
(request) =>
336+
Number(request.help_queue) === Number(selectedHelpQueue) &&
337+
request.student_count === 1 &&
338+
Boolean(request.is_private) === Boolean(is_private)
339+
)
340+
);
341+
}, [selectedHelpQueue, isCreatingSoloRequest, userActiveRequests, is_private]);
342+
343+
// Clear conflict error when conflict is resolved
344+
useEffect(() => {
345+
if (!wouldConflict && errors.root?.conflict) {
346+
clearErrors("root.conflict");
347+
}
348+
}, [wouldConflict, errors.root?.conflict, clearErrors]);
349+
325350
// Validate that selected queue is available and has active staff (skip for demo queues)
326351
useEffect(() => {
327352
if (selectedHelpQueue) {
@@ -357,6 +382,13 @@ export default function HelpRequestForm() {
357382
}
358383
}, [selectedHelpQueue, helpQueues, allHelpQueues, queueIdsWithActiveStaff, setError, clearErrors, errors.help_queue]);
359384

385+
// Clear students error when students are selected
386+
useEffect(() => {
387+
if (selectedStudents.length > 0 && errors.root?.students) {
388+
clearErrors("root.students");
389+
}
390+
}, [selectedStudents.length, errors.root?.students, clearErrors]);
391+
360392
const onSubmit = useCallback(
361393
async (e: React.FormEvent<HTMLFormElement>) => {
362394
e.preventDefault();
@@ -380,6 +412,10 @@ export default function HelpRequestForm() {
380412
title: "Error",
381413
description: "At least one student must be selected for the help request."
382414
});
415+
setError("root.students", {
416+
type: "manual",
417+
message: "At least one student must be selected for the help request."
418+
});
383419
return;
384420
}
385421

@@ -427,9 +463,14 @@ export default function HelpRequestForm() {
427463
);
428464

429465
if (hasSoloRequestInQueue) {
466+
const conflictMessage = `You already have a ${is_private ? "private" : "public"} solo help request in this queue. You can have up to 2 solo requests per queue (1 private + 1 public). Please resolve or close your current request(s) or switch privacy settings.`;
430467
toaster.error({
431468
title: "Error",
432-
description: `You already have a ${is_private ? "private" : "public"} solo help request in this queue. You can have up to 2 solo requests per queue (1 private + 1 public). Please resolve or close your current request(s) or switch privacy settings.`
469+
description: conflictMessage
470+
});
471+
setError("root.conflict", {
472+
type: "manual",
473+
message: conflictMessage
433474
});
434475
return;
435476
}
@@ -653,20 +694,6 @@ export default function HelpRequestForm() {
653694
</Box>
654695
);
655696
}
656-
const is_private = watch("is_private");
657-
658-
// Check if the selected queue would conflict with current requests
659-
const isCreatingSoloRequest = selectedStudents.length === 1 && selectedStudents[0] === private_profile_id;
660-
const wouldConflict = Boolean(
661-
selectedHelpQueue &&
662-
isCreatingSoloRequest &&
663-
userActiveRequests.some(
664-
(request) =>
665-
Number(request.help_queue) === Number(selectedHelpQueue) &&
666-
request.student_count === 1 &&
667-
Boolean(request.is_private) === Boolean(is_private)
668-
)
669-
);
670697

671698
return (
672699
<form onSubmit={onSubmit} aria-label="New Help Request Form">
@@ -1202,13 +1229,7 @@ export default function HelpRequestForm() {
12021229
<Button
12031230
type="submit"
12041231
loading={isSubmitting || isSubmittingGuard}
1205-
disabled={
1206-
isSubmitting ||
1207-
isSubmittingGuard ||
1208-
wouldConflict ||
1209-
selectedStudents.length === 0 ||
1210-
(templates.length > 0 && !watch("template_id"))
1211-
}
1232+
disabled={isSubmitting || isSubmittingGuard || Object.keys(errors).length > 0}
12121233
mt={4}
12131234
>
12141235
Submit Request

0 commit comments

Comments
 (0)