From c29d161a7573e787320b8f31280784df3095ed4b Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:19:19 +0700 Subject: [PATCH] fix(client): source super block structure in graphql and store in redux (#62613) Co-authored-by: Oliver Eyton-Williams --- client/src/components/Progress/progress.tsx | 74 ++++++++++++++++--- client/src/redux/prop-types.ts | 15 ++++ client/src/redux/selectors.js | 25 +++++-- .../Challenges/redux/completion-epic.js | 6 +- .../components/super-block-accordion.tsx | 44 +---------- .../src/templates/Introduction/redux/index.js | 17 ++++- .../Introduction/super-block-intro.tsx | 32 +++++++- .../gatsby-source-challenges/gatsby-node.js | 58 ++++++++++++++- 8 files changed, 204 insertions(+), 67 deletions(-) diff --git a/client/src/components/Progress/progress.tsx b/client/src/components/Progress/progress.tsx index bf9bfe240d7..0507500e4ac 100644 --- a/client/src/components/Progress/progress.tsx +++ b/client/src/components/Progress/progress.tsx @@ -13,7 +13,15 @@ import { } from '../../templates/Challenges/redux/selectors'; import { liveCerts } from '../../../config/cert-and-project-map'; import { updateAllChallengesInfo } from '../../redux/actions'; -import { CertificateNode, ChallengeNode } from '../../redux/prop-types'; +import type { + CertificateNode, + ChallengeNode, + SuperBlockStructure +} from '../../redux/prop-types'; +import { + updateSuperBlockStructures, + superBlockStructuresSelector +} from '../../templates/Introduction/redux'; import { getIsDailyCodingChallenge } from '../../../../shared-dist/config/challenge-types'; import { isValidDateString, @@ -26,6 +34,7 @@ const mapStateToProps = createSelector( challengeMetaSelector, completedChallengesInBlockSelector, completedPercentageSelector, + superBlockStructuresSelector, ( currentBlockIds: string[], { @@ -40,7 +49,8 @@ const mapStateToProps = createSelector( superBlock: string; }, completedChallengesInBlock: number, - completedPercent: number + completedPercent: number, + superBlockStructures: Record ) => ({ currentBlockIds, challengeType, @@ -48,11 +58,15 @@ const mapStateToProps = createSelector( block, superBlock, completedChallengesInBlock, - completedPercent + completedPercent, + superBlockStructures }) ); -const mapDispatchToProps = { updateAllChallengesInfo }; +const mapDispatchToProps = { + updateAllChallengesInfo, + updateSuperBlockStructures +}; type PropsFromRedux = ConnectedProps; @@ -68,7 +82,9 @@ function Progress({ completedChallengesInBlock, completedPercent, t, - updateAllChallengesInfo + updateAllChallengesInfo, + updateSuperBlockStructures, + superBlockStructures: superBlockStructuresFromStore }: ProgressProps): JSX.Element { let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); // Always false for legacy full stack, since it has no projects. @@ -86,10 +102,31 @@ function Progress({ } } - const { challengeNodes, certificateNodes } = useGetAllBlockIds(); + const { challengeNodes, certificateNodes, superBlockStructureNodes } = + useGetAllChallengeData(); + useEffect(() => { updateAllChallengesInfo({ challengeNodes, certificateNodes }); - }, [challengeNodes, certificateNodes, updateAllChallengesInfo]); + + const structuresMap: Record = {}; + + // 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 = @@ -119,13 +156,15 @@ function Progress({ // 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 useGetAllBlockIds = () => { +const useGetAllChallengeData = () => { const { allChallengeNode: { nodes: challengeNodes }, - allCertificateNode: { nodes: certificateNodes } + allCertificateNode: { nodes: certificateNodes }, + allSuperBlockStructure: { nodes: superBlockStructureNodes } }: { allChallengeNode: { nodes: ChallengeNode[] }; allCertificateNode: { nodes: CertificateNode[] }; + allSuperBlockStructure: { nodes: SuperBlockStructure[] }; } = useStaticQuery(graphql` query getBlockNode { allChallengeNode( @@ -154,10 +193,25 @@ const useGetAllBlockIds = () => { } } } + allSuperBlockStructure { + nodes { + superBlock + chapters { + dashedName + comingSoon + modules { + dashedName + comingSoon + moduleType + blocks + } + } + } + } } `); - return { challengeNodes, certificateNodes }; + return { challengeNodes, certificateNodes, superBlockStructureNodes }; }; Progress.displayName = 'Progress'; diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 69d12c72a4b..1ec1a8ed6d5 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -1,5 +1,6 @@ import { HandlerProps } from 'react-reflex'; import { SuperBlocks } from '../../../shared-dist/config/curriculum'; +import type { Chapter } from '../../../shared-dist/config/chapters'; import { BlockLayouts, BlockTypes } from '../../../shared-dist/config/blocks'; import type { ChallengeFile, Ext } from '../../../shared-dist/utils/polyvinyl'; import { type CertTitle } from '../../config/cert-and-project-map'; @@ -347,6 +348,20 @@ export type AllChallengesInfo = { certificateNodes: CertificateNode[]; }; +export type ChapterBasedSuperBlockStructure = { + superBlock: SuperBlocks; + chapters: Chapter[]; +}; + +export type BlockBasedSuperBlockStructure = { + superBlock: SuperBlocks; + blocks: string[]; +}; + +export type SuperBlockStructure = + | ChapterBasedSuperBlockStructure + | BlockBasedSuperBlockStructure; + export type AllChallengeNode = { edges: [ { diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index a60da02ded1..0b2fdf5ac26 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -1,10 +1,8 @@ import { createSelector } from 'reselect'; -// TODO: source the superblock structure via a GQL query, rather than directly -// from the curriculum -import superBlockStructure from '../../../curriculum/structure/superblocks/full-stack-developer.json'; import { randomBetween } from '../utils/random-between'; import { getSessionChallengeData } from '../utils/session-storage'; +import { superBlockStructuresSelector } from '../templates/Introduction/redux'; import { ns as MainApp } from './action-types'; export const savedChallengesSelector = state => @@ -121,6 +119,8 @@ export const createUserByNameSelector = username => state => { export const userFetchStateSelector = state => state[MainApp].userFetchState; export const allChallengesInfoSelector = state => state[MainApp].allChallengesInfo; +export const getSuperBlockStructure = (state, superBlock) => + superBlockStructuresSelector(state)[superBlock]; export const completedChallengesIdsSelector = createSelector( completedChallengesSelector, @@ -133,11 +133,24 @@ export const completedDailyCodingChallengesIdsSelector = createSelector( ); export const completionStateSelector = createSelector( - [allChallengesInfoSelector, completedChallengesIdsSelector], - (allChallengesInfo, completedChallengesIds) => { - const chapters = superBlockStructure.chapters; + [ + allChallengesInfoSelector, + completedChallengesIdsSelector, + superBlockStructuresSelector, + state => state.challenge.challengeMeta + ], + ( + allChallengesInfo, + completedChallengesIds, + superBlockStructures, + challengeMeta + ) => { const { challengeNodes } = allChallengesInfo; + const structure = superBlockStructures[challengeMeta.superBlock]; + + const chapters = structure.chapters ?? []; + const getCompletionState = ({ chapters, challenges, diff --git a/client/src/templates/Challenges/redux/completion-epic.js b/client/src/templates/Challenges/redux/completion-epic.js index 04f2ef0cbbe..221e2906e9e 100644 --- a/client/src/templates/Challenges/redux/completion-epic.js +++ b/client/src/templates/Challenges/redux/completion-epic.js @@ -35,7 +35,7 @@ import { isSignedInSelector, userSelector } from '../../../redux/selectors'; import { mapFilesToChallengeFiles } from '../../../utils/ajax'; import { standardizeRequestBody } from '../../../utils/challenge-request-helpers'; import postUpdate$ from '../utils/post-update'; -import { SuperBlocks } from '../../../../../shared-dist/config/curriculum'; +import { chapterBasedSuperBlocks } from '../../../../../shared-dist/config/curriculum'; import { actionTypes } from './action-types'; import { closeModal, @@ -295,11 +295,11 @@ export default function completionEpic(action$, state$) { if (action.type !== submitActionTypes.submitComplete) return null; const donationData = - superBlock === SuperBlocks.FullStackDeveloper && + chapterBasedSuperBlocks.includes(superBlock) && blockType !== 'review' && isModuleNewlyCompletedSelector(state) ? { module, superBlock } - : superBlock !== SuperBlocks.FullStackDeveloper && + : !chapterBasedSuperBlocks.includes(superBlock) && isBlockNewlyCompletedSelector(state) ? { block, superBlock } : null; diff --git a/client/src/templates/Introduction/components/super-block-accordion.tsx b/client/src/templates/Introduction/components/super-block-accordion.tsx index cd56f015572..d38db25a43f 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.tsx +++ b/client/src/templates/Introduction/components/super-block-accordion.tsx @@ -5,18 +5,7 @@ import { Disclosure } from '@headlessui/react'; import { SuperBlocks } from '../../../../../shared-dist/config/curriculum'; import DropDown from '../../../assets/icons/dropdown'; -// TODO: source the superblock structure via a GQL query, rather than directly -// from the curriculum -import fullStackCert from '../../../../../curriculum/structure/superblocks/full-stack-developer.json'; -import fullStackOpen from '../../../../../curriculum/structure/superblocks/full-stack-open.json'; -import a1Spanish from '../../../../../curriculum/structure/superblocks/a1-professional-spanish.json'; -import respWebDesignV9 from '../../../../../curriculum/structure/superblocks/responsive-web-design-v9.json'; -import javascriptV9 from '../../../../../curriculum/structure/superblocks/javascript-v9.json'; -import frontEndDevLibsV9 from '../../../../../curriculum/structure/superblocks/front-end-development-libraries-v9.json'; -import pythonV9 from '../../../../../curriculum/structure/superblocks/python-v9.json'; -import relationalDbV9 from '../../../../../curriculum/structure/superblocks/relational-databases-v9.json'; -import backEndDevApisV9 from '../../../../../curriculum/structure/superblocks/back-end-development-and-apis-v9.json'; - +import type { ChapterBasedSuperBlockStructure } from '../../../redux/prop-types'; import { ChapterIcon } from '../../../assets/chapter-icon'; import { type Chapter } from '../../../../../shared-dist/config/chapters'; import { @@ -67,6 +56,7 @@ interface Challenge { interface SuperBlockAccordionProps { challenges: Challenge[]; superBlock: SuperBlocks; + structure: ChapterBasedSuperBlockStructure; chosenBlock: string; completedChallengeIds: string[]; } @@ -182,37 +172,11 @@ const LinkBlock = ({ export const SuperBlockAccordion = ({ challenges, superBlock, + structure, chosenBlock, completedChallengeIds }: SuperBlockAccordionProps) => { - function getSuperblockStructure(superBlock: SuperBlocks): { - chapters: Chapter[]; - } { - switch (superBlock) { - case SuperBlocks.FullStackOpen: - return fullStackOpen; - case SuperBlocks.FullStackDeveloper: - return fullStackCert; - case SuperBlocks.A1Spanish: - return a1Spanish; - case SuperBlocks.RespWebDesignV9: - return respWebDesignV9; - case SuperBlocks.JsV9: - return javascriptV9; - case SuperBlocks.FrontEndDevLibsV9: - return frontEndDevLibsV9; - case SuperBlocks.PythonV9: - return pythonV9; - case SuperBlocks.RelationalDbV9: - return relationalDbV9; - case SuperBlocks.BackEndDevApisV9: - return backEndDevApisV9; - default: - throw new Error("The SuperBlock structure hasn't been imported."); - } - } - - const superBlockStructure = getSuperblockStructure(superBlock); + const superBlockStructure = structure; const modules = superBlockStructure.chapters.flatMap( ({ modules }) => modules diff --git a/client/src/templates/Introduction/redux/index.js b/client/src/templates/Introduction/redux/index.js index 31638ac43af..e0aa26f544f 100644 --- a/client/src/templates/Introduction/redux/index.js +++ b/client/src/templates/Introduction/redux/index.js @@ -7,16 +7,25 @@ export const ns = 'curriculumMap'; const initialState = { expandedState: { block: {} - } + }, + superBlockStructures: {} }; -const types = createTypes(['resetExpansion', 'toggleBlock'], ns); +const types = createTypes( + ['resetExpansion', 'toggleBlock', 'updateSuperBlockStructures'], + ns +); export const resetExpansion = createAction(types.resetExpansion); export const toggleBlock = createAction(types.toggleBlock); +export const updateSuperBlockStructures = createAction( + types.updateSuperBlockStructures +); export const makeExpandedBlockSelector = block => state => !!state[ns].expandedState.block[block]; +export const superBlockStructuresSelector = state => + state[ns].superBlockStructures || {}; export const reducer = handleActions( { @@ -35,6 +44,10 @@ export const reducer = handleActions( [payload]: !state.expandedState.block[payload] } } + }), + [types.updateSuperBlockStructures]: (state, { payload }) => ({ + ...state, + superBlockStructures: { ...payload } }) }, initialState diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index b0db3ef2991..05242dfa464 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -27,7 +27,11 @@ import { userFetchStateSelector, signInLoadingSelector } from '../../redux/selectors'; -import type { User } from '../../redux/prop-types'; +import type { + SuperBlockStructure, + User, + ChapterBasedSuperBlockStructure +} from '../../redux/prop-types'; import { CertTitle, liveCerts } from '../../../config/cert-and-project-map'; import { superBlockToCertMap } from '../../../../shared-dist/config/certification-settings'; import { @@ -71,6 +75,7 @@ type SuperBlockProps = { currentChallengeId: string; data: { allChallengeNode: { nodes: ChallengeNode[] }; + allSuperBlockStructure: { nodes: SuperBlockStructure[] }; }; expandedState: { [key: string]: boolean; @@ -143,7 +148,8 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { const { data: { - allChallengeNode: { nodes } + allChallengeNode: { nodes }, + allSuperBlockStructure }, isSignedIn, currentChallengeId, @@ -173,6 +179,10 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { const i18nTitle = i18next.t(`intro:${superBlock}.title`); + const currentSuperBlockStructure = allSuperBlockStructure.nodes.find( + node => node.superBlock === superBlock + ); + const showCertification = liveCerts.some( cert => superBlockToCertMap[superBlock] === cert.certSlug ); @@ -265,6 +275,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { c.id)} /> @@ -359,5 +372,20 @@ export const query = graphql` } } } + allSuperBlockStructure { + nodes { + superBlock + chapters { + dashedName + comingSoon + modules { + dashedName + comingSoon + moduleType + blocks + } + } + } + } } `; diff --git a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js index a646bfe2f23..cccf3c1b87e 100644 --- a/tools/client-plugins/gatsby-source-challenges/gatsby-node.js +++ b/tools/client-plugins/gatsby-source-challenges/gatsby-node.js @@ -1,9 +1,11 @@ +const path = require('path'); const chokidar = require('chokidar'); +const { getSuperblockStructure } = require('../../../curriculum/file-handler'); const { createChallengeNode } = require('./create-challenge-nodes'); exports.sourceNodes = function sourceChallengesSourceNodes( - { actions, reporter }, + { actions, reporter, createNodeId, createContentDigest }, pluginOptions ) { const { source, onSourceChange, curriculumPath } = pluginOptions; @@ -67,9 +69,13 @@ exports.sourceNodes = function sourceChallengesSourceNodes( function sourceAndCreateNodes() { return source() .then(challenges => Promise.all(challenges)) - .then(challenges => - challenges.map(challenge => createVisibleChallenge(challenge)) - ) + .then(challenges => { + // create challenge nodes + challenges.forEach(challenge => createVisibleChallenge(challenge)); + // create superblock structure nodes + createSuperBlockStructureNodes(); + return Promise.resolve(); + }) .catch(e => { console.log(e); reporter.panic(`fcc-source-challenges @@ -84,6 +90,50 @@ exports.sourceNodes = function sourceChallengesSourceNodes( createNode(createChallengeNode(challenge, reporter, options)); } + function createSuperBlockStructureNodes() { + const buildCurriculumPath = path.resolve( + curriculumPath, + '..', + '..', + 'build-curriculum' + ); + const buildCurriculum = require(buildCurriculumPath); + const superBlockToFilename = buildCurriculum.superBlockToFilename; + + if (!superBlockToFilename) { + reporter.panic( + 'superBlockToFilename is missing from build-curriculum. This map is required.' + ); + } + + Object.keys(superBlockToFilename).forEach(superBlock => { + const filename = superBlockToFilename[superBlock] || superBlock; + try { + const structure = getSuperblockStructure(filename); + + const nodeId = createNodeId(`SuperBlockStructure-${superBlock}`); + const nodeContent = JSON.stringify(structure); + + createNode({ + ...structure, + superBlock, + id: nodeId, + parent: null, + children: [], + internal: { + type: 'SuperBlockStructure', + content: nodeContent, + contentDigest: createContentDigest(structure) + } + }); + } catch (err) { + reporter.warn( + `Could not load structure for ${superBlock} (${filename}): ${err.message}` + ); + } + }); + } + return new Promise((resolve, reject) => { watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject)); });