mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
c29d161a75
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
import React, { ReactNode, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
// TODO: Add this component to freecodecamp/ui and remove this dependency
|
|
import { Disclosure } from '@headlessui/react';
|
|
|
|
import { SuperBlocks } from '../../../../../shared-dist/config/curriculum';
|
|
import DropDown from '../../../assets/icons/dropdown';
|
|
import type { ChapterBasedSuperBlockStructure } from '../../../redux/prop-types';
|
|
import { ChapterIcon } from '../../../assets/chapter-icon';
|
|
import { type Chapter } from '../../../../../shared-dist/config/chapters';
|
|
import {
|
|
BlockLayouts,
|
|
BlockTypes
|
|
} from '../../../../../shared-dist/config/blocks';
|
|
import { FsdChapters } from '../../../../../shared-dist/config/chapters';
|
|
import { type Module } from '../../../../../shared-dist/config/modules';
|
|
import envData from '../../../../config/env.json';
|
|
import Block from './block';
|
|
import CheckMark from './check-mark';
|
|
|
|
import './super-block-accordion.css';
|
|
|
|
const { showUpcomingChanges } = envData;
|
|
|
|
interface ChapterProps {
|
|
dashedName: string;
|
|
children: ReactNode;
|
|
comingSoon?: boolean;
|
|
isExpanded: boolean;
|
|
totalSteps: number;
|
|
completedSteps: number;
|
|
superBlock: SuperBlocks;
|
|
}
|
|
|
|
interface ModuleProps {
|
|
dashedName: string;
|
|
children: ReactNode;
|
|
isExpanded: boolean;
|
|
totalSteps: number;
|
|
completedSteps: number;
|
|
superBlock: SuperBlocks;
|
|
}
|
|
|
|
interface Challenge {
|
|
id: string;
|
|
block: string;
|
|
blockType: BlockTypes;
|
|
title: string;
|
|
fields: { slug: string };
|
|
dashedName: string;
|
|
challengeType: number;
|
|
blockLayout: BlockLayouts;
|
|
superBlock: SuperBlocks;
|
|
}
|
|
|
|
interface SuperBlockAccordionProps {
|
|
challenges: Challenge[];
|
|
superBlock: SuperBlocks;
|
|
structure: ChapterBasedSuperBlockStructure;
|
|
chosenBlock: string;
|
|
completedChallengeIds: string[];
|
|
}
|
|
|
|
const Chapter = ({
|
|
dashedName,
|
|
children,
|
|
isExpanded,
|
|
comingSoon,
|
|
totalSteps,
|
|
completedSteps,
|
|
superBlock
|
|
}: ChapterProps) => {
|
|
const { t } = useTranslation();
|
|
const isComplete = completedSteps === totalSteps;
|
|
|
|
return (
|
|
<Disclosure as='li' className='chapter' defaultOpen={isExpanded}>
|
|
<Disclosure.Button
|
|
className='chapter-button'
|
|
data-playwright-test-label='chapter-button'
|
|
>
|
|
<div className='chapter-button-left'>
|
|
<ChapterIcon
|
|
className='map-icon'
|
|
chapter={dashedName as FsdChapters}
|
|
/>
|
|
{t(`intro:${superBlock}.chapters.${dashedName}`)}
|
|
</div>
|
|
<div className='chapter-button-right'>
|
|
{!comingSoon && (
|
|
<>
|
|
<span className='chapter-steps'>
|
|
{t('learn.steps-completed', {
|
|
totalSteps,
|
|
completedSteps
|
|
})}
|
|
</span>
|
|
<span className='checkmark-wrap chapter-checkmark-wrap'>
|
|
<CheckMark isCompleted={isComplete} />
|
|
</span>
|
|
</>
|
|
)}
|
|
<span className='dropdown-wrap'>
|
|
<DropDown />
|
|
</span>
|
|
</div>
|
|
</Disclosure.Button>
|
|
<Disclosure.Panel as='ul' className='chapter-panel'>
|
|
{children}
|
|
</Disclosure.Panel>
|
|
</Disclosure>
|
|
);
|
|
};
|
|
|
|
const Module = ({
|
|
dashedName,
|
|
children,
|
|
isExpanded,
|
|
totalSteps,
|
|
completedSteps,
|
|
superBlock
|
|
}: ModuleProps) => {
|
|
const { t } = useTranslation();
|
|
const isComplete = completedSteps === totalSteps;
|
|
|
|
return (
|
|
<Disclosure as='li' defaultOpen={isExpanded}>
|
|
<Disclosure.Button className='module-button'>
|
|
<div className='module-button-left'>
|
|
<span className='dropdown-wrap'>
|
|
<DropDown />
|
|
</span>
|
|
{t(`intro:${superBlock}.modules.${dashedName}`)}
|
|
</div>
|
|
<div className='module-button-right'>
|
|
<span className='module-steps'>
|
|
{t('learn.steps-completed', {
|
|
totalSteps,
|
|
completedSteps
|
|
})}
|
|
</span>
|
|
<span className='checkmark-wrap'>
|
|
<CheckMark isCompleted={isComplete} />
|
|
</span>
|
|
</div>
|
|
</Disclosure.Button>
|
|
<Disclosure.Panel as='ul' className='module-panel'>
|
|
{children}
|
|
</Disclosure.Panel>
|
|
</Disclosure>
|
|
);
|
|
};
|
|
|
|
const LinkBlock = ({
|
|
superBlock,
|
|
challenges
|
|
}: {
|
|
superBlock: SuperBlocks;
|
|
challenges?: Challenge[];
|
|
}) =>
|
|
challenges?.length ? (
|
|
<li className='link-block'>
|
|
<Block
|
|
block={challenges[0].block}
|
|
blockType={challenges[0].blockType}
|
|
challenges={challenges}
|
|
superBlock={superBlock}
|
|
/>
|
|
</li>
|
|
) : null;
|
|
|
|
export const SuperBlockAccordion = ({
|
|
challenges,
|
|
superBlock,
|
|
structure,
|
|
chosenBlock,
|
|
completedChallengeIds
|
|
}: SuperBlockAccordionProps) => {
|
|
const superBlockStructure = structure;
|
|
|
|
const modules = superBlockStructure.chapters.flatMap<Module>(
|
|
({ modules }) => modules
|
|
);
|
|
|
|
const isLinkModule = (name: string) => {
|
|
const module = modules.find(module => module.dashedName === name);
|
|
|
|
return module?.moduleType === 'review' || module?.moduleType === 'exam';
|
|
};
|
|
|
|
const getBlockToChapterMap = () => {
|
|
const blockToChapterMap = new Map<string, string>();
|
|
superBlockStructure.chapters.forEach(chapter => {
|
|
chapter.modules.forEach(module => {
|
|
module.blocks.forEach(block => {
|
|
blockToChapterMap.set(block, chapter.dashedName);
|
|
});
|
|
});
|
|
});
|
|
|
|
return blockToChapterMap;
|
|
};
|
|
|
|
const getBlockToModuleMap = () => {
|
|
const blockToModuleMap = new Map<string, string>();
|
|
modules.forEach(module => {
|
|
module.blocks.forEach(block => {
|
|
blockToModuleMap.set(block, module.dashedName);
|
|
});
|
|
});
|
|
|
|
return blockToModuleMap;
|
|
};
|
|
|
|
const blockToChapterMap = getBlockToChapterMap();
|
|
const blockToModuleMap = getBlockToModuleMap();
|
|
|
|
const { t } = useTranslation();
|
|
const { allChapters } = useMemo(() => {
|
|
const chapters = superBlockStructure.chapters;
|
|
const populateBlocks = (blocks: string[]) =>
|
|
blocks.map(block => {
|
|
const blockChallenges = challenges.filter(
|
|
({ block: blockName }) => blockName === block
|
|
);
|
|
|
|
return {
|
|
name: block,
|
|
blockType: blockChallenges[0]?.blockType ?? null,
|
|
challenges: blockChallenges
|
|
};
|
|
});
|
|
|
|
const allChapters = chapters.map(chapter => ({
|
|
name: chapter.dashedName,
|
|
comingSoon: chapter.comingSoon,
|
|
modules: chapter.modules.map((module: Module) => ({
|
|
name: module.dashedName,
|
|
comingSoon: module.comingSoon,
|
|
moduleType: module.moduleType,
|
|
blocks: populateBlocks(module.blocks)
|
|
}))
|
|
}));
|
|
|
|
return { allChapters };
|
|
}, [challenges, superBlockStructure.chapters]);
|
|
|
|
// Expand the outer layers in order to reveal the chosen block.
|
|
const expandedChapter = blockToChapterMap.get(chosenBlock);
|
|
const expandedModule = blockToModuleMap.get(chosenBlock);
|
|
|
|
return (
|
|
<ul className='super-block-accordion'>
|
|
{allChapters.map(chapter => {
|
|
const chapterStepIds: string[] = [];
|
|
chapter.modules.forEach(module => {
|
|
const { blocks } = module;
|
|
blocks.forEach(block =>
|
|
chapterStepIds.push(...block.challenges.map(c => c.id))
|
|
);
|
|
});
|
|
|
|
const chapterStepIdsSet = new Set(chapterStepIds);
|
|
const completedStepsInChapter = new Set(
|
|
completedChallengeIds.filter(id => chapterStepIdsSet.has(id))
|
|
).size;
|
|
|
|
return (
|
|
<Chapter
|
|
key={chapter.name}
|
|
dashedName={chapter.name}
|
|
isExpanded={
|
|
expandedChapter === chapter.name || allChapters.length === 1
|
|
}
|
|
comingSoon={chapter.comingSoon}
|
|
totalSteps={chapterStepIds.length}
|
|
completedSteps={completedStepsInChapter}
|
|
superBlock={superBlock}
|
|
>
|
|
{chapter.modules.map(module => {
|
|
if (module.comingSoon && !showUpcomingChanges) {
|
|
if (
|
|
module.moduleType === 'review' ||
|
|
module.moduleType === 'exam'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const { note, intro } = t(
|
|
`intro:${superBlock}.module-intros.${module.name}`,
|
|
{ returnObjects: true }
|
|
) as {
|
|
note: string;
|
|
intro: string[];
|
|
};
|
|
|
|
return (
|
|
<Disclosure
|
|
key={module.name}
|
|
as='li'
|
|
defaultOpen={expandedModule === module.name}
|
|
>
|
|
<Disclosure.Button className='module-button'>
|
|
<div className='module-button-left'>
|
|
<span className='dropdown-wrap'>
|
|
<DropDown />
|
|
</span>
|
|
{t(`intro:${superBlock}.modules.${module.name}`)}
|
|
</div>
|
|
</Disclosure.Button>
|
|
<Disclosure.Panel as='ul' className='module-panel'>
|
|
<div className='module-intro'>
|
|
{note && (
|
|
<p>
|
|
<b>{note}</b>
|
|
</p>
|
|
)}
|
|
{intro &&
|
|
intro.length > 0 &&
|
|
intro.map(ntro => <p key={ntro}>{ntro}</p>)}
|
|
</div>
|
|
</Disclosure.Panel>
|
|
</Disclosure>
|
|
);
|
|
}
|
|
|
|
if (isLinkModule(module.name)) {
|
|
return (
|
|
<LinkBlock
|
|
key={module.name}
|
|
superBlock={superBlock}
|
|
challenges={module.blocks[0]?.challenges}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const moduleStepIds: string[] = [];
|
|
module.blocks.forEach(block =>
|
|
moduleStepIds.push(...block.challenges.map(c => c.id))
|
|
);
|
|
|
|
const moduleStepIdsSet = new Set(moduleStepIds);
|
|
const completedStepsInModule = new Set(
|
|
completedChallengeIds.filter(id => moduleStepIdsSet.has(id))
|
|
).size;
|
|
|
|
return (
|
|
<Module
|
|
key={module.name}
|
|
dashedName={module.name}
|
|
isExpanded={expandedModule === module.name}
|
|
totalSteps={moduleStepIds.length}
|
|
completedSteps={completedStepsInModule}
|
|
superBlock={superBlock}
|
|
>
|
|
{module.blocks.map(block => (
|
|
// maybe TODO: allow blocks to be "coming soon"
|
|
<li key={block.name}>
|
|
<Block
|
|
block={block.name}
|
|
blockType={block.blockType}
|
|
challenges={block.challenges}
|
|
superBlock={superBlock}
|
|
/>
|
|
</li>
|
|
))}
|
|
</Module>
|
|
);
|
|
})}
|
|
</Chapter>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
};
|