From eb649ff99cc267347d9e097177849ed47e5af33e Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Tue, 28 Oct 2025 15:44:16 +0200 Subject: [PATCH] feat: add unmet exam prerequisites (#63131) Co-authored-by: Oliver Eyton-Williams --- .../routes/exam-environment.test.ts | 6 +- .../routes/exam-environment.ts | 9 ++- .../schemas/exam-environment-exams.ts | 5 +- api/src/routes/protected/user.ts | 10 +++- .../Challenges/exam-download/show.tsx | 60 +++++++++++++++++-- client/src/utils/ajax.ts | 16 +++++ 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/api/src/exam-environment/routes/exam-environment.test.ts b/api/src/exam-environment/routes/exam-environment.test.ts index 06ea6857e12..aace3af5076 100644 --- a/api/src/exam-environment/routes/exam-environment.test.ts +++ b/api/src/exam-environment/routes/exam-environment.test.ts @@ -712,7 +712,8 @@ describe('/exam-environment/', () => { totalTimeInS: mock.exam.config.totalTimeInS, retakeTimeInS: mock.exam.config.retakeTimeInS }, - id: mock.examId + id: mock.examId, + prerequisites: mock.exam.prerequisites } ]); @@ -787,7 +788,8 @@ describe('/exam-environment/', () => { totalTimeInS: mock.exam.config.totalTimeInS, retakeTimeInS: mock.exam.config.retakeTimeInS }, - id: mock.examId + id: mock.examId, + prerequisites: mock.exam.prerequisites } ]); expect(res.body).toMatchObject([{ canTake: true }]); diff --git a/api/src/exam-environment/routes/exam-environment.ts b/api/src/exam-environment/routes/exam-environment.ts index 403d8aa930d..8a7bccd62e7 100644 --- a/api/src/exam-environment/routes/exam-environment.ts +++ b/api/src/exam-environment/routes/exam-environment.ts @@ -694,7 +694,11 @@ async function postExamAttemptHandler( return reply.code(200).send(); } -async function getExams( +/** + * Get all the public information about all exams. + * @returns Public information about exams + whether Camper may take the exam or not. + */ +export async function getExams( this: FastifyInstance, req: UpdateReqType, reply: FastifyReply @@ -763,7 +767,8 @@ async function getExams( retakeTimeInS: exam.config.retakeTimeInS, passingPercent: exam.config.passingPercent }, - canTake: false + canTake: false, + prerequisites: exam.prerequisites }; const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites); diff --git a/api/src/exam-environment/schemas/exam-environment-exams.ts b/api/src/exam-environment/schemas/exam-environment-exams.ts index 10b9c991d29..984059ad371 100644 --- a/api/src/exam-environment/schemas/exam-environment-exams.ts +++ b/api/src/exam-environment/schemas/exam-environment-exams.ts @@ -2,7 +2,7 @@ import { Type } from '@fastify/type-provider-typebox'; import { STANDARD_ERROR } from '../utils/errors.js'; export const examEnvironmentExams = { headers: Type.Object({ - 'exam-environment-authorization-token': Type.String() + 'exam-environment-authorization-token': Type.Optional(Type.String()) }), response: { 200: Type.Array( @@ -15,7 +15,8 @@ export const examEnvironmentExams = { retakeTimeInS: Type.Number(), passingPercent: Type.Number() }), - canTake: Type.Boolean() + canTake: Type.Boolean(), + prerequisites: Type.Array(Type.String()) }) ), 500: STANDARD_ERROR diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 53e2ae01fc7..6aa141d1bef 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -31,7 +31,8 @@ import { DEPLOYMENT_ENV, JWT_SECRET } from '../../utils/env.js'; import { getExamAttemptHandler, getExamAttemptsByExamIdHandler, - getExamAttemptsHandler + getExamAttemptsHandler, + getExams } from '../../exam-environment/routes/exam-environment.js'; import { ERRORS } from '../../exam-environment/utils/errors.js'; @@ -565,6 +566,13 @@ export const userRoutes: FastifyPluginCallbackTypebox = ( }, getExamAttemptsByExamIdHandler ); + fastify.get( + '/user/exam-environment/exams', + { + schema: examEnvironmentSchemas.examEnvironmentExams + }, + getExams + ); done(); }; diff --git a/client/src/templates/Challenges/exam-download/show.tsx b/client/src/templates/Challenges/exam-download/show.tsx index ef11b9779ab..1ec44f73514 100644 --- a/client/src/templates/Challenges/exam-download/show.tsx +++ b/client/src/templates/Challenges/exam-download/show.tsx @@ -16,8 +16,13 @@ import { connect } from 'react-redux'; import LearnLayout from '../../../components/layouts/learn'; import ChallengeTitle from '../components/challenge-title'; import useDetectOS from '../utils/use-detect-os'; -import { ChallengeNode } from '../../../redux/prop-types'; -import { isSignedInSelector } from '../../../redux/selectors'; +import { ChallengeNode, CompletedChallenge } from '../../../redux/prop-types'; +import { + completedChallengesSelector, + isSignedInSelector +} from '../../../redux/selectors'; +import { examAttempts } from '../../../utils/ajax'; +import MissingPrerequisites from '../exam/components/missing-prerequisites'; import { isChallengeCompletedSelector } from '../redux/selectors'; import { Attempts } from './attempts'; import ExamTokenControls from './exam-token-controls'; @@ -30,16 +35,26 @@ interface GitProps { } const mapStateToProps = createSelector( + completedChallengesSelector, isChallengeCompletedSelector, isSignedInSelector, - (isChallengeCompleted: boolean, isSignedIn: boolean) => ({ + ( + completedChallenges: CompletedChallenge[], + isChallengeCompleted: boolean, + isSignedIn: boolean + ) => ({ + completedChallenges, isChallengeCompleted, isSignedIn }) ); interface ShowExamDownloadProps { - data: { challengeNode: ChallengeNode }; + data: { + challengeNode: ChallengeNode; + allChallengeNode: { nodes: ChallengeNode[] }; + }; + completedChallenges: CompletedChallenge[]; isChallengeCompleted: boolean; isSignedIn: boolean; } @@ -48,8 +63,10 @@ function ShowExamDownload({ data: { challengeNode: { challenge: { id, title, translationPending } - } + }, + allChallengeNode: { nodes } }, + completedChallenges, isChallengeCompleted, isSignedIn }: ShowExamDownloadProps): JSX.Element { @@ -58,6 +75,9 @@ function ShowExamDownload({ const [downloadLink, setDownloadLink] = useState(''); const [downloadLinks, setDownloadLinks] = useState([]); + const getExamsQuery = examAttempts.useGetExamsQuery(); + const examIdsQuery = examAttempts.useGetExamIdsByChallengeIdQuery(id); + const os = useDetectOS(); const { t } = useTranslation(); @@ -135,6 +155,22 @@ function ShowExamDownload({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [os]); + const examId = examIdsQuery.data?.at(0)?.examId; + const exam = getExamsQuery.data?.find(examItem => examItem.id === examId); + const unmetPrerequisites = exam?.prerequisites?.filter( + prereq => !completedChallenges.some(challenge => challenge.id === prereq) + ); + const challenges = nodes.filter(({ challenge }) => + unmetPrerequisites?.includes(challenge.id) + ); + const missingPrerequisites = challenges.map(({ challenge }) => { + return { + id: challenge.id, + title: challenge.title, + slug: challenge.fields?.slug || '' + }; + }); + return ( @@ -151,6 +187,9 @@ function ShowExamDownload({ {title} + {!!missingPrerequisites.length && ( + + )}

{t('exam.download-header')}

{t('exam.explanation')}

@@ -228,5 +267,16 @@ export const query = graphql` translationPending } } + allChallengeNode { + nodes { + challenge { + id + title + fields { + slug + } + } + } + } } `; diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 295caae70ca..97a8b31da36 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -430,6 +430,19 @@ export interface ExamEnvironmentChallenge { challengeId: string; } +export type GetExamsResponse = Array<{ + id: string; + config: { + name: string; + note: string; + totalTimeInS: number; + retakeTimeInS: number; + passingPercent: number; + }; + canTake: boolean; + prerequisites: string[]; +}>; + export const examAttempts = createApi({ reducerPath: 'exam-attempts', baseQuery: fetchBaseQuery({ @@ -446,6 +459,9 @@ export const examAttempts = createApi({ getExamIdsByChallengeId: build.query({ query: challengeId => `/exam-environment/exam-challenge?challengeId=${challengeId}` + }), + getExams: build.query({ + query: () => '/user/exam-environment/exams' }) }) });