fix(client): pull curriculum data where needed (#65710)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Ahmad Abdolsaheb
2026-02-27 15:23:21 +03:00
committed by GitHub
parent cf3c22025d
commit ba61d33698
13 changed files with 254 additions and 149 deletions
+7 -111
View File
@@ -1,9 +1,8 @@
import React, { useEffect } from 'react';
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { createSelector } from 'reselect';
import { TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import { useStaticQuery, graphql } from 'gatsby';
import {
challengeMetaSelector,
@@ -12,29 +11,19 @@ import {
completedPercentageSelector
} from '../../templates/Challenges/redux/selectors';
import { liveCerts } from '../../../config/cert-and-project-map';
import { updateAllChallengesInfo } from '../../redux/actions';
import type {
CertificateNode,
ChallengeNode,
SuperBlockStructure
} from '../../redux/prop-types';
import {
updateSuperBlockStructures,
superBlockStructuresSelector
} from '../../templates/Introduction/redux';
import { getIsDailyCodingChallenge } from '@freecodecamp/shared/config/challenge-types';
import {
isValidDateString,
formatDisplayDate
} from '../daily-coding-challenge/helpers';
import ProgressInner from './progress-inner';
import { useFetchAllCurriculumData } from '../../templates/Challenges/utils/fetch-all-curriculum-data';
const mapStateToProps = createSelector(
currentBlockIdsSelector,
challengeMetaSelector,
completedChallengesInBlockSelector,
completedPercentageSelector,
superBlockStructuresSelector,
(
currentBlockIds: string[],
{
@@ -49,8 +38,7 @@ const mapStateToProps = createSelector(
superBlock: string;
},
completedChallengesInBlock: number,
completedPercent: number,
superBlockStructures: Record<string, SuperBlockStructure>
completedPercent: number
) => ({
currentBlockIds,
challengeType,
@@ -58,15 +46,11 @@ const mapStateToProps = createSelector(
block,
superBlock,
completedChallengesInBlock,
completedPercent,
superBlockStructures
completedPercent
})
);
const mapDispatchToProps = {
updateAllChallengesInfo,
updateSuperBlockStructures
};
const mapDispatchToProps = {};
type PropsFromRedux = ConnectedProps<typeof connector>;
@@ -81,11 +65,9 @@ function Progress({
challengeType,
completedChallengesInBlock,
completedPercent,
t,
updateAllChallengesInfo,
updateSuperBlockStructures,
superBlockStructures: superBlockStructuresFromStore
t
}: ProgressProps): JSX.Element {
useFetchAllCurriculumData(); // needed to compute completedPercent
let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`);
// Always false for legacy full stack, since it has no projects.
const isCertificationProject = liveCerts.some(cert =>
@@ -102,32 +84,6 @@ function Progress({
}
}
const { challengeNodes, certificateNodes, superBlockStructureNodes } =
useGetAllChallengeData();
useEffect(() => {
updateAllChallengesInfo({ challengeNodes, certificateNodes });
const structuresMap: Record<string, SuperBlockStructure> = {};
// The super block structures are pretty static, so we only want to
// update them if we don't already have them in the store.
if (Object.keys(superBlockStructuresFromStore).length === 0) {
superBlockStructureNodes.forEach((node: SuperBlockStructure) => {
structuresMap[node.superBlock] = node;
});
updateSuperBlockStructures(structuresMap);
}
}, [
challengeNodes,
certificateNodes,
superBlockStructureNodes,
updateAllChallengesInfo,
updateSuperBlockStructures,
superBlockStructuresFromStore
]);
const totalChallengesInBlock = currentBlockIds?.length ?? 0;
const meta =
isCertificationProject && totalChallengesInBlock > 0
@@ -152,66 +108,6 @@ function Progress({
);
}
// TODO: extract this hook and call it when needed (i.e. here, in the lower-jaw
// and in completion-modal). Then we don't have to pass the data into redux.
// This would mean that we have to memoize any complex calculations in the hook.
// Otherwise, this will undo all the recent performance improvements.
const useGetAllChallengeData = () => {
const {
allChallengeNode: { nodes: challengeNodes },
allCertificateNode: { nodes: certificateNodes },
allSuperBlockStructure: { nodes: superBlockStructureNodes }
}: {
allChallengeNode: { nodes: ChallengeNode[] };
allCertificateNode: { nodes: CertificateNode[] };
allSuperBlockStructure: { nodes: SuperBlockStructure[] };
} = useStaticQuery(graphql`
query getBlockNode {
allChallengeNode(
sort: [
{ challenge: { superOrder: ASC } }
{ challenge: { order: ASC } }
{ challenge: { challengeOrder: ASC } }
]
) {
nodes {
challenge {
block
id
}
}
}
allCertificateNode {
nodes {
challenge {
certification
tests {
id
}
}
}
}
allSuperBlockStructure {
nodes {
superBlock
chapters {
dashedName
comingSoon
modules {
dashedName
comingSoon
moduleType
blocks
}
}
}
}
}
`);
return { challengeNodes, certificateNodes, superBlockStructureNodes };
};
Progress.displayName = 'Progress';
const connector = connect(mapStateToProps, mapDispatchToProps);
+12
View File
@@ -124,6 +124,18 @@ export const createUserByNameSelector = username => state => {
export const userFetchStateSelector = state => state[MainApp].userFetchState;
export const allChallengesInfoSelector = state =>
state[MainApp].allChallengesInfo;
export const needsCurriculumDataSelector = createSelector(
allChallengesInfoSelector,
superBlockStructuresSelector,
(allChallengesInfo, superBlockStructures) => {
return (
!allChallengesInfo?.challengeNodes?.length ||
!Object.keys(superBlockStructures).length
);
}
);
export const getSuperBlockStructure = (state, superBlock) =>
superBlockStructuresSelector(state)[superBlock];
@@ -40,7 +40,6 @@ import {
saveEditorContent,
setEditorFocusability,
updateFile,
submitChallenge,
initTests,
stopResetting,
openModal,
@@ -70,6 +69,7 @@ import LowerJaw from './lower-jaw';
import reactTypes from './react-types.json';
import './editor.css';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
const MonacoEditor = Loadable(() => import('react-monaco-editor'));
@@ -105,7 +105,6 @@ export interface EditorProps {
saveEditorContent: () => void;
saveSubmissionToDB?: boolean;
setEditorFocusability: (isFocusable: boolean) => void;
submitChallenge: () => void;
stopResetting: () => void;
resetAttempts: () => void;
tests: Test[];
@@ -199,7 +198,6 @@ const mapDispatchToProps = {
saveEditorContent,
setEditorFocusability,
updateFile,
submitChallenge,
initTests,
stopResetting,
resetAttempts,
@@ -296,8 +294,10 @@ const Editor = (props: EditorProps): JSX.Element => {
const [lowerJawContainer, setLowerJawContainer] =
React.useState<HTMLDivElement | null>(null);
const submitChallenge = useSubmit();
const submitChallengeDebounceRef = useRef(
debounce(props.submitChallenge, 1000, { leading: true, trailing: false })
debounce(submitChallenge, 1000, { leading: true, trailing: false })
);
const player = useRef<{
@@ -21,6 +21,8 @@ import {
} from '../../../redux/selectors';
import { getTestRunner } from '../utils/build';
import CompletionModal, { combineFileData } from './completion-modal';
import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data';
import { useStaticQuery } from 'gatsby';
vi.mock('../../../analytics');
vi.mock('../../../utils/fire-confetti');
vi.mock('../../../components/Progress');
@@ -60,6 +62,10 @@ const id = '7';
const fakeCompletedChallengesIds = ['1', '3', '5', '7', '8'];
describe('<CompletionModal />', () => {
beforeEach(() => {
vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData);
});
describe('fireConfetti', () => {
beforeEach(() => {
mockFireConfetti.mockClear();
@@ -11,7 +11,7 @@ import {
completedChallengesIdsSelector
} from '../../../redux/selectors';
import { ChallengeFiles } from '../../../redux/prop-types';
import { closeModal, submitChallenge } from '../redux/actions';
import { closeModal } from '../redux/actions';
import {
isCompletionModalOpenSelector,
successMessageSelector,
@@ -24,6 +24,7 @@ import GreenPass from '../../../assets/icons/green-pass';
import { MAX_MOBILE_WIDTH } from '../../../../config/misc';
import './completion-modal.css';
import callGA from '../../../analytics/call-ga';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
const mapStateToProps = createSelector(
challengeFilesSelector,
@@ -54,15 +55,13 @@ const mapStateToProps = createSelector(
);
const mapDispatchToProps = {
close: () => closeModal('completion'),
submitChallenge
close: () => closeModal('completion')
};
type StateProps = ReturnType<typeof mapStateToProps>;
interface CompletionModalProps extends StateProps {
close: () => void;
submitChallenge: () => void;
t: TFunction;
}
@@ -80,10 +79,10 @@ function CompletionModal({
isSignedIn,
isSubmitting,
message,
submitChallenge,
t
}: CompletionModalProps): JSX.Element {
const [downloadURL, setDownloadURL] = useState<string>();
const submitChallenge = useSubmit();
// We can't useMemo here, because it does not guarantee that the URL object
// will be revoked when the dependencies change.
useEffect(() => {
@@ -13,7 +13,6 @@ import type {
import { userSelector } from '../../../redux/selectors';
import {
setEditorFocusability,
submitChallenge,
openModal,
setIsAdvancing
} from '../redux/actions';
@@ -30,6 +29,7 @@ import {
import './hotkeys.css';
import { isProjectBased } from '../../../utils/curriculum-layout';
import type { EditorProps } from '../classic/editor';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
const mapStateToProps = createSelector(
isHelpModalOpenSelector,
@@ -67,7 +67,6 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = {
setEditorFocusability,
submitChallenge,
openShortcutsModal: () => openModal('shortcuts'),
setIsAdvancing
};
@@ -84,11 +83,7 @@ export type HotkeysProps = Pick<
> &
Pick<
EditorProps,
| 'containerRef'
| 'tests'
| 'challengeFiles'
| 'submitChallenge'
| 'setEditorFocusability'
'containerRef' | 'tests' | 'challengeFiles' | 'setEditorFocusability'
> & {
isHelpModalOpen?: boolean;
isResetModalOpen?: boolean;
@@ -116,7 +111,6 @@ function Hotkeys({
prevChallengePath,
setEditorFocusability,
setIsAdvancing,
submitChallenge,
tests,
usesMultifileEditor,
openShortcutsModal,
@@ -127,6 +121,8 @@ function Hotkeys({
isShortcutsModalOpen,
isProjectPreviewModalOpen
}: HotkeysProps): JSX.Element {
const submitChallenge = useSubmit();
const isModalOpen = [
isHelpModalOpen,
isResetModalOpen,
@@ -1,9 +1,14 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useStaticQuery } from 'gatsby';
import type { ChallengeMeta, Test } from '../../../redux/prop-types';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import { IndependentLowerJaw } from './independent-lower-jaw';
import { createStore } from '../../../redux/create-store';
import { mockCurriculumData } from '../utils/__fixtures__/curriculum-data';
import { render } from '../../../../utils/test-utils';
const baseChallengeMeta: ChallengeMeta = {
block: 'test-block',
@@ -30,9 +35,19 @@ const baseProps = {
currentBlockIds: ['id-1', 'test-challenge-id']
};
vi.mock('../../../utils/get-words');
describe('<IndependentLowerJaw />', () => {
beforeEach(() => {
vi.mocked(useStaticQuery).mockReturnValue(mockCurriculumData);
});
afterEach(() => {
vi.resetAllMocks();
});
it('shows share buttons when the block is completed on the last step', () => {
render(<IndependentLowerJaw {...baseProps} />);
render(<IndependentLowerJaw {...baseProps} />, createStore());
expect(screen.getByTestId('share-on-x')).toBeInTheDocument();
expect(screen.getByTestId('share-on-bluesky')).toBeInTheDocument();
@@ -45,7 +60,8 @@ describe('<IndependentLowerJaw />', () => {
{...baseProps}
completedPercent={50}
completedChallengeIds={['id-1']}
/>
/>,
createStore()
);
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
@@ -57,7 +73,8 @@ describe('<IndependentLowerJaw />', () => {
{...baseProps}
currentBlockIds={[baseChallengeMeta.id, 'id-2']}
completedChallengeIds={[baseChallengeMeta.id, 'id-2']}
/>
/>,
createStore()
);
expect(screen.queryByTestId('share-on-x')).not.toBeInTheDocument();
@@ -15,13 +15,14 @@ import {
currentBlockIdsSelector
} from '../redux/selectors';
import { apiLocation } from '../../../../config/env.json';
import { openModal, submitChallenge, executeChallenge } from '../redux/actions';
import { openModal, executeChallenge } from '../redux/actions';
import Help from '../../../assets/icons/help';
import callGA from '../../../analytics/call-ga';
import { Share } from '../../../components/share';
import Reset from '../../../assets/icons/reset';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
import './independent-lower-jaw.css';
import Reset from '../../../assets/icons/reset';
const mapStateToProps = createSelector(
challengeTestsSelector,
@@ -50,15 +51,13 @@ const mapStateToProps = createSelector(
const mapDispatchToProps = {
openHelpModal: () => openModal('help'),
openResetModal: () => openModal('reset'),
executeChallenge,
submitChallenge
executeChallenge
};
interface IndependentLowerJawProps {
openHelpModal: () => void;
openResetModal: () => void;
executeChallenge: () => void;
submitChallenge: () => void;
tests: Test[];
isSignedIn: boolean;
challengeMeta: ChallengeMeta;
@@ -70,7 +69,6 @@ export function IndependentLowerJaw({
openHelpModal,
openResetModal,
executeChallenge,
submitChallenge,
tests,
isSignedIn,
challengeMeta,
@@ -79,6 +77,7 @@ export function IndependentLowerJaw({
currentBlockIds
}: IndependentLowerJawProps): JSX.Element {
const { t } = useTranslation();
const submitChallenge = useSubmit();
const firstFailedTest = tests.find(test => !!test.err);
const hint = firstFailedTest?.message;
const [showHint, setShowHint] = React.useState(false);
@@ -32,7 +32,6 @@ import {
updateChallengeMeta,
openModal,
closeModal,
submitChallenge,
setUserCompletedExam,
updateSolutionFormValues,
initTests
@@ -60,6 +59,7 @@ import FinishExamModal from './components/finish-exam-modal';
import ExamResults from './components/exam-results';
import MissingPrerequisites from './components/missing-prerequisites';
import FoundationalCSharpSurveyAlert from './components/foundational-c-sharp-survey-alert';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
import './exam.css';
@@ -101,7 +101,6 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
stopExam,
setUserCompletedExam,
clearExamResults,
submitChallenge,
initTests,
updateChallengeMeta,
updateSolutionFormValues
@@ -132,7 +131,6 @@ interface ShowExamProps {
t: TFunction;
startExam: () => void;
stopExam: () => void;
submitChallenge: () => void;
setUserCompletedExam: (arg0: UserExam) => void;
updateChallengeMeta: (arg0: ChallengeMeta) => void;
}
@@ -171,6 +169,8 @@ function ShowExam(props: ShowExamProps) {
const container = useRef<HTMLElement>(null);
const submitChallenge = useSubmit();
const [examTimeInSeconds, setExamTimeInSeconds] = useState(0);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [generatedExamQuestions, setGeneratedExamQuestions] = useState<
@@ -309,7 +309,7 @@ function ShowExam(props: ShowExamProps) {
// TODO: show loader
cleanUp();
const { setUserCompletedExam, submitChallenge } = props;
const { setUserCompletedExam } = props;
setUserCompletedExam({ userExamQuestions, examTimeInSeconds });
submitChallenge();
@@ -107,7 +107,6 @@ const ShowFillInTheBlank = ({
}: ShowFillInTheBlankProps) => {
const { t } = useTranslation();
const emptyArray = fillInTheBlank.blanks.map(() => null);
const [showWrong, setShowWrong] = useState(false);
const [userAnswers, setUserAnswers] = useState<(null | string)[]>(emptyArray);
const [answersCorrect, setAnswersCorrect] =
@@ -22,7 +22,6 @@ import {
updateChallengeMeta,
openModal,
updateSolutionFormValues,
submitChallenge,
initTests
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
@@ -32,6 +31,7 @@ import {
msUsernameSelector
} from '../../../redux/selectors';
import LinkMsUser from './link-ms-user';
import { useSubmit } from '../utils/fetch-all-curriculum-data';
// Redux Setup
const mapStateToProps = createSelector(
@@ -58,8 +58,7 @@ const mapDispatchToProps = (dispatch: Dispatch) =>
updateSolutionFormValues,
openCompletionModal: () => openModal('completion'),
openHelpModal: () => openModal('help'),
setIsProcessing,
submitChallenge
setIsProcessing
},
dispatch
);
@@ -78,7 +77,6 @@ interface MsTrophyProps {
pageContext: {
challengeMeta: ChallengeMeta;
};
submitChallenge: () => void;
t: TFunction;
updateChallengeMeta: (arg0: ChallengeMeta) => void;
}
@@ -92,6 +90,9 @@ function MsTrophy(props: MsTrophyProps) {
}
}
} = props;
const submitChallenge = useSubmit();
useEffect(() => {
const {
challengeMounted,
@@ -124,7 +125,7 @@ function MsTrophy(props: MsTrophyProps) {
}, []);
const handleSubmit = () => {
const { setIsProcessing, submitChallenge } = props;
const { setIsProcessing } = props;
setIsProcessing(true);
submitChallenge();
@@ -0,0 +1,53 @@
import { Certification } from '@freecodecamp/shared/config/certification-settings';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
// Mock curriculum data for fetch-all-curriculum-data
export const mockCurriculumData = {
allChallengeNode: {
nodes: [
{
challenge: {
block: 'test-block',
id: 'test-challenge-id'
}
},
{
challenge: {
block: 'another-block',
id: 'another-challenge-id'
}
}
]
},
allCertificateNode: {
nodes: [
{
challenge: {
certification: Certification.RespWebDesign,
tests: [{ id: 'test-project-1' }, { id: 'test-project-2' }]
}
}
]
},
allSuperBlockStructure: {
nodes: [
{
superBlock: SuperBlocks.RespWebDesignV9,
chapters: [
{
dashedName: 'chapter-1',
comingSoon: false,
modules: [
{
dashedName: 'module-1',
comingSoon: false,
moduleType: 'workshop',
blocks: ['test-block', 'another-block']
}
]
}
]
}
]
}
};
@@ -0,0 +1,127 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useStaticQuery, graphql } from 'gatsby';
import { updateAllChallengesInfo } from '../../../redux/actions';
import { submitChallenge } from '../redux/actions';
import {
updateSuperBlockStructures,
superBlockStructuresSelector
} from '../../../templates/Introduction/redux';
import {
allChallengesInfoSelector,
needsCurriculumDataSelector
} from '../../../redux/selectors';
import type {
CertificateNode,
ChallengeNode,
SuperBlockStructure
} from '../../../redux/prop-types';
interface AllCurriculumData {
allChallengeNode: { nodes: ChallengeNode[] };
allCertificateNode: { nodes: CertificateNode[] };
allSuperBlockStructure: { nodes: SuperBlockStructure[] };
}
export function useFetchAllCurriculumData(): void {
const dispatch = useDispatch();
const needsCurriculumData = useSelector(needsCurriculumDataSelector);
const allChallengesInfo = useSelector(allChallengesInfoSelector) as {
challengeNodes?: Array<{ challenge: { id: string } }>;
} | null;
const superBlockStructures = useSelector(
superBlockStructuresSelector
) as Record<string, SuperBlockStructure>;
const {
allChallengeNode: { nodes: challengeNodes },
allCertificateNode: { nodes: certificateNodes },
allSuperBlockStructure: { nodes: superBlockStructureNodes }
}: AllCurriculumData = useStaticQuery(graphql`
query GetAllCurriculumData {
allChallengeNode(
sort: {
fields: [
challenge___superOrder
challenge___order
challenge___challengeOrder
]
}
) {
nodes {
challenge {
block
id
}
}
}
allCertificateNode {
nodes {
challenge {
certification
tests {
id
}
}
}
}
allSuperBlockStructure {
nodes {
superBlock
chapters {
dashedName
comingSoon
modules {
dashedName
comingSoon
moduleType
blocks
}
}
}
}
}
`);
useEffect(() => {
// Only dispatch if curriculum data is needed
if (!needsCurriculumData) return;
// Update allChallengesInfo if not already loaded
if (!allChallengesInfo?.challengeNodes?.length) {
dispatch(
updateAllChallengesInfo({
challengeNodes,
certificateNodes
})
);
}
// Update superBlockStructures if not already loaded
if (Object.keys(superBlockStructures || {}).length === 0) {
const structuresMap: Record<string, SuperBlockStructure> = {};
superBlockStructureNodes.forEach(node => {
structuresMap[node.superBlock] = node;
});
dispatch(updateSuperBlockStructures(structuresMap));
}
}, [
dispatch,
needsCurriculumData,
challengeNodes,
certificateNodes,
superBlockStructureNodes,
allChallengesInfo,
superBlockStructures
]);
}
export function useSubmit() {
// The submitChallenge epic needs the curriculum data to be loaded, so this
// useFetchAllCurriculumData call must happen first.
useFetchAllCurriculumData();
const dispatch = useDispatch();
return () => dispatch(submitChallenge());
}