feat(client): handle quiz finish and exit (#56644)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Huyen Nguyen
2024-11-06 11:39:32 -08:00
committed by GitHub
parent e2cc42cf5e
commit 2e76ea10da
11 changed files with 448 additions and 58 deletions

View File

@@ -96,6 +96,8 @@
"exit": "Exit",
"finish-exam": "Finish the exam",
"finish": "Finish",
"exit-quiz": "Exit the quiz",
"finish-quiz": "Finish the quiz",
"submit-exam-results": "Submit my results",
"verify-trophy": "Verify Trophy",
"link-account": "Link Account",
@@ -519,7 +521,15 @@
"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."
"have-n-correct-questions": "You have {{ correctAnswerCount }} out of {{ total }} questions correct.",
"finish-modal-header": "Finish Quiz",
"finish-modal-body": "Are you sure you want to finish the quiz? You will not be able to change any answers.",
"finish-modal-yes": "Yes, I am finished",
"finish-modal-no": "No, I would like to continue the quiz",
"exit-modal-header": "Exit Quiz",
"exit-modal-body": "Are you sure you want to leave the quiz? You will lose any progress you have made.",
"exit-modal-yes": "Yes, I want to leave the quiz",
"exit-modal-no": "No, I would like to continue the quiz"
},
"exam": {
"qualified": "Congratulations, you have completed all the requirements to qualify for the exam.",

View File

@@ -50,7 +50,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@freecodecamp/loop-protect": "3.0.0",
"@freecodecamp/react-calendar-heatmap": "1.1.0",
"@freecodecamp/ui": "2.1.0",
"@freecodecamp/ui": "3.0.0",
"@growthbook/growthbook-react": "0.20.0",
"@loadable/component": "5.16.3",
"@reach/router": "1.3.4",

View File

@@ -0,0 +1 @@
export { usePageLeave } from './use-page-leave';

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { useLocation, globalHistory } from '@reach/router';
interface Props {
onWindowClose: (event: BeforeUnloadEvent) => void;
onHistoryChange: () => void;
}
export const usePageLeave = ({ onWindowClose, onHistoryChange }: Props) => {
const curLocation = useLocation();
useEffect(() => {
window.addEventListener('beforeunload', onWindowClose);
// This is a workaround as @reach/router doesn't support blocking history change.
// https://github.com/reach/router/issues/464
const unlistenHistory = globalHistory.listen(({ action, location }) => {
const isBack = action === 'POP';
const isRouteChanged =
action === 'PUSH' && location.pathname !== curLocation.pathname;
if (isBack || isRouteChanged) {
onHistoryChange();
}
});
return () => {
window.removeEventListener('beforeunload', onWindowClose);
unlistenHistory();
};
}, [onWindowClose, onHistoryChange, curLocation]);
};

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import { closeModal } from '../redux/actions';
import { isExitQuizModalOpenSelector } from '../redux/selectors';
interface ExitQuizModalProps {
closeExitQuizModal: () => void;
isExitQuizModalOpen: boolean;
onExit: () => void;
}
const mapStateToProps = (state: unknown) => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
isExitQuizModalOpen: isExitQuizModalOpenSelector(state)
});
const mapDispatchToProps = {
closeExitQuizModal: () => closeModal('exitQuiz')
};
const ExitQuizModal = ({
closeExitQuizModal,
isExitQuizModalOpen,
onExit
}: ExitQuizModalProps) => {
const { t } = useTranslation();
return (
<Modal
onClose={closeExitQuizModal}
open={isExitQuizModalOpen}
variant='danger'
>
<Modal.Header closeButtonClassNames='close'>
{t('learn.quiz.exit-modal-header')}
</Modal.Header>
<Modal.Body alignment='center'>
{t('learn.quiz.exit-modal-body')}
</Modal.Body>
<Modal.Footer>
<Button block variant='primary' onClick={closeExitQuizModal}>
{t('learn.quiz.exit-modal-no')}
</Button>
<Spacer size='xxs' />
<Button block variant='danger' onClick={onExit}>
{t('learn.quiz.exit-modal-yes')}
</Button>
</Modal.Footer>
</Modal>
);
};
ExitQuizModal.displayName = 'ExitQuizModal';
export default connect(mapStateToProps, mapDispatchToProps)(ExitQuizModal);

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { connect } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import { closeModal } from '../redux/actions';
import { isFinishQuizModalOpenSelector } from '../redux/selectors';
interface FinishQuizModalProps {
closeFinishQuizModal: () => void;
isFinishQuizModalOpen: boolean;
onFinish: () => void;
}
const mapStateToProps = (state: unknown) => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
isFinishQuizModalOpen: isFinishQuizModalOpenSelector(state)
});
const mapDispatchToProps = {
closeFinishQuizModal: () => closeModal('finishQuiz')
};
const FinishQuizModal = ({
closeFinishQuizModal,
isFinishQuizModalOpen,
onFinish
}: FinishQuizModalProps) => {
const { t } = useTranslation();
return (
<Modal onClose={closeFinishQuizModal} open={isFinishQuizModalOpen}>
<Modal.Header closeButtonClassNames='close'>
{t('learn.quiz.finish-modal-header')}
</Modal.Header>
<Modal.Body alignment='center'>
{t('learn.quiz.finish-modal-body')}
</Modal.Body>
<Modal.Footer>
<Button block size='medium' variant='primary' onClick={onFinish}>
{t('learn.quiz.finish-modal-yes')}
</Button>
<Spacer size='xxs' />
<Button
block
size='medium'
variant='primary'
onClick={closeFinishQuizModal}
>
{t('learn.quiz.finish-modal-no')}
</Button>
</Modal.Footer>
</Modal>
);
};
FinishQuizModal.displayName = 'FinishQuizModal';
export default connect(mapStateToProps, mapDispatchToProps)(FinishQuizModal);

