feat: add socrates (#65430)

Co-authored-by: Mrugesh Mohapatra <noreply@mrugesh.dev>
This commit is contained in:
Ahmad Abdolsaheb
2026-04-07 16:33:20 +03:00
committed by GitHub
parent 2ad6e448fa
commit 2906599bef
51 changed files with 1915 additions and 56 deletions

View File

@@ -0,0 +1,31 @@
import React from 'react';
function Stars(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='1.3rem'
height='1.3rem'
viewBox='0 0 15 15'
fill='none'
{...props}
>
<g clipPath='url(#clip0_2310_64)'>
<path
d='M9 3C10.1053 7.18421 10.8158 8.21053 15 9C10.7368 9.86842 10.1053 10.8158 9.1579 15C8.28947 10.7368 7.57895 10.0263 3 9C7.5 8.05263 7.97368 7.26316 9 3Z'
fill='currentColor'
/>
<path
d='M3 0C3.55263 2.09211 3.90789 2.60526 6 3C3.86842 3.43421 3.55263 3.90789 3.07895 6C2.64474 3.86842 2.28947 3.51316 0 3C2.25 2.52632 2.48684 2.13158 3 0Z'
fill='currentColor'
/>
</g>
</svg>
);
}
Stars.displayName = 'Stars';
export default Stars;

View File

@@ -11,6 +11,9 @@ import { initialState } from '../redux';
const testUsername = 'testuser';
vi.mock('../utils/get-words');
vi.mock('@growthbook/growthbook-react', () => ({
useFeature: () => ({ on: false })
}));
const { apiLocation } = envData;

View File

