diff --git a/.gitignore b/.gitignore index b06e156451f..4bef9b7513b 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ shared/utils/get-lines.test.js shared/utils/validate.js shared/utils/validate.test.js shared/utils/is-audited.js +shared/utils/shuffle-array.js ### Old Generated files ### # These files are no longer generated by the client, but can diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index ef5c3abfb15..95c46ce5c8c 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -483,6 +483,12 @@ "preview-external-window": "Preview currently showing in external window.", "fill-in-the-blank": "Fill in the blank", "blank": "blank", + "quiz": { + "correct-answer": "Correct", + "incorrect-answer": "Incorrect", + "unanswered-questions": "The following questions are unanswered: {{ unansweredQuestions }}. You must answer all questions.", + "have-n-correct-questions": "You have {{ correctAnswerCount }} out of {{ total }} questions correct." + }, "exam": { "qualified": "Congratulations, you have completed all the requirements to qualify for the exam.", "not-qualified": "You have not met the requirements to be eligible for the exam. To qualify, please complete the following challenges:", diff --git a/client/package.json b/client/package.json index 7e2a2ee0274..c3f1ab09902 100644 --- a/client/package.json +++ b/client/package.json @@ -49,7 +49,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@freecodecamp/loop-protect": "3.0.0", "@freecodecamp/react-calendar-heatmap": "1.1.0", - "@freecodecamp/ui": "1.2.0", + "@freecodecamp/ui": "2.1.0", "@growthbook/growthbook-react": "0.20.0", "@loadable/component": "5.16.3", "@reach/router": "1.3.4", diff --git a/client/src/templates/Challenges/quiz/show.css b/client/src/templates/Challenges/quiz/show.css new file mode 100644 index 00000000000..fac1aa39cd1 --- /dev/null +++ b/client/src/templates/Challenges/quiz/show.css @@ -0,0 +1,10 @@ +/* Override global ul styles */ +.quiz-challenge-container ul { + padding-inline-start: 0; + list-style-type: none; +} + +/* Override the bottom margin set in global.css */ +.quiz-challenge-container .quiz-answer-label p:last-child { + margin-bottom: 0; +} diff --git a/client/src/templates/Challenges/quiz/show.tsx b/client/src/templates/Challenges/quiz/show.tsx index 5c506f79300..020bbc0a6c6 100644 --- a/client/src/templates/Challenges/quiz/show.tsx +++ b/client/src/templates/Challenges/quiz/show.tsx @@ -1,17 +1,16 @@ -// Package Utilities import { graphql } from 'gatsby'; -import React, { Component } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Helmet from 'react-helmet'; import { ObserveKeys } from 'react-hotkeys'; -import type { TFunction } from 'i18next'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import type { Dispatch } from 'redux'; import { createSelector } from 'reselect'; -import { Container, Col, Row, Button, Quiz } from '@freecodecamp/ui'; +import { Container, Col, Row, Button, Quiz, useQuiz } from '@freecodecamp/ui'; // Local Utilities +import { shuffleArray } from '../../../../../shared/utils/shuffle-array'; import Spacer from '../../../components/helpers/spacer'; import LearnLayout from '../../../components/layouts/learn'; import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types'; @@ -28,6 +27,9 @@ import { initTests } from '../redux/actions'; import { isChallengeCompletedSelector } from '../redux/selectors'; +import PrismFormatted from '../components/prism-formatted'; + +import './show.css'; // Redux Setup const mapStateToProps = createSelector( @@ -43,8 +45,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => updateChallengeMeta, challengeMounted, updateSolutionFormValues, - openCompletionModal: () => openModal('completion'), - openHelpModal: () => openModal('help') + openCompletionModal: () => openModal('completion') }, dispatch ); @@ -57,52 +58,102 @@ interface ShowQuizProps { initTests: (xs: Test[]) => void; isChallengeCompleted: boolean; openCompletionModal: () => void; - openHelpModal: () => void; pageContext: { challengeMeta: ChallengeMeta; }; - t: TFunction; updateChallengeMeta: (arg0: ChallengeMeta) => void; updateSolutionFormValues: () => void; } -interface ShowQuizState { - hasSubmitted: boolean; - quiz: null; -} +const ShowQuiz = ({ + challengeMounted, + data: { + challengeNode: { + challenge: { + fields: { tests }, + title, + description, + challengeType, + helpCategory, + superBlock, + block, + translationPending, + quizzes + } + } + }, + pageContext: { challengeMeta }, + initTests, + updateChallengeMeta, + openCompletionModal, + isChallengeCompleted +}: ShowQuizProps) => { + const { t } = useTranslation(); + const { nextChallengePath, prevChallengePath } = challengeMeta; + const container = useRef(null); -// Component -class ShowQuiz extends Component { - static displayName: string; - private container: React.RefObject = React.createRef(); + // Campers are not allowed to change their answers once the quiz is submitted. + // `hasSubmitted` is used as a flag to disable the quiz. + const [hasSubmitted, setHasSubmitted] = useState(false); - constructor(props: ShowQuizProps) { - super(props); - this.state = { - hasSubmitted: false, - quiz: null - }; + // `isPassed` is used as a flag to conditionally render the test or submit button. + const [isPassed, setIsPassed] = useState(false); - this.handleSubmit = this.handleSubmit.bind(this); - } + const blockNameTitle = `${t( + `intro:${superBlock}.blocks.${block}.title` + )} - ${title}`; - componentDidMount(): void { - const { - challengeMounted, - data: { - challengeNode: { - challenge: { - fields: { tests }, - title, - challengeType, - helpCategory - } - } - }, - pageContext: { challengeMeta }, - initTests, - updateChallengeMeta - } = this.props; + const [quizId] = useState(Math.floor(Math.random() * quizzes.length)); + const quiz = quizzes[quizId].questions; + + // Initialize the data passed to `useQuiz` + const [initialQuizData] = useState( + quiz.map(question => { + const distractors = question.distractors.map((distractor, index) => { + return { + label: ( + + ), + value: index + 1 + }; + }); + + const answer = { + label: ( + + ), + value: 4 + }; + + return { + question: , + answers: shuffleArray([...distractors, answer]), + correctAnswer: answer.value + }; + }) + ); + + const { + questions: quizData, + validateAnswers, + correctAnswerCount + } = useQuiz({ + initialQuestions: initialQuizData, + validationMessages: { + correct: t('learn.quiz.correct-answer'), + incorrect: t('learn.quiz.incorrect-answer') + }, + onSuccess: () => { + openCompletionModal(); + setIsPassed(true); + }, + onFailure: () => setIsPassed(false) + }); + + useEffect(() => { initTests(tests); updateChallengeMeta({ ...challengeMeta, @@ -111,147 +162,130 @@ class ShowQuiz extends Component { helpCategory }); challengeMounted(challengeMeta.id); - this.container.current?.focus(); - } + container.current?.focus(); + // This effect should be run once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - componentDidUpdate(prevProps: ShowQuizProps): void { - const { - data: { - challengeNode: { - challenge: { title: prevTitle } - } - } - } = prevProps; - const { - challengeMounted, - data: { - challengeNode: { - challenge: { title: currentTitle, challengeType, helpCategory } - } - }, - pageContext: { challengeMeta }, - updateChallengeMeta - } = this.props; - if (prevTitle !== currentTitle) { - updateChallengeMeta({ - ...challengeMeta, - title: currentTitle, - challengeType, - helpCategory - }); - challengeMounted(challengeMeta.id); - } - } - - handleSubmit() { - console.log('handleSubmit'); - } - - render() { - const { - data: { - challengeNode: { - challenge: { - title, - // challengeType, - description, - superBlock, - block, - translationPending, - quizzes - } - } - }, - // openCompletionModal, - openHelpModal, - pageContext: { - challengeMeta: { nextChallengePath, prevChallengePath } - }, - t, - isChallengeCompleted - } = this.props; - - const blockNameTitle = `${t( - `intro:${superBlock}.blocks.${block}.title` - )} - ${title}`; - - const random = Math.floor(Math.random() * quizzes.length); - const quiz = quizzes[random].questions; - const quizForComponent = quiz.map(question => { - const distractors = question.distractors.map((distractor, index) => { - return { - label: distractor, - value: index + 1 - }; - }); - const answer = { - label: question.answer, - value: 4 - }; - - return { - question: question.text, - answers: [...distractors, answer] - }; + useEffect(() => { + updateChallengeMeta({ + ...challengeMeta, + title, + challengeType, + helpCategory }); + challengeMounted(challengeMeta.id); + }, [ + title, + challengeMeta, + challengeType, + helpCategory, + challengeMounted, + updateChallengeMeta + ]); - return ( - { - this.handleSubmit(); - }} - containerRef={this.container} - nextChallengePath={nextChallengePath} - prevChallengePath={prevChallengePath} - > - - - - + const handleAnswersCheck = () => { + validateAnswers(); + setHasSubmitted(true); + }; + + const handleSubmitAndGo = () => { + openCompletionModal(); + }; + + function getErrorMessage() { + if (!hasSubmitted) return ''; + + const unansweredList = quizData.reduce( + (acc, curr, id) => (curr.selectedAnswer == null ? [...acc, id + 1] : acc), + [] + ); + + if (unansweredList.length > 0) { + return t('learn.quiz.unanswered-questions', { + unansweredQuestions: unansweredList.join(', ') + }); + } + + return t('learn.quiz.have-n-correct-questions', { + correctAnswerCount, + total: quiz.length + }); + } + + const errorMessage = getErrorMessage(); + + return ( + + + + + + + + {title} + + + + + + + - - {title} - +
+ {errorMessage} +
+ + {/* + There are three cases for the button display: + 1. Campers submit the answers but don't pass + 2. Campers submit the answers and pass, click the submit button on the completion modal + 3. Campers submit the answers and pass, but they close the completion modal - - - - - - + This rendering logic is only handling (2) and (3). + TODO: Update the logic to handle (1). + The code should render a link that points campers to the module's review block. + */} + {!isPassed ? ( - - - - - -
-
-
-
- ); - } -} + )} + + + +
+
+
+
+ ); +}; ShowQuiz.displayName = 'ShowQuiz'; -export default connect( - mapStateToProps, - mapDispatchToProps -)(withTranslation()(ShowQuiz)); +export default connect(mapStateToProps, mapDispatchToProps)(ShowQuiz); export const query = graphql` query QuizChallenge($id: String!) { @@ -266,6 +300,10 @@ export const query = graphql` fields { blockName slug + tests { + text + testString + } } quizzes { questions { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14b14c0ed8d..44d2049bfa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,8 +468,8 @@ importers: specifier: 1.1.0 version: 1.1.0(react@16.14.0) '@freecodecamp/ui': - specifier: 1.2.0 - version: 1.2.0(@types/react-dom@16.9.24)(@types/react@16.14.56) + specifier: 2.1.0 + version: 2.1.0(@types/react-dom@16.9.24)(@types/react@16.14.56)(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@growthbook/growthbook-react': specifier: 0.20.0 version: 0.20.0(react@16.14.0) @@ -1122,13 +1122,13 @@ importers: version: 4.17.12 babel-loader: specifier: 8.3.0 - version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0)) + version: 8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))) chai: specifier: 4.4.1 version: 4.4.1 copy-webpack-plugin: specifier: 9.1.0 - version: 9.1.0(webpack@5.90.3(webpack-cli@4.10.0)) + version: 9.1.0(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))) enzyme: specifier: 3.11.0 version: 3.11.0 @@ -1155,7 +1155,7 @@ importers: version: 0.12.5 webpack: specifier: 5.90.3 - version: 5.90.3(webpack-cli@4.10.0) + version: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) webpack-cli: specifier: 4.10.0 version: 4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3) @@ -2934,9 +2934,12 @@ packages: peerDependencies: react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 - '@freecodecamp/ui@1.2.0': - resolution: {integrity: sha512-WXNMwT3UO5FfN4jb5JhCel6Ddrnr/KMG3yyKtFF33I7JuGb8P7ykRCuM4aeCpceH7L1aXXmEZ7gcf1Gjgy3WDw==} + '@freecodecamp/ui@2.1.0': + resolution: {integrity: sha512-KL9QVvBlVjkls9BWTpsxNQdp88OmR+x9qE/jNvKcGRByFVsLXbAblb1cRcz8YXPPdB4vpImMT8Q3baxZTHn28Q==} engines: {node: '>=20', pnpm: '9'} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 '@gatsbyjs/reach-router@1.3.9': resolution: {integrity: sha512-/354IaUSM54xb7K/TxpLBJB94iEAJ3P82JD38T8bLnIDWF+uw8+W/82DKnQ7y24FJcKxtVmG43aiDLG88KSuYQ==} @@ -16718,7 +16721,7 @@ snapshots: prop-types: 15.8.1 react: 16.14.0 - '@freecodecamp/ui@1.2.0(@types/react-dom@16.9.24)(@types/react@16.14.56)': + '@freecodecamp/ui@2.1.0(@types/react-dom@16.9.24)(@types/react@16.14.56)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)': dependencies: '@fortawesome/fontawesome-svg-core': 6.6.0 '@fortawesome/free-solid-svg-icons': 6.6.0 @@ -18023,7 +18026,7 @@ snapshots: dependencies: '@types/node': 20.8.0 tapable: 2.2.1 - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) transitivePeerDependencies: - '@swc/core' - esbuild @@ -18765,9 +18768,9 @@ snapshots: '@webassemblyjs/ast': 1.11.6 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0))': + '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)))': dependencies: - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) webpack-cli: 4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3) '@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))': @@ -18820,9 +18823,9 @@ snapshots: acorn: 8.11.3 acorn-walk: 8.2.0 - acorn-import-assertions@1.9.0(acorn@8.10.0): + acorn-import-assertions@1.9.0(acorn@8.11.3): dependencies: - acorn: 8.10.0 + acorn: 8.11.3 acorn-jsx@5.3.2(acorn@7.4.1): dependencies: @@ -19266,14 +19269,14 @@ snapshots: schema-utils: 2.7.1 webpack: 5.90.3 - babel-loader@8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0)): + babel-loader@8.3.0(@babel/core@7.23.7)(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))): dependencies: '@babel/core': 7.23.7 find-cache-dir: 3.3.2 loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) babel-plugin-add-module-exports@1.0.4: {} @@ -20391,7 +20394,7 @@ snapshots: copy-descriptor@0.1.1: {} - copy-webpack-plugin@9.1.0(webpack@5.90.3(webpack-cli@4.10.0)): + copy-webpack-plugin@9.1.0(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))): dependencies: fast-glob: 3.3.1 glob-parent: 6.0.2 @@ -20399,7 +20402,7 @@ snapshots: normalize-path: 3.0.0 schema-utils: 3.3.0 serialize-javascript: 6.0.1 - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) core-js-compat@3.33.0: dependencies: @@ -29164,18 +29167,18 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.10(webpack@5.90.3(webpack-cli@4.10.0)): + terser-webpack-plugin@5.3.10(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))): dependencies: - '@jridgewell/trace-mapping': 0.3.22 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 terser: 5.28.1 - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) terser-webpack-plugin@5.3.10(webpack@5.90.3): dependencies: - '@jridgewell/trace-mapping': 0.3.22 + '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.1 @@ -30039,7 +30042,7 @@ snapshots: webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0)) + '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))) '@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) '@webpack-cli/serve': 1.7.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) colorette: 2.0.20 @@ -30049,7 +30052,7 @@ snapshots: import-local: 3.1.0 interpret: 2.2.0 rechoir: 0.7.1 - webpack: 5.90.3(webpack-cli@4.10.0) + webpack: 5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)) webpack-merge: 5.9.0 optionalDependencies: webpack-bundle-analyzer: 4.10.1 @@ -30091,9 +30094,9 @@ snapshots: '@webassemblyjs/ast': 1.11.6 '@webassemblyjs/wasm-edit': 1.11.6 '@webassemblyjs/wasm-parser': 1.11.6 - acorn: 8.10.0 - acorn-import-assertions: 1.9.0(acorn@8.10.0) - browserslist: 4.22.2 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 chrome-trace-event: 1.0.3 enhanced-resolve: 5.15.0 es-module-lexer: 1.3.1 @@ -30115,16 +30118,16 @@ snapshots: - esbuild - uglify-js - webpack@5.90.3(webpack-cli@4.10.0): + webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3)): dependencies: '@types/eslint-scope': 3.7.5 '@types/estree': 1.0.5 '@webassemblyjs/ast': 1.11.6 '@webassemblyjs/wasm-edit': 1.11.6 '@webassemblyjs/wasm-parser': 1.11.6 - acorn: 8.10.0 - acorn-import-assertions: 1.9.0(acorn@8.10.0) - browserslist: 4.22.2 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 chrome-trace-event: 1.0.3 enhanced-resolve: 5.15.0 es-module-lexer: 1.3.1 @@ -30138,7 +30141,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.90.3(webpack-cli@4.10.0)) + terser-webpack-plugin: 5.3.10(webpack@5.90.3(webpack-cli@4.10.0(webpack-bundle-analyzer@4.10.1)(webpack@5.90.3))) watchpack: 2.4.0 webpack-sources: 3.2.3 optionalDependencies: diff --git a/shared/config/challenge-types.ts b/shared/config/challenge-types.ts index 267034c6189..eacc9ebc465 100644 --- a/shared/config/challenge-types.ts +++ b/shared/config/challenge-types.ts @@ -118,7 +118,7 @@ export const submitTypes = { [backEndProject]: 'project.backEnd', [pythonProject]: 'project.backEnd', [step]: 'step', - [quiz]: 'quiz', + [quiz]: 'tests', [backend]: 'backend', [modern]: 'tests', [video]: 'tests', diff --git a/shared/utils/shuffle-array.ts b/shared/utils/shuffle-array.ts new file mode 100644 index 00000000000..0cc7dc5e1fb --- /dev/null +++ b/shared/utils/shuffle-array.ts @@ -0,0 +1,11 @@ +/** Shuffle array using the Fisher–Yates shuffle algorithm */ +export const shuffleArray = (arrToShuffle: Array) => { + const arr = [...arrToShuffle]; + + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + + return arr; +};