refactor(client/challenge views): extract items into sharable components (#55946)

pull/56027/head
Tom 2024-09-06 05:16:26 -05:00 committed by GitHub
parent 8d0550d76c
commit 3074bb199d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 318 additions and 334 deletions

View File

@ -385,7 +385,7 @@
"add-subtitles": "Help improve or add subtitles",
"wrong-answer": "Sorry, that's not the right answer. Give it another try?",
"check-answer": "Click the button below to check your answer.",
"assignment-not-complete": "Please finish the assignments",
"assignment-not-complete": "Please complete the assignments",
"assignments": "Assignments",
"question": "Question",
"solution-link": "Solution Link",

View File

@ -0,0 +1,4 @@
.assignments-not-complete {
text-align: center;
color: var(--danger-color);
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import Spacer from '../../../components/helpers/spacer';
import ChallengeHeading from './challenge-heading';
import PrismFormatted from './prism-formatted';
import './assignments.css';
type AssignmentsProps = {
assignments: string[];
allAssignmentsCompleted: boolean;
handleAssignmentChange: (
event: React.ChangeEvent<HTMLInputElement>,
totalAssignments: number
) => void;
};
function Assignments({
assignments,
allAssignmentsCompleted,
handleAssignmentChange
}: AssignmentsProps): JSX.Element {
const { t } = useTranslation();
return (
<>
<ChallengeHeading heading={t('learn.assignments')} />
<div className='video-quiz-options'>
{assignments.map((assignment, index) => (
<label className='video-quiz-option-label' key={index}>
<input
name='assignment'
type='checkbox'
onChange={event =>
handleAssignmentChange(event, assignments.length)
}
/>
<PrismFormatted className={'video-quiz-option'} text={assignment} />
<Spacer size='medium' />
</label>
))}
</div>
{!allAssignmentsCompleted && (
<>
<Spacer size='medium' />
<div className='assignments-not-complete'>
{t('learn.assignment-not-complete')}
</div>
</>
)}
<Spacer size='medium' />
</>
);
}
Assignments.displayName = 'Assignments';
export default Assignments;

View File

@ -0,0 +1,87 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { parseBlanks } from '../fill-in-the-blank/parse-blanks';
import Spacer from '../../../components/helpers/spacer';
import PrismFormatted from '../components/prism-formatted';
import { FillInTheBlank } from '../../../redux/prop-types';
import ChallengeHeading from './challenge-heading';
type FillInTheBlankProps = {
fillInTheBlank: FillInTheBlank;
answersCorrect: (boolean | null)[];
showFeedback: boolean;
feedback: string | null;
showWrong: boolean;
handleInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
function FillInTheBlanks({
fillInTheBlank: { sentence, blanks },
answersCorrect,
showFeedback,
feedback,
showWrong,
handleInputChange
}: FillInTheBlankProps): JSX.Element {
const { t } = useTranslation();
const addInputClass = (index: number): string => {
if (answersCorrect[index] === true) return 'green-underline';
if (answersCorrect[index] === false) return 'red-underline';
return '';
};
const paragraphs = parseBlanks(sentence);
const blankAnswers = blanks.map(b => b.answer);
return (
<>
<ChallengeHeading heading={t('learn.fill-in-the-blank')} />
<Spacer size='small' />
<div className='fill-in-the-blank-wrap'>
{paragraphs.map((p, i) => {
return (
// both keys, i and j, are stable between renders, since
// the paragraphs are static.
<p key={i}>
{p.map((node, j) => {
const { type, value } = node;
if (type === 'text') return value;
if (type === 'blank')
return (
<input
key={j}
type='text'
maxLength={blankAnswers[value].length + 3}
className={`fill-in-the-blank-input ${addInputClass(
value
)}`}
onChange={handleInputChange}
data-index={node.value}
size={blankAnswers[value].length}
aria-label={t('learn.blank')}
/>
);
})}
</p>
);
})}
</div>
<Spacer size='medium' />
{showFeedback && feedback && (
<>
<PrismFormatted text={feedback} />
<Spacer size='medium' />
</>
)}
<div className='text-center'>
{showWrong && <span>{t('learn.wrong-answer')}</span>}
</div>
</>
);
}
FillInTheBlanks.displayName = 'FillInTheBlanks';
export default FillInTheBlanks;

View File

@ -0,0 +1,85 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Question } from '../../../redux/prop-types';
import Spacer from '../../../components/helpers/spacer';
import ChallengeHeading from './challenge-heading';
import PrismFormatted from './prism-formatted';
type MultipleChoiceQuestionsProps = {
questions: Question;
selectedOption: number | null;
isWrongAnswer: boolean;
handleOptionChange: (
changeEvent: React.ChangeEvent<HTMLInputElement>
) => void;
};
function MultipleChoiceQuestions({
questions: { text, answers },
selectedOption,
isWrongAnswer,
handleOptionChange
}: MultipleChoiceQuestionsProps): JSX.Element {
const { t } = useTranslation();
const feedback =
selectedOption !== null ? answers[selectedOption].feedback : undefined;
return (
<>
<ChallengeHeading heading={t('learn.question')} />
<PrismFormatted className={'line-numbers'} text={text} />
<div className='video-quiz-options'>
{answers.map(({ answer }, index) => (
<label
className='video-quiz-option-label'
key={index}
htmlFor={`mc-question-${index}`}
>
<input
name='quiz'
checked={selectedOption === index}
className='sr-only'
onChange={handleOptionChange}
type='radio'
value={index}
id={`mc-question-${index}`}
/>{' '}
<span className='video-quiz-input-visible'>
{selectedOption === index ? (
<span className='video-quiz-selected-input' />
) : null}
</span>
<PrismFormatted
className={'video-quiz-option'}
text={answer.replace(/^<p>|<\/p>$/g, '')}
useSpan
noAria
/>
</label>
))}
</div>
{isWrongAnswer && (
<>
<Spacer size='medium' />
<div className='text-center'>
{feedback ? (
<PrismFormatted
className={'multiple-choice-feedback'}
text={feedback}
/>
) : (
t('learn.wrong-answer')
)}
</div>
</>
)}
<Spacer size='medium' />
</>
);
}
MultipleChoiceQuestions.displayName = 'MultipleChoiceQuestions';
export default MultipleChoiceQuestions;

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; //, ReactElement } f
import { Col } from '@freecodecamp/ui';
import { useTranslation } from 'react-i18next';
import { FullScene } from '../../../../redux/prop-types';
import { Loader } from '../../../../components/helpers';
import { Loader, Spacer } from '../../../../components/helpers';
import ClosedCaptionsIcon from '../../../../assets/icons/closedcaptions';
import { sounds, images, backgrounds, characterAssets } from './scene-assets';
import Character from './character';
@ -283,6 +283,7 @@ export function Scene({
</>
)}
</div>
<Spacer size='medium' />
</Col>
);
}

