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
@@ -712,7 +712,8 @@ describe('/exam-environment/', () => {
totalTimeInS: mock.exam.config.totalTimeInS, totalTimeInS: mock.exam.config.totalTimeInS,
retakeTimeInS: mock.exam.config.retakeTimeInS 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, totalTimeInS: mock.exam.config.totalTimeInS,
retakeTimeInS: mock.exam.config.retakeTimeInS retakeTimeInS: mock.exam.config.retakeTimeInS
}, },
id: mock.examId id: mock.examId,
prerequisites: mock.exam.prerequisites
} }
]); ]);
expect(res.body).toMatchObject([{ canTake: true }]); expect(res.body).toMatchObject([{ canTake: true }]);
@@ -694,7 +694,11 @@ async function postExamAttemptHandler(
return reply.code(200).send(); 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, this: FastifyInstance,
req: UpdateReqType<typeof schemas.examEnvironmentExams>, req: UpdateReqType<typeof schemas.examEnvironmentExams>,
reply: FastifyReply reply: FastifyReply
@@ -763,7 +767,8 @@ async function getExams(
retakeTimeInS: exam.config.retakeTimeInS, retakeTimeInS: exam.config.retakeTimeInS,
passingPercent: exam.config.passingPercent passingPercent: exam.config.passingPercent
}, },
canTake: false canTake: false,
prerequisites: exam.prerequisites
}; };
const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites); const isExamPrerequisitesMet = checkPrerequisites(user, exam.prerequisites);
@@ -2,7 +2,7 @@ import { Type } from '@fastify/type-provider-typebox';
import { STANDARD_ERROR } from '../utils/errors.js'; import { STANDARD_ERROR } from '../utils/errors.js';
export const examEnvironmentExams = { export const examEnvironmentExams = {
headers: Type.Object({ headers: Type.Object({
'exam-environment-authorization-token': Type.String() 'exam-environment-authorization-token': Type.Optional(Type.String())
}), }),
response: { response: {
200: Type.Array( 200: Type.Array(
@@ -15,7 +15,8 @@ export const examEnvironmentExams = {
retakeTimeInS: Type.Number(), retakeTimeInS: Type.Number(),
passingPercent: Type.Number() passingPercent: Type.Number()
}), }),
canTake: Type.Boolean() canTake: Type.Boolean(),
prerequisites: Type.Array(Type.String())
}) })
), ),
500: STANDARD_ERROR 500: STANDARD_ERROR
+9 -1
View File
@@ -31,7 +31,8 @@ import { DEPLOYMENT_ENV, JWT_SECRET } from '../../utils/env.js';
import { import {
getExamAttemptHandler, getExamAttemptHandler,
getExamAttemptsByExamIdHandler, getExamAttemptsByExamIdHandler,
getExamAttemptsHandler getExamAttemptsHandler,
getExams
} from '../../exam-environment/routes/exam-environment.js'; } from '../../exam-environment/routes/exam-environment.js';
import { ERRORS } from '../../exam-environment/utils/errors.js'; import { ERRORS } from '../../exam-environment/utils/errors.js';
@@ -565,6 +566,13 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
}, },
getExamAttemptsByExamIdHandler getExamAttemptsByExamIdHandler
); );
fastify.get(
'/user/exam-environment/exams',
{
schema: examEnvironmentSchemas.examEnvironmentExams
},
getExams
);
done(); done();
}; };
@@ -16,8 +16,13 @@ import { connect } from 'react-redux';
import LearnLayout from '../../../components/layouts/learn'; import LearnLayout from '../../../components/layouts/learn';
import ChallengeTitle from '../components/challenge-title'; import ChallengeTitle from '../components/challenge-title';
import useDetectOS from '../utils/use-detect-os'; import useDetectOS from '../utils/use-detect-os';
import { ChallengeNode } from '../../../redux/prop-types'; import { ChallengeNode, CompletedChallenge } from '../../../redux/prop-types';
import { isSignedInSelector } from '../../../redux/selectors'; import {
completedChallengesSelector,
isSignedInSelector
} from '../../../redux/selectors';
import { examAttempts } from '../../../utils/ajax';
import MissingPrerequisites from '../exam/components/missing-prerequisites';
import { isChallengeCompletedSelector } from '../redux/selectors'; import { isChallengeCompletedSelector } from '../redux/selectors';
import { Attempts } from './attempts'; import { Attempts } from './attempts';
import ExamTokenControls from './exam-token-controls'; import ExamTokenControls from './exam-token-controls';
@@ -30,16 +35,26 @@ interface GitProps {
} }
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
completedChallengesSelector,
isChallengeCompletedSelector, isChallengeCompletedSelector,
isSignedInSelector, isSignedInSelector,
(isChallengeCompleted: boolean, isSignedIn: boolean) => ({ (
completedChallenges: CompletedChallenge[],
isChallengeCompleted: boolean,
isSignedIn: boolean
) => ({
completedChallenges,
isChallengeCompleted, isChallengeCompleted,
isSignedIn isSignedIn
}) })
); );
interface ShowExamDownloadProps { interface ShowExamDownloadProps {
data: { challengeNode: ChallengeNode }; data: {
challengeNode: ChallengeNode;
allChallengeNode: { nodes: ChallengeNode[] };
};
completedChallenges: CompletedChallenge[];
isChallengeCompleted: boolean; isChallengeCompleted: boolean;
isSignedIn: boolean; isSignedIn: boolean;
} }
@@ -48,8 +63,10 @@ function ShowExamDownload({
data: { data: {
challengeNode: { challengeNode: {
challenge: { id, title, translationPending } challenge: { id, title, translationPending }
} },
allChallengeNode: { nodes }
}, },
completedChallenges,
isChallengeCompleted, isChallengeCompleted,
isSignedIn isSignedIn
}: ShowExamDownloadProps): JSX.Element { }: ShowExamDownloadProps): JSX.Element {
@@ -58,6 +75,9 @@ function ShowExamDownload({
const [downloadLink, setDownloadLink] = useState<string | undefined>(''); const [downloadLink, setDownloadLink] = useState<string | undefined>('');
const [downloadLinks, setDownloadLinks] = useState<string[]>([]); const [downloadLinks, setDownloadLinks] = useState<string[]>([]);
const getExamsQuery = examAttempts.useGetExamsQuery();
const examIdsQuery = examAttempts.useGetExamIdsByChallengeIdQuery(id);
const os = useDetectOS(); const os = useDetectOS();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -135,6 +155,22 @@ function ShowExamDownload({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [os]); }, [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 ( return (
<LearnLayout> <LearnLayout>
<Helmet> <Helmet>
@@ -151,6 +187,9 @@ function ShowExamDownload({
{title} {title}
</ChallengeTitle> </ChallengeTitle>
<Spacer size='l' /> <Spacer size='l' />
{!!missingPrerequisites.length && (
<MissingPrerequisites missingPrerequisites={missingPrerequisites} />
)}
<h2>{t('exam.download-header')}</h2> <h2>{t('exam.download-header')}</h2>
<p>{t('exam.explanation')}</p> <p>{t('exam.explanation')}</p>
<Spacer size='l' /> <Spacer size='l' />
@@ -228,5 +267,16 @@ export const query = graphql`
translationPending translationPending
} }
} }
allChallengeNode {
nodes {
challenge {
id
title
fields {
slug
}
}
}
}
} }
`; `;
+16
View File
@@ -430,6 +430,19 @@ export interface ExamEnvironmentChallenge {
challengeId: string; 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({ export const examAttempts = createApi({
reducerPath: 'exam-attempts', reducerPath: 'exam-attempts',
baseQuery: fetchBaseQuery({ baseQuery: fetchBaseQuery({
@@ -446,6 +459,9 @@ export const examAttempts = createApi({
getExamIdsByChallengeId: build.query<ExamEnvironmentChallenge[], string>({ getExamIdsByChallengeId: build.query<ExamEnvironmentChallenge[], string>({
query: challengeId => query: challengeId =>
`/exam-environment/exam-challenge?challengeId=${challengeId}` `/exam-environment/exam-challenge?challengeId=${challengeId}`
}),
getExams: build.query<GetExamsResponse, void>({
query: () => '/user/exam-environment/exams'
}) })
}) })
}); });