Files
freeCodeCamp/client/src/templates/Challenges/components/independent-lower-jaw.tsx
T
Ahmad Abdolsaheb 2906599bef feat: add socrates (#65430)
Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
2026-04-07 19:03:20 +05:30

407 lines
13 KiB
TypeScript

import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useTranslation } from 'react-i18next';
import sanitizeHtml from 'sanitize-html';
import { Button, Spacer } from '@freecodecamp/ui';
import { useFeature } from '@growthbook/growthbook-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faLightbulb,
faClose,
faZap,
faSave,
faClockRotateLeft,
faRotateLeft
} from '@fortawesome/free-solid-svg-icons';
import Progress from '../../../components/Progress';
import {
completedChallengesIdsSelector,
isSignedInSelector,
isSocratesOnSelector
} from '../../../redux/selectors';
import { ChallengeMeta, Test } from '../../../redux/prop-types';
import {
challengeMetaSelector,
challengeTestsSelector,
completedPercentageSelector,
currentBlockIdsSelector,
socratesHintStateSelector
} from '../redux/selectors';
import { apiLocation } from '../../../../config/env.json';
import { openModal, executeChallenge, askSocrates } from '../redux/actions';
import { saveChallenge } from '../../../redux/actions';
import Help from '../../../assets/icons/help';
import callGA from '../../../analytics/call-ga';
import { Share } from '../../../components/share';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
import './independent-lower-jaw.css';
import Stars from '../../../assets/icons/stars';
type SocratesHintState = {
hint: null | string;
isLoading: boolean;
error: null | string;
attempts: null | number;
limit: null | number;
};
const mapStateToProps = createSelector(
challengeTestsSelector,
isSignedInSelector,
challengeMetaSelector,
completedPercentageSelector,
completedChallengesIdsSelector,
currentBlockIdsSelector,
socratesHintStateSelector,
isSocratesOnSelector,
(
tests: Test[],
isSignedIn: boolean,
challengeMeta: ChallengeMeta,
completedPercent: number,
completedChallengeIds: string[],
currentBlockIds: string[],
socratesHintState: SocratesHintState,
hasSocratesAccess: boolean
) => ({
tests,
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds,
socratesHintState,
hasSocratesAccess
})
);
const mapDispatchToProps = {
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset'),
askSocrates: () => askSocrates(),
executeChallenge,
saveChallenge
};
interface IndependentLowerJawProps {
openHelpModal: () => void;
openResetModal: () => void;
executeChallenge: () => void;
askSocrates: () => void;
saveChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
challengeMeta: ChallengeMeta;
completedPercent: number;
completedChallengeIds: string[];
currentBlockIds: string[];
socratesHintState: SocratesHintState;
hasSocratesAccess: boolean;
}
export function IndependentLowerJaw({
openHelpModal,
openResetModal,
askSocrates,
executeChallenge,
saveChallenge,
tests,
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds,
socratesHintState,
hasSocratesAccess
}: IndependentLowerJawProps): JSX.Element {
const { t } = useTranslation();
const showSocratesFlag = useFeature('show-socrates').on;
const submitChallenge = useSubmit();
const firstFailedTest = tests.find(test => !!test.err);
const hint = firstFailedTest?.message;
const [showHint, setShowHint] = React.useState(false);
const [showSocratesResults, setShowSocratesResults] = React.useState(false);
const [showSubmissionHint, setShowSubmissionHint] = React.useState(true);
const signInLinkRef = React.useRef<HTMLAnchorElement>(null);
const submitButtonRef = React.useRef<HTMLButtonElement>(null);
const [wasCheckButtonClicked, setWasCheckButtonClicked] =
React.useState(false);
const isChallengeComplete = tests.every(test => test.pass);
const hasBlockIds = currentBlockIds.length > 0;
const isLastStepInBlock =
hasBlockIds &&
currentBlockIds[currentBlockIds.length - 1] === challengeMeta.id;
const isBlockCompletedByIds =
hasBlockIds &&
currentBlockIds.every(challengeId =>
completedChallengeIds.includes(challengeId)
);
const hasCompletedPercent = Number.isFinite(completedPercent);
const isBlockCompleted =
isBlockCompletedByIds || (hasCompletedPercent && completedPercent === 100);
const showShareButton =
isChallengeComplete && isLastStepInBlock && isBlockCompleted;
React.useEffect(() => {
setShowHint(!!hint);
}, [hint]);
React.useEffect(() => {
if (!isChallengeComplete || !wasCheckButtonClicked) return;
const focusTarget = isSignedIn
? submitButtonRef.current
: signInLinkRef.current;
focusTarget?.focus();
setWasCheckButtonClicked(false);
}, [isChallengeComplete, isSignedIn, wasCheckButtonClicked]);
const handleCheckButtonClick = () => {
setWasCheckButtonClicked(true);
setShowSocratesResults(false);
executeChallenge();
};
const isMacOS = navigator.userAgent.includes('Mac OS');
const showRevertButton = isSignedIn && challengeMeta.saveSubmissionToDB;
const checkButtonText = isMacOS
? t('buttons.command-enter')
: t('buttons.ctrl-enter');
const askSocratesAttempt = () => {
setShowSocratesResults(true);
setShowHint(false);
setShowSubmissionHint(false);
if (socratesHintState.isLoading) return;
askSocrates();
};
return (
<div
className='independent-lower-jaw'
data-playwright-test-label='independentLowerJaw-container'
tabIndex={-1}
>
{showHint && hint && (
<div
className='hint-container'
data-playwright-test-label='independentLowerJaw-failing-hint'
>
<div className='hint-header'>
<FontAwesomeIcon icon={faLightbulb} />
<button
className={'tooltip'}
data-playwright-test-label='independentLowerJaw-hint-close-button'
onClick={() => setShowHint(false)}
aria-label={t('buttons.close')}
>
<FontAwesomeIcon icon={faClose} />
<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
<div
className='hint-body'
dangerouslySetInnerHTML={{
__html: sanitizeHtml(hint, {
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
})
}}
/>
</div>
)}
{showSocratesResults && (
<div className='hint-container'>
<div className='hint-header'>
<Stars />
<button
className={'tooltip'}
onClick={() => setShowSocratesResults(false)}
>
<FontAwesomeIcon icon={faClose} />
<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
{socratesHintState.isLoading ? (
<div className='socrates-skeleton'>
<div className='skeleton-line skeleton-line-1' />
<div className='skeleton-line skeleton-line-2' />
</div>
) : (
<div
className='hint-body'
dangerouslySetInnerHTML={{
__html: sanitizeHtml(
socratesHintState.hint || socratesHintState.error || '',
{
allowedTags: ['b', 'i', 'em', 'strong', 'code', 'wbr']
}
)
}}
/>
)}
{socratesHintState.attempts !== null &&
socratesHintState.limit !== null && (
<div className='socrates-usage-info'>
{socratesHintState.attempts}/{socratesHintState.limit}{' '}
{t('learn.hints-used-today')}
</div>
)}
</div>
)}
{isChallengeComplete && showSubmissionHint && (
<div
className='hint-container'
data-playwright-test-label='independentLowerJaw-submission-hint'
>
<div className='hint-header'>
<FontAwesomeIcon icon={faZap} />
<button
className={'tooltip'}
aria-label={t('buttons.close')}
data-playwright-test-label='independentLowerJaw-submission-hint-close-button'
onClick={() => setShowSubmissionHint(false)}
>
<FontAwesomeIcon icon={faClose} />
<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
<b>{t('learn.congratulations-code-passes')}</b>
<div className='progress-bar-container'>
<Progress minified={true} />
</div>
{isSignedIn && showShareButton && (
<div
className='share-button-wrapper'
data-testid='share-button-wrapper'
>
<Share
superBlock={challengeMeta.superBlock}
block={challengeMeta.block}
minified={true}
/>
</div>
)}
{!isSignedIn && (
<>
<Spacer size='xxs' />
<a
href={`${apiLocation}/signin`}
className='btn-cta btn btn-block'
data-playwright-test-label='independentLowerJaw-signin-link'
ref={signInLinkRef}
onClick={() => {
callGA({
event: 'sign_in'
});
}}
>
{t('learn.sign-in-save')}
</a>
</>
)}
</div>
)}
<div className='buttons-row-container'>
<div className='action-row-left'>
{isChallengeComplete ? (
<Button
block
className={`${isSignedIn && 'btn-cta'} tooltip`}
id='independent-lower-jaw-submit-button'
data-playwright-test-label='independentLowerJaw-submit-button'
aria-label={t('buttons.submit-continue')}
onClick={() => submitChallenge()}
ref={submitButtonRef}
>
{t('buttons.submit-continue')}
<span className='tooltiptext left-tooltip'>
{checkButtonText}
</span>
</Button>
) : (
<button
type='button'
className='btn-cta tooltip'
data-playwright-test-label='independentLowerJaw-check-button'
aria-label={t('buttons.check-code')}
onClick={handleCheckButtonClick}
>
{t('buttons.check-code')}
<span className='tooltiptext left-tooltip'>
{checkButtonText}
</span>
</button>
)}
</div>
<div className='action-row-right'>
{hasSocratesAccess && showSocratesFlag && (
<button
type='button'
className='icon-button tooltip socrates-button'
onClick={askSocratesAttempt}
>
<Stars />
<span className='tooltiptext'>{t('buttons.ask-socrates')}</span>
</button>
)}
{showRevertButton ? (
<>
<button
type='button'
className='icon-botton tooltip'
data-playwright-test-label='independentLowerJaw-save-button'
aria-label={t('buttons.save')}
onClick={() => saveChallenge()}
>
<FontAwesomeIcon icon={faSave} />
<span className='tooltiptext'> {t('buttons.save')}</span>
</button>
<button
type='button'
className='icon-botton tooltip'
data-playwright-test-label='independentLowerJaw-revert-button'
aria-label={t('buttons.revert')}
onClick={openResetModal}
>
<FontAwesomeIcon icon={faClockRotateLeft} />
<span className='tooltiptext'> {t('buttons.revert')}</span>
</button>
</>
) : (
<button
type='button'
className='icon-botton tooltip'
data-playwright-test-label='independentLowerJaw-reset-button'
aria-label={t('buttons.reset')}
onClick={openResetModal}
>
<FontAwesomeIcon icon={faRotateLeft} />
<span className='tooltiptext'> {t('buttons.reset')}</span>
</button>
)}
<button
type='button'
className='icon-button tooltip'
data-playwright-test-label='independentLowerJaw-help-button'
aria-label={t('buttons.help')}
onClick={openHelpModal}
>
<Help />
<span className='tooltiptext'> {t('buttons.help')}</span>
</button>
</div>
</div>
</div>
);
}
IndependentLowerJaw.displayName = 'IndependentLowerJaw';
export default connect(
mapStateToProps,
mapDispatchToProps
)(IndependentLowerJaw);