mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-02-19 13:00:32 -05:00
feat(client): handle quiz finish and exit (#56644)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
client/src/templates/Challenges/hooks/index.ts
Normal file
1
client/src/templates/Challenges/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { usePageLeave } from './use-page-leave';
|
||||
32
client/src/templates/Challenges/hooks/use-page-leave.ts
Normal file
32
client/src/templates/Challenges/hooks/use-page-leave.ts
Normal 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]);
|
||||
};
|
||||
58
client/src/templates/Challenges/quiz/exit-quiz-modal.tsx
Normal file
58
client/src/templates/Challenges/quiz/exit-quiz-modal.tsx
Normal 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);
|
||||
59
client/src/templates/Challenges/quiz/finish-quiz-modal.tsx
Normal file
59
client/src/templates/Challenges/quiz/finish-quiz-modal.tsx
Normal 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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,8 @@ const initialState = {
|
||||
reset: false,
|
||||
exitExam: false,
|
||||
finishExam: false,
|
||||
exitQuiz: false,
|
||||
finishQuiz: false,
|
||||
examResults: false,
|
||||
survey: false,
|
||||
projectPreview: false,
|
||||
|
||||
@@ -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
137
e2e/quiz-challenge.spec.ts
Normal 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
43
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user