From 2fbdfa5d097c329d5d02165bbedecc2a27266df9 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 17 May 2022 08:52:03 -0700 Subject: [PATCH] feat: add ask for help button (#45636) * feat: add ask for help button * feat: move button to test output area * feat: track number of attempts * feat: add ask button to buttons container * feat: restructure lower jaw * feat: adjust the lower jaw styles * fix: unmount component * feat: restructure the lowerjaw * fix: move aria live region to encompass both status and hint * feat: rebase main * feat: remove lower jaw update from test useEffect * fix: output viewzone resize on output change * fix: change encouragement message based on attemps * fix: maintain lowerjaw height when tests run * feat: remove set height and clear feedback * fix: adjust lower jaw state on reset * fix: focus submit button when challenge is completed * fix: add aria-hidden after 500ms * add translations * fix: clean up * feat: fade in test feedback after each attempt * feat: make lower jaw accessible * fix: clean up Co-authored-by: ahmad abdolsaheb Co-authored-by: Bruce B Co-authored-by: Shaun Hamilton --- client/i18n/locales/english/translations.json | 16 +- client/src/assets/icons/lightbulb.tsx | 44 +++ .../templates/Challenges/classic/editor.css | 93 ++++-- .../templates/Challenges/classic/editor.tsx | 299 ++++++------------ .../Challenges/classic/lower-jaw.tsx | 231 ++++++++++++++ 5 files changed, 442 insertions(+), 241 deletions(-) create mode 100644 client/src/assets/icons/lightbulb.tsx create mode 100644 client/src/templates/Challenges/classic/lower-jaw.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index cbc3c7bcef2..1a1b483167e 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -50,6 +50,7 @@ "reset-lesson": "Reset this lesson", "run": "Run", "run-test": "Run the Tests (Ctrl + Enter)", + "check-code": "Check Your Code (Ctrl + Enter)", "reset": "Reset", "reset-code": "Reset All Code", "help": "Help", @@ -268,6 +269,7 @@ "solution-link": "Solution Link", "github-link": "GitHub Link", "submit-and-go": "Submit and go to my next challenge", + "congradulations": "Congratulations, your code passes. Submit your code to complete this step and move on to the next one.", "i-completed": "I've completed this challenge", "test-output": "Your test output will go here", "running-tests": "// running tests", @@ -307,7 +309,14 @@ "complete-both-steps": "Complete both steps below to finish the challenge.", "runs-in-vm": "The project runs in a virtual machine, complete the user stories described in there and get all the tests to pass to finish step 1.", "completed": "Completed", - "not-started": "Not started" + "not-started": "Not started", + "hint": "Hint", + "test": "Test", + "sorry-try-again":"Sorry, your code does not pass. Try again.", + "sorry-keep-trying":"Sorry, your code does not pass. Keep trying.", + "sorry-getting-there":"Sorry, your code does not pass. You're getting there.", + "sorry-hang-in-there":"Sorry, your code does not pass. Hang in there.", + "sorry-dont-giveup":"Sorry, your code does not pass. Don't give up." }, "donate": { "title": "Support our nonprofit", @@ -444,6 +453,7 @@ "fail": "Test Failed", "not-passed": "Not Passed", "passed": "Passed", + "hint": "Hint", "heart": "Heart", "initial": "Initial", "info": "Intro Information", @@ -463,7 +473,9 @@ "next-page": "Go to next page", "last-page": "Go to last page", "primary-nav": "primary", - "breadcrumb-nav": "breadcrumb" + "breadcrumb-nav": "breadcrumb", + "submit": "Use Ctrl + Enter to submit.", + "running-tests": "Running tests" }, "flash": { "honest-first": "To claim a certification, you must first accept our academic honesty policy", diff --git a/client/src/assets/icons/lightbulb.tsx b/client/src/assets/icons/lightbulb.tsx new file mode 100644 index 00000000000..b2979c4dab5 --- /dev/null +++ b/client/src/assets/icons/lightbulb.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function LightBulb( + props: JSX.IntrinsicAttributes & React.SVGProps +): JSX.Element { + const { t } = useTranslation(); + + return ( + <> + + + + + ); +} + +LightBulb.displayName = 'LightBulb'; + +export default LightBulb; diff --git a/client/src/templates/Challenges/classic/editor.css b/client/src/templates/Challenges/classic/editor.css index 3fbce79332b..6f715aa43c4 100644 --- a/client/src/templates/Challenges/classic/editor.css +++ b/client/src/templates/Challenges/classic/editor.css @@ -29,7 +29,7 @@ textarea.inputarea { } .action-row button { - padding: 4px 16px; + padding: 6px 12px; } .active-tab { @@ -50,9 +50,9 @@ textarea.inputarea { .action-row-container, .description-container { background-color: var(--secondary-background); - padding: 10px; + padding: 1rem; border: 2px solid var(--quaternary-background); - max-width: 700px; + max-width: 600px; } .description-container h1 { @@ -89,32 +89,6 @@ textarea.inputarea { margin: 0px; } -#test-status { - padding-bottom: 5px; - padding-top: 5px; -} - -#test-status .status { - font-family: revert; -} - -#test-status p { - margin: 0.5rem 0 0; -} - -#test-status h2.hint { - font-size: 1rem; - line-height: 1.5; - padding-right: 0.5rem; - /* using float instead of inline display so screen readers recognize h2 as a block element */ - float: left; - margin: 0; -} - -#test-status h2.hint::after { - content: ':'; -} - .myEditableLineDecoration { background-color: var(--gray-45); width: 15px !important; @@ -135,3 +109,64 @@ textarea.inputarea { .accessibilityHelpWidget { z-index: 1; } + +.test-feedback { + display: flex; + padding: 0; + flex-direction: column; +} + +.test-feedback p { + margin: 0.5rem 0 0; + font-family: 'Lato', sans-serif; +} + +.test-feedback h2 { + font-size: 1rem; + line-height: 1.5; + padding-right: 0.5rem; + /* using float instead of inline display so screen readers recognize h2 as a block element */ + float: left; + margin: 0.5em 0 0; +} + +.test-feedback h2:after { + content: ':'; +} + +.test-feedback svg { + height: 1.5rem; + width: auto; + margin-right: 0.5rem; + margin-top: 0.6rem; +} + +.test-feedback > div { + margin-top: 1rem; + display: flex; + flex-direction: row; +} + +.test-status-description, +.hint-description { + width: 100%; +} + +.fade-in { + animation-name: FadeIn; + animation-duration: 0.5s; + transition-timing-function: linear; +} + +@keyframes FadeIn { + 0% { + opacity: 0; + } + /* keep test feedback contents initially hidden for 200ms*/ + 40% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index 3b6846e7ebc..e77f933bad4 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -9,16 +9,17 @@ import type { import { highlightAllUnder } from 'prismjs'; import React, { useEffect, - useState, Suspense, RefObject, MutableRefObject, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import store from 'store'; +import { debounce } from 'lodash'; import { Loader } from '../../../components/helpers'; import { Themes } from '../../../components/settings/theme'; import { @@ -51,8 +52,10 @@ import { initTests, isResettingSelector, stopResetting, - isProjectPreviewModalOpenSelector + isProjectPreviewModalOpenSelector, + openModal } from '../redux'; +import LowerJaw from './lower-jaw'; import './editor.css'; @@ -78,6 +81,7 @@ interface EditorProps { initialTests: Test[]; isResetting: boolean; isSignedIn: boolean; + openHelpModal: () => void; output: string[]; resizeProps: ResizeProps; saveChallenge: () => void; @@ -156,7 +160,8 @@ const mapDispatchToProps = { updateFile, submitChallenge, initTests, - stopResetting + stopResetting, + openHelpModal: () => openModal('help') }; const modeMap = { @@ -213,17 +218,6 @@ const initialData: EditorProperties = { }; const Editor = (props: EditorProps): JSX.Element => { - const [timeoutHasElapsed, setTimeoutHasElapsed] = useState(false); - const minTimeout = useRef(); - useEffect(() => { - minTimeout.current = setTimeout(() => { - setTimeoutHasElapsed(true); - }, 1000 * 60); - - return () => { - clearTimeout(minTimeout.current); - }; - }, []); const { editorRef, initTests } = props; // These refs are used during initialisation of the editor as well as by // callbacks. Since they have to be initialised before editorWillMount and @@ -245,6 +239,7 @@ const Editor = (props: EditorProps): JSX.Element => { noteIndex: 0, shouldPlay: store.get('fcc-sound') as boolean | undefined }); + const attemptRef = useRef<{ attempts: number }>({ attempts: 0 }); // since editorDidMount runs once with the initial props object, it keeps a // reference to *those* props. If we want it to use the latest props, we can @@ -338,11 +333,11 @@ const Editor = (props: EditorProps): JSX.Element => { const { challengeFiles, fileKey } = props; const { model } = dataRef.current; - const newContents = challengeFiles?.find( + const initialContents = challengeFiles?.find( challengeFile => challengeFile.fileKey === fileKey )?.contents; - if (model?.getValue() !== newContents) { - model?.setValue(newContents ?? ''); + if (model?.getValue() !== initialContents) { + model?.setValue(initialContents ?? ''); } }; @@ -420,17 +415,10 @@ const Editor = (props: EditorProps): JSX.Element => { run: () => { if (props.usesMultifileEditor) { if (challengeIsComplete()) { - const { submitChallenge } = props; - if (timeoutHasElapsed) { - submitChallenge(); - } else { - debounce.current = setTimeout(() => { - submitChallenge(); - }, 750); - } + debounce(props.submitChallenge, 2000); } else { - clearTestFeedback(); props.executeChallenge(); + attemptRef.current.attempts++; } } else { props.executeChallenge({ showCompletionModal: true }); @@ -565,35 +553,66 @@ const Editor = (props: EditorProps): JSX.Element => { dataRef.current.descriptionZoneId = changeAccessor.addZone(viewZone); }; - const outputZoneCallback = ( - changeAccessor: editor.IViewZoneChangeAccessor - ) => { + function createLowerJaw(outputNode: HTMLElement, callback?: () => void) { + const { output } = props; + const isChallengeComplete = challengeIsComplete(); + const isEditorInFocus = document.activeElement?.tagName === 'TEXTAREA'; + ReactDOM.render( + attemptRef.current.attempts++} + isEditorInFocus={isEditorInFocus} + />, + outputNode, + callback + ); + } + + const updateOutputZone = () => { const editor = dataRef.current.editor; - if (!editor) return; - const outputNode = createOutputNode(editor); + if (!editor || !dataRef.current.outputNode) return; + const outputNode = dataRef.current.outputNode; + createLowerJaw(outputNode, () => { + if (dataRef.current.outputNode) { + updateOutputViewZone(outputNode, editor); + } + }); + }; + + const updateOutputViewZone = ( + outputNode: HTMLDivElement, + editor: editor.IStandaloneCodeEditor + ) => { // make sure the overlayWidget has resized before using it to set the height - outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; - // We have to wait for the viewZone to finish rendering before adjusting the // position of the overlayWidget (i.e. trigger it via onComputedHeight). If // not the editor may report the wrong value for position of the lines. - const viewZone = { - afterLineNumber: getLastLineOfEditableRegion(), - heightInPx: outputNode.offsetHeight, - domNode: document.createElement('div'), - onComputedHeight: () => - dataRef.current.outputWidget && - editor.layoutOverlayWidget(dataRef.current.outputWidget), - onDomNodeTop: (top: number) => { - dataRef.current.outputZoneTop = top; - if (dataRef.current.outputWidget) - editor.layoutOverlayWidget(dataRef.current.outputWidget); - } - }; - - dataRef.current.outputZoneId = changeAccessor.addZone(viewZone); + editor?.changeViewZones(changeAccessor => { + changeAccessor.removeZone(dataRef.current.outputZoneId); + const viewZone = { + afterLineNumber: getLastLineOfEditableRegion(), + heightInPx: outputNode.offsetHeight, + domNode: document.createElement('div'), + onComputedHeight: () => + dataRef.current.outputWidget && + editor.layoutOverlayWidget(dataRef.current.outputWidget), + onDomNodeTop: (top: number) => { + dataRef.current.outputZoneTop = top; + if (dataRef.current.outputWidget) + editor.layoutOverlayWidget(dataRef.current.outputWidget); + } + }; + dataRef.current.outputZoneId = changeAccessor.addZone(viewZone); + }); }; function createDescription(editor: editor.IStandaloneCodeEditor) { @@ -622,77 +641,20 @@ const Editor = (props: EditorProps): JSX.Element => { return domNode; } - function setTestFeedbackHeight(height?: number): void { - const testStatus = document.getElementById('test-status'); - const newHeight = height === undefined ? 'auto' : `${height}px`; - if (testStatus) { - testStatus.style.height = newHeight; - } - } - - function clearTestFeedback() { - const testStatus = document.getElementById('test-status'); - if (testStatus && testStatus.innerHTML) { - // Explicitly set the height to what it currently is so that we - // don't get a big content shift every time the code is checked. - setTestFeedbackHeight(testStatus.offsetHeight); - testStatus.innerHTML = ''; - } - } - function createOutputNode(editor: editor.IStandaloneCodeEditor) { if (dataRef.current.outputNode) return dataRef.current.outputNode; const outputNode = document.createElement('div'); - const statusNode = document.createElement('div'); - const editorActionRow = document.createElement('div'); - editorActionRow.classList.add('action-row-container'); outputNode.classList.add('editor-lower-jaw'); - outputNode.appendChild(editorActionRow); - statusNode.setAttribute('id', 'test-status'); - statusNode.setAttribute('aria-live', 'assertive'); - const button = document.createElement('button'); - button.setAttribute('id', 'test-button'); - button.classList.add('btn-block'); - button.innerHTML = 'Check Your Code (Ctrl + Enter)'; - const submitButton = document.createElement('button'); - submitButton.setAttribute('id', 'submit-button'); - submitButton.setAttribute('aria-hidden', 'true'); - submitButton.innerHTML = 'Submit your code (Ctrl + Enter)'; - submitButton.classList.add('btn-block'); - editorActionRow.appendChild(button); - editorActionRow.appendChild(submitButton); - editorActionRow.appendChild(statusNode); - button.onclick = () => { - clearTestFeedback(); - const { executeChallenge } = props; - executeChallenge(); - }; - + outputNode.setAttribute('id', 'editor-lower-jaw'); outputNode.style.left = `${editor.getLayoutInfo().contentLeft}px`; outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; outputNode.style.top = getOutputZoneTop(); - dataRef.current.outputNode = outputNode; return outputNode; } - function resetOutputNode() { + function resetMarginDecorations() { const { model, insideEditDecId } = dataRef.current; - const testButton = document.getElementById('test-button'); - if (testButton) { - testButton.innerHTML = 'Check Your Code (Ctrl + Enter)'; - testButton.onclick = () => { - clearTestFeedback(); - props.executeChallenge(); - }; - } - - // Must manually set test feedback height back to zero since - // clearTestFeedback does not. - setTestFeedbackHeight(0); - clearTestFeedback(); - - // Resetting margin decorations const range = model?.getDecorationRange(insideEditDecId); if (range) { updateEditableRegion(range, { model }); @@ -931,7 +893,7 @@ const Editor = (props: EditorProps): JSX.Element => { getOutputZoneTop ); editor.addOverlayWidget(dataRef.current.outputWidget); - editor.changeViewZones(outputZoneCallback); + editor.changeViewZones(updateOutputZone); } editor.onDidScrollChange(() => { @@ -1019,6 +981,10 @@ const Editor = (props: EditorProps): JSX.Element => { return tests.some(test => test.err); } + function resetAttampts() { + attemptRef.current.attempts = 0; + } + // runs every update to the editor and when the challenge is reset useEffect(() => { // If a challenge is reset, it needs to communicate that change to the @@ -1042,12 +1008,9 @@ const Editor = (props: EditorProps): JSX.Element => { if (props.isResetting) { initializeDescriptionAndOutputWidgets(); updateDescriptionZone(); - updateOutputZone(); showEditableRegion(editor); - - // Since the outputNode is only reset when the step is restarted, users - // that want to try different solutions will need to do that. - resetOutputNode(); + resetAttampts(); + resetMarginDecorations(); } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -1064,100 +1027,25 @@ const Editor = (props: EditorProps): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.previewOpen]); - const debounce = useRef(); - useEffect(() => { - clearTimeout(debounce.current); - - const { output } = props; const { model, insideEditDecId } = dataRef.current; - const editableRegion = getEditableRegionFromRedux(); - const isEditorInFocus = document.activeElement?.tagName === 'TEXTAREA'; - if (editableRegion.length === 2) { - const testStatus = document.getElementById('test-status'); - if (challengeIsComplete()) { - const testButton = document.getElementById('test-button'); - // In case test button has focus, only visually hide it for now so we - // don't lose the focus before we set it on submit button. - testButton?.classList.add('sr-only'); - const submitButton = document.getElementById('submit-button'); - if (submitButton) { - submitButton.removeAttribute('aria-hidden'); - submitButton.onclick = () => { - clearTestFeedback(); - const { submitChallenge } = props; - if (timeoutHasElapsed) { - submitChallenge(); - } else { - debounce.current = setTimeout(() => { - submitChallenge(); - }, 750); - } - }; - // Delay setting focus on submit button to ensure aria-live status - // message is announced first by screen reader. - setTimeout(() => { - // Must set focus on submit button before removing test button from - // accessibility API since test button might have focus. - if (!isEditorInFocus) { - submitButton.focus(); - } - testButton?.setAttribute('aria-hidden', 'true'); - }, 500); + const lowerJawElement = dataRef.current.outputNode; + const isChallengeComplete = challengeIsComplete(); + const range = model?.getDecorationRange(insideEditDecId); + if (range && isChallengeComplete) { + updateEditableRegion( + range, + { model }, + { + linesDecorationsClassName: 'myEditableLineDecoration tests-passed' } - - const range = model?.getDecorationRange(insideEditDecId); - if (range) { - updateEditableRegion( - range, - { model }, - { - linesDecorationsClassName: 'myEditableLineDecoration tests-passed' - } - ); - } - - const submitKeyboardInstructions = isEditorInFocus - ? 'Use Ctrl + Enter to submit.' - : ''; - - if (testStatus) { - testStatus.innerHTML = `

Congratulations, your code passes. Submit your code to complete this step and move on to the next one. ${submitKeyboardInstructions}

`; - setTestFeedbackHeight(); - } - } else if (challengeHasErrors() && testStatus) { - const wordsArray = [ - 'Try again.', - 'Keep trying.', - "You're getting there.", - 'Hang in there.', - "Don't give up." - ]; - testStatus.innerHTML = `

Sorry, your code does not pass. ${ - wordsArray[Math.floor(Math.random() * wordsArray.length)] - }

Hint

${output[1]}
`; - setTestFeedbackHeight(); - } + ); } + dataRef.current.outputNode = lowerJawElement; + updateOutputZone(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.tests]); - useEffect(() => { - const { output } = props; - // TODO: do we need this condition? What happens if the ref is empty? - if (dataRef.current.outputNode) { - // TODO: output gets wiped when the preview gets updated, keeping the - // display is an anti-pattern (the render should not ignore props!). - // The correct solution is probably to create a new redux variable - // (shownHint,maybe) and have that persist through previews. But, for - // now: - if (output) { - if (hasEditableRegion()) { - updateOutputZone(); - } - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.output]); + useEffect(() => { const editor = dataRef.current.editor; editor?.layout(); @@ -1168,15 +1056,6 @@ const Editor = (props: EditorProps): JSX.Element => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.dimensions]); - // TODO: DRY (there's going to be a lot of that) - function updateOutputZone() { - const editor = dataRef.current.editor; - editor?.changeViewZones(changeAccessor => { - changeAccessor.removeZone(dataRef.current.outputZoneId); - outputZoneCallback(changeAccessor); - }); - } - function updateDescriptionZone() { const editor = dataRef.current.editor; editor?.changeViewZones(changeAccessor => { diff --git a/client/src/templates/Challenges/classic/lower-jaw.tsx b/client/src/templates/Challenges/classic/lower-jaw.tsx new file mode 100644 index 00000000000..c07ce8a1330 --- /dev/null +++ b/client/src/templates/Challenges/classic/lower-jaw.tsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { debounce } from 'lodash'; +import Fail from '../../../assets/icons/fail'; +import LightBulb from '../../../assets/icons/lightbulb'; +import GreenPass from '../../../assets/icons/green-pass'; + +interface LowerJawProps { + hint?: string; + challengeIsCompleted?: boolean; + openHelpModal: () => void; + executeChallenge: () => void; + submitChallenge: () => void; + showFeedback?: boolean; + isEditorInFocus?: boolean; + challengeHasErrors?: boolean; + testsLength?: number; + attemptsNumber?: number; + onAttempt?: () => void; +} + +const LowerJaw = ({ + openHelpModal, + challengeIsCompleted, + challengeHasErrors, + hint, + executeChallenge, + submitChallenge, + attemptsNumber, + testsLength, + onAttempt, + isEditorInFocus +}: LowerJawProps): JSX.Element => { + const [previousHint, setpreviousHint] = useState(''); + const [runningTests, setRunningTests] = useState(false); + const [testFeedbackheight, setTestFeedbackheight] = useState(0); + const [isFeedbackHidden, setIsFeedbackHidden] = useState(false); + const [testBtnariaHidden, setTestBtnariaHidden] = useState(false); + const { t } = useTranslation(); + const submitButtonRef = React.createRef(); + const testFeedbackRef = React.createRef(); + + useEffect(() => { + if (attemptsNumber && attemptsNumber > 0) { + //hide the feedback from SR untill the "Running tests" are displayed and removed. + setIsFeedbackHidden(true); + + //allow the lower jaw height to be picked up by the editor. + setTimeout(() => { + setRunningTests(true); + }, 200); + + //display the test feedback contents. + setTimeout(() => { + setRunningTests(false); + setIsFeedbackHidden(false); + }, 300); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [attemptsNumber]); + + useEffect(() => { + // only save error hints + if (challengeHasErrors && hint && previousHint !== hint) { + setpreviousHint(hint); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [challengeHasErrors, hint]); + + useEffect(() => { + if (challengeIsCompleted && submitButtonRef?.current) { + submitButtonRef.current.focus(); + setTimeout(() => { + setTestBtnariaHidden(true); + }, 500); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [challengeIsCompleted]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + if (testFeedbackRef.current) { + setTestFeedbackheight(testFeedbackRef.current.clientHeight); + } + }); + + const renderTestFeedbackContainer = () => { + if (attemptsNumber === 0) { + return ''; + } else if (runningTests) { + return {t('aria.running-tests')}; + } else if (challengeIsCompleted) { + const submitKeyboardInstructions = isEditorInFocus ? ( + {t('aria.submit')} + ) : ( + '' + ); + return ( +
+ +
+

{t('learn.test')}

+

+ {t('learn.congradulations')} + {submitKeyboardInstructions} +

+
+
+ ); + + // show the hint if the previousHint is not set + } else if (hint || previousHint) { + const hintDiscription = `

${t('learn.hint')}

${ + hint || previousHint + }`; + return ( + <> +
+ +
+

{t('learn.test')}

+

{t(sentencePicker())}

+
+
+
+ +
+
+ + ); + } + }; + + const sentencePicker = () => { + const sentenceArray = [ + 'learn.sorry-try-again', + 'learn.sorry-keep-trying', + 'learn.sorry-getting-there', + 'learn.sorry-hang-in-there', + 'learn.sorry-dont-giveup' + ]; + return attemptsNumber + ? sentenceArray[attemptsNumber % sentenceArray.length] + : sentenceArray[0]; + }; + + const onTestButtonClick = () => { + executeChallenge(); + if (onAttempt) onAttempt(); + }; + + const renderHelpButton = () => { + const isAtteptsLargerThanTest = + attemptsNumber && testsLength && attemptsNumber >= testsLength; + + if (isAtteptsLargerThanTest && !challengeIsCompleted) + return ( + + ); + }; + + const renderButtons = () => { + return ( + <> + +
+ + {renderHelpButton()} +
+ + ); + }; + + return ( +
+ {renderButtons()} +
+ {renderTestFeedbackContainer()} +
+
+ ); +}; + +LowerJaw.displayName = 'LowerJaw'; + +export default LowerJaw;