mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
472 lines
13 KiB
TypeScript
472 lines
13 KiB
TypeScript
import { graphql } from 'gatsby';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import Helmet from 'react-helmet';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { connect } from 'react-redux';
|
|
import { Container, Col, Row, Button, Spacer } from '@freecodecamp/ui';
|
|
import { isEqual } from 'lodash';
|
|
import store from 'store';
|
|
import { YouTubeEvent } from 'react-youtube';
|
|
import { ObserveKeys } from 'react-hotkeys';
|
|
|
|
// Local Utilities
|
|
import PrismFormatted from '../components/prism-formatted';
|
|
import LearnLayout from '../../../components/layouts/learn';
|
|
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
|
|
import ChallengeDescription from '../components/challenge-description';
|
|
import InteractiveEditor from '../components/interactive-editor';
|
|
import ActionRow from '../classic/action-row';
|
|
import Hotkeys from '../components/hotkeys';
|
|
import ChallengeTitle from '../components/challenge-title';
|
|
import VideoPlayer from '../components/video-player';
|
|
import CompletionModal from '../components/completion-modal';
|
|
import Assignments from '../components/assignments';
|
|
import {
|
|
challengeMounted,
|
|
updateChallengeMeta,
|
|
openModal,
|
|
updateSolutionFormValues,
|
|
initTests
|
|
} from '../redux/actions';
|
|
import { isChallengeCompletedSelector } from '../redux/selectors';
|
|
import { getChallengePaths } from '../utils/challenge-paths';
|
|
import Scene from '../components/scene/scene';
|
|
import MultipleChoiceQuestions from '../components/multiple-choice-questions';
|
|
import ChallengeExplanation from '../components/challenge-explanation';
|
|
import ChallengeTranscript from '../components/challenge-transcript';
|
|
import HelpModal from '../components/help-modal';
|
|
import { SceneSubject } from '../components/scene/scene-subject';
|
|
|
|
// Styles
|
|
import './show.css';
|
|
import '../video.css';
|
|
|
|
// Redux Setup
|
|
const mapStateToProps = (state: unknown) => ({
|
|
isChallengeCompleted: isChallengeCompletedSelector(state)
|
|
});
|
|
|
|
const mapDispatchToProps = {
|
|
initTests,
|
|
updateChallengeMeta,
|
|
challengeMounted,
|
|
updateSolutionFormValues,
|
|
openCompletionModal: () => openModal('completion'),
|
|
openHelpModal: () => openModal('help')
|
|
};
|
|
|
|
// Types
|
|
interface ShowQuizProps {
|
|
challengeMounted: (arg0: string) => void;
|
|
data: { challengeNode: ChallengeNode };
|
|
description: string;
|
|
initTests: (xs: Test[]) => void;
|
|
isChallengeCompleted: boolean;
|
|
openCompletionModal: () => void;
|
|
openHelpModal: () => void;
|
|
pageContext: {
|
|
challengeMeta: ChallengeMeta;
|
|
};
|
|
updateChallengeMeta: (arg0: ChallengeMeta) => void;
|
|
updateSolutionFormValues: () => void;
|
|
}
|
|
|
|
function renderNodule(
|
|
nodule: ChallengeNode['challenge']['nodules'][number],
|
|
showInteractiveEditor: boolean
|
|
) {
|
|
switch (nodule.type) {
|
|
case 'paragraph':
|
|
return <PrismFormatted text={nodule.contents} />;
|
|
case 'interactiveEditor':
|
|
if (showInteractiveEditor) {
|
|
return <InteractiveEditor files={nodule.files} />;
|
|
} else {
|
|
const { files } = nodule;
|
|
return files.map((file, index) => (
|
|
<PrismFormatted key={index} text={file.contentsHtml} />
|
|
));
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const ShowGeneric = ({
|
|
challengeMounted,
|
|
data: {
|
|
challengeNode: {
|
|
challenge: {
|
|
assignments,
|
|
bilibiliIds,
|
|
block,
|
|
description,
|
|
nodules,
|
|
explanation,
|
|
challengeType,
|
|
helpCategory,
|
|
instructions,
|
|
questions,
|
|
tests,
|
|
title,
|
|
transcript,
|
|
translationPending,
|
|
scene,
|
|
superBlock,
|
|
videoId,
|
|
videoLocaleIds
|
|
}
|
|
}
|
|
},
|
|
pageContext: { challengeMeta },
|
|
initTests,
|
|
updateChallengeMeta,
|
|
openCompletionModal,
|
|
openHelpModal,
|
|
isChallengeCompleted
|
|
}: ShowQuizProps) => {
|
|
const { t } = useTranslation();
|
|
const container = useRef<HTMLElement | null>(null);
|
|
|
|
const blockNameTitle = `${t(
|
|
`intro:${superBlock}.blocks.${block}.title`
|
|
)} - ${title}`;
|
|
|
|
useEffect(() => {
|
|
initTests(tests);
|
|
const challengePaths = getChallengePaths({
|
|
currentCurriculumPaths: challengeMeta
|
|
});
|
|
updateChallengeMeta({
|
|
...challengeMeta,
|
|
title,
|
|
challengeType,
|
|
helpCategory,
|
|
...challengePaths
|
|
});
|
|
challengeMounted(challengeMeta.id);
|
|
// hack to ensure the container is focused after the component mounts
|
|
// and Gatsby doesn't interfere with the focus.
|
|
requestAnimationFrame(() => container.current?.focus());
|
|
// This effect should be run once on mount
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// video
|
|
const [videoIsLoaded, setVideoIsLoaded] = useState(false);
|
|
|
|
const handleVideoIsLoaded = (e: YouTubeEvent) => {
|
|
const playbackRate = Number(store.get('fcc-yt-playback-rate')) || 1;
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const player = e.target;
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
player.setPlaybackRate(playbackRate);
|
|
|
|
setVideoIsLoaded(true);
|
|
};
|
|
|
|
// assignments
|
|
const [assignmentsCompleted, setAssignmentsCompleted] = useState(0);
|
|
const allAssignmentsCompleted = assignmentsCompleted === assignments.length;
|
|
|
|
const handleAssignmentChange = (
|
|
event: React.ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const isCompleted = event.target.checked; // extract value before target is nullified
|
|
setAssignmentsCompleted(a => (isCompleted ? a + 1 : a - 1));
|
|
};
|
|
|
|
// multiple choice questions
|
|
const [selectedMcqOptions, setSelectedMcqOptions] = useState(
|
|
questions.map<number | null>(() => null)
|
|
);
|
|
const [submittedMcqAnswers, setSubmittedMcqAnswers] = useState(
|
|
questions.map<number | null>(() => null)
|
|
);
|
|
|
|
const [hasAnsweredMcqCorrectly, sethasAnsweredMcqCorrectly] = useState(true);
|
|
|
|
const [showFeedback, setShowFeedback] = useState(false);
|
|
|
|
const handleMcqOptionChange = (
|
|
questionIndex: number,
|
|
answerIndex: number
|
|
): void => {
|
|
setSelectedMcqOptions(prev =>
|
|
prev.map((option, index) =>
|
|
index === questionIndex ? answerIndex : option
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const hasCompletedAssignments =
|
|
assignments.length === 0 || allAssignmentsCompleted;
|
|
const mcqSolutions = questions.map(question => question.solution - 1);
|
|
const mcqCorrect = isEqual(mcqSolutions, selectedMcqOptions);
|
|
|
|
setSubmittedMcqAnswers(selectedMcqOptions);
|
|
setShowFeedback(true);
|
|
if (hasCompletedAssignments && mcqCorrect) {
|
|
openCompletionModal();
|
|
}
|
|
|
|
if (mcqSolutions.length > selectedMcqOptions.length || !mcqCorrect) {
|
|
sethasAnsweredMcqCorrectly(false);
|
|
} else {
|
|
sethasAnsweredMcqCorrectly(true);
|
|
}
|
|
};
|
|
|
|
const sceneSubject = new SceneSubject();
|
|
|
|
// interactive editor
|
|
const hasInteractiveEditor = nodules?.some(
|
|
nodule => nodule.type === 'interactiveEditor'
|
|
);
|
|
|
|
const [showInteractiveEditor, setShowInteractiveEditor] = useState(
|
|
() => !!store.get('showInteractiveEditor')
|
|
);
|
|
|
|
const toggleInteractiveEditor = () => {
|
|
store.set('showInteractiveEditor', !showInteractiveEditor);
|
|
setShowInteractiveEditor(!showInteractiveEditor);
|
|
};
|
|
|
|
return (
|
|
<Hotkeys
|
|
executeChallenge={handleSubmit}
|
|
containerRef={container}
|
|
playScene={scene ? () => sceneSubject.notify('play') : undefined}
|
|
>
|
|
<LearnLayout>
|
|
<Helmet
|
|
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
|
|
/>
|
|
<Container fluid>
|
|
{hasInteractiveEditor && (
|
|
<ActionRow
|
|
hasInteractiveEditor={hasInteractiveEditor}
|
|
showInteractiveEditor={showInteractiveEditor}
|
|
toggleInteractiveEditor={toggleInteractiveEditor}
|
|
/>
|
|
)}
|
|
|
|
<Container>
|
|
<Row>
|
|
<Spacer size='m' />
|
|
<ChallengeTitle
|
|
isCompleted={isChallengeCompleted}
|
|
translationPending={translationPending}
|
|
>
|
|
{title}
|
|
</ChallengeTitle>
|
|
|
|
<Spacer size='m' />
|
|
|
|
{description && (
|
|
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
|
<ChallengeDescription
|
|
description={description}
|
|
superBlock={superBlock}
|
|
/>
|
|
<Spacer size='m' />
|
|
</Col>
|
|
)}
|
|
|
|
{nodules?.map((nodule, i) => {
|
|
return (
|
|
<React.Fragment key={i}>
|
|
{renderNodule(nodule, showInteractiveEditor)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
|
|
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
|
|
{videoId && (
|
|
<>
|
|
<VideoPlayer
|
|
bilibiliIds={bilibiliIds}
|
|
onVideoLoad={handleVideoIsLoaded}
|
|
title={title}
|
|
videoId={videoId}
|
|
videoIsLoaded={videoIsLoaded}
|
|
videoLocaleIds={videoLocaleIds}
|
|
/>
|
|
<Spacer size='m' />
|
|
</>
|
|
)}
|
|
</Col>
|
|
|
|
{scene && <Scene scene={scene} sceneSubject={sceneSubject} />}
|
|
|
|
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
|
|
{transcript && <ChallengeTranscript transcript={transcript} />}
|
|
|
|
{instructions && (
|
|
<>
|
|
<ChallengeDescription
|
|
instructions={instructions}
|
|
superBlock={superBlock}
|
|
/>
|
|
<Spacer size='m' />
|
|
</>
|
|
)}
|
|
|
|
{assignments.length > 0 && (
|
|
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
|
|
<Assignments
|
|
assignments={assignments}
|
|
allAssignmentsCompleted={allAssignmentsCompleted}
|
|
handleAssignmentChange={handleAssignmentChange}
|
|
/>
|
|
</ObserveKeys>
|
|
)}
|
|
|
|
{questions.length > 0 && (
|
|
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
|
|
<MultipleChoiceQuestions
|
|
questions={questions}
|
|
selectedOptions={selectedMcqOptions}
|
|
handleOptionChange={handleMcqOptionChange}
|
|
submittedMcqAnswers={submittedMcqAnswers}
|
|
showFeedback={showFeedback}
|
|
superBlock={superBlock}
|
|
/>
|
|
</ObserveKeys>
|
|
)}
|
|
|
|
{explanation ? (
|
|
<ChallengeExplanation explanation={explanation} />
|
|
) : null}
|
|
|
|
{!hasAnsweredMcqCorrectly && (
|
|
<p className='text-center'>{t('learn.answered-mcq')}</p>
|
|
)}
|
|
|
|
<Button block={true} variant='primary' onClick={handleSubmit}>
|
|
{questions.length == 0
|
|
? t('buttons.submit')
|
|
: t('buttons.check-answer')}
|
|
</Button>
|
|
<Spacer size='xxs' />
|
|
<Button block={true} variant='primary' onClick={openHelpModal}>
|
|
{t('buttons.ask-for-help')}
|
|
</Button>
|
|
|
|
<Spacer size='l' />
|
|
</Col>
|
|
<CompletionModal />
|
|
<HelpModal
|
|
challengeTitle={title}
|
|
challengeBlock={block}
|
|
superBlock={superBlock}
|
|
/>
|
|
</Row>
|
|
</Container>
|
|
</Container>
|
|
</LearnLayout>
|
|
</Hotkeys>
|
|
);
|
|
};
|
|
|
|
ShowGeneric.displayName = 'ShowGeneric';
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(ShowGeneric);
|
|
|
|
export const query = graphql`
|
|
query GenericChallenge($id: String!) {
|
|
challengeNode(id: { eq: $id }) {
|
|
challenge {
|
|
assignments
|
|
bilibiliIds {
|
|
aid
|
|
bvid
|
|
cid
|
|
}
|
|
block
|
|
challengeType
|
|
description
|
|
nodules {
|
|
type
|
|
contents
|
|
files {
|
|
ext
|
|
name
|
|
contents
|
|
contentsHtml
|
|
}
|
|
}
|
|
explanation
|
|
helpCategory
|
|
instructions
|
|
fields {
|
|
slug
|
|
}
|
|
questions {
|
|
text
|
|
answers {
|
|
answer
|
|
feedback
|
|
audioId
|
|
}
|
|
solution
|
|
}
|
|
scene {
|
|
setup {
|
|
background
|
|
characters {
|
|
character
|
|
position {
|
|
x
|
|
y
|
|
z
|
|
}
|
|
opacity
|
|
}
|
|
audio {
|
|
filename
|
|
startTime
|
|
startTimestamp
|
|
finishTimestamp
|
|
}
|
|
alwaysShowDialogue
|
|
}
|
|
commands {
|
|
background
|
|
character
|
|
position {
|
|
x
|
|
y
|
|
z
|
|
}
|
|
opacity
|
|
startTime
|
|
finishTime
|
|
dialogue {
|
|
text
|
|
align
|
|
}
|
|
}
|
|
}
|
|
superBlock
|
|
tests {
|
|
text
|
|
testString
|
|
}
|
|
title
|
|
transcript
|
|
translationPending
|
|
videoId
|
|
videoId
|
|
videoLocaleIds {
|
|
espanol
|
|
italian
|
|
portuguese
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|