View File

@@ -1,5 +1,5 @@
import { graphql } from 'gatsby';
import React, { useEffect, useRef, useState } from 'react';
import { graphql, navigate } from 'gatsby';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Helmet from 'react-helmet';
import { ObserveKeys } from 'react-hotkeys';
import { useTranslation } from 'react-i18next';
@@ -7,6 +7,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { useLocation } from '@reach/router';
import {
Container,
Col,
@@ -21,7 +22,6 @@ import {
import { shuffleArray } from '../../../../../shared/utils/shuffle-array';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
// import { challengeTypes } from '../../../../../shared/config/challenge-types';
import ChallengeDescription from '../components/challenge-description';
import Hotkeys from '../components/hotkeys';
import ChallengeTitle from '../components/challenge-title';
@@ -30,11 +30,15 @@ import {
challengeMounted,
updateChallengeMeta,
openModal,
closeModal,
updateSolutionFormValues,
initTests
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
import PrismFormatted from '../components/prism-formatted';
import { usePageLeave } from '../hooks';
import ExitQuizModal from './exit-quiz-modal';
import FinishQuizModal from './finish-quiz-modal';
import './show.css';
@@ -52,7 +56,11 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
updateChallengeMeta,
challengeMounted,
updateSolutionFormValues,
openCompletionModal: () => openModal('completion')
openCompletionModal: () => openModal('completion'),
openExitQuizModal: () => openModal('exitQuiz'),
closeExitQuizModal: () => closeModal('exitQuiz'),
openFinishQuizModal: () => openModal('finishQuiz'),
closeFinishQuizModal: () => closeModal('finishQuiz')
},
dispatch
);
@@ -64,12 +72,16 @@ interface ShowQuizProps {
description: string;
initTests: (xs: Test[]) => void;
isChallengeCompleted: boolean;
openCompletionModal: () => void;
pageContext: {
challengeMeta: ChallengeMeta;
};
updateChallengeMeta: (arg0: ChallengeMeta) => void;
updateSolutionFormValues: () => void;
openCompletionModal: () => void;
openExitQuizModal: () => void;
closeExitQuizModal: () => void;
openFinishQuizModal: () => void;
closeFinishQuizModal: () => void;
}
const ShowQuiz = ({
@@ -77,7 +89,7 @@ const ShowQuiz = ({
data: {
challengeNode: {
challenge: {
fields: { tests },
fields: { tests, blockHashSlug },
title,
description,
challengeType,
@@ -92,10 +104,16 @@ const ShowQuiz = ({
pageContext: { challengeMeta },
initTests,
updateChallengeMeta,
isChallengeCompleted,
openCompletionModal,
isChallengeCompleted
openExitQuizModal,
closeExitQuizModal,
openFinishQuizModal,
closeFinishQuizModal
}: ShowQuizProps) => {
const { t } = useTranslation();
const curLocation = useLocation();
const { nextChallengePath, prevChallengePath } = challengeMeta;
const container = useRef<HTMLElement | null>(null);
@@ -106,6 +124,10 @@ const ShowQuiz = ({
// `isPassed` is used as a flag to conditionally render the test or submit button.
const [isPassed, setIsPassed] = useState(false);
const [showUnanswered, setShowUnanswered] = useState(false);
const [exitConfirmed, setExitConfirmed] = useState(false);
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)} - ${title}`;
@@ -146,6 +168,7 @@ const ShowQuiz = ({
const {
questions: quizData,
validateAnswers,
validated,
correctAnswerCount
} = useQuiz({
initialQuestions: initialQuizData,
@@ -153,13 +176,18 @@ const ShowQuiz = ({
correct: t('learn.quiz.correct-answer'),
incorrect: t('learn.quiz.incorrect-answer')
},
passingGrade: 85,
onSuccess: () => {
openCompletionModal();
setIsPassed(true);
openCompletionModal(), setIsPassed(true);
},
onFailure: () => setIsPassed(false)
});
const unanswered = quizData.reduce<number[]>(
(acc, curr, id) => (curr.selectedAnswer == null ? [...acc, id + 1] : acc),
[]
);
useEffect(() => {
initTests(tests);
updateChallengeMeta({
@@ -191,40 +219,85 @@ const ShowQuiz = ({
updateChallengeMeta
]);
const handleAnswersCheck = () => {
const handleFinishQuiz = () => {
setShowUnanswered(true);
if (unanswered.length === 0) {
openFinishQuizModal();
}
};
const handleFinishQuizModalBtnClick = () => {
validateAnswers();
setHasSubmitted(true);
closeFinishQuizModal();
};
const handleSubmitAndGo = () => {
openCompletionModal();
};
const handleExitQuiz = () => {
openExitQuizModal();
};
const handleExitQuizModalBtnClick = () => {
setExitConfirmed(true);
void navigate(blockHashSlug);
closeExitQuizModal();
};
const onWindowClose = useCallback(
(event: BeforeUnloadEvent) => {
event.preventDefault();
window.confirm(t('misc.navigation-warning'));
},
[t]
);
const onHistoryChange = useCallback(() => {
// We don't block navigation in the following cases.
// - When campers have submitted the quiz:
// - If they don't pass, the Finish Quiz button is disabled, there isn't anything for them to do other than leaving the page
// - If they pass, the Submit-and-go button shows up, and campers should be allowed to leave the page
// - When they have clicked the exit button on the exit modal
if (hasSubmitted || exitConfirmed) {
return;
}
void navigate(`${curLocation.pathname}`);
openExitQuizModal();
}, [curLocation.pathname, hasSubmitted, exitConfirmed, openExitQuizModal]);
usePageLeave({
onWindowClose,
onHistoryChange
});
function getErrorMessage() {
if (!hasSubmitted) return '';
const unansweredList = quizData.reduce<number[]>(
(acc, curr, id) => (curr.selectedAnswer == null ? [...acc, id + 1] : acc),
[]
);
if (unansweredList.length > 0) {
if (showUnanswered && unanswered.length > 0) {
return t('learn.quiz.unanswered-questions', {
unansweredQuestions: unansweredList.join(', ')
unansweredQuestions: unanswered.join(', ')
});
}
return t('learn.quiz.have-n-correct-questions', {
correctAnswerCount,
total: quiz.length
});
if (validated) {
// TODO: Update the message to include link(s) to the review materials
// if campers didn't pass the quiz.
return t('learn.quiz.have-n-correct-questions', {
correctAnswerCount,
total: quiz.length
});
}
return '';
}
const errorMessage = getErrorMessage();
return (
<Hotkeys
executeChallenge={!isPassed ? handleAnswersCheck : handleSubmitAndGo}
executeChallenge={!isPassed ? handleFinishQuiz : handleSubmitAndGo}
containerRef={container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
@@ -255,24 +328,17 @@ const ShowQuiz = ({
{errorMessage}
</div>
<Spacer size='m' />
{/*
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 ? (
<Button
block={true}
variant='primary'
onClick={handleAnswersCheck}
>
{t('buttons.check-answer')}
</Button>
<>
<Button
block={true}
variant='primary'
onClick={handleFinishQuiz}
disabled={hasSubmitted}
>
{t('buttons.finish-quiz')}
</Button>
</>
) : (
<Button
block={true}
@@ -282,11 +348,17 @@ const ShowQuiz = ({
{t('buttons.submit-and-go')}
</Button>
)}
<Spacer size='xxs' />
<Button block={true} variant='primary' onClick={handleExitQuiz}>
{t('buttons.exit-quiz')}
</Button>
<Spacer size='l' />
</Col>
<CompletionModal />
</Row>
</Container>
<CompletionModal />
<ExitQuizModal onExit={handleExitQuizModalBtnClick} />
<FinishQuizModal onFinish={handleFinishQuizModalBtnClick} />
</LearnLayout>
</Hotkeys>
);
@@ -307,6 +379,7 @@ export const query = graphql`
superBlock
block
fields {
blockHashSlug
blockName
slug
tests {

View File

@@ -43,6 +43,8 @@ const initialState = {
reset: false,
exitExam: false,
finishExam: false,
exitQuiz: false,
finishQuiz: false,
examResults: false,
survey: false,
projectPreview: false,

View File

@@ -37,6 +37,9 @@ export const isFinishExamModalOpenSelector = state =>
export const isSurveyModalOpenSelector = state => state[ns].modal.survey;
export const isExamResultsModalOpenSelector = state =>
state[ns].modal.examResults;
export const isExitQuizModalOpenSelector = state => state[ns].modal.exitQuiz;
export const isFinishQuizModalOpenSelector = state =>
state[ns].modal.finishQuiz;
export const isProjectPreviewModalOpenSelector = state =>
state[ns].modal.projectPreview;
export const isShortcutsModalOpenSelector = state => state[ns].modal.shortcuts;

137
e2e/quiz-challenge.spec.ts Normal file
View File

@@ -0,0 +1,137 @@
import { test, expect } from '@playwright/test';
test.describe('Quiz challenge', () => {
test.skip(
() => process.env.SHOW_UPCOMING_CHANGES !== 'true',
'The FSD superblock is not available if SHOW_UPCOMING_CHANGES is false'
);
test.use({ storageState: 'playwright/.auth/certified-user.json' });
test.beforeEach(async ({ page }) => {
await page.goto(
'/learn/full-stack-developer/quiz-basic-html/quiz-basic-html'
);
});
test('should display a list of unanswered questions if user has not answered all questions', async ({
page
}) => {
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
for (let i = 0; i < 15; i++) {
const radioGroups = await page.getByRole('radiogroup').all();
await radioGroups[i].getByRole('radio').first().click();
}
await page.getByRole('button', { name: 'Finish the quiz' }).click();
await expect(
page.getByText(
'The following questions are unanswered: 16, 17, 18, 19, 20. You must answer all questions.'
)
).toBeVisible();
});
test('should show a confirm finish modal when user clicks the finish button, and disable the quiz once they have confirmed', async ({
page
}) => {
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
const radioGroups = await page.getByRole('radiogroup').all();
for (const radioGroup of radioGroups) {
await radioGroup.getByRole('radio').first().click();
}
await page.getByRole('button', { name: 'Finish the quiz' }).click();
await expect(
page.getByRole('dialog', { name: 'Finish Quiz' })
).toBeVisible();
await page.getByRole('button', { name: 'Yes, I am finished' }).click();
await expect(
page.getByRole('dialog', { name: 'Finish Quiz' })
).toBeHidden();
const radios = await page.getByRole('radio').all();
for (const radio of radios) {
await expect(radio).toBeDisabled();
}
});
test('should show a confirm exit modal when user clicks on the exit button', async ({
page
}) => {
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
await page.getByRole('button', { name: 'Exit the quiz' }).click();
// The navigation should be blocked, the user should stay on the same page
await expect(page).toHaveURL(
'/learn/front-end-development/quiz-basic-html/quiz-basic-html'
);
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
await page
.getByRole('button', { name: 'No, I would like to continue the quiz' })
.click();
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeHidden();
await page.getByRole('button', { name: 'Exit the quiz' }).click();
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
await page
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
.click();
await page.waitForURL('/learn/front-end-development/#quiz-basic-html');
await expect(
page.getByRole('heading', { level: 3, name: 'Basic HTML Quiz' })
).toBeVisible();
});
test('should show a confirm exit modal when user clicks on a link', async ({
page
}) => {
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
await page.getByRole('link', { name: 'Basic HTML Quiz' }).click();
await expect(page.getByRole('dialog', { name: 'Exit Quiz' })).toBeVisible();
await page
.getByRole('button', { name: 'Yes, I want to leave the quiz' })
.click();
await page.waitForURL('/learn/front-end-development/#quiz-basic-html');
await expect(
page.getByRole('heading', { level: 3, name: 'Basic HTML Quiz' })
).toBeVisible();
});
test('should show a confirm exit modal when user closes the page', async ({
page,
browserName
}) => {
test.skip(
browserName === 'webkit' || browserName === 'chromium',
'This test is flaky on Chromium and WebKit'
);
// Wait for the page content to render
await expect(page.getByRole('radiogroup')).toHaveCount(20);
page.on('dialog', async dialog => {
expect(dialog.type()).toBe('beforeunload');
await dialog.dismiss();
});
await page.close({ runBeforeUnload: true });
});
});

43
pnpm-lock.yaml generated
View File

@@ -471,8 +471,8 @@ importers:
specifier: 1.1.0
version: 1.1.0(react@16.14.0)
'@freecodecamp/ui':
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)
specifier: 3.0.0
version: 3.0.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)
@@ -3090,8 +3090,8 @@ packages:
peerDependencies:
react: ^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
'@freecodecamp/ui@2.1.0':
resolution: {integrity: sha512-KL9QVvBlVjkls9BWTpsxNQdp88OmR+x9qE/jNvKcGRByFVsLXbAblb1cRcz8YXPPdB4vpImMT8Q3baxZTHn28Q==}
'@freecodecamp/ui@3.0.0':
resolution: {integrity: sha512-/Gqw8YLYgpugIJctyGuqTjN+Tp9zT4o126rbct9bISdgiCH0+qFkbksGoH/B2lVyFzHg8/Pl1i5t2jvuxMT19Q==}
engines: {node: '>=20', pnpm: '9'}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0
@@ -3571,6 +3571,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.1':
resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-direction@1.1.0':
resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==}
peerDependencies:
@@ -3589,8 +3598,8 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-presence@1.1.0':
resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==}
'@radix-ui/react-presence@1.1.1':
resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -3637,8 +3646,8 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tabs@1.1.0':
resolution: {integrity: sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==}
'@radix-ui/react-tabs@1.1.1':
resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -16976,13 +16985,13 @@ snapshots:
prop-types: 15.8.1
react: 16.14.0
'@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)':
'@freecodecamp/ui@3.0.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
'@fortawesome/react-fontawesome': 0.2.2(@fortawesome/fontawesome-svg-core@6.6.0)(react@16.14.0)
'@headlessui/react': 1.7.19(react-dom@16.14.0(react@16.14.0))(react@16.14.0)
'@radix-ui/react-tabs': 1.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)
'@radix-ui/react-tabs': 1.1.1(@types/react-dom@16.9.24)(@types/react@16.14.56)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)
babel-plugin-prismjs: 2.1.0(prismjs@1.29.0)
prismjs: 1.29.0
react: 16.14.0
@@ -17595,6 +17604,12 @@ snapshots:
optionalDependencies:
'@types/react': 16.14.56
'@radix-ui/react-context@1.1.1(@types/react@16.14.56)(react@16.14.0)':
dependencies:
react: 16.14.0
optionalDependencies:
'@types/react': 16.14.56
'@radix-ui/react-direction@1.1.0(@types/react@16.14.56)(react@16.14.0)':
dependencies:
react: 16.14.0
@@ -17608,7 +17623,7 @@ snapshots:
optionalDependencies:
'@types/react': 16.14.56
'@radix-ui/react-presence@1.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)':
'@radix-ui/react-presence@1.1.1(@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:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@16.14.56)(react@16.14.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@16.14.56)(react@16.14.0)
@@ -17651,13 +17666,13 @@ snapshots:
optionalDependencies:
'@types/react': 16.14.56
'@radix-ui/react-tabs@1.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)':
'@radix-ui/react-tabs@1.1.1(@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:
'@radix-ui/primitive': 1.1.0
'@radix-ui/react-context': 1.1.0(@types/react@16.14.56)(react@16.14.0)
'@radix-ui/react-context': 1.1.1(@types/react@16.14.56)(react@16.14.0)
'@radix-ui/react-direction': 1.1.0(@types/react@16.14.56)(react@16.14.0)
'@radix-ui/react-id': 1.1.0(@types/react@16.14.56)(react@16.14.0)
'@radix-ui/react-presence': 1.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)
'@radix-ui/react-presence': 1.1.1(@types/react-dom@16.9.24)(@types/react@16.14.56)(react-dom@16.14.0(react@16.14.0))(react@16.14.0)
'@radix-ui/react-primitive': 2.0.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)
'@radix-ui/react-roving-focus': 1.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)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@16.14.56)(react@16.14.0)