Skip to content

Commit 2432f5e

Browse files
DanielRosa74huyenltnguyenojeytonwilliams
authored
feat(tools, client): add speaking tasks logic (freeCodeCamp#61906)
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
1 parent e32bad0 commit 2432f5e

24 files changed

Lines changed: 1388 additions & 82 deletions

client/gatsby-node.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ exports.createSchemaCustomization = ({ actions }) => {
386386
type Answer {
387387
answer: String
388388
feedback: String
389+
audioId: String
389390
}
390391
type RequiredResource {
391392
link: String

client/i18n/locales/english/translations.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,6 +1354,26 @@
13541354
"two-questions": "Congratulations on getting this far. Before you can start the exam, please answer these two short survey questions."
13551355
}
13561356
},
1357+
"speaking-modal": {
1358+
"heading": "Speaking Practice",
1359+
"repeat-sentence": "Repeat aloud this sentence:",
1360+
"play": "Play",
1361+
"playing": "Playing...",
1362+
"record": "Record",
1363+
"stop": "Stop",
1364+
"incorrect-words": "Incorrect words: {{words}}.",
1365+
"misplaced-words": "Misplaced words: {{words}}.",
1366+
"correct-congratulations": "That's correct! Congratulations!",
1367+
"very-good": "Very good!",
1368+
"try-again": "Try again.",
1369+
"no-audio-available": "No audio file available.",
1370+
"no-speech-detected": "Recording stopped. No speech detected.",
1371+
"speech-recognition-not-supported": "Speech recognition not supported in this browser.",
1372+
"recording-speak-now": "Recording. Speak now.",
1373+
"recording-stopped-processing": "Recording stopped. Processing...",
1374+
"microphone-access-error": "Error: Could not access microphone.",
1375+
"speaking-button": "Practice speaking"
1376+
},
13571377
"curriculum": {
13581378
"catalog": {
13591379
"title": "Explore our Catalog",

client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"@reduxjs/toolkit": "2.8.2",
6363
"@stripe/react-stripe-js": "1.16.5",
6464
"@stripe/stripe-js": "1.54.2",
65+
"@types/react-speech-recognition": "3.9.6",
6566
"algoliasearch": "4.22.1",
6667
"assert": "2.0.0",
6768
"babel-plugin-preval": "5.1.0",
@@ -113,6 +114,7 @@
113114
"react-reflex": "4.1.0",
114115
"react-responsive": "9.0.2",
115116
"react-scroll": "1.9.0",
117+
"react-speech-recognition": "4.0.1",
116118
"react-spinkit": "3.0.0",
117119
"react-tooltip": "4.5.1",
118120
"react-transition-group": "4.4.5",

client/src/redux/prop-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type MarkdownRemark = {
3737
type MultipleChoiceAnswer = {
3838
answer: string;
3939
feedback: string | null;
40+
audioId: string | null;
4041
};
4142

4243
export type Question = {
Lines changed: 141 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
2+
import { connect } from 'react-redux';
23
import { useTranslation } from 'react-i18next';
4+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5+
import { faMicrophone } from '@fortawesome/free-solid-svg-icons';
36

4-
import { Spacer } from '@freecodecamp/ui';
7+
import { Button, Spacer } from '@freecodecamp/ui';
58
import { Question } from '../../../redux/prop-types';
9+
import { openModal } from '../redux/actions';
10+
import { SuperBlocks } from '../../../../../shared/config/curriculum';
11+
import SpeakingModal from './speaking-modal';
612
import ChallengeHeading from './challenge-heading';
713
import PrismFormatted from './prism-formatted';
814

@@ -12,6 +18,8 @@ type MultipleChoiceQuestionsProps = {
1218
handleOptionChange: (questionIndex: number, answerIndex: number) => void;
1319
submittedMcqAnswers: (number | null)[];
1420
showFeedback: boolean;
21+
openSpeakingModal: () => void;
22+
superBlock: SuperBlocks;
1523
};
1624

1725
function removeParagraphTags(text: string): string {
@@ -23,10 +31,45 @@ function MultipleChoiceQuestions({
2331
selectedOptions,
2432
handleOptionChange,
2533
submittedMcqAnswers,
26-
showFeedback
34+
showFeedback,
35+
openSpeakingModal,
36+
superBlock
2737
}: MultipleChoiceQuestionsProps): JSX.Element {
2838
const { t } = useTranslation();
2939

40+
const [modalText, setModalText] = useState('');
41+
const [modalAnswerIndex, setModalAnswerIndex] = useState<number>(0);
42+
const [modalQuestionIndex, setModalQuestionIndex] = useState<number>(0);
43+
44+
function stripCodeTags(text: string): string {
45+
return text.replace(/<code>(.*?)<\/code>/g, '$1');
46+
}
47+
48+
const handleSpeakingButtonClick = (
49+
answer: string,
50+
answerIndex: number,
51+
questionIndex: number
52+
) => {
53+
setModalText(stripCodeTags(removeParagraphTags(answer)));
54+
setModalAnswerIndex(answerIndex);
55+
setModalQuestionIndex(questionIndex);
56+
openSpeakingModal();
57+
};
58+
59+
const constructAudioUrl = (audioId?: string): string | undefined =>
60+
audioId
61+
? `https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/${audioId}`
62+
: undefined;
63+
64+
const getAudioUrl = (
65+
questionIndex: number,
66+
answerIndex: number
67+
): string | undefined => {
68+
const answer = questions[questionIndex]?.answers[answerIndex];
69+
const audioId = answer?.audioId ?? undefined;
70+
return constructAudioUrl(audioId);
71+
};
72+
3073
return (
3174
<>
3275
<ChallengeHeading
@@ -50,74 +93,116 @@ function MultipleChoiceQuestions({
5093
// -1 because the solution is 1-indexed
5194
questions[questionIndex].solution - 1;
5295

96+
const labelId = `mc-question-${questionIndex}-answer-${answerIndex}-label`;
97+
const hasAudio =
98+
questions[questionIndex]?.answers[answerIndex]?.audioId;
99+
53100
return (
54-
<React.Fragment key={answerIndex}>
55-
<label
56-
className={`video-quiz-option-label
57-
${showFeedback && isSubmittedAnswer ? 'mcq-hide-border' : ''}
58-
${showFeedback && isSubmittedAnswer ? (isCorrect ? 'mcq-correct-border' : 'mcq-incorrect-border') : ''}`}
59-
htmlFor={`mc-question-${questionIndex}-answer-${answerIndex}`}
60-
>
61-
<input
62-
name={`mc-question-${questionIndex}`}
63-
checked={selectedOptions[questionIndex] === answerIndex}
64-
className='sr-only'
65-
onChange={() =>
66-
handleOptionChange(questionIndex, answerIndex)
67-
}
68-
type='radio'
69-
value={answerIndex}
70-
id={`mc-question-${questionIndex}-answer-${answerIndex}`}
71-
/>{' '}
72-
<span className='video-quiz-input-visible'>
73-
{selectedOptions[questionIndex] === answerIndex ? (
74-
<span className='video-quiz-selected-input' />
75-
) : null}
76-
</span>
77-
<PrismFormatted
78-
className={'video-quiz-option'}
79-
text={removeParagraphTags(answer)}
80-
useSpan
81-
noAria
82-
/>
83-
</label>
84-
{showFeedback && isSubmittedAnswer && (
85-
<div
86-
className={`video-quiz-option-label mcq-feedback ${isCorrect ? 'mcq-correct' : 'mcq-incorrect'}`}
87-
>
88-
<p>
89-
{isCorrect
90-
? t('learn.quiz.correct-answer')
91-
: t('learn.quiz.incorrect-answer')}
92-
</p>
93-
{feedback && (
101+
<div key={answerIndex} className='mcq-option-row'>
102+
<div className='mcq-option-with-feedback'>
103+
<div className='mcq-option-content'>
104+
<label
105+
id={labelId}
106+
className={`video-quiz-option-label mcq-option-label
107+
${showFeedback && isSubmittedAnswer ? 'mcq-hide-border' : ''}
108+
${showFeedback && isSubmittedAnswer ? (isCorrect ? 'mcq-correct-border' : 'mcq-incorrect-border') : ''}`}
109+
htmlFor={`mc-question-${questionIndex}-answer-${answerIndex}`}
110+
>
111+
<input
112+
name={`mc-question-${questionIndex}`}
113+
checked={
114+
selectedOptions[questionIndex] === answerIndex
115+
}
116+
className='sr-only'
117+
onChange={() =>
118+
handleOptionChange(questionIndex, answerIndex)
119+
}
120+
type='radio'
121+
value={answerIndex}
122+
id={`mc-question-${questionIndex}-answer-${answerIndex}`}
123+
/>{' '}
124+
<span className='video-quiz-input-visible'>
125+
{selectedOptions[questionIndex] === answerIndex ? (
126+
<span className='video-quiz-selected-input' />
127+
) : null}
128+
</span>
129+
<PrismFormatted
130+
className={'video-quiz-option'}
131+
text={removeParagraphTags(answer)}
132+
useSpan
133+
noAria
134+
/>
135+
</label>
136+
</div>
137+
{showFeedback && isSubmittedAnswer && (
138+
<div
139+
className={`video-quiz-option-label mcq-feedback ${isCorrect ? 'mcq-correct' : 'mcq-incorrect'}`}
140+
>
94141
<p>
95-
<PrismFormatted
96-
className={
97-
isCorrect
98-
? 'mcq-prism-correct'
99-
: 'mcq-prism-incorrect'
100-
}
101-
text={removeParagraphTags(feedback)}
102-
useSpan
103-
noAria
104-
/>
142+
{isCorrect
143+
? t('learn.quiz.correct-answer')
144+
: t('learn.quiz.incorrect-answer')}
105145
</p>
106-
)}
146+
{feedback && (
147+
<p>
148+
<PrismFormatted
149+
className={
150+
isCorrect
151+
? 'mcq-prism-correct'
152+
: 'mcq-prism-incorrect'
153+
}
154+
text={removeParagraphTags(feedback)}
155+
useSpan
156+
noAria
157+
/>
158+
</p>
159+
)}
160+
</div>
161+
)}
162+
</div>
163+
164+
{hasAudio && (
165+
<div className='mcq-speaking-button-wrapper'>
166+
<Button
167+
size='medium'
168+
onClick={() =>
169+
handleSpeakingButtonClick(
170+
answer,
171+
answerIndex,
172+
questionIndex
173+
)
174+
}
175+
className='mcq-speaking-button'
176+
aria-describedby={labelId}
177+
aria-label={t('speaking-modal.speaking-button')}
178+
>
179+
<FontAwesomeIcon icon={faMicrophone} />
180+
</Button>
107181
</div>
108182
)}
109-
</React.Fragment>
183+
</div>
110184
);
111185
})}
112186
</div>
113187
<Spacer size='m' />
114188
</fieldset>
115189
))}
190+
116191
<Spacer size='m' />
192+
<SpeakingModal
193+
sentence={modalText}
194+
audioUrl={getAudioUrl(modalQuestionIndex, modalAnswerIndex)}
195+
answerIndex={modalAnswerIndex}
196+
superBlock={superBlock}
197+
/>
117198
</>
118199
);
119200
}
120201

202+
const mapDispatchToProps = {
203+
openSpeakingModal: () => openModal('speaking')
204+
};
205+
121206
MultipleChoiceQuestions.displayName = 'MultipleChoiceQuestions';
122207

123-
export default MultipleChoiceQuestions;
208+
export default connect(null, mapDispatchToProps)(MultipleChoiceQuestions);

0 commit comments

Comments
 (0)