fix(client): source super block structure in graphql and store in redux (#62613)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Huyen Nguyen
2025-10-17 10:19:19 +07:00
committed by GitHub
parent ed568658c1
commit c29d161a75
8 changed files with 204 additions and 67 deletions
+64 -10
View File
@@ -13,7 +13,15 @@ import {
} from '../../templates/Challenges/redux/selectors'; } from '../../templates/Challenges/redux/selectors';
import { liveCerts } from '../../../config/cert-and-project-map'; import { liveCerts } from '../../../config/cert-and-project-map';
import { updateAllChallengesInfo } from '../../redux/actions'; 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 { getIsDailyCodingChallenge } from '../../../../shared-dist/config/challenge-types';
import { import {
isValidDateString, isValidDateString,
@@ -26,6 +34,7 @@ const mapStateToProps = createSelector(
challengeMetaSelector, challengeMetaSelector,
completedChallengesInBlockSelector, completedChallengesInBlockSelector,
completedPercentageSelector, completedPercentageSelector,
superBlockStructuresSelector,
( (
currentBlockIds: string[], currentBlockIds: string[],
{ {
@@ -40,7 +49,8 @@ const mapStateToProps = createSelector(
superBlock: string; superBlock: string;
}, },
completedChallengesInBlock: number, completedChallengesInBlock: number,
completedPercent: number completedPercent: number,
superBlockStructures: Record<string, SuperBlockStructure>
) => ({ ) => ({
currentBlockIds, currentBlockIds,
challengeType, challengeType,
@@ -48,11 +58,15 @@ const mapStateToProps = createSelector(
block, block,
superBlock, superBlock,
completedChallengesInBlock, completedChallengesInBlock,
completedPercent completedPercent,
superBlockStructures
}) })
); );
const mapDispatchToProps = { updateAllChallengesInfo }; const mapDispatchToProps = {
updateAllChallengesInfo,
updateSuperBlockStructures
};
type PropsFromRedux = ConnectedProps<typeof connector>; type PropsFromRedux = ConnectedProps<typeof connector>;
@@ -68,7 +82,9 @@ function Progress({
completedChallengesInBlock, completedChallengesInBlock,
completedPercent, completedPercent,
t, t,
updateAllChallengesInfo updateAllChallengesInfo,
updateSuperBlockStructures,
superBlockStructures: superBlockStructuresFromStore
}: ProgressProps): JSX.Element { }: ProgressProps): JSX.Element {
let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`); let blockTitle = t(`intro:${superBlock}.blocks.${block}.title`);
// Always false for legacy full stack, since it has no projects. // 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(() => { useEffect(() => {
updateAllChallengesInfo({ challengeNodes, certificateNodes }); updateAllChallengesInfo({ challengeNodes, certificateNodes });
}, [challengeNodes, certificateNodes, updateAllChallengesInfo]);
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 totalChallengesInBlock = currentBlockIds?.length ?? 0;
const meta = const meta =
@@ -119,13 +156,15 @@ function Progress({
// and in completion-modal). Then we don't have to pass the data into redux. // 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. // This would mean that we have to memoize any complex calculations in the hook.
// Otherwise, this will undo all the recent performance improvements. // Otherwise, this will undo all the recent performance improvements.
const useGetAllBlockIds = () => { const useGetAllChallengeData = () => {
const { const {
allChallengeNode: { nodes: challengeNodes }, allChallengeNode: { nodes: challengeNodes },
allCertificateNode: { nodes: certificateNodes } allCertificateNode: { nodes: certificateNodes },
allSuperBlockStructure: { nodes: superBlockStructureNodes }
}: { }: {
allChallengeNode: { nodes: ChallengeNode[] }; allChallengeNode: { nodes: ChallengeNode[] };
allCertificateNode: { nodes: CertificateNode[] }; allCertificateNode: { nodes: CertificateNode[] };
allSuperBlockStructure: { nodes: SuperBlockStructure[] };
} = useStaticQuery(graphql` } = useStaticQuery(graphql`
query getBlockNode { query getBlockNode {
allChallengeNode( 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'; Progress.displayName = 'Progress';
+15
View File
@@ -1,5 +1,6 @@
import { HandlerProps } from 'react-reflex'; import { HandlerProps } from 'react-reflex';
import { SuperBlocks } from '../../../shared-dist/config/curriculum'; import { SuperBlocks } from '../../../shared-dist/config/curriculum';
import type { Chapter } from '../../../shared-dist/config/chapters';
import { BlockLayouts, BlockTypes } from '../../../shared-dist/config/blocks'; import { BlockLayouts, BlockTypes } from '../../../shared-dist/config/blocks';
import type { ChallengeFile, Ext } from '../../../shared-dist/utils/polyvinyl'; import type { ChallengeFile, Ext } from '../../../shared-dist/utils/polyvinyl';
import { type CertTitle } from '../../config/cert-and-project-map'; import { type CertTitle } from '../../config/cert-and-project-map';
@@ -347,6 +348,20 @@ export type AllChallengesInfo = {
certificateNodes: CertificateNode[]; certificateNodes: CertificateNode[];
}; };
export type ChapterBasedSuperBlockStructure = {
superBlock: SuperBlocks;
chapters: Chapter[];
};
export type BlockBasedSuperBlockStructure = {
superBlock: SuperBlocks;
blocks: string[];
};
export type SuperBlockStructure =
| ChapterBasedSuperBlockStructure
| BlockBasedSuperBlockStructure;
export type AllChallengeNode = { export type AllChallengeNode = {
edges: [ edges: [
{ {
+19 -6
View File
@@ -1,10 +1,8 @@
import { createSelector } from 'reselect'; 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 { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage'; import { getSessionChallengeData } from '../utils/session-storage';
import { superBlockStructuresSelector } from '../templates/Introduction/redux';
import { ns as MainApp } from './action-types'; import { ns as MainApp } from './action-types';
export const savedChallengesSelector = state => export const savedChallengesSelector = state =>
@@ -121,6 +119,8 @@ export const createUserByNameSelector = username => state => {
export const userFetchStateSelector = state => state[MainApp].userFetchState; export const userFetchStateSelector = state => state[MainApp].userFetchState;
export const allChallengesInfoSelector = state => export const allChallengesInfoSelector = state =>
state[MainApp].allChallengesInfo; state[MainApp].allChallengesInfo;
export const getSuperBlockStructure = (state, superBlock) =>
superBlockStructuresSelector(state)[superBlock];
export const completedChallengesIdsSelector = createSelector( export const completedChallengesIdsSelector = createSelector(
completedChallengesSelector, completedChallengesSelector,
@@ -133,11 +133,24 @@ export const completedDailyCodingChallengesIdsSelector = createSelector(
); );
export const completionStateSelector = createSelector( export const completionStateSelector = createSelector(
[allChallengesInfoSelector, completedChallengesIdsSelector], [
(allChallengesInfo, completedChallengesIds) => { allChallengesInfoSelector,
const chapters = superBlockStructure.chapters; completedChallengesIdsSelector,
superBlockStructuresSelector,
state => state.challenge.challengeMeta
],
(
allChallengesInfo,
completedChallengesIds,
superBlockStructures,
challengeMeta
) => {
const { challengeNodes } = allChallengesInfo; const { challengeNodes } = allChallengesInfo;
const structure = superBlockStructures[challengeMeta.superBlock];
const chapters = structure.chapters ?? [];
const getCompletionState = ({ const getCompletionState = ({
chapters, chapters,
challenges, challenges,
@@ -35,7 +35,7 @@ import { isSignedInSelector, userSelector } from '../../../redux/selectors';
import { mapFilesToChallengeFiles } from '../../../utils/ajax'; import { mapFilesToChallengeFiles } from '../../../utils/ajax';
import { standardizeRequestBody } from '../../../utils/challenge-request-helpers'; import { standardizeRequestBody } from '../../../utils/challenge-request-helpers';
import postUpdate$ from '../utils/post-update'; 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 { actionTypes } from './action-types';
import { import {
closeModal, closeModal,
@@ -295,11 +295,11 @@ export default function completionEpic(action$, state$) {
if (action.type !== submitActionTypes.submitComplete) return null; if (action.type !== submitActionTypes.submitComplete) return null;
const donationData = const donationData =
superBlock === SuperBlocks.FullStackDeveloper && chapterBasedSuperBlocks.includes(superBlock) &&
blockType !== 'review' && blockType !== 'review' &&
isModuleNewlyCompletedSelector(state) isModuleNewlyCompletedSelector(state)
? { module, superBlock } ? { module, superBlock }
: superBlock !== SuperBlocks.FullStackDeveloper && : !chapterBasedSuperBlocks.includes(superBlock) &&
isBlockNewlyCompletedSelector(state) isBlockNewlyCompletedSelector(state)
? { block, superBlock } ? { block, superBlock }
: null; : null;
@@ -5,18 +5,7 @@ import { Disclosure } from '@headlessui/react';
import { SuperBlocks } from '../../../../../shared-dist/config/curriculum'; import { SuperBlocks } from '../../../../../shared-dist/config/curriculum';
import DropDown from '../../../assets/icons/dropdown'; import DropDown from '../../../assets/icons/dropdown';
// TODO: source the superblock structure via a GQL query, rather than directly import type { ChapterBasedSuperBlockStructure } from '../../../redux/prop-types';
// 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 { ChapterIcon } from '../../../assets/chapter-icon'; import { ChapterIcon } from '../../../assets/chapter-icon';
import { type Chapter } from '../../../../../shared-dist/config/chapters'; import { type Chapter } from '../../../../../shared-dist/config/chapters';
import { import {
@@ -67,6 +56,7 @@ interface Challenge {
interface SuperBlockAccordionProps { interface SuperBlockAccordionProps {
challenges: Challenge[]; challenges: Challenge[];
superBlock: SuperBlocks; superBlock: SuperBlocks;
structure: ChapterBasedSuperBlockStructure;
chosenBlock: string; chosenBlock: string;
completedChallengeIds: string[]; completedChallengeIds: string[];
} }
@@ -182,37 +172,11 @@ const LinkBlock = ({
export const SuperBlockAccordion = ({ export const SuperBlockAccordion = ({
challenges, challenges,
superBlock, superBlock,
structure,
chosenBlock, chosenBlock,
completedChallengeIds completedChallengeIds
}: SuperBlockAccordionProps) => { }: SuperBlockAccordionProps) => {
function getSuperblockStructure(superBlock: SuperBlocks): { const superBlockStructure = structure;
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 modules = superBlockStructure.chapters.flatMap<Module>( const modules = superBlockStructure.chapters.flatMap<Module>(
({ modules }) => modules ({ modules }) => modules
@@ -7,16 +7,25 @@ export const ns = 'curriculumMap';
const initialState = { const initialState = {
expandedState: { expandedState: {
block: {} block: {}
} },
superBlockStructures: {}
}; };
const types = createTypes(['resetExpansion', 'toggleBlock'], ns); const types = createTypes(
['resetExpansion', 'toggleBlock', 'updateSuperBlockStructures'],
ns
);
export const resetExpansion = createAction(types.resetExpansion); export const resetExpansion = createAction(types.resetExpansion);
export const toggleBlock = createAction(types.toggleBlock); export const toggleBlock = createAction(types.toggleBlock);
export const updateSuperBlockStructures = createAction(
types.updateSuperBlockStructures
);
export const makeExpandedBlockSelector = block => state => export const makeExpandedBlockSelector = block => state =>
!!state[ns].expandedState.block[block]; !!state[ns].expandedState.block[block];
export const superBlockStructuresSelector = state =>
state[ns].superBlockStructures || {};
export const reducer = handleActions( export const reducer = handleActions(
{ {
@@ -35,6 +44,10 @@ export const reducer = handleActions(
[payload]: !state.expandedState.block[payload] [payload]: !state.expandedState.block[payload]
} }
} }
}),
[types.updateSuperBlockStructures]: (state, { payload }) => ({
...state,
superBlockStructures: { ...payload }
}) })
}, },
initialState initialState
@@ -27,7 +27,11 @@ import {
userFetchStateSelector, userFetchStateSelector,
signInLoadingSelector signInLoadingSelector
} from '../../redux/selectors'; } 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 { CertTitle, liveCerts } from '../../../config/cert-and-project-map';
import { superBlockToCertMap } from '../../../../shared-dist/config/certification-settings'; import { superBlockToCertMap } from '../../../../shared-dist/config/certification-settings';
import { import {
@@ -71,6 +75,7 @@ type SuperBlockProps = {
currentChallengeId: string; currentChallengeId: string;
data: { data: {
allChallengeNode: { nodes: ChallengeNode[] }; allChallengeNode: { nodes: ChallengeNode[] };
allSuperBlockStructure: { nodes: SuperBlockStructure[] };
}; };
expandedState: { expandedState: {
[key: string]: boolean; [key: string]: boolean;
@@ -143,7 +148,8 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
const { const {
data: { data: {
allChallengeNode: { nodes } allChallengeNode: { nodes },
allSuperBlockStructure
}, },
isSignedIn, isSignedIn,
currentChallengeId, currentChallengeId,
@@ -173,6 +179,10 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
const i18nTitle = i18next.t(`intro:${superBlock}.title`); const i18nTitle = i18next.t(`intro:${superBlock}.title`);
const currentSuperBlockStructure = allSuperBlockStructure.nodes.find(
node => node.superBlock === superBlock
);
const showCertification = liveCerts.some( const showCertification = liveCerts.some(
cert => superBlockToCertMap[superBlock] === cert.certSlug cert => superBlockToCertMap[superBlock] === cert.certSlug
); );
@@ -265,6 +275,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
<SuperBlockAccordion <SuperBlockAccordion
challenges={superBlockChallenges} challenges={superBlockChallenges}
superBlock={superBlock} superBlock={superBlock}
structure={
currentSuperBlockStructure as ChapterBasedSuperBlockStructure
}
chosenBlock={initialExpandedBlock} chosenBlock={initialExpandedBlock}
completedChallengeIds={completedChallenges.map(c => c.id)} completedChallengeIds={completedChallenges.map(c => c.id)}
/> />
@@ -359,5 +372,20 @@ export const query = graphql`
} }
} }
} }
allSuperBlockStructure {
nodes {
superBlock
chapters {
dashedName
comingSoon
modules {
dashedName
comingSoon
moduleType
blocks
}
}
}
}
} }
`; `;
@@ -1,9 +1,11 @@
const path = require('path');
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { getSuperblockStructure } = require('../../../curriculum/file-handler');
const { createChallengeNode } = require('./create-challenge-nodes'); const { createChallengeNode } = require('./create-challenge-nodes');
exports.sourceNodes = function sourceChallengesSourceNodes( exports.sourceNodes = function sourceChallengesSourceNodes(
{ actions, reporter }, { actions, reporter, createNodeId, createContentDigest },
pluginOptions pluginOptions
) { ) {
const { source, onSourceChange, curriculumPath } = pluginOptions; const { source, onSourceChange, curriculumPath } = pluginOptions;
@@ -67,9 +69,13 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
function sourceAndCreateNodes() { function sourceAndCreateNodes() {
return source() return source()
.then(challenges => Promise.all(challenges)) .then(challenges => Promise.all(challenges))
.then(challenges => .then(challenges => {
challenges.map(challenge => createVisibleChallenge(challenge)) // create challenge nodes
) challenges.forEach(challenge => createVisibleChallenge(challenge));
// create superblock structure nodes
createSuperBlockStructureNodes();
return Promise.resolve();
})
.catch(e => { .catch(e => {
console.log(e); console.log(e);
reporter.panic(`fcc-source-challenges reporter.panic(`fcc-source-challenges
@@ -84,6 +90,50 @@ exports.sourceNodes = function sourceChallengesSourceNodes(
createNode(createChallengeNode(challenge, reporter, options)); 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) => { return new Promise((resolve, reject) => {
watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject)); watcher.on('ready', () => sourceAndCreateNodes().then(resolve, reject));
}); });