Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9971fb1
add `QuizView` page
barna-isaac Apr 9, 2025
a8d9a8e
hide `Test Sections` when there aren't any
barna-isaac Apr 9, 2025
69dc490
change breadcrumbs, actionMessage on `QuizView`
barna-isaac Apr 9, 2025
0c35462
clean up redundant tests
barna-isaac Apr 10, 2025
eaf12a2
show "Take Test" button
barna-isaac Apr 10, 2025
e61fad5
allow navigation between the `view` and `attempt` pages
barna-isaac Apr 10, 2025
cb69193
just a style fix
barna-isaac Apr 10, 2025
ab5b131
show `Preview` button for teachers and above
barna-isaac Apr 10, 2025
900b1e8
simplify `QuizAttemptFooter`
barna-isaac Apr 10, 2025
dc8c6c1
take user to "View Test" from "Practice Tests"
barna-isaac Apr 10, 2025
2671a18
undo change I comitted by accident
barna-isaac Apr 11, 2025
ea81ef8
refactor: extract error response from handlers
barna-isaac Apr 11, 2025
0fcd569
prevent overflow on `Take Test` button
barna-isaac Apr 11, 2025
a9bc488
`QuizView` works on Ada
barna-isaac Apr 11, 2025
ec8a23f
fix spacing
barna-isaac Apr 11, 2025
f5eff81
a minor refactor
barna-isaac Apr 11, 2025
44d9f22
Continue on `QuizDoFreeAttempt` allows going back
barna-isaac Apr 11, 2025
f61741f
quiz attempt pages always allow navigating back
barna-isaac Apr 11, 2025
c9ed9b1
allow backward navigation when previewing
barna-isaac Apr 11, 2025
8786913
extract Quiz page helpers
barna-isaac Apr 11, 2025
b0b62c9
apply Ada-specific casing differences
barna-isaac Apr 11, 2025
f7c7118
refactor: fix typo in filename
barna-isaac Apr 15, 2025
2710697
fix editing mistake in tooltip text
barna-isaac Apr 15, 2025
93493e1
`IsaacRubricDTO` -> `DetailedQuizSummaryDTO`
barna-isaac Apr 15, 2025
058a720
preserve consistency with API types
barna-isaac Apr 16, 2025
a12c43d
simplify QuizAttemptComponent
barna-isaac Apr 16, 2025
93b66da
rename `QuizAttemptComponent`
barna-isaac Apr 16, 2025
2fdd92a
remove `anySections` check in `QuizContents`
barna-isaac Apr 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/IsaacApiTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ export interface IsaacQuizDTO extends SeguePageDTO, HasTitleOrId {
individualFeedback?: QuizFeedbackDTO;
}

export interface IsaacRubricDTO extends HasTitleOrId{
rubric?: ContentDTO;
hiddenFromRoles: UserRole[];
tags: string[];
type: string;
url: string
}

export interface IsaacQuizSectionDTO extends SeguePageDTO {
}