@@ -161,7 +161,8 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
isHonest,
sendQuincyEmail,
username,
keyboardShortcuts
keyboardShortcuts,
socrates
} = user;
const sound = (store.get('fcc-sound') as boolean) ?? false;
@@ -199,6 +200,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
resetEditorLayout={resetEditorLayout}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
toggleSoundMode={toggleSoundMode}
socrates={socrates}
/>
</ScrollElement>
<Spacer size='m' />

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useFeature } from '@growthbook/growthbook-react';
import { Button, Spacer } from '@freecodecamp/ui';
import { FullWidthRow } from '../helpers';
@@ -7,11 +8,13 @@ import SoundSettings from './sound';
import KeyboardShortcutsSettings from './keyboard-shortcuts';
import ScrollbarWidthSettings from './scrollbar-width';
import SectionHeader from './section-header';
import SocratesSettings from './socrates';
type MiscSettingsProps = {
keyboardShortcuts: boolean;
sound: boolean;
editorLayout: boolean | null;
socrates: boolean;
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
toggleSoundMode: (sound: boolean) => void;
resetEditorLayout: () => void;
@@ -23,14 +26,17 @@ const MiscSettings = ({
editorLayout,
resetEditorLayout,
toggleKeyboardShortcuts,
toggleSoundMode
toggleSoundMode,
socrates
}: MiscSettingsProps) => {
const { t } = useTranslation();
const showSocratesFlag = useFeature('show-socrates').on;
return (
<div className='account-settings'>
<SectionHeader>{t('settings.headings.account')}</SectionHeader>
<FullWidthRow>
{showSocratesFlag && <SocratesSettings socrates={socrates} />}
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
keyboardShortcuts={keyboardShortcuts}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Spacer } from '@freecodecamp/ui';
import { FullWidthRow } from '../helpers';
import SoundSettings from '../../components/settings/sound';
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
type MiscSettingsProps = {
keyboardShortcuts: boolean;
sound: boolean;
editorLayout: boolean | null;
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
toggleSoundMode: (sound: boolean) => void;
resetEditorLayout: () => void;
};
const MiscSettings = ({
keyboardShortcuts,
sound,
editorLayout,
resetEditorLayout,
toggleKeyboardShortcuts,
toggleSoundMode
}: MiscSettingsProps) => {
const { t } = useTranslation();
return (
<>
<Spacer size='m' />
<FullWidthRow>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
keyboardShortcuts={keyboardShortcuts}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
explain={t('settings.shortcuts-explained')?.toString()}
/>
<ScrollbarWidthSettings />
<label htmlFor='reset-layout-btn'>
{t('settings.reset-editor-layout-tooltip')}
</label>
<Spacer size='xs' />
<Button
onClick={resetEditorLayout}
id='reset-layout-btn'
data-playwright-test-label='reset-layout-btn'
disabled={!editorLayout}
aria-disabled={!editorLayout}
>
{t('settings.reset-editor-layout')}
</Button>
</FullWidthRow>
</>
);
};
export default MiscSettings;

View File

@@ -0,0 +1,46 @@
import React from 'react';
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 { updateMySocrates as updateMySocratesAction } from '../../redux/settings/actions';
import ToggleButtonSetting from './toggle-button-setting';
const mapStateToProps = () => ({});
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators({ updateMySocrates: updateMySocratesAction }, dispatch);
type SocratesProps = {
socrates: boolean;
t: TFunction;
updateMySocrates: (socrates: { socrates: boolean }) => void;
};
function SocratesSettings({
socrates,
t,
updateMySocrates
}: SocratesProps): JSX.Element {
return (
<div className='socrates-settings'>
<ToggleButtonSetting
action={t('settings.socrates.p1')}
explain={t('settings.socrates.p2')}
flag={!!socrates}
flagName='socrates'
offLabel={t('buttons.off')}
onLabel={t('buttons.on')}
toggleFlag={() => updateMySocrates({ socrates: !socrates })}
/>
</div>
);
}
SocratesSettings.displayName = 'SocratesSettings';
export default connect(
mapStateToProps,
mapDispatchToProps
)(withTranslation()(SocratesSettings));

View File

@@ -487,6 +487,8 @@ export const reducer = handleActions(
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyQuincyEmailComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMySocratesComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyPortfolioComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyExperienceComplete]: (state, { payload }) =>

View File

@@ -451,6 +451,7 @@ export type User = {
sound: boolean;
theme: UserThemes;
keyboardShortcuts: boolean;
socrates: boolean;
twitter: string;
bluesky: string;
username: string;
@@ -516,6 +517,7 @@ export type ChallengeMeta = {
title?: string;
challengeType?: number;
helpCategory: string;
description?: string;
disableLoopProtectTests: boolean;
disableLoopProtectPreview: boolean;
saveSubmissionToDB?: boolean;

View File

@@ -27,6 +27,7 @@ export const isDonatingSelector = state => userSelector(state)?.isDonating;
export const isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
export const isSignedInSelector = state => !!userSelector(state);
export const isSocratesOnSelector = state => userSelector(state)?.socrates;
export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal;
export const isSignoutModalOpenSelector = state =>

View File

@@ -13,6 +13,7 @@ export const actionTypes = createTypes(
...createAsyncTypes('updateMyKeyboardShortcuts'),
...createAsyncTypes('updateMyHonesty'),
...createAsyncTypes('updateMyQuincyEmail'),
...createAsyncTypes('updateMySocrates'),
...createAsyncTypes('updateMyPortfolio'),
...createAsyncTypes('updateMyExperience'),
...createAsyncTypes('submitProfileUI'),

View File

@@ -73,6 +73,13 @@ export const updateMyQuincyEmailError = createAction(
types.updateMyQuincyEmailError
);
export const updateMySocrates = createAction(types.updateMySocrates);
export const updateMySocratesComplete = createAction(
types.updateMySocratesComplete,
checkForSuccessPayload
);
export const updateMySocratesError = createAction(types.updateMySocratesError);
export const updateMyPortfolio = createAction(types.updateMyPortfolio);
export const updateMyPortfolioComplete = createAction(
types.updateMyPortfolioComplete,

View File

@@ -22,6 +22,7 @@ import {
putUpdateMyExperience,
putUpdateMyProfileUI,
putUpdateMyQuincyEmail,
putUpdateMySocrates,
putUpdateMySocials,
putUpdateMyUsername,
putVerifyCert
@@ -46,6 +47,8 @@ import {
updateMyExperienceError,
updateMyQuincyEmailComplete,
updateMyQuincyEmailError,
updateMySocratesComplete,
updateMySocratesError,
updateMySocialsComplete,
updateMySocialsError,
updateMySoundComplete,
@@ -162,6 +165,16 @@ function* updateMyQuincyEmailSaga({ payload: update }) {
}
}
function* updateMySocratesSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMySocrates, update);
yield put(updateMySocratesComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch {
yield put(updateMySocratesError);
}
}
function* updateMyPortfolioSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMyPortfolio, update);
@@ -250,6 +263,7 @@ export function createSettingsSagas(types) {
takeEvery(types.resetMyEditorLayout, resetMyEditorLayoutSaga),
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
takeEvery(types.updateMySocrates, updateMySocratesSaga),
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
takeEvery(types.updateMyExperience, updateMyExperienceSaga),
takeLatest(types.submitNewAbout, submitNewAboutSaga),

View File

@@ -400,6 +400,7 @@ function ShowClassic({
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -150,7 +150,6 @@ function ShowCodeAlly({
}
}
} = data;
const blockNameTitle = `${t(
`intro:${superBlock}.blocks.${block}.title`
)}: ${title}`;
@@ -174,6 +173,7 @@ function ShowCodeAlly({
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -27,6 +27,7 @@
display: flex;
justify-content: space-between;
flex-direction: row;
margin-bottom: 10px;
}
.independent-lower-jaw .hint-container .hint-header svg {
@@ -67,9 +68,7 @@
}
.independent-lower-jaw .hint-container button {
height: 30px;
font-size: 1.5rem;
min-width: 30px;
display: flex;
justify-content: center;
align-items: center;
@@ -102,6 +101,7 @@
font-size: 1rem;
text-align: center;
position: absolute;
width: max-content;
top: -60px;
z-index: 1;
}
@@ -113,6 +113,8 @@
transition: opacity 0.5s ease 1s;
}
/* .independent-lower-jaw */
.tooltiptext::after {
content: '';
position: absolute;
@@ -143,6 +145,52 @@
gap: 10px;
}
.socrates-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.skeleton-line {
height: 16px;
border-radius: 2px;
}
.skeleton-line-1 {
background-color: var(--tertiary-background);
width: 100%;
animation: pulse-1 1.5s ease-in-out infinite;
}
.skeleton-line-2 {
background-color: var(--tertiary-background);
width: 85%;
animation: pulse-2 1.5s ease-in-out infinite;
}
@keyframes pulse-1 {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes pulse-2 {
0% {
opacity: 0.5;
}
50% {
opacity: 1;
}
100% {
opacity: 0.5;
}
}
.share-button-wrapper a {
border: 1px solid var(--quaternary-color);
padding: 2px 6px;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useStaticQuery } from 'gatsby';
@@ -11,6 +12,11 @@ import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data';
import { render } from '../../../../utils/test-utils';
vi.mock('../../../components/Progress');
let showSocratesFlag = true;
vi.mock('@growthbook/growthbook-react', () => ({
useFeature: () => ({ on: showSocratesFlag })
}));
vi.mock('../utils/fetch-all-curriculum-data', () => ({
useSubmit: () => vi.fn()
}));
@@ -30,19 +36,30 @@ const baseProps = {
openHelpModal: vi.fn(),
openResetModal: vi.fn(),
executeChallenge: vi.fn(),
submitChallenge: vi.fn(),
askSocrates: vi.fn(),
saveChallenge: vi.fn(),
tests: passingTests,
isSignedIn: true,
challengeMeta: baseChallengeMeta,
completedPercent: 100,
completedChallengeIds: ['id-1', 'test-challenge-id'],
currentBlockIds: ['id-1', 'test-challenge-id']
currentBlockIds: ['id-1', 'test-challenge-id'],
hasSocratesAccess: false,
socratesHintState: {
hint: null,
isLoading: false,
error: null,
attempts: null,
limit: null
}
};
vi.mock('../../../utils/get-words');
describe('<IndependentLowerJaw />', () => {
beforeEach(() => {
showSocratesFlag = true;
vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData);
});
@@ -83,4 +100,61 @@ describe('<IndependentLowerJaw />', () => {
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
});
it('shows socrates button when hasSocratesAccess is true and flag is on', () => {
render(
<IndependentLowerJaw {...baseProps} hasSocratesAccess={true} />,
createStore()
);
expect(screen.getByText('buttons.ask-socrates')).toBeInTheDocument();
});
it('hides socrates button when show-socrates flag is off', () => {
showSocratesFlag = false;
render(
<IndependentLowerJaw {...baseProps} hasSocratesAccess={true} />,
createStore()
);
expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
});
it('hides socrates button when hasSocratesAccess is false', () => {
render(
<IndependentLowerJaw {...baseProps} hasSocratesAccess={false} />,
createStore()
);
expect(screen.queryByText('buttons.ask-socrates')).not.toBeInTheDocument();
});
it('displays usage counter when attempts and limit are set', async () => {
const failingTests: Test[] = [
{ pass: false, err: 'fail', text: 'test', testString: 'test' }
];
render(
<IndependentLowerJaw
{...baseProps}
tests={failingTests}
hasSocratesAccess={true}
socratesHintState={{
hint: 'Try a closing tag.',
isLoading: false,
error: null,
attempts: 2,
limit: 3
}}
/>,
createStore()
);
// Click the socrates button to open the results panel
await userEvent.click(screen.getByRole('button', { name: /ask-socrates/ }));
expect(screen.getByText(/2\/3/)).toBeInTheDocument();
expect(screen.getByText(/learn\.hints-used-today/)).toBeInTheDocument();
});
});

View File

@@ -2,7 +2,9 @@ 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,
@@ -15,17 +17,19 @@ import {
import Progress from '../../../components/Progress';
import {
completedChallengesIdsSelector,
isSignedInSelector
isSignedInSelector,
isSocratesOnSelector
} from '../../../redux/selectors';
import { ChallengeMeta, Test } from '../../../redux/prop-types';
import {
challengeMetaSelector,
challengeTestsSelector,
completedPercentageSelector,
currentBlockIdsSelector
currentBlockIdsSelector,
socratesHintStateSelector
} from '../redux/selectors';
import { apiLocation } from '../../../../config/env.json';
import { openModal, executeChallenge } from '../redux/actions';
import { openModal, executeChallenge, askSocrates } from '../redux/actions';
import { saveChallenge } from '../../../redux/actions';
import Help from '../../../assets/icons/help';
import callGA from '../../../analytics/call-ga';
@@ -33,6 +37,15 @@ 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,
@@ -41,26 +54,33 @@ const mapStateToProps = createSelector(
completedPercentageSelector,
completedChallengesIdsSelector,
currentBlockIdsSelector,
socratesHintStateSelector,
isSocratesOnSelector,
(
tests: Test[],
isSignedIn: boolean,
challengeMeta: ChallengeMeta,
completedPercent: number,
completedChallengeIds: string[],
currentBlockIds: string[]
currentBlockIds: string[],
socratesHintState: SocratesHintState,
hasSocratesAccess: boolean
) => ({
tests,
isSignedIn,
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds
currentBlockIds,
socratesHintState,
hasSocratesAccess
})
);
const mapDispatchToProps = {
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset'),
askSocrates: () => askSocrates(),
executeChallenge,
saveChallenge
};
@@ -69,6 +89,7 @@ interface IndependentLowerJawProps {
openHelpModal: () => void;
openResetModal: () => void;
executeChallenge: () => void;
askSocrates: () => void;
saveChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
@@ -76,10 +97,13 @@ interface IndependentLowerJawProps {
completedPercent: number;
completedChallengeIds: string[];
currentBlockIds: string[];
socratesHintState: SocratesHintState;
hasSocratesAccess: boolean;
}
export function IndependentLowerJaw({
openHelpModal,
openResetModal,
askSocrates,
executeChallenge,
saveChallenge,
tests,
@@ -87,13 +111,17 @@ export function IndependentLowerJaw({
challengeMeta,
completedPercent,
completedChallengeIds,
currentBlockIds
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);
@@ -132,6 +160,7 @@ export function IndependentLowerJaw({
const handleCheckButtonClick = () => {
setWasCheckButtonClicked(true);
setShowSocratesResults(false);
executeChallenge();
};
@@ -141,6 +170,14 @@ export function IndependentLowerJaw({
? 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'
@@ -164,7 +201,53 @@ export function IndependentLowerJaw({
<span className='tooltiptext'> {t('buttons.close')}</span>
</button>
</div>
<div dangerouslySetInnerHTML={{ __html: hint }} />
<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 && (
@@ -254,6 +337,16 @@ export function IndependentLowerJaw({
)}
</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
@@ -291,7 +384,7 @@ export function IndependentLowerJaw({
)}
<button
type='button'
className='icon-botton tooltip'
className='icon-button tooltip'
data-playwright-test-label='independentLowerJaw-help-button'
aria-label={t('buttons.help')}
onClick={openHelpModal}

View File

@@ -185,7 +185,7 @@ function ShowExam(props: ShowExamProps) {
challengeMounted,
data: {
challengeNode: {
challenge: { tests, challengeType, helpCategory, title }
challenge: { tests, challengeType, helpCategory, description, title }
}
},
pageContext: { challengeMeta },
@@ -201,6 +201,7 @@ function ShowExam(props: ShowExamProps) {
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -128,6 +128,7 @@ const ShowFillInTheBlank = ({
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -157,6 +157,7 @@ const ShowGeneric = ({
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -98,7 +98,7 @@ function MsTrophy(props: MsTrophyProps) {
challengeMounted,
data: {
challengeNode: {
challenge: { tests, title, challengeType, helpCategory }
challenge: { tests, title, challengeType, helpCategory, description }
}
},
pageContext: { challengeMeta },
@@ -114,6 +114,7 @@ function MsTrophy(props: MsTrophyProps) {
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -112,7 +112,7 @@ const ShowBackEnd = (props: BackEndProps) => {
updateChallengeMeta,
data: {
challengeNode: {
challenge: { challengeType, helpCategory, tests, title }
challenge: { challengeType, helpCategory, description, tests, title }
}
},
pageContext: { challengeMeta }
@@ -127,6 +127,7 @@ const ShowBackEnd = (props: BackEndProps) => {
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -86,7 +86,7 @@ const ShowFrontEndProject = (props: ProjectProps) => {
challengeMounted,
data: {
challengeNode: {
challenge: { tests, title, challengeType, helpCategory }
challenge: { tests, title, challengeType, helpCategory, description }
}
},
pageContext: { challengeMeta },
@@ -102,6 +102,7 @@ const ShowFrontEndProject = (props: ProjectProps) => {
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -236,6 +236,7 @@ const ShowQuiz = ({
title,
challengeType,
helpCategory,
description,
...challengePaths
});
challengeMounted(challengeMeta.id);

View File

@@ -44,7 +44,8 @@ export const actionTypes = createTypes(
'setEditorFocusability',
'toggleVisibleEditor',
...createAsyncTypes('submitChallenge'),
...createAsyncTypes('executeChallenge')
...createAsyncTypes('executeChallenge'),
...createAsyncTypes('askSocrates')
],
ns
);

View File

@@ -59,6 +59,13 @@ export const executeChallenge = createAction(actionTypes.executeChallenge);
export const executeChallengeComplete = createAction(
actionTypes.executeChallengeComplete
);
export const askSocrates = createAction(actionTypes.askSocrates);
export const askSocratesComplete = createAction(
actionTypes.askSocratesComplete
);
export const askSocratesError = createAction(actionTypes.askSocratesError);
export const resetChallenge = createAction(actionTypes.resetChallenge);
export const stopResetting = createAction(actionTypes.stopResetting);
export const submitChallenge = createAction(actionTypes.submitChallenge);

View File

@@ -0,0 +1,129 @@
import i18next from 'i18next';
import { takeEvery, select, call, put } from 'redux-saga/effects';
import {
challengeDataSelector,
challengeTestsSelector,
challengeMetaSelector
} from './selectors';
import { buildChallenge } from '@freecodecamp/challenge-builder/build';
import { getSocratesHint } from '../../../utils/ajax';
import { isSocratesOnSelector } from '../../../redux/selectors';
import { askSocratesError, askSocratesComplete } from './actions';
// Maps server-side error keys to client-side translation keys.
const serverErrorKeyMap = {
'socrates-no-access': 'learn.socrates-no-access',
'socrates-daily-limit': 'learn.socrates-daily-limit',
'socrates-rate-limit': 'learn.socrates-rate-limit',
'socrates-unable-to-generate': 'learn.socrates-unable-to-generate',
'socrates-unavailable': 'learn.socrates-unavailable',
'socrates-invalid-request': 'learn.socrates-invalid-request'
};
function translateServerError(errorKey) {
const translationKey = serverErrorKeyMap[errorKey];
return translationKey ? i18next.t(translationKey) : errorKey;
}
export function* askSocratesSaga() {
const isSocratesOn = yield select(isSocratesOnSelector);
if (!isSocratesOn) {
yield put(
askSocratesError({
error: i18next.t('learn.socrates-not-enabled')
})
);
return;
}
try {
const challengeData = yield select(challengeDataSelector);
const tests = yield select(challengeTestsSelector);
const { description } = yield select(challengeMetaSelector);
const hasCheckedCode = tests.some(test => test.pass || test.err);
if (!hasCheckedCode) {
yield put(
askSocratesError({
error: i18next.t('learn.socrates-check-code-first')
})
);
return;
}
const allTestsPass = tests.every(test => test.pass);
if (allTestsPass) {
yield put(
askSocratesError({
error: i18next.t('learn.socrates-code-passes')
})
);
return;
}
const buildData = yield call(buildChallenge, challengeData);
const { sources, build } = buildData;
const seed = build;
const userInput = sources?.editableContents;
if (!seed) {
yield put(
askSocratesError({
error: i18next.t('learn.socrates-write-code-first')
})
);
return;
}
const hints = Array.isArray(tests)
? tests.map(({ text, err }) => {
const item = { text };
if (err) item.failed = true;
return item;
})
: [];
const optimizedPayload = {
seed,
description,
hints
};
if (userInput) {
optimizedPayload.userInput = userInput;
}
const response = yield call(getSocratesHint, optimizedPayload);
const responseData = response?.data;
const error = responseData?.error;
if (error) {
yield put(
askSocratesError({
error: translateServerError(error),
attempts: responseData?.attempts,
limit: responseData?.limit
})
);
} else {
yield put(
askSocratesComplete({
hint: responseData.hint,
attempts: responseData.attempts,
limit: responseData.limit
})
);
}
} catch {
yield put(
askSocratesError({
error: i18next.t('learn.socrates-generic-error')
})
);
}
}
export function createAskSocratesSaga(types) {
return [takeEvery(types.askSocrates, askSocratesSaga)];
}

View File

@@ -0,0 +1,262 @@
/* eslint-disable vitest/expect-expect */
import { expectSaga } from 'redux-saga-test-plan';
import * as matchers from 'redux-saga-test-plan/matchers';
import { throwError } from 'redux-saga-test-plan/providers';
import { describe, it, vi } from 'vitest';
import { askSocratesSaga } from './ask-socrates-saga';
vi.mock('i18next', async () => ({
default: {
t: key => key
}
}));
vi.mock('@freecodecamp/challenge-builder/build', () => ({
buildChallenge: vi.fn()
}));
vi.mock('../../../utils/ajax', () => ({
getSocratesHint: vi.fn()
}));
const baseState = {
app: {
user: {
sessionUser: {
socrates: true
}
}
},
challenge: {
challengeTests: [
{ text: 'Test 1', pass: false, err: 'Expected true' },
{ text: 'Test 2', pass: true }
],
challengeMeta: { description: 'Make the text say hello' },
challengeFiles: {
indexhtml: {
contents: '<h1>Hello</h1>',
editableContents: 'Hello world',
ext: 'html',
key: 'indexhtml'
}
}
}
};
function reducer(state = baseState) {
return state;
}
describe('askSocratesSaga', () => {
it('dispatches error when socrates is not enabled', () => {
const state = {
...baseState,
app: {
user: {
sessionUser: {
socrates: false
}
}
}
};
return expectSaga(askSocratesSaga)
.withReducer(reducer, state)
.put({
type: 'challenge.askSocratesError',
payload: { error: 'learn.socrates-not-enabled' }
})
.silentRun();
});
it('dispatches error when code has not been checked', () => {
const state = {
...baseState,
challenge: {
...baseState.challenge,
challengeTests: [
{ text: 'Test 1', pass: false },
{ text: 'Test 2', pass: false }
]
}
};
return expectSaga(askSocratesSaga)
.withReducer(reducer, state)
.put({
type: 'challenge.askSocratesError',
payload: {
error: 'learn.socrates-check-code-first'
}
})
.silentRun();
});
it('dispatches error when all tests pass', () => {
const state = {
...baseState,
challenge: {
...baseState.challenge,
challengeTests: [
{ text: 'Test 1', pass: true },
{ text: 'Test 2', pass: true }
]
}
};
return expectSaga(askSocratesSaga)
.withReducer(reducer, state)
.put({
type: 'challenge.askSocratesError',
payload: {
error: 'learn.socrates-code-passes'
}
})
.silentRun();
});
it('dispatches error when buildChallenge returns no seed', async () => {
const { buildChallenge } = await import(
'@freecodecamp/challenge-builder/build'
);
return expectSaga(askSocratesSaga)
.withReducer(reducer)
.provide([
[
matchers.call.fn(buildChallenge),
{ sources: { editableContents: 'Hello world' }, build: '' }
]
])
.put({
type: 'challenge.askSocratesError',
payload: {
error: 'learn.socrates-write-code-first'
}
})
.silentRun();
});
it('dispatches complete without userInput when editableContents is empty', async () => {
const { buildChallenge } = await import(
'@freecodecamp/challenge-builder/build'
);
const { getSocratesHint } = await import('../../../utils/ajax');
return expectSaga(askSocratesSaga)
.withReducer(reducer)
.provide([
[
matchers.call.fn(buildChallenge),
{
sources: { editableContents: '', contents: '' },
build: '<h1>Hello</h1>'
}
],
[
matchers.call.fn(getSocratesHint),
{
data: { hint: 'A hint.', attempts: 1, limit: 3 }
}
]
])
.put({
type: 'challenge.askSocratesComplete',
payload: { hint: 'A hint.', attempts: 1, limit: 3 }
})
.silentRun();
});
it('dispatches complete with hint on successful API response', async () => {
const { buildChallenge } = await import(
'@freecodecamp/challenge-builder/build'
);
const { getSocratesHint } = await import('../../../utils/ajax');
return expectSaga(askSocratesSaga)
.withReducer(reducer)
.provide([
[
matchers.call.fn(buildChallenge),
{
sources: { editableContents: 'Hello world' },
build: '<h1>Hello</h1>'
}
],
[
matchers.call.fn(getSocratesHint),
{
data: { hint: 'Try adding a closing tag.', attempts: 1, limit: 3 }
}
]
])
.put({
type: 'challenge.askSocratesComplete',
payload: { hint: 'Try adding a closing tag.', attempts: 1, limit: 3 }
})
.silentRun();
});
it('dispatches error with attempts/limit on API error response', async () => {
const { buildChallenge } = await import(
'@freecodecamp/challenge-builder/build'
);
const { getSocratesHint } = await import('../../../utils/ajax');
return expectSaga(askSocratesSaga)
.withReducer(reducer)
.provide([
[
matchers.call.fn(buildChallenge),
{
sources: { editableContents: 'Hello world' },
build: '<h1>Hello</h1>'
}
],
[
matchers.call.fn(getSocratesHint),
{
data: {
error: 'Daily limit reached.',
attempts: 3,
limit: 3
}
}
]
])
.put({
type: 'challenge.askSocratesError',
payload: { error: 'Daily limit reached.', attempts: 3, limit: 3 }
})
.silentRun();
});
it('dispatches generic error when API call throws', async () => {
const { buildChallenge } = await import(
'@freecodecamp/challenge-builder/build'
);
const { getSocratesHint } = await import('../../../utils/ajax');
return expectSaga(askSocratesSaga)
.withReducer(reducer)
.provide([
[
matchers.call.fn(buildChallenge),
{
sources: { editableContents: 'Hello world' },
build: '<h1>Hello</h1>'
}
],
[matchers.call.fn(getSocratesHint), throwError(new Error('Network'))]
])
.put({
type: 'challenge.askSocratesError',
payload: {
error: 'learn.socrates-generic-error'
}
})
.silentRun();
});
});

View File

@@ -8,6 +8,7 @@ import codeStorageEpic from './code-storage-epic';
import completionEpic from './completion-epic';
import createQuestionEpic from './create-question-epic';
import { createCurrentChallengeSaga } from './current-challenge-saga';
import { createAskSocratesSaga } from './ask-socrates-saga';
import { createExecuteChallengeSaga } from './execute-challenge-saga';
export { ns };
@@ -26,7 +27,8 @@ const initialState = {
nextChallengePath: '/',
prevChallengePath: '/',
challengeType: -1,
saveSubmissionToDB: false
saveSubmissionToDB: false,
description: ''
},
challengeTests: [],
consoleOut: [],
@@ -58,14 +60,22 @@ const initialState = {
successMessage: 'Happy Coding!',
isAdvancing: false,
chapterSlug: '',
isSubmitting: false
isSubmitting: false,
socratesHintState: {
hint: null,
isLoading: false,
error: null,
attempts: null,
limit: null
}
};
export const epics = [completionEpic, createQuestionEpic, codeStorageEpic];
export const sagas = [
...createExecuteChallengeSaga(actionTypes),
...createCurrentChallengeSaga(actionTypes)
...createCurrentChallengeSaga(actionTypes),
...createAskSocratesSaga(actionTypes)
];
export const reducer = handleActions(
@@ -277,6 +287,36 @@ export const reducer = handleActions(
...state,
isExecuting: false
}),
[actionTypes.askSocrates]: state => ({
...state,
socratesHintState: {
hint: null,
isLoading: true,
error: null,
attempts: state.socratesHintState.attempts,
limit: state.socratesHintState.limit
}
}),
[actionTypes.askSocratesComplete]: (state, { payload }) => ({
...state,
socratesHintState: {
hint: payload.hint,
isLoading: false,
error: null,
attempts: payload.attempts,
limit: payload.limit
}
}),
[actionTypes.askSocratesError]: (state, { payload }) => ({
...state,
socratesHintState: {
hint: null,
isLoading: false,
error: payload.error,
attempts: payload.attempts ?? state.socratesHintState.attempts,
limit: payload.limit ?? state.socratesHintState.limit
}
}),
[actionTypes.setEditorFocusability]: (state, { payload }) => ({
...state,
canFocusEditor: payload

View File

@@ -17,6 +17,7 @@ import { ns } from './action-types';
export const challengeFilesSelector = state => state[ns].challengeFiles;
export const challengeMetaSelector = state => state[ns].challengeMeta;
export const socratesHintStateSelector = state => state[ns].socratesHintState;
export const challengeHooksSelector = state => state[ns].challengeHooks;
export const challengeTestsSelector = state => state[ns].challengeTests;
export const consoleOutputSelector = state => {

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi } from 'vitest';
import { reducer } from './index';
import { actionTypes } from './action-types';
vi.mock('../../../utils/get-words');
const baseState = {
socratesHintState: {
hint: null,
isLoading: false,
error: null,
attempts: null,
limit: null
}
};
describe('socratesHintState reducer', () => {
it('sets isLoading true and preserves attempts/limit on askSocrates', () => {
const state = {
...baseState,
socratesHintState: {
...baseState.socratesHintState,
attempts: 2,
limit: 3
}
};
const result = reducer(state, { type: actionTypes.askSocrates });
expect(result.socratesHintState).toEqual({
hint: null,
isLoading: true,
error: null,
attempts: 2,
limit: 3
});
});
it('stores hint, attempts, limit on askSocratesComplete', () => {
const state = {
...baseState,
socratesHintState: { ...baseState.socratesHintState, isLoading: true }
};
const result = reducer(state, {
type: actionTypes.askSocratesComplete,
payload: { hint: 'Try a closing tag.', attempts: 1, limit: 3 }
});
expect(result.socratesHintState).toEqual({
hint: 'Try a closing tag.',
isLoading: false,
error: null,
attempts: 1,
limit: 3
});
});
it('stores error with attempts/limit on askSocratesError', () => {
const state = {
...baseState,
socratesHintState: { ...baseState.socratesHintState, isLoading: true }
};
const result = reducer(state, {
type: actionTypes.askSocratesError,
payload: { error: 'Daily limit reached.', attempts: 3, limit: 3 }
});
expect(result.socratesHintState).toEqual({
hint: null,
isLoading: false,
error: 'Daily limit reached.',
attempts: 3,
limit: 3
});
});
it('preserves previous attempts/limit when error payload omits them', () => {
const state = {
...baseState,
socratesHintState: {
...baseState.socratesHintState,
isLoading: true,
attempts: 2,
limit: 3
}
};
const result = reducer(state, {
type: actionTypes.askSocratesError,
payload: { error: 'Something went wrong.' }
});
expect(result.socratesHintState).toEqual({
hint: null,
isLoading: false,
error: 'Something went wrong.',
attempts: 2,
limit: 3
});
});
});

View File

@@ -106,7 +106,7 @@ type SavedChallengeFromApi = {
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<SavedChallenge, 'challengeFiles'>;
type ApiUser = Omit<User, 'completedChallenges' & 'savedChallenges'> & {
type ApiUser = Omit<User, 'completedChallenges' | 'savedChallenges'> & {
completedChallenges?: CompleteChallengeFromApi[];
savedChallenges?: SavedChallengeFromApi[];
};
@@ -259,6 +259,21 @@ interface Donation {
customerId: string;
startDate: Date;
}
interface SocratesHintPayload {
userInput: string;
seed: string;
description: string;
hints: Array<{ text: string; failed?: boolean }>;
}
interface SocratesHintResponse {
hint?: string;
error?: string;
type?: string;
attempts?: number;
limit?: number;
}
// TODO: Verify if the body has and needs this Donation type. The api seems to
// just need the body to exist, but doesn't seem to use the properties.
export function addDonation(body: Donation): Promise<ResponseWithData<void>> {
@@ -286,6 +301,11 @@ export function generateExamToken(): Promise<
> {
return post('/user/exam-environment/token', {});
}
export function getSocratesHint(
body: SocratesHintPayload
): Promise<ResponseWithData<SocratesHintResponse>> {
return put('/socrates/get-hint', body);
}
type PaymentIntentResponse = Promise<
ResponseWithData<
@@ -403,6 +423,12 @@ export function putUpdateMyQuincyEmail(update: {
return put('/update-my-quincy-email', update);
}
export function putUpdateMySocrates(update: {
socrates: boolean;
}): Promise<ResponseWithData<void>> {
return put('/update-socrates', { socrates: update.socrates });
}
export function putUpdateMyPortfolio(
update: Record<string, string>
): Promise<ResponseWithData<void>> {