diff --git a/api-server/src/server/boot/challenge.js b/api-server/src/server/boot/challenge.js
index ed4f45bbafd..6863ddbc41b 100644
--- a/api-server/src/server/boot/challenge.js
+++ b/api-server/src/server/boot/challenge.js
@@ -63,6 +63,13 @@ export default async function bootChallenge(app, done) {
backendChallengeCompleted
);
+ api.post(
+ '/exam-challenge-completed',
+ send200toNonUser,
+ isValidChallengeCompletion,
+ examChallengeCompleted
+ );
+
api.post(
'/save-challenge',
send200toNonUser,
@@ -423,6 +430,37 @@ async function backendChallengeCompleted(req, res, next) {
});
}
+async function examChallengeCompleted(req, res, next) {
+ // TODO: verify shape of exam results
+ const { user, body = {} } = req;
+ const completedChallenge = pick(body, ['id', 'examResults']);
+ completedChallenge.completedDate = Date.now();
+
+ try {
+ await user.getCompletedChallenges$().toPromise();
+ } catch (e) {
+ return next(e);
+ }
+
+ const { alreadyCompleted, updateData } = buildUserUpdate(
+ user,
+ completedChallenge.id,
+ completedChallenge
+ );
+
+ user.updateAttributes(updateData, err => {
+ if (err) {
+ return next(err);
+ }
+
+ return res.json({
+ alreadyCompleted,
+ points: alreadyCompleted ? user.points : user.points + 1,
+ completedDate: completedChallenge.completedDate
+ });
+ });
+}
+
async function saveChallenge(req, res, next) {
const user = req.user;
const { savedChallenges = [] } = user;
diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json
index 8c285a07d34..2025d4514ff 100644
--- a/client/i18n/locales/english/intro.json
+++ b/client/i18n/locales/english/intro.json
@@ -1000,6 +1000,16 @@
}
}
},
+ "example-certification": {
+ "title": "Example Certification",
+ "intro": ["placeholder"],
+ "blocks": {
+ "example-certification-exam": {
+ "title": "Example Certification Exam",
+ "intro": ["placeholder"]
+ }
+ }
+ },
"misc-text": {
"certification": "{{cert}} Certification",
"browse-other": "Browse our other free certifications\n(we recommend doing these in order)",
diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index acc87dc213d..8ba838481e0 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -72,9 +72,14 @@
"go-to-settings": "Go to settings to claim your certification",
"click-start-course": "Start the course",
"click-start-project": "Start the project",
+ "click-start-exam": "Start the exam",
"change-language": "Change Language",
"resume-project": "Resume project",
- "start-project": "Start project"
+ "start-project": "Start project",
+ "previous-question": "Previous question",
+ "next-question": "Next question",
+ "finish-exam": "Finish the exam",
+ "submit-exam-results": "Submit my results"
},
"landing": {
"big-heading-1": "Learn to code — for free.",
diff --git a/client/src/assets/icons/index.tsx b/client/src/assets/icons/index.tsx
index 45b35510e50..ff2c4ac8001 100644
--- a/client/src/assets/icons/index.tsx
+++ b/client/src/assets/icons/index.tsx
@@ -33,7 +33,8 @@ const iconMap = {
[SuperBlocks.CodingInterviewPrep]: Algorithm,
[SuperBlocks.TheOdinProject]: VikingHelmet,
[SuperBlocks.ProjectEuler]: Graduation,
- [SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra
+ [SuperBlocks.CollegeAlgebraPy]: CollegeAlgebra,
+ [SuperBlocks.ExampleCertification]: ResponsiveDesign
};
const generateIconComponent = (
diff --git a/client/src/components/layouts/default.tsx b/client/src/components/layouts/default.tsx
index 81d2b266c75..4f6e89a631b 100644
--- a/client/src/components/layouts/default.tsx
+++ b/client/src/components/layouts/default.tsx
@@ -22,6 +22,7 @@ import {
} from '../../redux/actions';
import {
isSignedInSelector,
+ examInProgressSelector,
userSelector,
isOnlineSelector,
isServerOnlineSelector,
@@ -53,6 +54,7 @@ import { Themes } from '../settings/theme';
const mapStateToProps = createSelector(
isSignedInSelector,
+ examInProgressSelector,
flashMessageSelector,
isOnlineSelector,
isServerOnlineSelector,
@@ -61,6 +63,7 @@ const mapStateToProps = createSelector(
userSelector,
(
isSignedIn,
+ examInProgress: boolean,
flashMessage,
isOnline: boolean,
isServerOnline: boolean,
@@ -69,6 +72,7 @@ const mapStateToProps = createSelector(
user: User
) => ({
isSignedIn,
+ examInProgress,
flashMessage,
hasMessage: !!flashMessage.message,
isOnline,
@@ -102,6 +106,7 @@ interface DefaultLayoutProps extends StateProps, DispatchProps {
showFooter?: boolean;
isChallenge?: boolean;
block?: string;
+ examInProgress: boolean;
showCodeAlly: boolean;
superBlock?: string;
}
@@ -116,6 +121,7 @@ const getSystemTheme = () =>
function DefaultLayout({
children,
hasMessage,
+ examInProgress,
fetchState,
flashMessage,
isOnline,
@@ -239,7 +245,7 @@ function DefaultLayout({
/>
) : null}
- {isChallenge && !showCodeAlly && (
+ {isChallenge && !showCodeAlly && !examInProgress && (
{
+ return {
+ ...state,
+ examInProgress: true
+ };
+ },
+ [actionTypes.stopExam]: state => {
+ return {
+ ...state,
+ examInProgress: false
+ };
+ },
[challengeTypes.challengeMounted]: (state, { payload }) => ({
...state,
currentChallengeId: payload
diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js
index f2949dff658..a8cfcc17088 100644
--- a/client/src/redux/selectors.js
+++ b/client/src/redux/selectors.js
@@ -65,6 +65,10 @@ export const showCodeAllySelector = state => {
return state[MainApp].showCodeAlly;
};
+export const examInProgressSelector = state => {
+ return state[MainApp].examInProgress;
+};
+
export const userByNameSelector = username => state => {
const { user } = state[MainApp];
// return initial state empty user empty object instead of empty
diff --git a/client/src/resources/cert-and-project-map.ts b/client/src/resources/cert-and-project-map.ts
index 23a28317805..c9d946521db 100644
--- a/client/src/resources/cert-and-project-map.ts
+++ b/client/src/resources/cert-and-project-map.ts
@@ -27,6 +27,7 @@ const machineLearningPyBase =
'/learn/machine-learning-with-python/machine-learning-with-python-projects';
const collegeAlgebraPyBase = '/learn/college-algebra-with-python';
const takeHomeBase = '/learn/coding-interview-prep/take-home-projects';
+const exampleCertBase = '/learn/example-certification';
const legacyFrontEndBase = feLibsBase;
const legacyFrontEndResponsiveBase = responsiveWebBase;
const legacyFrontEndTakeHomeBase = takeHomeBase;
@@ -757,7 +758,23 @@ const certMap = [
]
}
] as const;
-const upcomingCertMap = [] as const;
+
+const upcomingCertMap = [
+ {
+ id: '64514fda6c245de4d11eb7bb',
+ title: 'Example Certification',
+ certSlug: 'example-certification-v8',
+ flag: 'isExampleCertV8',
+ projects: [
+ {
+ id: '645147516c245de4d11eb7ba',
+ title: 'Certification Exam',
+ link: `${exampleCertBase}/example-certification-exam`,
+ certSlug: 'example-certification-v8'
+ }
+ ]
+ }
+] as const;
function getResponsiveWebDesignPath(project: string) {
return `${responsiveWeb22Base}/${project}-project/${project}`;
@@ -773,11 +790,9 @@ function getJavaScriptAlgoPath(project: string) {
: `${jsAlgoBase}/${project}`;
}
-const certMapWithoutFullStack = [
- ...upcomingCertMap,
- ...legacyCertMap,
- ...certMap
-] as const;
+const certMapWithoutFullStack = showUpcomingChanges
+ ? [...upcomingCertMap, ...legacyCertMap, ...certMap]
+ : ([...legacyCertMap, ...certMap] as const);
const fullCertMap = [...certMapWithoutFullStack, legacyFullStack] as const;
diff --git a/client/src/templates/Challenges/exam/components/exam-results.tsx b/client/src/templates/Challenges/exam/components/exam-results.tsx
new file mode 100644
index 00000000000..c624eb9887a
--- /dev/null
+++ b/client/src/templates/Challenges/exam/components/exam-results.tsx
@@ -0,0 +1,109 @@
+import { Button } from '@freecodecamp/react-bootstrap';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { bindActionCreators, Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import GreenPass from '../../../../assets/icons/green-pass';
+import Fail from '../../../../assets/icons/fail';
+import Spacer from '../../../../components/helpers/spacer';
+import { submitChallenge } from '../../redux/actions';
+import { examResultsSelector } from '../../redux/selectors';
+
+interface ExamResultQuestion {
+ question: string;
+ answer: string;
+ correct: boolean;
+}
+
+interface ExamResults {
+ timeInSeconds: number;
+ results: ExamResultQuestion[];
+}
+
+interface ExamResultsProps {
+ examResults: ExamResults;
+ submitExamResults: () => void;
+ title: string;
+ submitChallenge: () => void;
+}
+
+const mapStateToProps = createSelector(
+ examResultsSelector,
+ (examResults: ExamResults) => ({
+ examResults
+ })
+);
+
+const mapDispatchToProps = (dispatch: Dispatch) =>
+ bindActionCreators(
+ {
+ submitChallenge
+ },
+ dispatch
+ );
+
+function ExamResults({
+ examResults: { timeInSeconds, results },
+ title,
+ submitExamResults
+}: ExamResultsProps): JSX.Element {
+ const { t } = useTranslation();
+
+ const correctAnswers = results.filter(r => r.correct);
+ const correctPercent = (correctAnswers.length / results.length) * 100;
+
+ return (
+
+
+
+
+
+
+
Time: {timeInSeconds}
+
+ {correctAnswers.length} of {results.length} correct answers |{' '}
+ {correctPercent}%
+
+
+ {results.map((result, index) => (
+ <>
+
+
+ {result.correct ? : }
+
+
+
+
+ Question {index + 1}
+
+
{result.question}
+
Your Answer:
+
{result.answer}
+
+
+
+ >
+ ))}
+
+
+
+
+
+
+ );
+}
+
+ExamResults.displayName = 'ExamResults';
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExamResults);
diff --git a/client/src/templates/Challenges/exam/components/finish-exam-modal.tsx b/client/src/templates/Challenges/exam/components/finish-exam-modal.tsx
new file mode 100644
index 00000000000..0590ca724a5
--- /dev/null
+++ b/client/src/templates/Challenges/exam/components/finish-exam-modal.tsx
@@ -0,0 +1,84 @@
+// Package Utilities
+import { Button, Modal } from '@freecodecamp/react-bootstrap';
+import React from 'react';
+import { connect } from 'react-redux';
+import { bindActionCreators, Dispatch } from 'redux';
+import { createSelector } from 'reselect';
+
+// Local Utilities
+import { closeModal } from '../../redux/actions';
+import { isFinishExamModalOpenSelector } from '../../redux/selectors';
+
+// Types
+interface FinishExamModalProps {
+ closeFinishExamModal: () => void;
+ isFinishExamModalOpen: boolean;
+ finishExam: () => void;
+}
+
+// Redux Setup
+const mapStateToProps = createSelector(
+ isFinishExamModalOpenSelector,
+ (isFinishExamModalOpen: boolean) => ({
+ isFinishExamModalOpen
+ })
+);
+
+const mapDispatchToProps = (dispatch: Dispatch) =>
+ bindActionCreators(
+ {
+ closeFinishExamModal: () => closeModal('finishExam')
+ },
+ dispatch
+ );
+
+// Component
+function FinishExamModal({
+ closeFinishExamModal,
+ isFinishExamModalOpen,
+ finishExam
+}: FinishExamModalProps): JSX.Element {
+ return (
+
+
+ Finish Exam
+
+
+
+ Are you sure? You will not be able to change any answers. Your results
+ will be final.
+
+
+
+
+
+
+
+ );
+}
+
+FinishExamModal.displayName = 'FinishExamModal';
+
+export default connect(mapStateToProps, mapDispatchToProps)(FinishExamModal);
diff --git a/client/src/templates/Challenges/exam/exam.css b/client/src/templates/Challenges/exam/exam.css
new file mode 100644
index 00000000000..e54715a3ee5
--- /dev/null
+++ b/client/src/templates/Challenges/exam/exam.css
@@ -0,0 +1,83 @@
+.exam-wrapper {
+ padding: 25px;
+ background-color: var(--primary-background);
+ border: 2px solid var(--tertiary-background);
+}
+
+.exam-header {
+ width: 100%;
+ display: flex;
+ justify-content: space-evenly;
+}
+
+.exam-questions {
+ padding: 0 30px;
+}
+
+.exam-answers {
+ display: flex;
+ flex-direction: column;
+}
+
+.exam-answer-label {
+ display: flex;
+ margin-bottom: 20px;
+ padding: 20px;
+ cursor: pointer;
+ font-weight: normal;
+}
+
+.exam-answer-input-hidden {
+ position: absolute;
+ left: -9999px;
+}
+
+.exam-answer-input-visible {
+ margin-inline-end: 15px;
+ position: relative;
+ top: 2px;
+ display: inline-block;
+ min-width: 20px;
+ min-height: 20px;
+ max-width: 20px;
+ max-height: 20px;
+ border-radius: 50%;
+ background-color: var(--secondary-background);
+ border: 2px solid var(--primary-color);
+}
+
+.exam-answer-input-selected {
+ width: 10px;
+ height: 10px;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ background-color: var(--primary-color);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.exam-buttons {
+ display: flex;
+ justify-content: center;
+}
+
+.exam-buttons .exam-button {
+ margin: 0 10px;
+ max-width: 40%;
+}
+
+.exam-result {
+ display: flex;
+}
+
+.exam-result-icon > svg {
+ width: 40px;
+ height: 40px;
+}
+
+.exam-result-questions {
+ display: flex;
+ flex-direction: column;
+ padding-inline-start: 30px;
+}
diff --git a/client/src/templates/Challenges/exam/show.tsx b/client/src/templates/Challenges/exam/show.tsx
new file mode 100644
index 00000000000..8d7ccd04d95
--- /dev/null
+++ b/client/src/templates/Challenges/exam/show.tsx
@@ -0,0 +1,667 @@
+// Package Utilities
+import { Grid, Col, Row, Button } from '@freecodecamp/react-bootstrap';
+import { graphql } from 'gatsby';
+import React, { Component, RefObject } from 'react';
+import Helmet from 'react-helmet';
+import type { TFunction } from 'i18next';
+import { withTranslation } from 'react-i18next';
+import { connect } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import type { Dispatch } from 'redux';
+import { createSelector } from 'reselect';
+
+// Local Utilities
+import Spacer from '../../../components/helpers/spacer';
+import LearnLayout from '../../../components/layouts/learn';
+import ChallengeTitle from '../components/challenge-title';
+import PrismFormatted from '../components/prism-formatted';
+import CompletionModal from '../components/completion-modal';
+import HelpModal from '../components/help-modal';
+import Hotkeys from '../components/hotkeys';
+import { startExam, stopExam } from '../../../redux/actions';
+import {
+ completedChallengesSelector,
+ partiallyCompletedChallengesSelector,
+ isSignedInSelector,
+ examInProgressSelector
+} from '../../../redux/selectors';
+import {
+ challengeMounted,
+ updateChallengeMeta,
+ openModal,
+ closeModal,
+ submitChallenge,
+ setExamResults,
+ updateSolutionFormValues
+} from '../redux/actions';
+import { isChallengeCompletedSelector } from '../redux/selectors';
+import { createFlashMessage } from '../../../components/Flash/redux';
+import {
+ ChallengeNode,
+ ChallengeMeta,
+ CompletedChallenge
+} from '../../../redux/prop-types';
+import FinishExamModal from './components/finish-exam-modal';
+import ExamResults from './components/exam-results';
+
+import './exam.css';
+
+// Redux
+const mapStateToProps = createSelector(
+ completedChallengesSelector,
+ isChallengeCompletedSelector,
+ isSignedInSelector,
+ partiallyCompletedChallengesSelector,
+ examInProgressSelector,
+ (
+ completedChallenges: CompletedChallenge[],
+ isChallengeCompleted: boolean,
+ isSignedIn: boolean,
+ partiallyCompletedChallenges: CompletedChallenge[],
+ examInProgress: boolean
+ ) => ({
+ completedChallenges,
+ isChallengeCompleted,
+ isSignedIn,
+ partiallyCompletedChallenges,
+ examInProgress
+ })
+);
+
+const mapDispatchToProps = (dispatch: Dispatch) =>
+ bindActionCreators(
+ {
+ challengeMounted,
+ createFlashMessage,
+ openFinishExamModal: () => openModal('finishExam'),
+ closeFinishExamModal: () => closeModal('finishExam'),
+ startExam,
+ stopExam,
+ setExamResults,
+ submitChallenge,
+ updateChallengeMeta,
+ updateSolutionFormValues
+ },
+ dispatch
+ );
+
+// Types
+interface ShowExamProps {
+ challengeMounted: (arg0: string) => void;
+ completedChallenges: CompletedChallenge[];
+ createFlashMessage: typeof createFlashMessage;
+ data: { challengeNode: ChallengeNode };
+ examInProgress: boolean;
+ isChallengeCompleted: boolean;
+ isSignedIn: boolean;
+ openFinishExamModal: () => void;
+ closeFinishExamModal: () => void;
+ pageContext: {
+ challengeMeta: ChallengeMeta;
+ };
+ t: TFunction;
+ startExam: () => void;
+ stopExam: () => void;
+ submitChallenge: () => void;
+ setExamResults: (arg0: ExamResults) => void;
+ updateChallengeMeta: (arg0: ChallengeMeta) => void;
+}
+
+interface ShowExamState {
+ currentQuestionIndex: number;
+ examTimeInSeconds: number;
+ generatedExam: GeneratedExamQuestion[];
+ userExam: UserExamQuestion[];
+ showResults: boolean;
+}
+
+interface GeneratedExamQuestion {
+ question: string;
+ answers: string[];
+}
+
+interface UserExamQuestion {
+ question: string;
+ answer: string | null;
+}
+
+interface ExamResultQuestion {
+ question: string;
+ answer: string;
+ correct: boolean;
+}
+
+interface ExamResults {
+ timeInSeconds: number;
+ results: ExamResultQuestion[];
+}
+
+const examInDatabase = [
+ {
+ question: 'Which of the following is a programming language?',
+ wrongAnswers: ['Apple', 'Orange', 'Banana', 'Mango'],
+ correctAnswer: 'Python'
+ },
+ {
+ question: 'What does CSS stand for?',
+ wrongAnswers: [
+ 'Computer Style Sheets',
+ 'Complete Style Sheets',
+ 'Cool Style Sheets',
+ 'Creative Style Sheets'
+ ],
+ correctAnswer: 'Cascading Style Sheets'
+ },
+ {
+ question: 'What is the extension for a JavaScript file?',
+ wrongAnswers: ['.txt', '.doc', '.html', '.css'],
+ correctAnswer: '.js'
+ },
+ {
+ question: 'What is the purpose of the "if" statement in programming?',
+ wrongAnswers: [
+ 'To repeat a set of instructions',
+ 'To define a function',
+ 'To assign a value to a variable',
+ 'To declare a loop'
+ ],
+ correctAnswer: 'To check a condition'
+ },
+ {
+ question:
+ 'What is the symbol used to represent addition in most programming languages?',
+ wrongAnswers: ['-', '*', '=', '%'],
+ correctAnswer: '+'
+ },
+ {
+ question: 'Which of the following is NOT a programming language?',
+ wrongAnswers: ['Java', 'Ruby', 'Swift', 'PHP'],
+ correctAnswer: 'Spanish'
+ }
+];
+
+Object.freeze(examInDatabase);
+
+// TODO: move helper functions to utility file
+// helper functions
+function shuffleArray(
+ array: string[] | GeneratedExamQuestion[]
+): string[] | GeneratedExamQuestion[] {
+ let currentIndex = array.length,
+ randomIndex;
+
+ while (currentIndex != 0) {
+ randomIndex = Math.floor(Math.random() * currentIndex);
+ currentIndex--;
+ [array[currentIndex], array[randomIndex]] = [
+ array[randomIndex],
+ array[currentIndex]
+ ];
+ }
+
+ return array;
+}
+
+function formatSecondsToTime(s: number) {
+ const hourInSeconds = 60 * 60;
+ const minuteInSeconds = 60;
+ const h = Math.floor(s / hourInSeconds);
+ s -= h * hourInSeconds;
+
+ const minutes = Math.floor(s / minuteInSeconds);
+ s -= minutes * minuteInSeconds;
+
+ const mm = minutes < 10 && h >= 1 ? `0${minutes}` : minutes;
+ const seconds = s % 60;
+ const ss = seconds < 10 ? `0${seconds}` : seconds;
+
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
+}
+
+// TODO: generate exam on server
+function generateExam(): GeneratedExamQuestion[] {
+ const NUMBER_OF_ANSWERS_PER_QUESTION = 4;
+ const NUMBER_OF_QUESTIONS_IN_EXAM = 5;
+ const generatedExam: GeneratedExamQuestion[] = [];
+ const examFromDatabase = Array.from(examInDatabase);
+
+ while (generatedExam.length < NUMBER_OF_QUESTIONS_IN_EXAM) {
+ const randomIndex = Math.floor(
+ Math.random() * (examFromDatabase.length - 1)
+ );
+ const randomQuestion = examFromDatabase.splice(randomIndex, 1)[0];
+ const wrongAnswers = randomQuestion.wrongAnswers;
+ const answers = [randomQuestion.correctAnswer];
+
+ while (answers.length < NUMBER_OF_ANSWERS_PER_QUESTION) {
+ const index = Math.floor(Math.random() * (wrongAnswers.length - 1));
+ const randomAnswer = wrongAnswers.splice(index, 1)[0];
+ answers.push(randomAnswer);
+ }
+
+ const newExamQuestion: GeneratedExamQuestion = {
+ question: randomQuestion.question,
+ answers: shuffleArray(answers) as string[]
+ };
+
+ generatedExam.push(newExamQuestion);
+ }
+
+ return shuffleArray(generatedExam) as GeneratedExamQuestion[];
+}
+
+/* const exampleGeneratedExam = [
+ {
+ "question": "What is the extension for a JavaScript file?",
+ "answers": ['.txt', '.js', '.html', '.css']
+ },
+ ...rest_of_questions
+]*/
+
+/* const exampleUserExam = [
+ {
+ "question": "What is the extension for a JavaScript file?",
+ "answer": ".doc"
+ },
+ ...rest_of_questions
+]*/
+
+/* const exampleExamResults = [
+ {
+ "question": "What is the extension for a JavaScript file?",
+ "answer": ".doc",
+ "correct": false
+ }
+ ...rest_of_questions
+ ]
+ */
+
+/* example item added to completedChalleges array
+ {
+ "id": "645147516c245de4d11eb7ba",
+ "completedDate": 1644532946064,
+ "exam": {
+ "completionTimeInSeconds": number,
+ "results": [
+ {
+ "question": "What is the extension for a JavaScript file?",
+ "answer": ".doc",
+ "correct": false
+ },
+ ...rest_of_questions
+ ]
+ }
+ }*/
+
+const generatedExam = generateExam();
+Object.freeze(generatedExam);
+
+class ShowExam extends Component {
+ static displayName: string;
+ private _container: RefObject | undefined;
+ timerInterval!: NodeJS.Timeout;
+
+ constructor(props: ShowExamProps) {
+ super(props);
+ this.state = {
+ currentQuestionIndex: 0,
+ generatedExam: generatedExam,
+ examTimeInSeconds: 0,
+ userExam: [],
+ showResults: false
+ };
+
+ this.runExam = this.runExam.bind(this);
+ this.goToPreviousQuestion = this.goToPreviousQuestion.bind(this);
+ this.goToNextQuestion = this.goToNextQuestion.bind(this);
+ this.selectAnswer = this.selectAnswer.bind(this);
+ this.finishExam = this.finishExam.bind(this);
+ this.createExamResults = this.createExamResults.bind(this);
+ this.submitExamResults = this.submitExamResults.bind(this);
+ }
+
+ componentDidMount(): void {
+ const {
+ challengeMounted,
+ data: {
+ challengeNode: {
+ challenge: { challengeType, helpCategory, title }
+ }
+ },
+ pageContext: { challengeMeta },
+ updateChallengeMeta
+ } = this.props;
+ updateChallengeMeta({
+ ...challengeMeta,
+ title,
+ challengeType,
+ helpCategory
+ });
+ challengeMounted(challengeMeta.id);
+
+ this._container?.current?.focus();
+ }
+
+ componentWillUnmount() {
+ this.props.stopExam();
+ clearInterval(this.timerInterval);
+ window.removeEventListener('beforeunload', this.stopWindowClose);
+ window.removeEventListener('unload', this.stopWindowClose);
+ window.removeEventListener('popstate', this.stopBrowserBack);
+ }
+
+ stopWindowClose = (event: Event) => {
+ event.preventDefault();
+ alert('stop!');
+ };
+
+ stopBrowserBack = (event: Event) => {
+ event.preventDefault();
+ window.history.forward();
+ alert('stop!');
+ };
+
+ runExam = () => {
+ // TODO: show loader
+ // TODO: fetch exam from server/database
+ const newExam = this.state.generatedExam.map(q => {
+ return { question: q.question, answer: null };
+ });
+
+ this.timerInterval = setInterval(() => {
+ this.setState({
+ examTimeInSeconds: this.state.examTimeInSeconds + 1
+ });
+ }, 1000);
+
+ this.setState(
+ {
+ userExam: newExam
+ },
+ this.props.startExam
+ );
+
+ window.addEventListener('beforeunload', this.stopWindowClose);
+ window.addEventListener('unload', this.stopWindowClose);
+ window.addEventListener('popstate', this.stopBrowserBack);
+ };
+
+ selectAnswer = (index: number, option: string): void => {
+ const newExam = Array.from(this.state.userExam);
+ newExam[index].answer = option;
+ this.setState({
+ userExam: newExam
+ });
+ };
+
+ goToPreviousQuestion = () => {
+ this.setState({
+ currentQuestionIndex: this.state.currentQuestionIndex - 1
+ });
+ };
+
+ goToNextQuestion = () => {
+ this.setState({
+ currentQuestionIndex: this.state.currentQuestionIndex + 1
+ });
+ };
+
+ // TODO: have server check the exam
+ createExamResults = () => {
+ // TODO: show loader
+ const { setExamResults } = this.props;
+ const { userExam, examTimeInSeconds } = this.state;
+
+ const results: ExamResultQuestion[] = [];
+
+ userExam.forEach(userQuestion => {
+ const questionInDb = examInDatabase.find(
+ dbQuestion => userQuestion.question === dbQuestion.question
+ );
+
+ const questionResult = {
+ question: userQuestion.question,
+ answer: userQuestion.answer,
+ correct: questionInDb?.correctAnswer === userQuestion.answer
+ };
+
+ results.push(questionResult as ExamResultQuestion);
+ });
+
+ const examResults = {
+ timeInSeconds: examTimeInSeconds,
+ results
+ };
+
+ setExamResults(examResults);
+ };
+
+ finishExam = () => {
+ clearInterval(this.timerInterval);
+ this.props.closeFinishExamModal();
+ this.createExamResults();
+ this.setState({
+ showResults: true
+ });
+ };
+
+ submitExamResults = () => {
+ window.removeEventListener('beforeunload', this.stopWindowClose);
+ window.removeEventListener('unload', this.stopWindowClose);
+ window.removeEventListener('popstate', this.stopBrowserBack);
+ this.props.submitChallenge();
+ this.props.stopExam();
+ };
+
+ render() {
+ const {
+ data: {
+ challengeNode: {
+ challenge: {
+ block,
+ description,
+ fields: { blockName },
+ instructions,
+ superBlock,
+ title,
+ translationPending
+ }
+ }
+ },
+ examInProgress,
+ isChallengeCompleted,
+ openFinishExamModal,
+ pageContext: {
+ challengeMeta: { nextChallengePath, prevChallengePath }
+ },
+ t
+ } = this.props;
+
+ const {
+ examTimeInSeconds,
+ currentQuestionIndex,
+ generatedExam,
+ userExam,
+ showResults
+ } = this.state;
+
+ const blockNameTitle = `${t(
+ `intro:${superBlock}.blocks.${block}.title`
+ )}: ${title}`;
+ const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
+ const ariaLabel = t('aria.answer');
+
+ console.log(this.state);
+
+ return examInProgress ? (
+
+
+
+
+ {showResults ? (
+
+ ) : (
+
+
+
{title}
+
|
+
Time: {formatSecondsToTime(examTimeInSeconds)}
+
|
+
+ Question {currentQuestionIndex + 1} of{' '}
+ {generatedExam.length}
+
+
+
+
+
+
+
{generatedExam[currentQuestionIndex].question}
+
+
+
+
+
+
+
+
+ {currentQuestionIndex === generatedExam.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ShowExam.displayName = 'ShowExam';
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(withTranslation()(ShowExam));
+
+// GraphQL
+export const query = graphql`
+ query ExamChallenge($slug: String!) {
+ challengeNode(challenge: { fields: { slug: { eq: $slug } } }) {
+ challenge {
+ challengeType
+ description
+ fields {
+ blockName
+ }
+ helpCategory
+ id
+ instructions
+ superBlock
+ title
+ translationPending
+ }
+ }
+ }
+`;
diff --git a/client/src/templates/Challenges/redux/action-types.js b/client/src/templates/Challenges/redux/action-types.js
index 767e83fd4ac..4858cde0260 100644
--- a/client/src/templates/Challenges/redux/action-types.js
+++ b/client/src/templates/Challenges/redux/action-types.js
@@ -31,6 +31,7 @@ export const actionTypes = createTypes(
'closeModal',
'openModal',
'setIsAdvancing',
+ 'setExamResults',
'previewMounted',
'projectPreviewMounted',
'storePortalWindow',
diff --git a/client/src/templates/Challenges/redux/actions.js b/client/src/templates/Challenges/redux/actions.js
index 0d513681ba4..a24434070a1 100644
--- a/client/src/templates/Challenges/redux/actions.js
+++ b/client/src/templates/Challenges/redux/actions.js
@@ -50,6 +50,8 @@ export const storedCodeFound = createAction(actionTypes.storedCodeFound);
export const noStoredCodeFound = createAction(actionTypes.noStoredCodeFound);
export const saveEditorContent = createAction(actionTypes.saveEditorContent);
export const setIsAdvancing = createAction(actionTypes.setIsAdvancing);
+export const setExamResults = createAction(actionTypes.setExamResults);
+
export const closeModal = createAction(actionTypes.closeModal);
export const openModal = createAction(actionTypes.openModal);
diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js
index 5e0819ee86e..c92dccecce2 100644
--- a/client/src/templates/Challenges/redux/completion-epic.js
+++ b/client/src/templates/Challenges/redux/completion-epic.js
@@ -35,6 +35,7 @@ import {
challengeFilesSelector,
challengeMetaSelector,
challengeTestsSelector,
+ examResultsSelector,
projectFormValuesSelector,
isBlockNewlyCompletedSelector
} from './selectors';
@@ -159,9 +160,27 @@ const submitters = {
tests: submitModern,
backend: submitBackendChallenge,
'project.frontEnd': submitProject,
- 'project.backEnd': submitProject
+ 'project.backEnd': submitProject,
+ exam: submitExam
};
+function submitExam(type, state) {
+ // TODO: verify shape of examResults?
+ if (type === actionTypes.submitChallenge) {
+ const { id } = challengeMetaSelector(state);
+ const examResults = examResultsSelector(state);
+ const { username } = userSelector(state);
+ const challengeInfo = { id, examResults };
+
+ const update = {
+ endpoint: '/exam-challenge-completed',
+ payload: challengeInfo
+ };
+ return postChallenge(update, username);
+ }
+ return empty();
+}
+
export default function completionEpic(action$, state$) {
return action$.pipe(
ofType(actionTypes.submitChallenge),
diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js
index d6526b82a21..abb65b49076 100644
--- a/client/src/templates/Challenges/redux/index.js
+++ b/client/src/templates/Challenges/redux/index.js
@@ -27,6 +27,10 @@ const initialState = {
},
challengeTests: [],
consoleOut: [],
+ examResults: {
+ timeInSeconds: 0,
+ results: []
+ },
hasCompletedBlock: false,
isBuildEnabled: true,
isResetting: false,
@@ -36,6 +40,7 @@ const initialState = {
help: false,
video: false,
reset: false,
+ finishExam: false,
projectPreview: false,
shortcuts: false
},
@@ -189,6 +194,10 @@ export const reducer = handleActions(
...state,
isAdvancing: payload
}),
+ [actionTypes.setExamResults]: (state, { payload }) => ({
+ ...state,
+ examResults: payload
+ }),
[actionTypes.closeModal]: (state, { payload }) => ({
...state,
modal: {
diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js
index 00bfa7bb5c3..85b0dc8c1be 100644
--- a/client/src/templates/Challenges/redux/selectors.js
+++ b/client/src/templates/Challenges/redux/selectors.js
@@ -28,6 +28,8 @@ export const isCompletionModalOpenSelector = state =>
export const isHelpModalOpenSelector = state => state[ns].modal.help;
export const isVideoModalOpenSelector = state => state[ns].modal.video;
export const isResetModalOpenSelector = state => state[ns].modal.reset;
+export const isFinishExamModalOpenSelector = state =>
+ state[ns].modal.finishExam;
export const isProjectPreviewModalOpenSelector = state =>
state[ns].modal.projectPreview;
export const isShortcutsModalOpenSelector = state => state[ns].modal.shortcuts;
@@ -42,6 +44,7 @@ export const isAdvancingToChallengeSelector = state => state[ns].isAdvancing;
export const portalDocumentSelector = state => state[ns].portalWindow?.document;
export const portalWindowSelector = state => state[ns].portalWindow;
+export const examResultsSelector = state => state[ns].examResults;
export const challengeDataSelector = state => {
const { challengeType } = challengeMetaSelector(state);
let challengeData = { challengeType };
diff --git a/client/utils/challenge-types.js b/client/utils/challenge-types.js
index 4542b2ab215..42e92bc4d21 100644
--- a/client/utils/challenge-types.js
+++ b/client/utils/challenge-types.js
@@ -16,6 +16,7 @@ const codeAllyCert = 13;
const multifileCertProject = 14;
const theOdinProject = 15;
const colab = 16;
+const exam = 17;
// individual exports
exports.backend = backend;
@@ -24,6 +25,7 @@ exports.backEndProject = backEndProject;
exports.pythonProject = pythonProject;
exports.codeAllyCert = codeAllyCert;
exports.colab = colab;
+exports.exam = exam;
exports.challengeTypes = {
html,
@@ -43,7 +45,8 @@ exports.challengeTypes = {
codeAllyCert,
multifileCertProject,
theOdinProject,
- colab
+ colab,
+ exam
};
exports.isFinalProject = challengeType => {
@@ -55,7 +58,8 @@ exports.isFinalProject = challengeType => {
challengeType === jsProject ||
challengeType === pythonProject ||
challengeType === codeAllyCert ||
- challengeType === multifileCertProject
+ challengeType === multifileCertProject ||
+ challengeType === exam
);
};
@@ -82,7 +86,8 @@ exports.viewTypes = {
[codeAllyCert]: 'codeAlly',
[multifileCertProject]: 'classic',
[theOdinProject]: 'odin',
- [colab]: 'frontend'
+ [colab]: 'frontend',
+ [exam]: 'exam'
};
// determine the type of submit function to use for the challenge on completion
@@ -106,5 +111,6 @@ exports.submitTypes = {
[codeAllyCert]: 'project.frontEnd',
[multifileCertProject]: 'tests',
[theOdinProject]: 'tests',
- [colab]: 'project.backEnd'
+ [colab]: 'project.backEnd',
+ [exam]: 'exam'
};
diff --git a/client/utils/gatsby/challenge-page-creator.js b/client/utils/gatsby/challenge-page-creator.js
index 8ef899c4293..8b1f2726295 100644
--- a/client/utils/gatsby/challenge-page-creator.js
+++ b/client/utils/gatsby/challenge-page-creator.js
@@ -37,6 +37,11 @@ const odin = path.resolve(
'../../src/templates/Challenges/odin/show.tsx'
);
+const exam = path.resolve(
+ __dirname,
+ '../../src/templates/Challenges/exam/show.tsx'
+);
+
const views = {
backend,
classic,
@@ -44,7 +49,8 @@ const views = {
frontend,
video,
codeAlly,
- odin
+ odin,
+ exam
// quiz: Quiz
};
diff --git a/config/certification-settings.ts b/config/certification-settings.ts
index 1e655cc5fec..717ca5fbe25 100644
--- a/config/certification-settings.ts
+++ b/config/certification-settings.ts
@@ -35,7 +35,8 @@ export enum SuperBlocks {
CodingInterviewPrep = 'coding-interview-prep',
TheOdinProject = 'the-odin-project',
ProjectEuler = 'project-euler',
- CollegeAlgebraPy = 'college-algebra-with-python'
+ CollegeAlgebraPy = 'college-algebra-with-python',
+ ExampleCertification = 'example-certification'
}
export const certIds = {
diff --git a/config/superblock-order.test.ts b/config/superblock-order.test.ts
index 4b5f215a31f..3a70b970716 100644
--- a/config/superblock-order.test.ts
+++ b/config/superblock-order.test.ts
@@ -153,6 +153,7 @@ describe("'superBlockOrder' helper functions", () => {
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification,
SuperBlocks.RespWebDesign
];
expect(learnSuperBlocks).toStrictEqual(test);
@@ -180,6 +181,7 @@ describe("'superBlockOrder' helper functions", () => {
showNewCurriculum: 'true',
showUpcomingChanges: 'true'
});
+ console.log(notAuditedSuperBlocks);
const test = [
SuperBlocks.RespWebDesignNew,
SuperBlocks.DataVis,
@@ -194,7 +196,8 @@ describe("'superBlockOrder' helper functions", () => {
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
];
expect(notAuditedSuperBlocks).toStrictEqual(test);
expect(notAuditedSuperBlocks.length).toEqual(test.length);
diff --git a/config/superblock-order.ts b/config/superblock-order.ts
index d46d4e7acde..57ac00db7d5 100644
--- a/config/superblock-order.ts
+++ b/config/superblock-order.ts
@@ -81,7 +81,8 @@ export const defaultSuperBlockOrder: SuperBlocks[] = [
SuperBlocks.CollegeAlgebraPy,
SuperBlocks.CodingInterviewPrep,
SuperBlocks.ProjectEuler,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
];
/*
@@ -132,7 +133,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: [SuperBlocks.RespWebDesign]
},
@@ -187,7 +189,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -236,7 +239,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -285,7 +289,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -334,7 +339,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -380,7 +386,10 @@ export const superBlockOrder: SuperBlockOrder = {
[TranslationStates.NotAudited]: {
[SuperBlockStates.Current]: [],
[SuperBlockStates.New]: [],
- [SuperBlockStates.Upcoming]: [SuperBlocks.JsAlgoDataStructNew],
+ [SuperBlockStates.Upcoming]: [
+ SuperBlocks.JsAlgoDataStructNew,
+ SuperBlocks.ExampleCertification
+ ],
[SuperBlockStates.Legacy]: []
}
}
@@ -426,7 +435,10 @@ export const superBlockOrder: SuperBlockOrder = {
SuperBlocks.ProjectEuler
],
[SuperBlockStates.New]: [],
- [SuperBlockStates.Upcoming]: [SuperBlocks.JsAlgoDataStructNew],
+ [SuperBlockStates.Upcoming]: [
+ SuperBlocks.JsAlgoDataStructNew,
+ SuperBlocks.ExampleCertification
+ ],
[SuperBlockStates.Legacy]: []
}
}
@@ -475,7 +487,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -525,7 +538,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: []
}
@@ -574,7 +588,8 @@ export const superBlockOrder: SuperBlockOrder = {
[SuperBlockStates.New]: [],
[SuperBlockStates.Upcoming]: [
SuperBlocks.JsAlgoDataStructNew,
- SuperBlocks.TheOdinProject
+ SuperBlocks.TheOdinProject,
+ SuperBlocks.ExampleCertification
],
[SuperBlockStates.Legacy]: [SuperBlocks.RespWebDesign]
}
diff --git a/curriculum/challenges/_meta/example-certification-exam/meta.json b/curriculum/challenges/_meta/example-certification-exam/meta.json
new file mode 100644
index 00000000000..0e523a67f40
--- /dev/null
+++ b/curriculum/challenges/_meta/example-certification-exam/meta.json
@@ -0,0 +1,16 @@
+{
+ "name": "Example Certification Exam",
+ "isUpcomingChange": true,
+ "dashedName": "example-certification-exam",
+ "helpCategory": "HTML-CSS",
+ "order": 0,
+ "time": "2 hours",
+ "template": "",
+ "required": [],
+ "superBlock": "example-certification",
+ "challengeOrder": [
+ [
+ "645147516c245de4d11eb7ba",
+ "Certification Exam"
+ ]
+ ]}
diff --git a/curriculum/challenges/english/00-certifications/example-certification/example-certifcation.yml b/curriculum/challenges/english/00-certifications/example-certification/example-certifcation.yml
new file mode 100644
index 00000000000..b63efaa2cf3
--- /dev/null
+++ b/curriculum/challenges/english/00-certifications/example-certification/example-certifcation.yml
@@ -0,0 +1,9 @@
+---
+id: 64514fda6c245de4d11eb7bb
+title: Example Certification
+certification: example-certification
+challengeType: 7
+isPrivate: true
+tests:
+ - id: 645147516c245de4d11eb7ba
+ title: Certification Exam
diff --git a/curriculum/challenges/english/99-example-certification/example-certification-exam/certification-exam.md b/curriculum/challenges/english/99-example-certification/example-certification-exam/certification-exam.md
new file mode 100644
index 00000000000..60c290b16ff
--- /dev/null
+++ b/curriculum/challenges/english/99-example-certification/example-certification-exam/certification-exam.md
@@ -0,0 +1,20 @@
+---
+id: 645147516c245de4d11eb7ba
+title: Certification Exam
+challengeType: 17
+dashedName: certification-exam
+---
+
+# --description--
+
+Here are some rules:
+
+- click start
+
+# --instructions--
+
+# --hints--
+
+# --seed--
+
+# --solutions--
diff --git a/curriculum/dictionaries/english/comments-to-not-translate.json b/curriculum/dictionaries/english/comments-to-not-translate.json
index 82939041687..1fc44661720 100644
--- a/curriculum/dictionaries/english/comments-to-not-translate.json
+++ b/curriculum/dictionaries/english/comments-to-not-translate.json
@@ -4,5 +4,8 @@
"7e3lpb": "Redux:",
"b0atz9": "Hello World
\n\nCatPhotoApp
\n\nKitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.
",
"b1x7w0": "import React from 'react'\nimport ReactDOM from 'react-dom'\nimport { Provider, connect } from 'react-redux'\nimport { createStore, combineReducers, applyMiddleware } from 'redux'\nimport thunk from 'redux-thunk'\n\nimport rootReducer from './redux/reducers'\nimport App from './components/App'\n\nconst store = createStore(\n rootReducer,\n applyMiddleware(thunk)\n);\n\nReactDOM.render(\n \n \n ,\n document.getElementById('root')\n);",
- "4143lg": "TODO: Add link to cat photos"
+ "4143lg": "TODO: Add link to cat photos",
+ "3q1p2l": "console.log(spreadsheetFunctions[\"random\"](1, 1000) === spreadsheetFunctions[\"random\"](1, 1000))",
+ "gq2zsq": "window.onload();",
+ "3xhkhx": "highPrecedence(\"2*2\");"
}
diff --git a/curriculum/dictionaries/english/comments.json b/curriculum/dictionaries/english/comments.json
index 30a1def647a..0aff9a98ac5 100644
--- a/curriculum/dictionaries/english/comments.json
+++ b/curriculum/dictionaries/english/comments.json
@@ -98,6 +98,8 @@
"bheu99": "This will hold the set",
"x1djjr": "Use console.clear() on the next line to clear the browser console.",
"22ta95": "Use console.log() to print the output variable.",
+ "owgrP6": "Use the classes ABOVE this line",
+ "oszrtn": "Use the classes BELOW this line",
"w43c7l": "Using s = [2, 5, 7] would be invalid",
"pgckoj": "Variable assignments",
"2xiqvv": "Variable declarations",
diff --git a/curriculum/schema/challenge-schema.js b/curriculum/schema/challenge-schema.js
index f14186dad7b..8adc1412f5d 100644
--- a/curriculum/schema/challenge-schema.js
+++ b/curriculum/schema/challenge-schema.js
@@ -28,7 +28,7 @@ const schema = Joi.object()
challengeOrder: Joi.number(),
removeComments: Joi.bool(),
certification: Joi.string().regex(slugRE),
- challengeType: Joi.number().min(0).max(15).required(),
+ challengeType: Joi.number().min(0).max(17).required(),
checksum: Joi.number(),
// TODO: require this only for normal challenges, not certs
dashedName: Joi.string().regex(slugRE),
diff --git a/curriculum/utils.js b/curriculum/utils.js
index f2a441d3d27..53f209ca435 100644
--- a/curriculum/utils.js
+++ b/curriculum/utils.js
@@ -123,7 +123,8 @@ const directoryToSuperblock = {
'2022/javascript-algorithms-and-data-structures',
'16-the-odin-project': 'the-odin-project',
'17-college-algebra-with-python': 'college-algebra-with-python',
- '18-project-euler': 'project-euler'
+ '18-project-euler': 'project-euler',
+ '99-example-certification': 'example-certification'
};
function getSuperBlockFromDir(dir) {
diff --git a/curriculum/utils.test.ts b/curriculum/utils.test.ts
index ab0a1a323d6..281b0a9b013 100644
--- a/curriculum/utils.test.ts
+++ b/curriculum/utils.test.ts
@@ -44,7 +44,8 @@ const upcomingTest = {
[SuperBlocks.ProjectEuler]: 13,
[SuperBlocks.JsAlgoDataStructNew]: 14,
[SuperBlocks.TheOdinProject]: 15,
- [SuperBlocks.RespWebDesign]: 16
+ [SuperBlocks.ExampleCertification]: 16,
+ [SuperBlocks.RespWebDesign]: 17
};
const espanolTest = {
@@ -153,7 +154,7 @@ describe('getSuperOrder', () => {
if (process.env.SHOW_UPCOMING_CHANGES !== 'true') {
expect.assertions(15);
} else {
- expect.assertions(17);
+ expect.assertions(18);
}
expect(getSuperOrder(SuperBlocks.RespWebDesignNew)).toBe(0);
@@ -174,7 +175,8 @@ describe('getSuperOrder', () => {
if (process.env.SHOW_UPCOMING_CHANGES === 'true') {
expect(getSuperOrder(SuperBlocks.JsAlgoDataStructNew)).toBe(14);
expect(getSuperOrder(SuperBlocks.TheOdinProject)).toBe(15);
- expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(16);
+ expect(getSuperOrder(SuperBlocks.ExampleCertification)).toBe(16);
+ expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(17);
} else {
expect(getSuperOrder(SuperBlocks.RespWebDesign)).toBe(14);
}
@@ -187,7 +189,7 @@ describe('getSuperBlockFromPath', () => {
);
it('handles all the directories in ./challenges/english', () => {
- expect.assertions(18);
+ expect.assertions(19);
for (const directory of directories) {
expect(() => getSuperBlockFromDir(directory)).not.toThrow();
@@ -195,7 +197,7 @@ describe('getSuperBlockFromPath', () => {
});
it("returns valid superblocks (or 'certifications') for all valid arguments", () => {
- expect.assertions(18);
+ expect.assertions(19);
const superBlockPaths = directories.filter(x => x !== '00-certifications');
diff --git a/tools/challenge-auditor/index.ts b/tools/challenge-auditor/index.ts
index b385b7a6a0e..d82bebca4da 100644
--- a/tools/challenge-auditor/index.ts
+++ b/tools/challenge-auditor/index.ts
@@ -45,7 +45,8 @@ const superBlockFolderMap = {
'15-javascript-algorithms-and-data-structures-22',
'the-odin-project': '16-the-odin-project',
'college-algebra-with-python': '17-college-algebra-with-python',
- 'project-euler': '18-project-euler'
+ 'project-euler': '18-project-euler',
+ 'example-certification': '99-example-certification'
};
// These blocks are in the incorrect superblock. They should be moved but, for
diff --git a/tools/challenge-helper-scripts/fs-utils.ts b/tools/challenge-helper-scripts/fs-utils.ts
index 18266da0a9e..6a8512b0999 100644
--- a/tools/challenge-helper-scripts/fs-utils.ts
+++ b/tools/challenge-helper-scripts/fs-utils.ts
@@ -20,7 +20,8 @@ export function getSuperBlockSubPath(superBlock: SuperBlocks): string {
'15-javascript-algorithms-and-data-structures-22',
[SuperBlocks.TheOdinProject]: '16-the-odin-project',
[SuperBlocks.CollegeAlgebraPy]: '17-college-algebra-with-python',
- [SuperBlocks.ProjectEuler]: '18-project-euler'
+ [SuperBlocks.ProjectEuler]: '18-project-euler',
+ [SuperBlocks.ExampleCertification]: '99-example-certification'
};
return pathMap[superBlock];
}
diff --git a/tools/scripts/build/build-external-curricula-data.test.ts b/tools/scripts/build/build-external-curricula-data.test.ts
index 63b3dd7d19f..4fd12596bac 100644
--- a/tools/scripts/build/build-external-curricula-data.test.ts
+++ b/tools/scripts/build/build-external-curricula-data.test.ts
@@ -87,7 +87,8 @@ if (envData.clientLocale == 'english' && !envData.showUpcomingChanges) {
const isUpcoming = [
'2022/javascript-algorithms-and-data-structures',
'college-algebra-with-python',
- 'the-odin-project'
+ 'the-odin-project',
+ 'example-certification'
];
// TODO: this is a hack, we should have a single source of truth for the
diff --git a/utils/is-audited.js b/utils/is-audited.js
index 035feda2824..f3140a0487a 100644
--- a/utils/is-audited.js
+++ b/utils/is-audited.js
@@ -1,9 +1,14 @@
const { getAuditedSuperBlocks } = require('../config/superblock-order');
+const { Languages } = require('../config/i18n');
function isAuditedCert(language, superblock) {
if (!language || !superblock)
throw Error('Both arguments must be provided for auditing');
+ if (language === Languages.English) {
+ return true;
+ }
+
const auditedSuperBlocks = getAuditedSuperBlocks({ language });
return auditedSuperBlocks.includes(superblock);
}