mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00: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,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user