refactor(client): use generic comp for multiple choice (#56825)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-11-07 13:02:14 +01:00
committed by GitHub
parent 9c73159f10
commit 50f0c23d15
7 changed files with 74 additions and 495 deletions
@@ -32,8 +32,6 @@ import {
import Scene from '../components/scene/scene';
import { isChallengeCompletedSelector } from '../redux/selectors';
// Styles
import '../video.css';
import './show.css';
// Redux Setup
@@ -4,6 +4,7 @@ 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';
// Local Utilities
import LearnLayout from '../../../components/layouts/learn';
@@ -24,6 +25,13 @@ import {
import { isChallengeCompletedSelector } from '../redux/selectors';
import { BlockTypes } from '../../../../../shared/config/blocks';
import Scene from '../components/scene/scene';
import MultipleChoiceQuestions from '../components/multiple-choice-questions';
import ChallengeExplanation from '../components/challenge-explanation';
import HelpModal from '../components/help-modal';
// Styles
import './show.css';
import '../video.css';
// Redux Setup
const mapStateToProps = (state: unknown) => ({
@@ -35,7 +43,8 @@ const mapDispatchToProps = {
updateChallengeMeta,
challengeMounted,
updateSolutionFormValues,
openCompletionModal: () => openModal('completion')
openCompletionModal: () => openModal('completion'),
openHelpModal: () => openModal('help')
};
// Types
@@ -46,6 +55,7 @@ interface ShowQuizProps {
initTests: (xs: Test[]) => void;
isChallengeCompleted: boolean;
openCompletionModal: () => void;
openHelpModal: () => void;
pageContext: {
challengeMeta: ChallengeMeta;
};
@@ -63,10 +73,12 @@ const ShowGeneric = ({
block,
blockType,
description,
explanation,
challengeType,
fields: { tests },
fields: { blockName, tests },
helpCategory,
instructions,
questions,
title,
translationPending,
scene,
@@ -80,6 +92,7 @@ const ShowGeneric = ({
initTests,
updateChallengeMeta,
openCompletionModal,
openHelpModal,
isChallengeCompleted
}: ShowQuizProps) => {
const { t } = useTranslation();
@@ -142,9 +155,36 @@ const ShowGeneric = ({
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 [showFeedback, setShowFeedback] = useState(false);
const handleMcqOptionChange = (
questionIndex: number,
answerIndex: number
): void => {
setSelectedMcqOptions(prev =>
prev.map((option, index) =>
index === questionIndex ? answerIndex : option
)
);
};
// submit
const handleSubmit = () => {
if (assignments.length == 0 || allAssignmentsCompleted) {
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();
}
};
@@ -214,15 +254,34 @@ const ShowGeneric = ({
/>
)}
{!!questions && (
<MultipleChoiceQuestions
questions={questions}
selectedOptions={selectedMcqOptions}
handleOptionChange={handleMcqOptionChange}
submittedMcqAnswers={submittedMcqAnswers}
showFeedback={showFeedback}
/>
)}
{explanation ? (
<ChallengeExplanation explanation={explanation} />
) : null}
<Button block={true} variant='primary' onClick={handleSubmit}>
{blockType === BlockTypes.review
? 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={blockName} />
</Row>
</Container>
</LearnLayout>
@@ -248,6 +307,7 @@ export const query = graphql`
blockType
challengeType
description
explanation
helpCategory
instructions
fields {
@@ -258,6 +318,14 @@ export const query = graphql`
testString
}
}
questions {
text
answers {
answer
feedback
}
solution
}
scene {
setup {
background
@@ -1,478 +0,0 @@
// Package Utilities
import { graphql } from 'gatsby';
import React, { Component } from 'react';
import Helmet from 'react-helmet';
import { ObserveKeys } from 'react-hotkeys';
import type { TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { isEqual } from 'lodash-es';
import { Container, Col, Row, Button, Spacer } from '@freecodecamp/ui';
import ShortcutsModal from '../components/shortcuts-modal';
// Local Utilities
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta, Test } from '../../../redux/prop-types';
import Hotkeys from '../components/hotkeys';
import VideoPlayer from '../components/video-player';
import CompletionModal from '../components/completion-modal';
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 ChallegeExplanation from '../components/challenge-explanation';
import MultipleChoiceQuestions from '../components/multiple-choice-questions';
import Assignments from '../components/assignments';
import {
challengeMounted,
updateChallengeMeta,
openModal,
updateSolutionFormValues,
initTests
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
// Styles
import './show.css';
import '../video.css';
// Redux Setup
const mapStateToProps = createSelector(
isChallengeCompletedSelector,
(isChallengeCompleted: boolean) => ({
isChallengeCompleted
})
);
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators(
{
initTests,
updateChallengeMeta,
challengeMounted,
updateSolutionFormValues,
openCompletionModal: () => openModal('completion'),
openHelpModal: () => openModal('help')
},
dispatch
);
// Types
interface ShowOdinProps {
challengeMounted: (arg0: string) => void;
data: { challengeNode: ChallengeNode };
initTests: (xs: Test[]) => void;
isChallengeCompleted: boolean;
openCompletionModal: () => void;
openHelpModal: () => void;
pageContext: {
challengeMeta: ChallengeMeta;
};
t: TFunction;
updateChallengeMeta: (arg0: ChallengeMeta) => void;
updateSolutionFormValues: () => void;
}
interface ShowOdinState {
subtitles: string;
downloadURL: string | null;
selectedMcqOptions: (number | null)[];
submittedMcqAnswers: (number | null)[];
showFeedback: boolean;
assignmentsCompleted: number;
allAssignmentsCompleted: boolean;
videoIsLoaded: boolean;
isScenePlaying: boolean;
}
// Component
class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
static displayName: string;
private container: React.RefObject<HTMLElement> = React.createRef();
constructor(props: ShowOdinProps) {
super(props);
const {
data: {
challengeNode: {
challenge: { assignments, questions }
}
}
} = this.props;
this.state = {
subtitles: '',
downloadURL: null,
selectedMcqOptions: questions.map(() => null),
submittedMcqAnswers: questions.map(() => null),
showFeedback: false,
assignmentsCompleted: 0,
allAssignmentsCompleted: assignments.length == 0,
videoIsLoaded: false,
isScenePlaying: false
};
this.handleSubmit = this.handleSubmit.bind(this);
}
componentDidMount(): void {
const {
challengeMounted,
data: {
challengeNode: {
challenge: {
fields: { tests },
title,
challengeType,
helpCategory
}
}
},
pageContext: { challengeMeta },
initTests,
updateChallengeMeta
} = this.props;
initTests(tests);
updateChallengeMeta({
...challengeMeta,
title,
challengeType,
helpCategory
});
challengeMounted(challengeMeta.id);
this.container.current?.focus();
}
componentDidUpdate(prevProps: ShowOdinProps): void {
const {
data: {
challengeNode: {
challenge: { title: prevTitle }
}
}
} = prevProps;
const {
challengeMounted,
data: {
challengeNode: {
challenge: { title: currentTitle, challengeType, helpCategory }
}
},
pageContext: { challengeMeta },
updateChallengeMeta
} = this.props;
if (prevTitle !== currentTitle) {
updateChallengeMeta({
...challengeMeta,
title: currentTitle,
challengeType,
helpCategory
});
challengeMounted(challengeMeta.id);
}
}
handleSubmit = () => {
const {
data: {
challengeNode: {
challenge: { questions }
}
},
openCompletionModal
} = this.props;
// subract 1 because the solutions are 1-indexed
const mcqSolutions = questions.map(question => question.solution - 1);
this.setState({
submittedMcqAnswers: this.state.selectedMcqOptions,
showFeedback: true
});
const allMcqAnswersCorrect = isEqual(
mcqSolutions,
this.state.selectedMcqOptions
);
if (this.state.allAssignmentsCompleted && allMcqAnswersCorrect) {
openCompletionModal();
}
};
handleMcqOptionChange = (
questionIndex: number,
answerIndex: number
): void => {
this.setState(state => ({
selectedMcqOptions: state.selectedMcqOptions.map((option, index) =>
index === questionIndex ? answerIndex : option
)
}));
};
handleAssignmentChange = (
event: React.ChangeEvent<HTMLInputElement>,
totalAssignments: number
): void => {
const assignmentsCompleted = event.target.checked
? this.state.assignmentsCompleted + 1
: this.state.assignmentsCompleted - 1;
const allAssignmentsCompleted = totalAssignments === assignmentsCompleted;
this.setState({
assignmentsCompleted,
allAssignmentsCompleted
});
};
onVideoLoad = () => {
this.setState({
videoIsLoaded: true
});
};
setIsScenePlaying = (shouldPlay: boolean) => {
this.setState({
isScenePlaying: shouldPlay
});
};
render() {
const {
data: {
challengeNode: {
challenge: {
title,
description,
instructions,
explanation,
superBlock,
block,
videoId,
videoLocaleIds,
bilibiliIds,
fields: { blockName },
questions,
assignments,
translationPending,
scene
}
}
},
openHelpModal,
pageContext: {
challengeMeta: { nextChallengePath, prevChallengePath }
},
t,
isChallengeCompleted
} = this.props;
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)} - ${title}`;
return (
<Hotkeys
executeChallenge={this.handleSubmit}
containerRef={this.container}
nextChallengePath={nextChallengePath}
prevChallengePath={prevChallengePath}
playScene={() => this.setIsScenePlaying(true)}
>
<LearnLayout>
<Helmet
title={`${blockNameTitle} | ${t('learn.learn')} | freeCodeCamp.org`}
/>
<Container>
<Row>
{videoId && (
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
<Spacer size='m' />
<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='m' />
<ChallengeTitle
isCompleted={isChallengeCompleted}
translationPending={translationPending}
>
{title}
</ChallengeTitle>
<PrismFormatted className={'line-numbers'} text={description} />
<Spacer size='m' />
</Col>
{scene && (
<Scene
scene={scene}
isPlaying={this.state.isScenePlaying}
setIsPlaying={this.setIsScenePlaying}
/>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
{instructions && (
<PrismFormatted
className={'line-numbers'}
text={instructions}
/>
)}
<ObserveKeys>
{assignments.length > 0 && (
<Assignments
assignments={assignments}
allAssignmentsCompleted={
this.state.allAssignmentsCompleted
}
handleAssignmentChange={this.handleAssignmentChange}
/>
)}
<MultipleChoiceQuestions
questions={questions}
selectedOptions={this.state.selectedMcqOptions}
handleOptionChange={this.handleMcqOptionChange}
submittedMcqAnswers={this.state.submittedMcqAnswers}
showFeedback={this.state.showFeedback}
/>
</ObserveKeys>
{explanation ? (
<ChallegeExplanation explanation={explanation} />
) : (
<Spacer size='m' />
)}
<Button
block={true}
size='medium'
variant='primary'
onClick={this.handleSubmit}
>
{t('buttons.check-answer')}
</Button>
<Spacer size='xxs' />
<Button
block={true}
size='medium'
variant='primary'
onClick={openHelpModal}
>
{t('buttons.ask-for-help')}
</Button>
<Spacer size='l' />
</Col>
<CompletionModal />
<HelpModal challengeTitle={title} challengeBlock={blockName} />
</Row>
</Container>
<ShortcutsModal />
</LearnLayout>
</Hotkeys>
);
}
}
ShowOdin.displayName = 'ShowOdin';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(ShowOdin));
export const query = graphql`
query TheOdinProject($id: String!) {
challengeNode(id: { eq: $id }) {
challenge {
videoId
videoLocaleIds {
espanol
italian
portuguese
}
bilibiliIds {
aid
bvid
cid
}
title
description
instructions
explanation
challengeType
helpCategory
superBlock
block
fields {
slug
blockName
tests {
text
testString
}
}
questions {
text
answers {
answer
feedback
}
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
}
}
}
translationPending
assignments
}
}
}
`;
@@ -32,9 +32,6 @@ import {
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
// Styles
import '../video.css';
// Redux Setup
const mapStateToProps = createSelector(
isChallengeCompletedSelector,
@@ -36,11 +36,6 @@ const video = path.resolve(
'../../src/templates/Challenges/video/show.tsx'
);
const odin = path.resolve(
__dirname,
'../../src/templates/Challenges/odin/show.tsx'
);
const exam = path.resolve(
__dirname,
'../../src/templates/Challenges/exam/show.tsx'
@@ -69,7 +64,6 @@ const views = {
quiz,
video,
codeAlly,
odin,
exam,
msTrophy,
fillInTheBlank,
+3 -3
View File
@@ -96,13 +96,13 @@ export const viewTypes = {
[codeAllyPractice]: 'codeAlly',
[codeAllyCert]: 'codeAlly',
[multifileCertProject]: 'classic',
[theOdinProject]: 'odin',
[theOdinProject]: 'generic',
[colab]: 'frontend',
[exam]: 'exam',
[msTrophy]: 'msTrophy',
[multipleChoice]: 'odin',
[multipleChoice]: 'generic',
[python]: 'modern',
[dialogue]: 'generic', // TODO: use generic challengeType for dialogues
[dialogue]: 'generic',
[fillInTheBlank]: 'fillInTheBlank',
[multifilePythonCertProject]: 'classic',
[generic]: 'generic'