diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index faadeed5b05..538c89d1067 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/package.json b/client/package.json index 1dc7b98d3f0..226eb94738f 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/templates/Challenges/hooks/index.ts b/client/src/templates/Challenges/hooks/index.ts new file mode 100644 index 00000000000..c2fd2e19ad1 --- /dev/null +++ b/client/src/templates/Challenges/hooks/index.ts @@ -0,0 +1 @@ +export { usePageLeave } from './use-page-leave'; diff --git a/client/src/templates/Challenges/hooks/use-page-leave.ts b/client/src/templates/Challenges/hooks/use-page-leave.ts new file mode 100644 index 00000000000..4a0ddec8e8e --- /dev/null +++ b/client/src/templates/Challenges/hooks/use-page-leave.ts @@ -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]); +}; diff --git a/client/src/templates/Challenges/quiz/exit-quiz-modal.tsx b/client/src/templates/Challenges/quiz/exit-quiz-modal.tsx new file mode 100644 index 00000000000..c2bf4877811 --- /dev/null +++ b/client/src/templates/Challenges/quiz/exit-quiz-modal.tsx @@ -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 ( + + + {t('learn.quiz.exit-modal-header')} + + + {t('learn.quiz.exit-modal-body')} + + + + + + + + ); +}; + +ExitQuizModal.displayName = 'ExitQuizModal'; + +export default connect(mapStateToProps, mapDispatchToProps)(ExitQuizModal); diff --git a/client/src/templates/Challenges/quiz/finish-quiz-modal.tsx b/client/src/templates/Challenges/quiz/finish-quiz-modal.tsx new file mode 100644 index 00000000000..b0afb1cd043 --- /dev/null +++ b/client/src/templates/Challenges/quiz/finish-quiz-modal.tsx @@ -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 ( + + + {t('learn.quiz.finish-modal-header')} + + + {t('learn.quiz.finish-modal-body')} + + + + + + + + ); +}; + +FinishQuizModal.displayName = 'FinishQuizModal'; + +export default connect(mapStateToProps, mapDispatchToProps)(FinishQuizModal); diff --git a/client/src/templates/Challenges/quiz/show.tsx b/client/src/templates/Challenges/quiz/show.tsx index 7c828a49e5a..29d61963c53 100644 --- a/client/src/templates/Challenges/quiz/show.tsx +++ b/client/src/templates/Challenges/quiz/show.tsx @@ -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(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( + (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( - (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 ( - {/* - 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 ? ( - + <> + + ) : ( - + + + ); @@ -307,6 +379,7 @@ export const query = graphql` superBlock block fields { + blockHashSlug blockName slug tests { diff --git a/client/src/templates/Challenges/redux/index.js b/client/src/templates/Challenges/redux/index.js index 2dbf477d233..0e331c87491 100644 --- a/client/src/templates/Challenges/redux/index.js +++ b/client/src/templates/Challenges/redux/index.js @@ -43,6 +43,8 @@ const initialState = { reset: false, exitExam: false, finishExam: false, + exitQuiz: false, + finishQuiz: false, examResults: false, survey: false, projectPreview: false, diff --git a/client/src/templates/Challenges/redux/selectors.js b/client/src/templates/Challenges/redux/selectors.js index cab9c97eabc..ea893c2ad9e 100644 --- a/client/src/templates/Challenges/redux/selectors.js +++ b/client/src/templates/Challenges/redux/selectors.js @@ -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; diff --git a/e2e/quiz-challenge.spec.ts b/e2e/quiz-challenge.spec.ts new file mode 100644 index 00000000000..fb6509da010 --- /dev/null +++ b/e2e/quiz-challenge.spec.ts @@ -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 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e99602d15d4..e6bd9a5e67e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)