Skip to content

Commit dbc5ae9

Browse files
authored
Merge pull request #1396 from isaacphysics/feature/quiz-view
Create `/test/view` pages
2 parents c0ffff6 + 2fdd92a commit dbc5ae9

28 files changed

+783
-124
lines changed

src/IsaacApiTypes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,10 @@ export interface QuizSummaryDTO extends ContentSummaryDTO {
420420
hiddenFromRoles?: UserRole[];
421421
}
422422

423+
export interface DetailedQuizSummaryDTO extends QuizSummaryDTO {
424+
rubric?: ContentDTO;
425+
}
426+
423427
export interface EmailTemplateDTO extends ContentDTO {
424428
subject?: string;
425429
plainTextContent?: string;

src/app/components/elements/quiz/QuizAttemptFooter.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {QuizAttemptProps, QuizPagination} from "./QuizAttemptComponent";
1+
import {QuizAttemptProps, QuizPagination} from "./QuizContentsComponent";
22
import {
33
mutationSucceeded,
44
showSuccessToast,
@@ -37,7 +37,6 @@ export function QuizAttemptFooter(props: QuizAttemptProps & {feedbackLink: strin
3737
const sectionCount = Object.keys(sections).length;
3838

3939
let controls;
40-
let prequel = null;
4140
if (page === null) {
4241
let anyAnswered = false;
4342
const completedSections = Object.keys(sections).reduce((map, sectionId) => {
@@ -55,17 +54,14 @@ export function QuizAttemptFooter(props: QuizAttemptProps & {feedbackLink: strin
5554
const totalCompleted = Object.values(completedSections).reduce((sum, complete) => sum + (complete ? 1 : 0), 0);
5655
const firstIncomplete = Object.values(completedSections).indexOf(false);
5756
const allCompleted = totalCompleted === sectionCount;
58-
59-
const primaryButton = anyAnswered ? "Continue" : "Start";
60-
const primaryDescription = anyAnswered ? "resume" : "begin";
6157
const submitButton = submitting ? <IsaacSpinner /> : allCompleted ? "Submit" : "Submit anyway";
6258

6359
if (allCompleted) {
6460
controls = <>
6561
{
6662
siteSpecific(
67-
<Button color="tertiary" tag={Link} replace to={pageLink(1)}>Review answers</Button>,
68-
<Button outline color="secondary" tag={Link} replace to={pageLink(1)}>Review answers</Button>
63+
<Button color="tertiary" tag={Link} to={pageLink(1)}>Review answers</Button>,
64+
<Button outline color="secondary" tag={Link} to={pageLink(1)}>Review answers</Button>
6965
)
7066
}
7167
<Spacer/>
@@ -74,7 +70,6 @@ export function QuizAttemptFooter(props: QuizAttemptProps & {feedbackLink: strin
7470
<Button color={siteSpecific("secondary", "primary")} onClick={submitQuiz}>{submitButton}</Button>
7571
</>;
7672
} else {
77-
prequel = <p>Click &lsquo;{primaryButton}&rsquo; when you are ready to {primaryDescription} the test.</p>;
7873
if (anyAnswered) {
7974
controls = <>
8075
<div className="text-center">
@@ -83,12 +78,12 @@ export function QuizAttemptFooter(props: QuizAttemptProps & {feedbackLink: strin
8378
<Spacer/>
8479
{totalCompleted} / {sectionCount} sections complete<br/>
8580
<Spacer/>
86-
<Button color={siteSpecific("secondary", "primary")} tag={Link} replace to={pageLink(firstIncomplete + 1)}>{primaryButton}</Button>
81+
<Button color={siteSpecific("secondary", "primary")} tag={Link} to={pageLink(firstIncomplete + 1)}>Continue</Button>
8782
</>;
8883
} else {
8984
controls = <>
9085
<Spacer/>
91-
<Button color={siteSpecific("secondary", "primary")} tag={Link} replace to={pageLink(1)}>{primaryButton}</Button>
86+
<Button color={siteSpecific("secondary", "primary")} tag={Link} to={pageLink(1)}>Continue</Button>
9287
</>;
9388
}
9489
}
@@ -97,7 +92,6 @@ export function QuizAttemptFooter(props: QuizAttemptProps & {feedbackLink: strin
9792
}
9893

9994
return <>
100-
{prequel}
10195
<div className="d-flex border-top pt-2 my-2 align-items-center">
10296
{controls}
10397
</div>

src/app/components/elements/quiz/QuizAttemptComponent.tsx renamed to src/app/components/elements/quiz/QuizContentsComponent.tsx

Lines changed: 81 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
IsaacQuizDTO,
2+
DetailedQuizSummaryDTO,
33
IsaacQuizSectionDTO,
44
QuestionDTO,
55
QuizAttemptDTO,
@@ -16,6 +16,8 @@ import {
1616
isTeacherOrAbove,
1717
QUIZ_VIEW_STUDENT_ANSWERS_RELEASE_TIMESTAMP,
1818
siteSpecific,
19+
SUBJECTS,
20+
TAG_ID,
1921
useDeviceSize
2022
} from "../../../services";
2123
import {Spacer} from "../Spacer";
@@ -33,25 +35,38 @@ import {Markup} from "../markup";
3335
import classNames from "classnames";
3436

3537
type PageLinkCreator = (page?: number) => string;
38+
type QuizView = { quiz?: DetailedQuizSummaryDTO & { subjectId: SUBJECTS | TAG_ID }, quizId: string | undefined };
3639

37-
export interface QuizAttemptProps {
38-
attempt: QuizAttemptDTO;
40+
interface QuizProps {
3941
user: RegisteredUserDTO;
40-
page: number | null;
41-
questions: QuestionDTO[];
42-
sections: { [id: string]: IsaacQuizSectionDTO };
43-
pageLink: PageLinkCreator;
4442
pageHelp: React.ReactElement;
45-
preview?: boolean;
4643
studentUser?: UserSummaryDTO;
4744
quizAssignmentId?: string;
4845
}
46+
export interface QuizAttemptProps extends QuizProps {
47+
attempt: QuizAttemptDTO
48+
view?: undefined;
49+
preview?: boolean;
50+
page: number | null;
51+
pageLink: PageLinkCreator;
52+
questions: QuestionDTO[];
53+
sections: { [id: string]: IsaacQuizSectionDTO };
54+
}
55+
interface QuizViewProps extends QuizProps {
56+
attempt?: undefined;
57+
view: QuizView;
58+
preview?: undefined;
59+
page?: undefined;
60+
pageLink?: undefined;
61+
questions?: undefined;
62+
sections?: undefined;
63+
}
4964

5065
function inSection(section: IsaacQuizSectionDTO, questions: QuestionDTO[]) {
5166
return questions.filter(q => q.id?.startsWith(section.id as string + "|"));
5267
}
5368

54-
function QuizContents({attempt, sections, questions, pageLink}: QuizAttemptProps) {
69+
function QuizDetails({attempt, sections, questions, pageLink}: QuizAttemptProps) {
5570
if (isDefined(attempt.completedDate)) {
5671
return attempt.feedbackMode === "NONE" ?
5772
<h4>No feedback available</h4>
@@ -71,7 +86,7 @@ function QuizContents({attempt, sections, questions, pageLink}: QuizAttemptProps
7186
const section = sections[k];
7287
return <tr key={k}>
7388
{attempt.feedbackMode === 'DETAILED_FEEDBACK' ?
74-
<td><Link replace to={pageLink(index + 1)}>{section.title}</Link></td> :
89+
<td><Link to={pageLink(index + 1)}>{section.title}</Link></td> :
7590
<td>{section.title}</td>
7691
}
7792
<td>
@@ -94,7 +109,7 @@ function QuizContents({attempt, sections, questions, pageLink}: QuizAttemptProps
94109
const answerCount = questionsInSection.filter(q => q.bestAttempt !== undefined).length;
95110
const completed = questionsInSection.length === answerCount;
96111
return <li key={k}>
97-
<Link replace to={pageLink(index + 1)}>{section.title}</Link>
112+
<Link to={pageLink(index + 1)}>{section.title}</Link>
98113
{" "}
99114
<small className="text-muted">{completed ? "Completed" : anyStarted ? `${answerCount} / ${questionsInSection.length}` : ""}</small>
100115
</li>;
@@ -104,19 +119,20 @@ function QuizContents({attempt, sections, questions, pageLink}: QuizAttemptProps
104119
}
105120
}
106121

107-
function QuizHeader({attempt, preview, user}: QuizAttemptProps) {
122+
function QuizHeader({attempt, preview, view, user}: QuizAttemptProps | QuizViewProps) {
108123
const dispatch = useAppDispatch();
109-
const assignment = attempt.quizAssignment;
110-
if (preview) {
124+
if (preview || view) {
125+
const quiz = view ? view.quiz : attempt.quiz;
111126
return <>
112-
<EditContentButton doc={attempt.quiz} />
113-
<div className="d-flex">
114-
<span>You are previewing this test.</span>
127+
{preview && <EditContentButton doc={attempt.quiz} />}
128+
<div data-testid="quiz-action" className="d-flex">
129+
<p>{ preview ? "You are previewing this test." : "You are viewing the rubric for this test."}</p>
115130
<Spacer />
116-
{isTeacherOrAbove(user) && <Button onClick={() => dispatch(showQuizSettingModal(attempt.quiz as IsaacQuizDTO))}>Set Test</Button>}
131+
{isTeacherOrAbove(user) && <Button onClick={() => dispatch(showQuizSettingModal(quiz!))}>Set Test</Button>}
117132
</div>
118133
</>;
119-
} else if (isDefined(assignment)) {
134+
} else if (isDefined(attempt.quizAssignment)) {
135+
const assignment = attempt.quizAssignment;
120136
return <>
121137
<p className="d-flex">
122138
<span>
@@ -139,12 +155,12 @@ function QuizHeader({attempt, preview, user}: QuizAttemptProps) {
139155
</Alert>}
140156
</>;
141157
} else {
142-
return <p>You {attempt.completedDate ? "freely attempted" : "are freely attempting"} this test.</p>;
158+
return <p data-testid="quiz-action">You {attempt.completedDate ? "freely attempted" : "are freely attempting"} this test.</p>;
143159
}
144160
}
145161

146-
function QuizRubric({attempt}: {attempt: QuizAttemptDTO}) {
147-
const rubric = attempt.quiz?.rubric;
162+
function QuizRubric({attempt, view}: Pick<QuizAttemptProps | QuizViewProps, "attempt" | "view">) {
163+
const rubric = attempt ? attempt.quiz?.rubric : view?.quiz?.rubric;
148164
const renderRubric = (rubric?.children || []).length > 0;
149165
return <div>
150166
{rubric && renderRubric && <div>
@@ -207,9 +223,20 @@ function QuizSection({attempt, page, studentUser, user, quizAssignmentId}: QuizA
207223

208224
export const myQuizzesCrumbs = [{title: siteSpecific("My Tests", "My tests"), to: `/tests`}];
209225
export const teacherQuizzesCrumbs = [{title: siteSpecific("Set / Manage Tests", "Set tests"), to: `/set_tests`}];
210-
const QuizTitle = ({attempt, page, pageLink, pageHelp, preview, studentUser, user}: QuizAttemptProps) => {
211-
let quizTitle = attempt.quiz?.title || attempt.quiz?.id || "Test";
212-
if (isDefined(attempt.completedDate)) {
226+
export const rubricCrumbs = [{title: siteSpecific("Practice Tests", "Practice tests"), to: "/practice_tests"}];
227+
const getCrumbs = (preview: boolean | undefined, view: boolean | undefined, user: RegisteredUserDTO) => {
228+
if (preview && isTeacherOrAbove(user)) {
229+
return teacherQuizzesCrumbs;
230+
} if (view) {
231+
return rubricCrumbs;
232+
}
233+
return myQuizzesCrumbs;
234+
};
235+
236+
const QuizTitle = ({attempt, view, page, pageLink, pageHelp, preview, studentUser, user}: QuizAttemptProps | QuizViewProps) => {
237+
const quiz = attempt ? attempt.quiz : view.quiz;
238+
let quizTitle = quiz?.title || quiz?.id || "Test";
239+
if (isDefined(attempt?.completedDate)) {
213240
quizTitle += " Feedback";
214241
}
215242
if (isDefined(studentUser)) {
@@ -218,8 +245,9 @@ const QuizTitle = ({attempt, page, pageLink, pageHelp, preview, studentUser, use
218245
if (preview) {
219246
quizTitle += " Preview";
220247
}
221-
const crumbs = preview && isTeacherOrAbove(user) ? teacherQuizzesCrumbs : myQuizzesCrumbs;
222-
if (page === null) {
248+
249+
const crumbs = getCrumbs(preview, !!view, user);
250+
if (page === null || page === undefined) {
223251
return <TitleAndBreadcrumb currentPageTitle={quizTitle} help={pageHelp}
224252
intermediateCrumbs={crumbs}
225253
/>;
@@ -246,31 +274,38 @@ export function QuizPagination({page, sections, pageLink, finalLabel}: QuizAttem
246274
const nextLink = pageLink(!finalSection ? page + 1 : undefined);
247275

248276
return <div className="d-flex w-100 justify-content-between align-items-center">
249-
<Button color="primary" outline size={below["sm"](deviceSize) ? "sm" : ""} tag={Link} replace to={backLink}>Back</Button>
277+
<Button color="primary" outline size={below["sm"](deviceSize) ? "sm" : ""} tag={Link} to={backLink}>Back</Button>
250278
<div className="d-none d-md-block">Section {page} / {sectionCount}</div>
251-
<Button color="secondary" size={below["sm"](deviceSize) ? "sm" : ""} tag={Link} replace to={nextLink}>{finalSection ? finalLabel : "Next"}</Button>
279+
<Button color="secondary" size={below["sm"](deviceSize) ? "sm" : ""} tag={Link} to={nextLink}>{finalSection ? finalLabel : "Next"}</Button>
280+
</div>;
281+
}
282+
283+
function QuizOverview(props: QuizAttemptProps | QuizViewProps) {
284+
const {studentUser, user, quizAssignmentId} = props;
285+
const viewingAsSomeoneElse = isDefined(studentUser) && studentUser?.id !== user?.id;
286+
return <div className="mt-4">
287+
{!isDefined(studentUser?.id) && <QuizHeader {...props} />}
288+
{viewingAsSomeoneElse && <div className="mb-2">
289+
You are viewing this test as <b>{studentUser?.givenName} {studentUser?.familyName}</b>.{quizAssignmentId && <> <Link to={`/test/assignment/${quizAssignmentId}/feedback`}>Click here</Link> to return to the teacher test feedback page.</>}
290+
</div>}
291+
<QuizRubric {...props}/>
292+
{ props.attempt && <QuizDetails {...props} /> }
252293
</div>;
253294
}
254295

255-
export function QuizAttemptComponent(props: QuizAttemptProps) {
256-
const {page, questions, studentUser, user, quizAssignmentId} = props;
296+
function QuizQuestions(props: Omit<QuizAttemptProps, 'page'> & {page: number}) {
257297
// Assumes that ids of questions are defined - I don't know why this is not enforced in the editor/backend, because
258298
// we do unchecked casts of "possibly undefined" content ids to strings almost everywhere
259-
const questionNumbers = Object.assign({}, ...questions.map((q, i) => ({[q.id as string]: i + 1})));
260-
const viewingAsSomeoneElse = isDefined(studentUser) && studentUser?.id !== user?.id;
299+
const questionNumbers = Object.assign({}, ...props.questions.map((q, i) => ({[q.id as string]: i + 1})));
300+
261301
return <QuizAttemptContext.Provider value={{quizAttempt: props.attempt, questionNumbers}}>
262-
<QuizTitle {...props} />
263-
{page === null ?
264-
<div className="mt-4">
265-
{!isDefined(studentUser?.id) && <QuizHeader {...props} />}
266-
{viewingAsSomeoneElse && <div className="mb-2">
267-
You are viewing this test as <b>{studentUser?.givenName} {studentUser?.familyName}</b>.{quizAssignmentId && <> <Link to={`/test/assignment/${quizAssignmentId}/feedback`}>Click here</Link> to return to the teacher test feedback page.</>}
268-
</div>}
269-
<QuizRubric {...props}/>
270-
<QuizContents {...props} />
271-
</div>
272-
:
273-
<QuizSection {...props} page={page}/>
274-
}
302+
<QuizSection {...props} page={props.page}/>
275303
</QuizAttemptContext.Provider>;
276304
}
305+
306+
export function QuizContentsComponent(props: QuizAttemptProps | QuizViewProps) {
307+
return <>
308+
<QuizTitle {...props} />
309+
{props.page === null || props.page === undefined ? <QuizOverview {...props}/> : <QuizQuestions {...props} page={props.page} />}
310+
</>;
311+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from "react";
2+
import {TitleAndBreadcrumb} from "../TitleAndBreadcrumb";
3+
import {Alert} from "reactstrap";
4+
import { getRTKQueryErrorMessage } from "../../../state";
5+
import type {FetchBaseQueryError} from "@reduxjs/toolkit/query";
6+
import type {SerializedError} from "@reduxjs/toolkit";
7+
import { LinkInfo } from "../../../services";
8+
9+
type Error = FetchBaseQueryError | SerializedError | undefined;
10+
11+
export const buildErrorComponent = (title: string, heading: string, breadcrumbs: LinkInfo[]) => function ErrorComponent(error: Error){
12+
return <>
13+
<TitleAndBreadcrumb currentPageTitle={title} intermediateCrumbs={breadcrumbs} />
14+
<Alert color="danger">
15+
<h4 className="alert-heading">{heading}</h4>
16+
<p data-testid="error-message">{getRTKQueryErrorMessage(error).message}</p>
17+
</Alert>
18+
</>;
19+
};

src/app/components/pages/quizzes/PracticeQuizzes.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,8 @@ const PracticeQuizzesComponent = ({user}: QuizzesPageProps) => {
7070
{quiz.summary && <div className="small text-muted d-none d-md-block">{quiz.summary}</div>}
7171
</div>
7272
<Spacer />
73-
{isTutorOrAbove(user) && <div className="d-none d-md-flex align-items-center me-4">
74-
<Link to={{pathname: `/test/preview/${quiz.id}`}}>
75-
<span>Preview</span>
76-
</Link>
77-
</div>}
78-
<Button tag={Link} to={{pathname: `/test/attempt/${quiz.id}`}}>
79-
{siteSpecific("Take Test", "Take test")}
73+
<Button tag={Link} to={{pathname: `/test/view/${quiz.id}`}}>
74+
{siteSpecific("View Test", "View test")}
8075
</Button>
8176
</div>
8277
</ListGroupItem>)}

src/app/components/pages/quizzes/QuizAttemptFeedback.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import {ShowLoading} from "../../handlers/ShowLoading";
44
import {isDefined, useQuizAttemptFeedback} from "../../../services";
55
import {
66
myQuizzesCrumbs,
7-
QuizAttemptComponent,
7+
QuizContentsComponent,
88
QuizAttemptProps,
99
QuizPagination
10-
} from "../../elements/quiz/QuizAttemptComponent";
10+
} from "../../elements/quiz/QuizContentsComponent";
1111
import {QuizAttemptDTO, RegisteredUserDTO} from "../../../../IsaacApiTypes";
1212
import {Spacer} from "../../elements/Spacer";
1313
import {TitleAndBreadcrumb} from "../../elements/TitleAndBreadcrumb";
@@ -22,7 +22,7 @@ function QuizFooter(props: QuizAttemptProps) {
2222
prequel = <p className="mt-3">Click on a section title or click &lsquo;Next&rsquo; to look at {isDefined(studentUser) ? "their" : "your"} detailed feedback.</p>;
2323
controls = <>
2424
<Spacer/>
25-
<Button tag={Link} replace to={pageLink(1)}>Next</Button>
25+
<Button tag={Link} to={pageLink(1)}>Next</Button>
2626
</>;
2727
} else {
2828
controls = <QuizPagination {...props} page={page} finalLabel="Back to Overview" />;
@@ -65,7 +65,7 @@ export const QuizAttemptFeedback = ({user}: {user: RegisteredUserDTO}) => {
6565
return <Container className={`mb-5 ${attempt?.quiz?.subjectId}`}>
6666
<ShowLoading until={attempt || error}>
6767
{isDefined(attempt) && <>
68-
<QuizAttemptComponent {...subProps} />
68+
<QuizContentsComponent {...subProps} />
6969
{attempt.feedbackMode === 'DETAILED_FEEDBACK' && <QuizFooter {...subProps} />}
7070
</>}
7171
{isDefined(error) && <>

src/app/components/pages/quizzes/QuizDoAssignment.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {clearQuizAttempt, loadQuizAssignmentAttempt, useAppDispatch} from "../..
33
import {useParams} from "react-router-dom";
44
import {ShowLoading} from "../../handlers/ShowLoading";
55
import {isDefined, useCurrentQuizAttempt} from "../../../services";
6-
import {myQuizzesCrumbs, QuizAttemptComponent, QuizAttemptProps} from "../../elements/quiz/QuizAttemptComponent";
6+
import {myQuizzesCrumbs, QuizContentsComponent, QuizAttemptProps} from "../../elements/quiz/QuizContentsComponent";
77
import {QuizAttemptDTO, RegisteredUserDTO} from "../../../../IsaacApiTypes";
88
import {TitleAndBreadcrumb} from "../../elements/TitleAndBreadcrumb";
99
import {QuizAttemptFooter} from "../../elements/quiz/QuizAttemptFooter";
@@ -41,7 +41,7 @@ export const QuizDoAssignment = ({user}: {user: RegisteredUserDTO}) => {
4141
return <Container className={`mb-5 ${attempt?.quiz?.subjectId}`}>
4242
<ShowLoading until={attempt || error}>
4343
{attempt && <>
44-
<QuizAttemptComponent {...subProps} />
44+
<QuizContentsComponent {...subProps} />
4545
<QuizAttemptFooter {...subProps} />
4646
</>}
4747
{error && <>

0 commit comments

Comments
 (0)