-
Notifications
You must be signed in to change notification settings - Fork 113
E2609: Calibration tab UI & comparison report #177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
a964561
026d3e4
b52a3aa
5ebfb50
ef79a24
4794985
35a6ae2
786c7bf
8c6f637
205b2da
cca7a17
2c70758
9a99a66
07ead6c
fedab4d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,159 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Alert, Card, Container, Spinner, Tab, Tabs } from "react-bootstrap"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Link, useParams } from "react-router-dom"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import axiosClient from "../../utils/axios_client"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizeCalibrationReport, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| type CalibrationReportResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } from "./calibrationReportNormalize"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import CalibrationStackedChart from "./components/CalibrationStackedChart"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import CalibrationRubricDetailPanel from "./components/CalibrationRubricDetailPanel"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const CalibrationReview = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const { assignmentId, mapId } = useParams(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [report, setReport] = useState<CalibrationReportResponse | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [loading, setLoading] = useState(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [error, setError] = useState<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [selectedReviewerId, setSelectedReviewerId] = useState<number | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const [activeTab, setActiveTab] = useState("comparison"); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const loadReport = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!assignmentId || !mapId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setError("Missing assignment or calibration map id."); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setLoading(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setError(null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await axiosClient.get<CalibrationReportResponse>( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| `/assignments/${assignmentId}/reports/calibration/${mapId}` | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setReport(response.data); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err: any) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setError(err?.response?.data?.error || "Unable to load calibration report"); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| loadReport(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [assignmentId, mapId]); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const normalizedReport = report ? normalizeCalibrationReport(report) : null; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| setSelectedReviewerId(normalizedReport?.defaultReviewerId ?? null); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [normalizedReport?.defaultReviewerId]); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const studentResponseCount = normalizedReport?.latestStudentResponses.length ?? 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const hyperlinks = report?.submitted_content?.hyperlinks ?? []; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const files = report?.submitted_content?.files ?? []; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const hasSubmittedContent = hyperlinks.length > 0 || files.length > 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const backHref = assignmentId ? `/assignments/edit/${assignmentId}` : "/assignments"; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const rubricDetailRows = | ||||||||||||||||||||||||||||||||||||||||||||||||||
| normalizedReport && selectedReviewerId | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ? normalizedReport.rubricDetailRowsByReviewer[selectedReviewerId] ?? normalizedReport.rubricDetailRows | ||||||||||||||||||||||||||||||||||||||||||||||||||
| : normalizedReport?.rubricDetailRows ?? []; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use an explicit null check for The current truthy check skips reviewer-specific rows when id is 🔧 Suggested fix const rubricDetailRows =
- normalizedReport && selectedReviewerId
+ normalizedReport && selectedReviewerId !== null
? normalizedReport.rubricDetailRowsByReviewer[selectedReviewerId] ?? normalizedReport.rubricDetailRows
: normalizedReport?.rubricDetailRows ?? [];📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Container fluid className="py-4 px-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <h1 className="mb-1">Calibration Report</h1> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <p className="text-muted mb-0"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Assignment {assignmentId} · Calibration map {mapId} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {report && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {" "} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| · {studentResponseCount} student response{studentResponseCount === 1 ? "" : "s"} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Link to={backHref} className="btn btn-outline-secondary btn-sm"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ← Back to assignment | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Link> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| {loading && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="d-flex align-items-center gap-2 mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Spinner animation="border" size="sm" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <span>Loading calibration report...</span> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| {!loading && error && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Alert variant="danger"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Alert.Heading>Unable to load calibration report</Alert.Heading> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div>{error}</div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Alert> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| {!loading && !error && report && normalizedReport && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Tabs | ||||||||||||||||||||||||||||||||||||||||||||||||||
| activeKey={activeTab} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| className="mb-4" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| id="calibration-report-tabs" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onSelect={(key) => setActiveTab(key ?? "comparison")} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Tab eventKey="comparison" title="Class comparison (stacked)"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="pt-3"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <CalibrationStackedChart | ||||||||||||||||||||||||||||||||||||||||||||||||||
| bucketKeys={normalizedReport.bucketKeys} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| chartData={normalizedReport.stackedChartData} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Tab> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Tab eventKey="detail" title="Rubric detail"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="pt-3"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <CalibrationRubricDetailPanel | ||||||||||||||||||||||||||||||||||||||||||||||||||
| reviewerOptions={normalizedReport.reviewerOptions} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| selectedReviewerId={selectedReviewerId} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| rows={rubricDetailRows} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onReviewerChange={setSelectedReviewerId} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| {hasSubmittedContent && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Card className="mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Card.Body> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <Card.Title as="h5">Submitted content</Card.Title> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {hyperlinks.length > 0 && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <h6 className="mt-3 mb-2 text-muted">Hyperlinks</h6> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <ul className="mb-3"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {hyperlinks.map((link, idx) => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <li key={`${link}-${idx}`}> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <a href={link} target="_blank" rel="noopener noreferrer"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {link} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </a> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+127
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate submitted hyperlink protocols before rendering anchor tags. Rendering backend-provided links directly allows unsafe schemes (e.g., 🔒 Suggested fix {hyperlinks.map((link, idx) => (
- <li key={`${link}-${idx}`}>
- <a href={link} target="_blank" rel="noopener noreferrer">
- {link}
- </a>
- </li>
+ <li key={`${link}-${idx}`}>
+ {(() => {
+ try {
+ const parsed = new URL(link);
+ const isSafe = parsed.protocol === "http:" || parsed.protocol === "https:";
+ return isSafe ? (
+ <a href={parsed.toString()} target="_blank" rel="noopener noreferrer">
+ {link}
+ </a>
+ ) : (
+ <span>{link}</span>
+ );
+ } catch {
+ return <span>{link}</span>;
+ }
+ })()}
+ </li>
))}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| </li> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </ul> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {files.length > 0 && ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <h6 className="mt-3 mb-2 text-muted">Files</h6> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <ul className="mb-0"> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {files.map((file, idx) => ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <li key={`${file}-${idx}`}>{file}</li> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </ul> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Card.Body> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Card> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Tab> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Tabs> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </Container> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export default CalibrationReview; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { useState } from "react"; | ||
| import CalibrationRubricDetailPanel from "../components/CalibrationRubricDetailPanel"; | ||
| import type { ReviewerOption, RubricDetailRow } from "../calibrationReportNormalize"; | ||
|
|
||
| describe("CalibrationRubricDetailPanel", () => { | ||
| const reviewerOptions: ReviewerOption[] = [ | ||
| { value: 21, label: "Reviewer Alpha", mapId: 9, responseId: 101 }, | ||
| { value: 22, label: "Reviewer Beta", mapId: 10, responseId: 102 }, | ||
| ]; | ||
|
|
||
| const rowsByReviewer: Record<number, RubricDetailRow[]> = { | ||
| 21: [ | ||
| { | ||
| itemId: 1, | ||
| itemLabel: "Code quality", | ||
| itemSeq: 1, | ||
| instructorScore: 4, | ||
| instructorComment: "Clear implementation", | ||
| studentScore: 3, | ||
| studentComment: "Mostly clear", | ||
| agreeCount: 2, | ||
| nearCount: 3, | ||
| disagreeCount: 1, | ||
| noScoreCount: 0, | ||
| totalResponses: 6, | ||
| averageScore: 3.2, | ||
| }, | ||
| ], | ||
| 22: [ | ||
| { | ||
| itemId: 1, | ||
| itemLabel: "Code quality", | ||
| itemSeq: 1, | ||
| instructorScore: 4, | ||
| instructorComment: "", | ||
| studentScore: null, | ||
| studentComment: "", | ||
| agreeCount: 1, | ||
| nearCount: 1, | ||
| disagreeCount: 2, | ||
| noScoreCount: 2, | ||
| totalResponses: 6, | ||
| averageScore: 2.5, | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| const PanelHarness = () => { | ||
| const [selectedReviewerId, setSelectedReviewerId] = useState<number | null>(21); | ||
|
|
||
| return ( | ||
| <CalibrationRubricDetailPanel | ||
| reviewerOptions={reviewerOptions} | ||
| selectedReviewerId={selectedReviewerId} | ||
| rows={selectedReviewerId ? rowsByReviewer[selectedReviewerId] : []} | ||
| onReviewerChange={setSelectedReviewerId} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| test("renders the default reviewer comparison rows", () => { | ||
| render(<PanelHarness />); | ||
|
|
||
| expect(screen.getByText(/rubric detail/i)).toBeInTheDocument(); | ||
| expect(screen.getByTestId("selected-reviewer-summary")).toHaveTextContent("Reviewer Alpha"); | ||
| expect(screen.getByText("1. Code quality")).toBeInTheDocument(); | ||
| expect(screen.getByText("Clear implementation")).toBeInTheDocument(); | ||
| expect(screen.getByText("Mostly clear")).toBeInTheDocument(); | ||
| expect(screen.getByText("1 below")).toBeInTheDocument(); | ||
| expect(screen.getByText("Green Agree: 2")).toBeInTheDocument(); | ||
| expect(screen.getByText("Yellow Near: 3")).toBeInTheDocument(); | ||
| expect(screen.getByText("Red Disagree: 1")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test("switches reviewers and updates the comparison rows", () => { | ||
| render(<PanelHarness />); | ||
|
|
||
| fireEvent.change(screen.getByLabelText(/student reviewer/i), { | ||
| target: { value: "22" }, | ||
| }); | ||
|
|
||
| expect(screen.getByTestId("selected-reviewer-summary")).toHaveTextContent("Reviewer Beta"); | ||
| expect(screen.getAllByText("No comments")).toHaveLength(2); | ||
| expect(screen.getAllByText("N/A")).toHaveLength(2); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import CalibrationStackedChart from "../components/CalibrationStackedChart"; | ||
| import type { StackedChartDataRow } from "../calibrationReportNormalize"; | ||
|
|
||
| describe("CalibrationStackedChart", () => { | ||
| const chartData: StackedChartDataRow[] = [ | ||
| { | ||
| itemId: 11, | ||
| itemLabel: "Code quality", | ||
| itemSeq: 1, | ||
| instructorScore: 4, | ||
| agreeCount: 0, | ||
| nearCount: 1, | ||
| disagreeCount: 0, | ||
| totalResponses: 1, | ||
| }, | ||
| { | ||
| itemId: 12, | ||
| itemLabel: "Documentation", | ||
| itemSeq: 2, | ||
| instructorScore: 5, | ||
| agreeCount: 0, | ||
| nearCount: 1, | ||
| disagreeCount: 0, | ||
| totalResponses: 1, | ||
| }, | ||
| ]; | ||
|
|
||
| test("renders chart title and instructor reference table", () => { | ||
| render( | ||
| <CalibrationStackedChart | ||
| bucketKeys={["agreeCount", "nearCount", "disagreeCount"]} | ||
| chartData={chartData} | ||
| /> | ||
| ); | ||
|
|
||
| expect(screen.getByText(/class comparison \(stacked\)/i)).toBeInTheDocument(); | ||
| expect(screen.getByTestId("calibration-stacked-chart")).toBeInTheDocument(); | ||
| expect(screen.getByText(/agree \(matches instructor score\)/i)).toBeInTheDocument(); | ||
| expect(screen.getByText(/near \(±1\)/i)).toBeInTheDocument(); | ||
| expect(screen.getByText(/disagree/i)).toBeInTheDocument(); | ||
| expect(screen.getByText(/instructor reference scores/i)).toBeInTheDocument(); | ||
| expect(screen.getByText("Code quality")).toBeInTheDocument(); | ||
| expect(screen.getByText("Documentation")).toBeInTheDocument(); | ||
| expect(screen.getByText("4")).toBeInTheDocument(); | ||
| expect(screen.getByText("5")).toBeInTheDocument(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent stale state from out-of-order fetch completions.
If route params change quickly, an older request can resolve last and overwrite the newer report/error state.
🔧 Suggested fix
useEffect(() => { + let isCurrent = true; const loadReport = async () => { if (!assignmentId || !mapId) { - setError("Missing assignment or calibration map id."); - setLoading(false); + if (isCurrent) { + setError("Missing assignment or calibration map id."); + setLoading(false); + } return; } try { - setLoading(true); - setError(null); + if (isCurrent) { + setLoading(true); + setError(null); + } const response = await axiosClient.get<CalibrationReportResponse>( `/assignments/${assignmentId}/reports/calibration/${mapId}` ); - setReport(response.data); + if (isCurrent) { + setReport(response.data); + } } catch (err: any) { - setError(err?.response?.data?.error || "Unable to load calibration report"); + if (isCurrent) { + setError(err?.response?.data?.error || "Unable to load calibration report"); + } } finally { - setLoading(false); + if (isCurrent) { + setLoading(false); + } } }; loadReport(); + return () => { + isCurrent = false; + }; }, [assignmentId, mapId]);🤖 Prompt for AI Agents