refactor(client): use functional component for exams (#57398)

This commit is contained in:
Oliver Eyton-Williams
2024-12-05 19:16:51 +01:00
committed by GitHub
parent 027473267a
commit 059b92d751
+301 -343
View File
@@ -1,7 +1,7 @@
// Package Utilities
import { graphql, navigate } from 'gatsby';
import React, { Component, RefObject } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import Helmet from 'react-helmet';
import type { TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
@@ -136,43 +136,54 @@ interface ShowExamProps {
updateChallengeMeta: (arg0: ChallengeMeta) => void;
}
interface ShowExamState {
currentQuestionIndex: number;
examTimeInSeconds: number;
generatedExamQuestions: GeneratedExamQuestion[];
userExamQuestions: UserExamQuestion[];
showResults: boolean;
}
function convertMd(md: string): string {
return micromark(md);
}
class ShowExam extends Component<ShowExamProps, ShowExamState> {
static displayName: string;
private container: RefObject<HTMLElement> | undefined = React.createRef();
timerInterval!: NodeJS.Timeout;
function ShowExam(props: ShowExamProps) {
const {
data: {
challengeNode: {
challenge: {
block,
dashedName,
description,
fields: { blockName },
instructions,
prerequisites,
superBlock,
title,
translationPending
}
}
},
examInProgress,
examResults,
completedChallenges,
completedSurveys,
isChallengeCompleted,
openExitExamModal,
openFinishExamModal,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
t
} = props;
constructor(props: ShowExamProps) {
super(props);
this.state = {
currentQuestionIndex: 0,
generatedExamQuestions: [],
examTimeInSeconds: 0,
userExamQuestions: [],
showResults: false
};
let timerInterval: NodeJS.Timeout;
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.exitExam = this.exitExam.bind(this);
this.cleanUp = this.cleanUp.bind(this);
}
const container = useRef<HTMLElement>(null);
componentDidMount(): void {
const [examTimeInSeconds, setExamTimeInSeconds] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [generatedExamQuestions, setGeneratedExamQuestions] = useState<
GeneratedExamQuestion[]
>([]);
const [userExamQuestions, setUserExamQuestions] = useState<
UserExamQuestion[]
>([]);
useEffect(() => {
const {
challengeMounted,
data: {
@@ -188,7 +199,7 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
pageContext: { challengeMeta },
initTests,
updateChallengeMeta
} = this.props;
} = props;
initTests(tests);
updateChallengeMeta({
...challengeMeta,
@@ -198,26 +209,31 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
});
challengeMounted(challengeMeta.id);
this.container?.current?.focus();
}
container.current?.focus();
componentWillUnmount() {
this.cleanUp();
this.props.stopExam();
}
return () => {
cleanUp();
props.stopExam();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
stopWindowClose = (event: Event) => {
// Normally you would clear listeners in a useEffect cleanup function, but we
// need to set them in the runExam function (rather than in a useEffect). The
// refs make them stable across renders and thus removable.
const stopWindowCloseRef = useRef((event: Event) => {
event.preventDefault();
alert(this.props.t('misc.navigation-warning'));
};
alert(props.t('misc.navigation-warning'));
});
stopBrowserBack = (event: Event) => {
const stopBrowserBackRef = useRef((event: Event) => {
event.preventDefault();
window.history.forward();
alert(this.props.t('misc.navigation-warning'));
};
// TODO: useTranslation
alert(props.t('misc.navigation-warning'));
});
runExam = async () => {
const runExam = async () => {
// TODO: show loader
const {
createFlashMessage,
@@ -226,13 +242,15 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
challenge: { id: challengeId }
}
}
} = this.props;
} = props;
const generateExamResponse = await getGenerateExam(challengeId);
const { response, data } = generateExamResponse;
if (response.status === 200) {
const { generatedExam = [] } = data;
const { generatedExam = [] } = data as {
generatedExam: GeneratedExamQuestion[];
};
const emptyUserExamQuestions = generatedExam.map(q => {
return {
id: q.id,
@@ -241,24 +259,17 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
};
}) as UserExamQuestion[];
this.setState(
{
generatedExamQuestions: generatedExam,
userExamQuestions: emptyUserExamQuestions
},
() => {
this.timerInterval = setInterval(() => {
this.setState({
examTimeInSeconds: this.state.examTimeInSeconds + 1
});
}, 1000);
setGeneratedExamQuestions(generatedExam);
setUserExamQuestions(emptyUserExamQuestions);
this.props.startExam();
timerInterval = setInterval(() => {
setExamTimeInSeconds(t => t + 1);
}, 1000);
window.addEventListener('beforeunload', this.stopWindowClose);
window.addEventListener('popstate', this.stopBrowserBack);
}
);
props.startExam();
window.addEventListener('beforeunload', stopWindowCloseRef.current);
window.addEventListener('popstate', stopBrowserBackRef.current);
} else {
createFlashMessage({
type: 'danger',
@@ -267,55 +278,47 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
}
};
selectAnswer = (index: number, id: string, answer: string): void => {
const newUserExamQuestions = Array.from(this.state.userExamQuestions);
const selectAnswer = (index: number, id: string, answer: string): void => {
const newUserExamQuestions = Array.from(userExamQuestions);
newUserExamQuestions[index].answer.id = id;
newUserExamQuestions[index].answer.answer = answer;
this.setState({
userExamQuestions: newUserExamQuestions
});
setUserExamQuestions(newUserExamQuestions);
};
goToPreviousQuestion = () => {
this.setState({
currentQuestionIndex: this.state.currentQuestionIndex - 1
});
const goToPreviousQuestion = () => {
setCurrentQuestionIndex(currentQuestionIndex - 1);
};
goToNextQuestion = () => {
this.setState({
currentQuestionIndex: this.state.currentQuestionIndex + 1
});
const goToNextQuestion = () => {
setCurrentQuestionIndex(currentQuestionIndex + 1);
};
cleanUp = () => {
clearInterval(this.timerInterval);
this.setState({
examTimeInSeconds: 0,
currentQuestionIndex: 0
});
const cleanUp = () => {
clearInterval(timerInterval);
window.removeEventListener('beforeunload', this.stopWindowClose);
window.removeEventListener('popstate', this.stopBrowserBack);
setExamTimeInSeconds(0);
setCurrentQuestionIndex(0);
this.props.clearExamResults();
this.props.closeExitExamModal();
this.props.closeFinishExamModal();
window.removeEventListener('beforeunload', stopWindowCloseRef.current);
window.removeEventListener('popstate', stopBrowserBackRef.current);
props.clearExamResults();
props.closeExitExamModal();
props.closeFinishExamModal();
};
finishExam = () => {
const finishExam = () => {
// TODO: show loader
this.cleanUp();
cleanUp();
const { setUserCompletedExam, submitChallenge } = this.props;
const { userExamQuestions, examTimeInSeconds } = this.state;
const { setUserCompletedExam, submitChallenge } = props;
setUserCompletedExam({ userExamQuestions, examTimeInSeconds });
submitChallenge();
};
exitExam = () => {
this.cleanUp();
const exitExam = () => {
cleanUp();
const {
data: {
@@ -326,262 +329,217 @@ class ShowExam extends Component<ShowExamProps, ShowExamState> {
}
},
stopExam
} = this.props;
} = props;
stopExam();
void navigate(blockHashSlug);
};
render() {
const {
data: {
challengeNode: {
challenge: {
block,
dashedName,
description,
fields: { blockName },
instructions,
prerequisites,
superBlock,
title,
translationPending
}
}
},
examInProgress,
examResults,
completedChallenges,
completedSurveys,
isChallengeCompleted,
openExitExamModal,
openFinishExamModal,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
t
} = this.props;
const {
examTimeInSeconds,
currentQuestionIndex,
generatedExamQuestions,
userExamQuestions
} = this.state;
let missingPrerequisites: PrerequisiteChallenge[] = [];
if (prerequisites) {
missingPrerequisites = prerequisites?.filter(
prerequisite =>
!completedChallenges.find(({ id }) => prerequisite.id === id)
);
}
const surveyCompleted = completedSurveys.some(
s => s.title === 'Foundational C# with Microsoft Survey'
);
const prerequisitesComplete = missingPrerequisites.length === 0;
const qualifiedForExam = prerequisitesComplete && surveyCompleted;
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
// TODO: If already taken exam, show different messages
return examInProgress ? (
<Container>
<Row>
<Spacer size='m' />
<Col md={10} mdOffset={1} sm={10} smOffset={1} xs={12}>
{examResults ? (
<ExamResults
dashedName={dashedName}
title={title}
examResults={examResults}
exitExam={this.exitExam}
/>
) : (
<div className='exam-wrapper'>
<div className='exam-header'>
<div data-playwright-test-label='exam-show-title'>
{title}
</div>
<span>|</span>
<div data-playwright-test-label='exam-show-question-time'>
{t('learn.exam.time', {
t: formatSecondsToTime(examTimeInSeconds)
})}
</div>
<span>|</span>
<div>
{t('learn.exam.questions', {
n: currentQuestionIndex + 1,
t: generatedExamQuestions.length
})}
</div>
</div>
<hr />
<Spacer size='m' />
<div className='exam-questions'>
<PrismFormatted
text={convertMd(
generatedExamQuestions[currentQuestionIndex].question
)}
/>
<Spacer size='l' />
<div className='exam-answers'>
{generatedExamQuestions[currentQuestionIndex].answers.map(
({ answer, id }) => (
<label className='exam-answer-label' key={id}>
<input
checked={
userExamQuestions[currentQuestionIndex].answer
.id === id
}
className='sr-only'
name={id}
onChange={() =>
this.selectAnswer(
currentQuestionIndex,
id,
answer
)
}
type='radio'
value={id}
/>{' '}
<span className='exam-answer-input-visible'>
{userExamQuestions[currentQuestionIndex].answer
.id === id ? (
<span className='exam-answer-input-selected' />
) : null}
</span>
<PrismFormatted text={convertMd(answer)} />
</label>
)
)}
</div>
</div>
<Spacer size='l' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
disabled={currentQuestionIndex <= 0}
variant='primary'
onClick={this.goToPreviousQuestion}
>
{t('buttons.previous-question')}
</Button>
{currentQuestionIndex ===
generatedExamQuestions.length - 1 ? (
<Button
block={true}
disabled={
!userExamQuestions[currentQuestionIndex].answer.id
}
className='exam-button'
variant='primary'
onClick={openFinishExamModal}
>
{t('buttons.finish-exam')}
</Button>
) : (
<Button
block={true}
disabled={
!userExamQuestions[currentQuestionIndex].answer.id
}
className='exam-button'
variant='primary'
onClick={this.goToNextQuestion}
>
{t('buttons.next-question')}
</Button>
)}
</div>
<Spacer size='m' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
variant='primary'
onClick={openExitExamModal}
>
{t('buttons.exit-exam')}
</Button>
</div>
</div>
)}
</Col>
<ExitExamModal exitExam={this.exitExam} />
<FinishExamModal finishExam={this.finishExam} />
</Row>
</Container>
) : (
<Hotkeys
containerRef={this.container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet title={windowTitle} />
<Container>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Spacer size='m' />
{qualifiedForExam ? (
<Alert variant='info'>
<p>{t('learn.exam.qualified')}</p>
</Alert>
) : (
<>
{!prerequisitesComplete ? (
<MissingPrerequisites
missingPrerequisites={missingPrerequisites}
/>
) : (
<FoundationalCSharpSurveyAlert />
)}
</>
)}
<PrismFormatted text={description} />
<Spacer size='m' />
<PrismFormatted text={instructions} />
<Button
block={true}
variant='primary'
disabled={!qualifiedForExam}
// `this.runExam` being an async callback is acceptable
//eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={this.runExam}
>
{t('buttons.click-start-exam')}
</Button>
</Col>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Container>
</LearnLayout>
</Hotkeys>
let missingPrerequisites: PrerequisiteChallenge[] = [];
if (prerequisites) {
missingPrerequisites = prerequisites?.filter(
prerequisite =>
!completedChallenges.find(({ id }) => prerequisite.id === id)
);
}
const surveyCompleted = completedSurveys.some(
s => s.title === 'Foundational C# with Microsoft Survey'
);
const prerequisitesComplete = missingPrerequisites.length === 0;
const qualifiedForExam = prerequisitesComplete && surveyCompleted;
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
const windowTitle = `${blockNameTitle} | freeCodeCamp.org`;
// TODO: If already taken exam, show different messages
return examInProgress ? (
<Container>
<Row>
<Spacer size='m' />
<Col md={10} mdOffset={1} sm={10} smOffset={1} xs={12}>
{examResults ? (
<ExamResults
dashedName={dashedName}
title={title}
examResults={examResults}
exitExam={exitExam}
/>
) : (
<div className='exam-wrapper'>
<div className='exam-header'>
<div data-playwright-test-label='exam-show-title'>{title}</div>
<span>|</span>
<div data-playwright-test-label='exam-show-question-time'>
{t('learn.exam.time', {
t: formatSecondsToTime(examTimeInSeconds)
})}
</div>
<span>|</span>
<div>
{t('learn.exam.questions', {
n: currentQuestionIndex + 1,
t: generatedExamQuestions.length
})}
</div>
</div>
<hr />
<Spacer size='m' />
<div className='exam-questions'>
<PrismFormatted
text={convertMd(
generatedExamQuestions[currentQuestionIndex].question
)}
/>
<Spacer size='l' />
<div className='exam-answers'>
{generatedExamQuestions[currentQuestionIndex].answers.map(
({ answer, id }) => (
<label className='exam-answer-label' key={id}>
<input
checked={
userExamQuestions[currentQuestionIndex].answer
.id === id
}
className='sr-only'
name={id}
onChange={() =>
selectAnswer(currentQuestionIndex, id, answer)
}
type='radio'
value={id}
/>{' '}
<span className='exam-answer-input-visible'>
{userExamQuestions[currentQuestionIndex].answer.id ===
id ? (
<span className='exam-answer-input-selected' />
) : null}
</span>
<PrismFormatted text={convertMd(answer)} />
</label>
)
)}
</div>
</div>
<Spacer size='l' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
disabled={currentQuestionIndex <= 0}
variant='primary'
onClick={goToPreviousQuestion}
>
{t('buttons.previous-question')}
</Button>
{currentQuestionIndex === generatedExamQuestions.length - 1 ? (
<Button
block={true}
disabled={
!userExamQuestions[currentQuestionIndex].answer.id
}
className='exam-button'
variant='primary'
onClick={openFinishExamModal}
>
{t('buttons.finish-exam')}
</Button>
) : (
<Button
block={true}
disabled={
!userExamQuestions[currentQuestionIndex].answer.id
}
className='exam-button'
variant='primary'
onClick={goToNextQuestion}
>
{t('buttons.next-question')}
</Button>
)}
</div>
<Spacer size='m' />
<div className='exam-buttons'>
<Button
block={true}
className='exam-button'
variant='primary'
onClick={openExitExamModal}
>
{t('buttons.exit-exam')}
</Button>
</div>
</div>
)}
</Col>
<ExitExamModal exitExam={exitExam} />
<FinishExamModal finishExam={finishExam} />
</Row>
</Container>
) : (
<Hotkeys
containerRef={container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet title={windowTitle} />
<Container>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Spacer size='m' />
{qualifiedForExam ? (
<Alert variant='info'>
<p>{t('learn.exam.qualified')}</p>
</Alert>
) : (
<>
{!prerequisitesComplete ? (
<MissingPrerequisites
missingPrerequisites={missingPrerequisites}
/>
) : (
<FoundationalCSharpSurveyAlert />
)}
</>
)}
<PrismFormatted text={description} />
<Spacer size='m' />
<PrismFormatted text={instructions} />
<Button
block={true}
variant='primary'
disabled={!qualifiedForExam}
// `runExam` being an async callback is acceptable
//eslint-disable-next-line @typescript-eslint/no-misused-promises
onClick={runExam}
>
{t('buttons.click-start-exam')}
</Button>
</Col>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Container>
</LearnLayout>
</Hotkeys>
);
}
ShowExam.displayName = 'ShowExam';