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 ( +
+
+
{title} Results
+
+
+ + +
+
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}
+ +
+ {generatedExam[currentQuestionIndex].answers.map( + (option, answerIndex) => ( + + ) + )} +
+
+ + +
+ + + {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\n

CatPhotoApp

\n\n

Kitty 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); }