feat: add unmet exam prerequisites (#63131)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2025-10-28 15:44:16 +02:00
committed by GitHub
parent 2d04d11056
commit eb649ff99c
6 changed files with 94 additions and 12 deletions

View File

@@ -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 }]);

View File

@@ -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<typeof schemas.examEnvironmentExams>,
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);

View File

@@ -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

View File

@@ -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();
};

View File

@@ -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<string | undefined>('');
const [downloadLinks, setDownloadLinks] = useState<string[]>([]);
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 (
<LearnLayout>
<Helmet>
@@ -151,6 +187,9 @@ function ShowExamDownload({
{title}
</ChallengeTitle>
<Spacer size='l' />
{!!missingPrerequisites.length && (
<MissingPrerequisites missingPrerequisites={missingPrerequisites} />
)}
<h2>{t('exam.download-header')}</h2>
<p>{t('exam.explanation')}</p>
<Spacer size='l' />
@@ -228,5 +267,16 @@ export const query = graphql`
translationPending
}
}
allChallengeNode {
nodes {
challenge {
id
title
fields {
slug
}
}
}
}
}
`;

View File

@@ -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<ExamEnvironmentChallenge[], string>({
query: challengeId =>
`/exam-environment/exam-challenge?challengeId=${challengeId}`
}),
getExams: build.query<GetExamsResponse, void>({
query: () => '/user/exam-environment/exams'
})
})
});