View File

@ -1,5 +1,7 @@
import React from 'react';
import YouTube from 'react-youtube';
import Loader from '../../../components/helpers/loader';
import envData from '../../../../config/env.json';
import type { BilibiliIds, VideoLocaleIds } from '../../../redux/prop-types';
@ -47,7 +49,13 @@ function VideoPlayer({
}
return (
<>
<div className='video-wrapper'>
{!videoIsLoaded ? (
<div className='video-placeholder-loader'>
<Loader />
</div>
) : null}
{bilibiliSrc ? (
<iframe
frameBorder='no'
@ -71,7 +79,7 @@ function VideoPlayer({
videoId={videoId}
/>
)}
</>
</div>
);
}

View File

@ -19,7 +19,6 @@ import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import Hotkeys from '../components/hotkeys';
import CompletionModal from '../components/completion-modal';
import ChallengeTitle from '../components/challenge-title';
import ChallengeHeading from '../components/challenge-heading';
import HelpModal from '../components/help-modal';
import PrismFormatted from '../components/prism-formatted';
import {
@ -30,6 +29,7 @@ import {
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
import Scene from '../components/scene/scene';
import Assignments from '../components/assignments';
// Styles
import '../odin/show.css';
@ -256,45 +256,12 @@ class ShowDialogue extends Component<ShowDialogueProps, ShowDialogueState> {
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='medium' />
<ObserveKeys>
<ChallengeHeading heading={t('learn.assignments')} />
<div className='video-quiz-options'>
{assignments.map((assignment, index) => (
<label className='video-quiz-option-label' key={index}>
<input
name='assignment'
type='checkbox'
onChange={event =>
this.handleAssignmentChange(
event,
assignments.length
)
}
/>
<PrismFormatted
className={'video-quiz-option'}
text={assignment}
/>
<Spacer size='medium' />
</label>
))}
</div>
<Spacer size='medium' />
<Assignments
assignments={assignments}
allAssignmentsCompleted={this.state.allAssignmentsCompleted}
handleAssignmentChange={this.handleAssignmentChange}
/>
</ObserveKeys>
<div
style={{
textAlign: 'center'
}}
>
{!this.state.allAssignmentsCompleted &&
assignments.length > 0 && (
<>
<br />
<span>{t('learn.assignment-not-complete')}</span>
</>
)}
</div>
<Spacer size='medium' />
<Button
block={true}

View File

@ -36,25 +36,6 @@
z-index: 2;
}
.code-tag code {
font-size: 100%;
z-index: 0;
position: relative;
}
.first-code-tag code {
border-right: none;
}
.middle-code-tag code {
border-left: none;
border-right: none;
}
.last-code-tag code {
border-left: none;
}
.green-underline {
border-bottom-color: var(--success-background) !important;
}

View File

@ -1,6 +1,6 @@
// Package Utilities
import { graphql } from 'gatsby';
import React, { Component, Fragment } from 'react';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
import { ObserveKeys } from 'react-hotkeys';
import type { TFunction } from 'i18next';
@ -18,9 +18,9 @@ import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import Hotkeys from '../components/hotkeys';
import ChallengeTitle from '../components/challenge-title';
import ChallengeHeading from '../components/challenge-heading';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import FillInTheBlanks from '../components/fill-in-the-blanks';
import PrismFormatted from '../components/prism-formatted';
import {
challengeMounted,
@ -31,7 +31,6 @@ import {
} from '../redux/actions';
import Scene from '../components/scene/scene';
import { isChallengeCompletedSelector } from '../redux/selectors';
import { parseBlanks } from './parse-blanks';
// Styles
import '../video.css';
@ -237,25 +236,6 @@ class ShowFillInTheBlank extends Component<
});
};
addCodeTags(str: string, index: number, numberOfBlanks: number): string {
if (index === 0) return `${str}</code>`;
if (index < numberOfBlanks) return `<code>${str}</code>`;
return `<code>${str}`;
}
addPrismClass(index: number, numberOfBlanks: number): string {
if (index === 0) return `first-code-tag`;
if (index < numberOfBlanks) return `middle-code-tag`;
return `last-code-tag`;
}
addInputClass(index: number): string {
const { answersCorrect } = this.state;
if (answersCorrect[index] === true) return 'green-underline';
if (answersCorrect[index] === false) return 'red-underline';
return '';
}
setIsScenePlaying = (shouldPlay: boolean) => {
this.setState({
isScenePlaying: shouldPlay
@ -274,7 +254,7 @@ class ShowFillInTheBlank extends Component<
block,
translationPending,
fields: { blockName },
fillInTheBlank: { sentence, blanks },
fillInTheBlank,
scene
}
}
@ -292,9 +272,6 @@ class ShowFillInTheBlank extends Component<
)} - ${title}`;
const { allBlanksFilled, feedback, showFeedback, showWrong } = this.state;
const paragraphs = parseBlanks(sentence);
const blankAnswers = blanks.map(b => b.answer);
return (
<Hotkeys
@ -324,78 +301,34 @@ class ShowFillInTheBlank extends Component<
</Col>
{scene && (
<>
<Scene
scene={scene}
isPlaying={this.state.isScenePlaying}
setIsPlaying={this.setIsScenePlaying}
/>
<Spacer size='medium' />
</>
<Scene
scene={scene}
isPlaying={this.state.isScenePlaying}
setIsPlaying={this.setIsScenePlaying}
/>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeHeading heading={t('learn.fill-in-the-blank')} />
<Spacer size='small' />
{instructions && (
<>
<PrismFormatted text={instructions} />
<Spacer size='small' />
</>
)}
{/* what we want to observe is ctrl/cmd + enter, but ObserveKeys is buggy and throws an error
if it encounters a key combination, so we have to pass in the individual keys to observe */}
<ObserveKeys only={['ctrl', 'cmd', 'enter']}>
<div className='fill-in-the-blank-wrap'>
{paragraphs.map((p, i) => {
return (
// both keys, i and j, are stable between renders, since
// the paragraphs are static.
<p key={i}>
{p.map((node, j) => {
if (node.type === 'text') return node.value;
if (node.type === 'blank')
return (
<input
key={j}
type='text'
maxLength={
blankAnswers[node.value].length + 3
}
className={`fill-in-the-blank-input ${this.addInputClass(
node.value
)}`}
onChange={this.handleInputChange}
data-index={node.value}
size={blankAnswers[node.value].length}
aria-label={t('learn.blank')}
/>
);
})}
</p>
);
})}
</div>
<FillInTheBlanks
fillInTheBlank={fillInTheBlank}
answersCorrect={this.state.answersCorrect}
showFeedback={showFeedback}
feedback={feedback}
showWrong={showWrong}
handleInputChange={this.handleInputChange}
/>
</ObserveKeys>
<Spacer size='medium' />
{showFeedback && feedback && (
<>
<PrismFormatted text={feedback} />
<Spacer size='small' />
</>
)}
<div
style={{
textAlign: 'center'
}}
>
{showWrong ? (
<span>{t('learn.wrong-answer')}</span>
) : (
<span>{t('learn.check-answer')}</span>
)}
</div>
<Spacer size='medium' />
<Button
block={true}
variant='primary'

View File

@ -13,7 +13,6 @@ import { Container, Col, Row, Button } from '@freecodecamp/ui';
import ShortcutsModal from '../components/shortcuts-modal';
// Local Utilities
import Loader from '../../../components/helpers/loader';
import Spacer from '../../../components/helpers/spacer';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
@ -24,7 +23,8 @@ import HelpModal from '../components/help-modal';
import Scene from '../components/scene/scene';
import PrismFormatted from '../components/prism-formatted';
import ChallengeTitle from '../components/challenge-title';
import ChallengeHeading from '../components/challenge-heading';
import MultipleChoiceQuestions from '../components/multiple-choice-questions';
import Assignments from '../components/assignments';
import {
challengeMounted,
updateChallengeMeta,
@ -236,7 +236,7 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
videoLocaleIds,
bilibiliIds,
fields: { blockName },
question: { text, answers, solution },
question,
assignments,
translationPending,
scene
@ -256,10 +256,7 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
`intro:${superBlock}.blocks.${block}.title`
)} - ${title}`;
const feedback =
this.state.selectedOption !== null
? answers[this.state.selectedOption].feedback
: undefined;
const { solution } = question;
return (
<Hotkeys
@ -280,23 +277,17 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
{videoId && (
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
<Spacer size='medium' />
<div className='video-wrapper'>
{!this.state.videoIsLoaded ? (
<div className='video-placeholder-loader'>
<Loader />
</div>
) : null}
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={this.onVideoLoad}
title={title}
videoId={videoId}
videoIsLoaded={this.state.videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
</div>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={this.onVideoLoad}
title={title}
videoId={videoId}
videoIsLoaded={this.state.videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
</Col>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='medium' />
<ChallengeTitle
@ -310,104 +301,33 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
</Col>
{scene && (
<>
<Scene
scene={scene}
isPlaying={this.state.isScenePlaying}
setIsPlaying={this.setIsScenePlaying}
/>{' '}
<Spacer size='medium' />
</>
<Scene
scene={scene}
isPlaying={this.state.isScenePlaying}
setIsPlaying={this.setIsScenePlaying}
/>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ObserveKeys>
{assignments.length > 0 && (
<>
<ChallengeHeading heading={t('learn.assignments')} />
<div className='video-quiz-options'>
{assignments.map((assignment, index) => (
<label
className='video-quiz-option-label'
key={index}
>
<input
name='assignment'
type='checkbox'
onChange={event =>
this.handleAssignmentChange(
event,
assignments.length
)
}
/>
<PrismFormatted
className={'video-quiz-option'}
text={assignment}
/>
<Spacer size='medium' />
</label>
))}
</div>{' '}
<Spacer size='medium' />
</>
<Assignments
assignments={assignments}
allAssignmentsCompleted={
this.state.allAssignmentsCompleted
}
handleAssignmentChange={this.handleAssignmentChange}
/>
)}
<ChallengeHeading heading={t('learn.question')} />
<PrismFormatted className={'line-numbers'} text={text} />
<div className='video-quiz-options'>
{answers.map(({ answer }, index) => (
<label className='video-quiz-option-label' key={index}>
<input
aria-label={t('aria.answer')}
checked={this.state.selectedOption === index}
className='sr-only'
name='quiz'
onChange={this.handleOptionChange}
type='radio'
value={index}
/>{' '}
<span className='video-quiz-input-visible'>
{this.state.selectedOption === index ? (
<span className='video-quiz-selected-input' />
) : null}
</span>
<PrismFormatted
className={'video-quiz-option'}
text={answer}
/>
</label>
))}
</div>
<MultipleChoiceQuestions
questions={question}
selectedOption={this.state.selectedOption}
isWrongAnswer={this.state.isWrongAnswer}
handleOptionChange={this.handleOptionChange}
/>
</ObserveKeys>
<Spacer size='medium' />
<div
style={{
textAlign: 'center'
}}
>
{this.state.isWrongAnswer && (
<span>
{feedback ? (
<PrismFormatted
className={'multiple-choice-feedback'}
text={feedback}
/>
) : (
t('learn.wrong-answer')
)}
</span>
)}
{!this.state.allAssignmentsCompleted &&
assignments.length > 0 && (
<>
<br />
<span>{t('learn.assignment-not-complete')}</span>
</>
)}
</div>
<Spacer size='medium' />
<Button
block={true}
size='medium'

View File

@ -12,7 +12,6 @@ import { createSelector } from 'reselect';
import { Container, Col, Row, Button } from '@freecodecamp/ui';
// Local Utilities
import Loader from '../../../components/helpers/loader';
import Spacer from '../../../components/helpers/spacer';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
@ -23,7 +22,7 @@ import VideoPlayer from '../components/video-player';
import ChallengeTitle from '../components/challenge-title';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import PrismFormatted from '../components/prism-formatted';
import MultipleChoiceQuestions from '../components/multiple-choice-questions';
import {
challengeMounted,
updateChallengeMeta,
@ -201,7 +200,7 @@ class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
videoLocaleIds,
bilibiliIds,
fields: { blockName },
question: { text, answers, solution }
question
}
}
},
@ -218,10 +217,7 @@ class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
`intro:${superBlock}.blocks.${block}.title`
)} - ${title}`;
const feedback =
this.state.selectedOption !== null
? answers[this.state.selectedOption].feedback
: undefined;
const { solution } = question;
return (
<Hotkeys
@ -248,84 +244,28 @@ class ShowVideo extends Component<ShowVideoProps, ShowVideoState> {
{challengeType === challengeTypes.video && (
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
<div className='video-wrapper'>
{!this.state.videoIsLoaded ? (
<div className='video-placeholder-loader'>
<Loader />
</div>
) : null}
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={this.onVideoLoad}
title={title}
videoId={videoId}
videoIsLoaded={this.state.videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
</div>
<VideoPlayer
bilibiliIds={bilibiliIds}
onVideoLoad={this.onVideoLoad}
title={title}
videoId={videoId}
videoIsLoaded={this.state.videoIsLoaded}
videoLocaleIds={videoLocaleIds}
/>
</Col>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ChallengeDescription description={description} />
<PrismFormatted className={'line-numbers'} text={text} />
<Spacer size='medium' />
<ObserveKeys>
<div className='video-quiz-options'>
{answers.map(({ answer }, index) => (
// answers are static and have no natural id property, so
// index should be fine as a key:
<label
className='video-quiz-option-label'
key={index}
htmlFor={`mc-question-${index}`}
>
<input
checked={this.state.selectedOption === index}
className='sr-only'
name='quiz'
onChange={this.handleOptionChange}
type='radio'
value={index}
id={`mc-question-${index}`}
/>{' '}
<span className='video-quiz-input-visible'>
{this.state.selectedOption === index ? (
<span className='video-quiz-selected-input' />
) : null}
</span>
<PrismFormatted
className={'video-quiz-option'}
text={answer.replace(/^<p>|<\/p>$/g, '')}
useSpan
noAria
/>
</label>
))}
</div>
<MultipleChoiceQuestions
questions={question}
selectedOption={this.state.selectedOption}
isWrongAnswer={this.state.showWrong}
handleOptionChange={this.handleOptionChange}
/>
</ObserveKeys>
<Spacer size='medium' />
<div
style={{
textAlign: 'center'
}}
>
{this.state.showWrong ? (
<span>
{feedback ? (
<PrismFormatted
className={'multiple-choice-feedback'}
text={feedback}
/>
) : (
t('learn.wrong-answer')
)}
</span>
) : (
<span>{t('learn.check-answer')}</span>
)}
</div>
<Spacer size='medium' />
<Button
block={true}
variant='primary'