mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-09-24 23:01:18 +08:00
feat: add exam environment (#50356)
* feat: add exam environment * fix: cypress * fix: lint-ts * fix: lint-js * fix: codefactor * fix: don't translate comments from new JS spreadsheet * fix: more tests * fix: challenge schema * fix: external curriculum test? * fix: add more comments
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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}
|
||||
<SignoutModal />
|
||||
{isChallenge && !showCodeAlly && (
|
||||
{isChallenge && !showCodeAlly && !examInProgress && (
|
||||
<div className='breadcrumbs-demo'>
|
||||
<BreadCrumb
|
||||
block={block as string}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Introduction to the Example Certification Exam
|
||||
block: example-certification-exam
|
||||
superBlock: example-certification
|
||||
---
|
||||
|
||||
## Introduction to the Example Certification Exam
|
||||
|
||||
Earn the Example Certification
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Example Certification
|
||||
superBlock: example-certification
|
||||
certification: example-certification
|
||||
---
|
||||
|
||||
## Introduction to Example Certification
|
||||
|
||||
Official Example Certification
|
||||
@@ -22,6 +22,8 @@ export const actionTypes = createTypes(
|
||||
'tryToShowDonationModal',
|
||||
'executeGA',
|
||||
'showCodeAlly',
|
||||
'startExam',
|
||||
'stopExam',
|
||||
'submitComplete',
|
||||
'updateComplete',
|
||||
'updateFailed',
|
||||
|
||||
@@ -95,5 +95,8 @@ export const hideCodeAlly = createAction(actionTypes.hideCodeAlly);
|
||||
export const showCodeAlly = createAction(actionTypes.showCodeAlly);
|
||||
export const tryToShowCodeAlly = createAction(actionTypes.tryToShowCodeAlly);
|
||||
|
||||
export const startExam = createAction(actionTypes.startExam);
|
||||
export const stopExam = createAction(actionTypes.stopExam);
|
||||
|
||||
export const closeSignoutModal = createAction(actionTypes.closeSignoutModal);
|
||||
export const openSignoutModal = createAction(actionTypes.openSignoutModal);
|
||||
|
||||
@@ -47,6 +47,7 @@ const initialState = {
|
||||
canRequestProgressDonation: true,
|
||||
completionCount: 0,
|
||||
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
|
||||
examInProgress: false,
|
||||
showCert: {},
|
||||
showCertFetchState: {
|
||||
...defaultFetchState
|
||||
@@ -363,6 +364,18 @@ export const reducer = handleActions(
|
||||
showCodeAlly: true
|
||||
};
|
||||
},
|
||||
[actionTypes.startExam]: state => {
|
||||
return {
|
||||
...state,
|
||||
examInProgress: true
|
||||
};
|
||||
},
|
||||
[actionTypes.stopExam]: state => {
|
||||
return {
|
||||
...state,
|
||||
examInProgress: false
|
||||
};
|
||||
},
|
||||
[challengeTypes.challengeMounted]: (state, { payload }) => ({
|
||||
...state,
|
||||
currentChallengeId: payload
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className='exam-wrapper'>
|
||||
<div className='exam-header'>
|
||||
<div>{title} Results</div>
|
||||
</div>
|
||||
<hr />
|
||||
<Spacer size='medium' />
|
||||
|
||||
<div className='exam-results'>
|
||||
<div>Time: {timeInSeconds}</div>
|
||||
<div>
|
||||
{correctAnswers.length} of {results.length} correct answers |{' '}
|
||||
{correctPercent}%
|
||||
</div>
|
||||
<Spacer size='medium' />
|
||||
{results.map((result, index) => (
|
||||
<>
|
||||
<div className='exam-result' key={index}>
|
||||
<div className='exam-result-icon'>
|
||||
{result.correct ? <GreenPass /> : <Fail />}
|
||||
</div>
|
||||
|
||||
<div className='exam-result-questions'>
|
||||
<div className='exam-result-question-label'>
|
||||
Question {index + 1}
|
||||
</div>
|
||||
<div className='exam-result-question'>{result.question}</div>
|
||||
<div className='exam-result-answer-label'>Your Answer:</div>
|
||||
<div className='exam-result-answer'>{result.answer}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Spacer size='medium' />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
<Spacer size='medium' />
|
||||
<div className='exam-buttons'>
|
||||
<Button
|
||||
block={true}
|
||||
className='exam-button'
|
||||
bsStyle='primary'
|
||||
data-cy='submit-exam-results'
|
||||
onClick={submitExamResults}
|
||||
>
|
||||
{t('buttons.submit-exam-results')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ExamResults.displayName = 'ExamResults';
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ExamResults);
|
||||
@@ -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 (
|
||||
<Modal
|
||||
animation={false}
|
||||
dialogClassName='finish-exam-modal'
|
||||
keyboard={true}
|
||||
onHide={closeFinishExamModal}
|
||||
show={isFinishExamModalOpen}
|
||||
>
|
||||
<Modal.Header className='finish-exam-modal-header' closeButton={true}>
|
||||
<Modal.Title className='text-center'>Finish Exam</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='reset-modal-body'>
|
||||
<div className='text-center'>
|
||||
Are you sure? You will not be able to change any answers. Your results
|
||||
will be final.
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className='reset-modal-footer'>
|
||||
<Button
|
||||
data-cy='finish-exam-modal-confirm'
|
||||
block={true}
|
||||
bsSize='medium'
|
||||
bsStyle='primary'
|
||||
onClick={finishExam}
|
||||
>
|
||||
Yes, I am finished
|
||||
</Button>
|
||||
<Button
|
||||
data-cy='finish-exam-modal-deny'
|
||||
block={true}
|
||||
bsSize='medium'
|
||||
bsStyle='primary'
|
||||
onClick={closeFinishExamModal}
|
||||
>
|
||||
No, I would like to continue the exam
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
FinishExamModal.displayName = 'FinishExamModal';
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(FinishExamModal);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ShowExamProps, ShowExamState> {
|
||||
static displayName: string;
|
||||
private _container: RefObject<HTMLElement> | 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 ? (
|
||||
<Grid>
|
||||
<Row>
|
||||
<Spacer size='medium' />
|
||||
<Col md={10} mdOffset={1} sm={10} smOffset={1} xs={12}>
|
||||
{showResults ? (
|
||||
<ExamResults
|
||||
title={title}
|
||||
submitExamResults={this.submitExamResults}
|
||||
/>
|
||||
) : (
|
||||
<div className='exam-wrapper'>
|
||||
<div className='exam-header'>
|
||||
<div>{title}</div>
|
||||
<span>|</span>
|
||||
<div> Time: {formatSecondsToTime(examTimeInSeconds)}</div>
|
||||
<span>|</span>
|
||||
<div>
|
||||
Question {currentQuestionIndex + 1} of{' '}
|
||||
{generatedExam.length}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<Spacer size='medium' />
|
||||
|
||||
<div className='exam-questions'>
|
||||
<div>{generatedExam[currentQuestionIndex].question}</div>
|
||||
<Spacer size='large' />
|
||||
<div className='exam-answers'>
|
||||
{generatedExam[currentQuestionIndex].answers.map(
|
||||
(option, answerIndex) => (
|
||||
<label className='exam-answer-label' key={answerIndex}>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
checked={
|
||||
userExam[currentQuestionIndex].answer === option
|
||||
}
|
||||
className='exam-answer-input-hidden'
|
||||
name='exam'
|
||||
onChange={() =>
|
||||
this.selectAnswer(currentQuestionIndex, option)
|
||||
}
|
||||
type='radio'
|
||||
value={option}
|
||||
/>{' '}
|
||||
<span className='exam-answer-input-visible'>
|
||||
{userExam[currentQuestionIndex].answer ===
|
||||
option ? (
|
||||
<span className='exam-answer-input-selected' />
|
||||
) : null}
|
||||
</span>
|
||||
<PrismFormatted
|
||||
className={'exam-answer'}
|
||||
text={option}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer size='large' />
|
||||
|
||||
<div className='exam-buttons'>
|
||||
<Button
|
||||
block={true}
|
||||
className='exam-button'
|
||||
disabled={currentQuestionIndex <= 0}
|
||||
bsStyle='primary'
|
||||
data-cy='previous-exam-question'
|
||||
onClick={this.goToPreviousQuestion}
|
||||
>
|
||||
{t('buttons.previous-question')}
|
||||
</Button>
|
||||
|
||||
{currentQuestionIndex === generatedExam.length - 1 ? (
|
||||
<Button
|
||||
block={true}
|
||||
disabled={!userExam[currentQuestionIndex].answer}
|
||||
className='exam-button'
|
||||
bsStyle='primary'
|
||||
data-cy='finish-exam'
|
||||
onClick={openFinishExamModal}
|
||||
>
|
||||
{t('buttons.finish-exam')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
block={true}
|
||||
disabled={!userExam[currentQuestionIndex].answer}
|
||||
className='exam-button'
|
||||
bsStyle='primary'
|
||||
data-cy='next-exam-question'
|
||||
onClick={this.goToNextQuestion}
|
||||
>
|
||||
{t('buttons.next-question')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
<FinishExamModal finishExam={this.finishExam} />
|
||||
</Row>
|
||||
</Grid>
|
||||
) : (
|
||||
<Hotkeys
|
||||
innerRef={this._container}
|
||||
nextChallengePath={nextChallengePath}
|
||||
prevChallengePath={prevChallengePath}
|
||||
>
|
||||
<LearnLayout>
|
||||
<Helmet title={windowTitle} />
|
||||
<Grid>
|
||||
<Row>
|
||||
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
||||
<ChallengeTitle
|
||||
isCompleted={isChallengeCompleted}
|
||||
translationPending={translationPending}
|
||||
>
|
||||
{title}
|
||||
</ChallengeTitle>
|
||||
<Spacer size='medium' />
|
||||
<PrismFormatted text={description} />
|
||||
<Spacer size='medium' />
|
||||
<PrismFormatted text={instructions} />
|
||||
|
||||
<Button
|
||||
block={true}
|
||||
bsStyle='primary'
|
||||
data-cy='start-exam'
|
||||
onClick={this.runExam}
|
||||
>
|
||||
{t('buttons.click-start-exam')}
|
||||
</Button>
|
||||
</Col>
|
||||
<CompletionModal />
|
||||
<HelpModal challengeTitle={title} challengeBlock={blockName} />
|
||||
</Row>
|
||||
</Grid>
|
||||
</LearnLayout>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -31,6 +31,7 @@ export const actionTypes = createTypes(
|
||||
'closeModal',
|
||||
'openModal',
|
||||
'setIsAdvancing',
|
||||
'setExamResults',
|
||||
'previewMounted',
|
||||
'projectPreviewMounted',
|
||||
'storePortalWindow',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+26
-11
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
]}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
id: 64514fda6c245de4d11eb7bb
|
||||
title: Example Certification
|
||||
certification: example-certification
|
||||
challengeType: 7
|
||||
isPrivate: true
|
||||
tests:
|
||||
- id: 645147516c245de4d11eb7ba
|
||||
title: Certification Exam
|
||||
+20
@@ -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--
|
||||
@@ -4,5 +4,8 @@
|
||||
"7e3lpb": "Redux:",
|
||||
"b0atz9": "<h1>Hello World</h1>\n\n<h2>CatPhotoApp</h2>\n\n<p>Kitty ipsum dolor sit amet, shed everywhere shed everywhere stretching attack your ankles chase the red dot, hairball run catnip eat the grass sniff.</p>",
|
||||
"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 <Provider store={store}>\n <App/>\n </Provider>,\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\");"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user