feat(client): quiz challenge with validation (#56163)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Huyen Nguyen
2024-10-01 12:08:09 -07:00
committed by GitHub
parent 377bfc42da
commit 6f4488998a
8 changed files with 270 additions and 201 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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:",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<HTMLElement | null>(null);
// Component
class ShowQuiz extends Component<ShowQuizProps, ShowQuizState> {
static displayName: string;
private container: React.RefObject<HTMLElement> = 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: (
<PrismFormatted className='quiz-answer-label' text={distractor} />
),
value: index + 1
};
});
const answer = {
label: (
<PrismFormatted
className='quiz-answer-label'
text={question.answer}
/>
),
value: 4
};
return {
question: <PrismFormatted text={question.text} />,
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<ShowQuizProps, ShowQuizState> {
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 (
<Hotkeys
executeChallenge={() => {
this.handleSubmit();
}}
containerRef={this.container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
/>
<Container>
<Row>
const handleAnswersCheck = () => {
validateAnswers();
setHasSubmitted(true);
};
const handleSubmitAndGo = () => {
openCompletionModal();
};
function getErrorMessage() {
if (!hasSubmitted) return '';
const unansweredList = quizData.reduce<number[]>(
(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 (
<Hotkeys
executeChallenge={!isPassed ? handleAnswersCheck : handleSubmitAndGo}
containerRef={container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
>
<LearnLayout>
<Helmet
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
/>
<Container className='quiz-challenge-container'>
<Row>
<Spacer size='medium' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription description={description} />
<ObserveKeys>
<Quiz questions={quizData} disabled={hasSubmitted} />
</ObserveKeys>
<Spacer size='medium' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<div aria-live='polite' aria-atomic='true'>
{errorMessage}
</div>
<Spacer size='medium' />
{/*
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
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription description={description} />
<ObserveKeys>
<Quiz questions={quizForComponent} />
</ObserveKeys>
<Spacer size='medium' />
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 ? (
<Button
block={true}
variant='primary'
onClick={() => this.handleSubmit()}
onClick={handleAnswersCheck}
>
{t('buttons.check-answer')}
</Button>
<Spacer size='xxSmall' />
<Button block={true} variant='primary' onClick={openHelpModal}>
{t('buttons.ask-for-help')}
) : (
<Button
block={true}
variant='primary'
onClick={handleSubmitAndGo}
>
{t('buttons.submit-and-go')}
</Button>
<Spacer size='large' />
</Col>
<CompletionModal />
</Row>
</Container>
</LearnLayout>
</Hotkeys>
);
}
}
)}
<Spacer size='large' />
</Col>
<CompletionModal />
</Row>
</Container>
</LearnLayout>
</Hotkeys>
);
};
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 {

65
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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',

View File

@@ -0,0 +1,11 @@
/** Shuffle array using the FisherYates shuffle algorithm */
export const shuffleArray = <T>(arrToShuffle: Array<T>) => {
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;
};