mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-03-31 18:01:36 -04:00
feat: add unmet exam prerequisites (#63131)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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 }]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user