Expand Down Expand Up @@ -386,7 +394,7 @@ export interface ContentDTO extends ContentBaseDTO {
encoding?: string;
layout?: string;
expandable?: boolean;
children?: ContentBaseDTO[];
children?: ContentDTO[];
value?: string;
attribution?: string;
relatedContent?: ContentSummaryDTO[];
Expand Down
19 changes: 19 additions & 0 deletions src/app/components/elements/quiz/builErrorComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";
import {TitleAndBreadcrumb} from "../../elements/TitleAndBreadcrumb";
import {myQuizzesCrumbs} from "./QuizAttemptComponent";
import {Alert} from "reactstrap";
import { getRTKQueryErrorMessage } from "../../../state";
import type {FetchBaseQueryError} from "@reduxjs/toolkit/query";
import type {SerializedError} from "@reduxjs/toolkit";

type Error = FetchBaseQueryError | SerializedError | undefined;

export const buildErrorComponent = (title: string, heading: string) => function ErrorComponent(error: Error){
return <>
<TitleAndBreadcrumb currentPageTitle={title} intermediateCrumbs={myQuizzesCrumbs} />
<Alert color="danger">
<h4 className="alert-heading">{heading}</h4>
<p data-testid="error-message">{getRTKQueryErrorMessage(error).message}</p>
</Alert>
</>;
};
27 changes: 7 additions & 20 deletions src/app/components/pages/quizzes/QuizPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import React, {useCallback, useMemo} from "react";
import {getRTKQueryErrorMessage, useGetQuizPreviewQuery} from "../../../state";
import {useGetQuizPreviewQuery} from "../../../state";
import {Link, useParams} from "react-router-dom";
import {isDefined, tags, useQuizQuestions, useQuizSections} from "../../../services";
import {
myQuizzesCrumbs,
QuizAttemptComponent,
QuizAttemptProps,
QuizPagination
} from "../../elements/quiz/QuizAttemptComponent";
import {QuizAttemptComponent, QuizAttemptProps, QuizPagination} from "../../elements/quiz/QuizAttemptComponent";
import {QuizAttemptDTO, RegisteredUserDTO} from "../../../../IsaacApiTypes";
import {Spacer} from "../../elements/Spacer";
import {TitleAndBreadcrumb} from "../../elements/TitleAndBreadcrumb";
import {Alert, Button, Container} from "reactstrap";
import {Button, Container} from "reactstrap";
import {ShowLoadingQuery} from "../../handlers/ShowLoadingQuery";
import {FetchBaseQueryError} from "@reduxjs/toolkit/query";
import {SerializedError} from "@reduxjs/toolkit";
import {buildErrorComponent} from "../../elements/quiz/builErrorComponent";

const QuizFooter = ({page, pageLink, ...rest}: QuizAttemptProps) =>
<div className="d-flex border-top pt-2 my-2 align-items-center">
Expand All @@ -30,6 +23,8 @@ const pageHelp = <span>
Preview the questions on this test.
</span>;

const Error = buildErrorComponent("Test Preview", "Error loading test preview");

export const QuizPreview = ({user}: {user: RegisteredUserDTO}) => {

const {page, quizId} = useParams<{quizId: string; page?: string;}>();
Expand All @@ -56,16 +51,8 @@ export const QuizPreview = ({user}: {user: RegisteredUserDTO}) => {

const subProps: QuizAttemptProps = {attempt: attempt as QuizAttemptDTO, page: pageNumber, questions, sections, pageLink, pageHelp, user};

const buildErrorComponent = (error: FetchBaseQueryError | SerializedError | undefined) => <>
<TitleAndBreadcrumb currentPageTitle="Test Preview" intermediateCrumbs={myQuizzesCrumbs} />
<Alert color="danger">
<h4 className="alert-heading">Error loading test preview</h4>
<p>{getRTKQueryErrorMessage(error).message}</p>
</Alert>
</>;

return <Container className={`mb-5 ${attempt?.quiz?.subjectId}`}>
<ShowLoadingQuery query={quizPreviewQuery} ifError={buildErrorComponent}>
<ShowLoadingQuery query={quizPreviewQuery} ifError={Error}>
<QuizAttemptComponent preview {...subProps} />
<QuizFooter {...subProps} />
</ShowLoadingQuery>
Expand Down
30 changes: 30 additions & 0 deletions src/app/components/pages/quizzes/QuizView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";
import {useGetQuizRubricQuery} from "../../../state";
import {useParams} from "react-router-dom";
import {tags} from "../../../services";
import {QuizAttemptComponent} from "../../elements/quiz/QuizAttemptComponent";
import {Container} from "reactstrap";
import {ShowLoadingQuery} from "../../handlers/ShowLoadingQuery";
import type { RegisteredUserDTO } from "../../../../IsaacApiTypes";
import { buildErrorComponent } from "../../elements/quiz/builErrorComponent";

const pageLink = () => '';

const pageHelp = <span> View information about this test. </span>;

const Error = buildErrorComponent("Viewing Test", "There was an error loading that test.");

export const QuizView = ({user}: {user: RegisteredUserDTO}) => {
const {quizId} = useParams<{quizId: string}>();
const quizRubricQuery = useGetQuizRubricQuery(quizId);
const attempt = {
quiz: quizRubricQuery.data && tags.augmentDocWithSubject(quizRubricQuery.data),
quizId: quizRubricQuery.data?.id,
};

return <Container className={`mb-5 ${attempt?.quiz?.subjectId}`}>
<ShowLoadingQuery query={quizRubricQuery} ifError={Error}>
<QuizAttemptComponent preview attempt={attempt} page={null} questions={[]} sections={{}} pageLink={pageLink} pageHelp={pageHelp} user={user} />
</ShowLoadingQuery>
</Container>;
};
3 changes: 3 additions & 0 deletions src/app/components/site/phy/RoutesPhy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { RegistrationSuccess } from "../../pages/RegistrationSuccess";
import { RegistrationSetPreferences } from "../../pages/RegistrationSetPreferences";
import { RegistrationGroupInvite } from "../../pages/RegistrationGroupInvite";
import { PracticeQuizzes } from "../../pages/quizzes/PracticeQuizzes";
import { QuizView } from "../../pages/quizzes/QuizView";

const Equality = lazy(() => import('../../pages/Equality'));
const EventDetails = lazy(() => import('../../pages/EventDetails'));
Expand Down Expand Up @@ -86,6 +87,8 @@ export const RoutesPhy = [
<TrackedRoute key={key++} exact path="/test/preview/:quizId/page/:page" ifUser={isTutorOrAbove} component={QuizPreview} />,
<TrackedRoute key={key++} exact path="/test/attempt/:quizId" ifUser={isLoggedIn} component={QuizDoFreeAttempt} />,
<TrackedRoute key={key++} exact path="/test/attempt/:quizId/page/:page" ifUser={isLoggedIn} component={QuizDoFreeAttempt} />,
<TrackedRoute key={key++} exact path="/test/view/:quizId" ifUser={isLoggedIn} component={QuizView} />,

// The order of these redirects matters to prevent substring replacement
<Redirect key={key++} from="/quiz/assignment/:quizAssignmentId/feedback" to="/test/assignment/:quizAssignmentId/feedback" />,
<Redirect key={key++} from="/quiz/assignment/:quizAssignmentId/page/:page" to="/test/assignment/:quizAssignmentId/page/:page" />,
Expand Down
9 changes: 9 additions & 0 deletions src/app/state/slices/api/quizApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AssignmentStatusDTO,
ChoiceDTO,
IsaacQuizDTO,
IsaacRubricDTO,
QuestionValidationResponseDTO,
QuizAssignmentDTO,
QuizAttemptDTO,
Expand Down Expand Up @@ -88,6 +89,13 @@ export const quizApi = isaacApi.enhanceEndpoints({
})
}),

getQuizRubric: build.query<IsaacRubricDTO, string>({
query: (quizId) => `/quiz/${quizId}/rubric`,
onQueryStarted: onQueryLifecycleEvents({
errorTitle: "Loading test rubric failed",
})
}),

// === Quiz attempt endpoints ===

// loadQuizAssignmentAttempt: (quizAssignmentId: number): AxiosPromise<QuizAttemptDTO> => {
Expand Down Expand Up @@ -231,6 +239,7 @@ export const {
useGetQuizAssignmentsAssignedToMeQuery,
useMarkQuizAttemptAsCompleteMutation,
useGetQuizPreviewQuery,
useGetQuizRubricQuery,
useLogQuizSectionViewMutation,
useGetStudentQuizAttemptWithFeedbackQuery,
useAssignQuizMutation,
Expand Down
27 changes: 27 additions & 0 deletions src/mocks/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
BookingStatus,
EmailVerificationStatus,
EventStatus,
IsaacRubricDTO,
USER_ROLES,
UserRole,
UserSummaryWithGroupMembershipDTO
} from "../IsaacApiTypes";
Expand Down Expand Up @@ -2258,6 +2260,31 @@ export const mockGameboards = {
totalResults: 7
};

export const mockRubrics: Record<string, IsaacRubricDTO> = {
a_level_1d_motion_test: {
id: "a_level_1d_motion_test",
title: "A Level 1-d Motion Test",
type: "isaacQuiz",
tags: [],
url: "/isaac-api/api/quiz/a_level_1d_motion_test",
hiddenFromRoles: [USER_ROLES[0], USER_ROLES[1]],
rubric:{
type: "content",
encoding: "markdown",
children:[
{
type:"content",
encoding:"markdown",
children:[],
value:"We recommend completing this test after studying the relevant concepts Equations of Motion, either in school or by doing the appropriate sections in the Essential Pre-Uni Physics book.\\n\\nFor this test make sure to follow the Isaac Physics rules for significant figures.",
tags:[]
}
],
"tags":[]
}
}
};

export const mockMyAssignments = [
{
id: 37,
Expand Down
15 changes: 14 additions & 1 deletion src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {
mockUserPreferences,
mockRegressionTestQuestions,
mockQuestionFinderResults,
mockConceptPage
mockConceptPage,
mockRubrics
} from "./data";
import {API_PATH} from "../app/services";
import {produce} from "immer";
Expand Down Expand Up @@ -63,6 +64,18 @@ export const handlers = [
status: 200,
});
}),
http.get(API_PATH + "/quiz/:quizId/rubric", ({ params }) => {
const quizId = params.quizId as string;
if (quizId in mockRubrics) {
return HttpResponse.json(mockRubrics[quizId], { status: 200 });
}
return HttpResponse.json({
bypassGenericSiteErrorPage: false,
errorMessage: "This test has become unavailable.",
responseCode: 404,
responseCodeType: "Not found"
}, { status: 404 });
}),
http.get(API_PATH + "/assignments/assign/:assignmentId", ({params}) => {
const {assignmentId: _assignmentId} = params;
const assignmentId = parseInt(_assignmentId as string);
Expand Down
81 changes: 81 additions & 0 deletions src/test/pages/QuizView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {act, screen} from "@testing-library/react";
import {expectH1, expectH4, expectTextInElementWithId, expectTitledSection, expectUrl, renderTestEnvironment, setUrl, waitForLoaded} from "../testUtils";
import {mockRubrics} from "../../mocks/data";
import {isPhy} from "../../app/services";
import type {UserRole} from "../../IsaacApiTypes";

describe("QuizView", () => {
if (!isPhy) {
return it('does not exist yet', () => {});
};

const rubricId = Object.keys(mockRubrics)[0];
const mockRubric = mockRubrics[rubricId];

const renderQuizView = async ({role, pathname}: {role: UserRole | "ANONYMOUS", pathname: string}) => {
await act(async () => renderTestEnvironment({ role }));
await act(async () => setUrl({ pathname }));
await waitForLoaded();
};

it('shows the rubric for the quiz', async () => {
await renderQuizView({ role: 'STUDENT', pathname: `/test/view/${rubricId}/` });
expectH1(mockRubric.title);
expectTitledSection("Instructions", mockRubric.rubric?.children?.[0].value);
});

it('does not show the Set Test button', async () => {
await renderQuizView({ role: 'STUDENT', pathname: `/test/view/${rubricId}/` });
expect(setTestButton()).toBe(null);
});

it("does not show the edit button", async () => {
await renderQuizView({ role: 'STUDENT', pathname: `/test/view/${rubricId}/` });
expect(editButton()).toBe(null);
});

describe('for teachers', () => {
it('shows the Set Test button', async () => {
await renderQuizView({ role: 'TEACHER', pathname: `/test/view/${rubricId}/` });
expect(setTestButton()).toBeInTheDocument();
});

it("does not show the edit button", async () => {
await renderQuizView({ role: 'TEACHER', pathname: `/test/view/${rubricId}/` });
expect(editButton()).toBe(null);
});
});

describe('for content editors', () => {
it('shows the Set Test Button', async () => {
await renderQuizView({ role: 'CONTENT_EDITOR', pathname: `/test/view/${rubricId}/` });
expect(setTestButton()).toBeInTheDocument();
});

// we'd need canonicalSourceFile for this, which the endpoint doesn't return
it('does not show the edit button', async () => {
await renderQuizView({ role: 'CONTENT_EDITOR', pathname: `/test/view/${rubricId}/` });
expect(editButton()).toBe(null);
});
});

describe('for unregistered users', () => {
it('redirects to log in', async () => {
await renderQuizView({ role: 'ANONYMOUS', pathname: '/test/view/some_non_existent_test'});
await expectUrl('/login');
});
});

describe('when a quiz does not exist', () => {
it('shows an error', async () => {
await renderQuizView({ role: 'STUDENT', pathname: '/test/view/some_non_existent_test'});
expectH1('Viewing Test');
expectH4('There was an error loading that test.');
expectErrorMessage('This test has become unavailable.');
});
});
});

const expectErrorMessage = expectTextInElementWithId('error-message');
const setTestButton = () => screen.queryByRole('button', {name: "Set Test"});
const editButton = () => screen.queryByRole('header', {name: "Published ✎"});
21 changes: 21 additions & 0 deletions src/test/testUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export const waitForLoaded = () => waitFor(() => {
expect(screen.queryAllByText("Loading...")).toHaveLength(0);
});

export const expectUrl = (text: string) => waitFor(() => {
expect(history.location.pathname).toBe(text);
});

export const expectUrlParams = (text: string) => waitFor(() => {
expect(history.location.search).toBe(text);
});
Expand Down Expand Up @@ -190,3 +194,20 @@ export const withMockedDate = async (date: number, fn: () => Promise<void>) => {
jest.spyOn(global.Date, 'now').mockRestore();
}
};

const expectHeader = (n: number) => (txt?: string) => expect(screen.getByRole('heading', { level: n })).toHaveTextContent(`${txt}`);

export const expectH1 = expectHeader(1);

export const expectH4 = expectHeader(4);

export const expectTextInElementWithId = (testId: string) => (msg: string) => expect(screen.getByTestId(testId)).toHaveTextContent(msg);

export const expectTitledSection = (title: string, message: string | undefined) => {
const titleE = screen.getByRole('heading', { name: title });
if (titleE.parentElement === null) {
throw new Error(`Could not find parent for heading: ${title}`);
}
const paragraph = within(titleE.parentElement).getByRole('paragraph');
return expect(paragraph).toHaveTextContent(`${message}`);
};