Files
freeCodeCamp/client/src/templates/Introduction/components/super-block-accordion.tsx
T

474 lines
13 KiB
TypeScript

import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SuperBlocks } from '@freecodecamp/shared/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 '@freecodecamp/shared/config/chapters';
import { Link } from '../../../components/helpers';
import { BlockLayouts, BlockLabel } from '@freecodecamp/shared/config/blocks';
import { FsdChapters } from '@freecodecamp/shared/config/chapters';
import { type Module } from '@freecodecamp/shared/config/modules';
import envData from '../../../../config/env.json';
import Block from './block';
import CheckMark from './check-mark';
import { default as BlockLabelComponent } from './block-label';
import './super-block-accordion.css';
const { showUpcomingChanges } = envData;
interface ChapterProps {
dashedName: string;
children: ReactNode;
comingSoon?: boolean;
isExpanded: boolean;
totalSteps: number;
completedSteps: number;
superBlock: SuperBlocks;
isLinkChapter?: boolean;
examSlug?: string;
}
interface ModuleProps {
dashedName: string;
children: ReactNode;
isExpanded: boolean;
totalSteps: number;
completedSteps: number;
superBlock: SuperBlocks;
comingSoon: boolean;
}
interface Challenge {
id: string;
block: string;
blockLabel?: BlockLabel;
title: string;
fields: { slug: string };
dashedName: string;
challengeType: number;
blockLayout: BlockLayouts;
superBlock: SuperBlocks;
}
interface PopulatedBlock {
name: string;
blockLabel: BlockLabel | null;
challenges: Challenge[];
}
interface PopulatedModule {
name: string;
comingSoon?: boolean;
moduleType?: Module['moduleType'];
blocks: PopulatedBlock[];
}
interface PopulatedChapter {
name: string;
comingSoon?: boolean;
modules: PopulatedModule[];
}
interface SuperBlockAccordionProps {
challenges: Challenge[];
superBlock: SuperBlocks;
structure: ChapterBasedSuperBlockStructure;
chosenBlock: string;
completedChallengeIds: string[];
/**
* When true, expands all chapters and modules and hides those with no matching challenges.
* Used during search/filter.
*/
expandAll?: boolean;
}
const Chapter = ({
dashedName,
children,
isExpanded,
comingSoon,
totalSteps,
completedSteps,
superBlock,
isLinkChapter,
examSlug
}: ChapterProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(isExpanded);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `chapter-panel-${dashedName}`;
const isComplete = completedSteps === totalSteps && totalSteps > 0;
const chapterLabel = t(`intro:${superBlock}.chapters.${dashedName}`);
const chapterButtonContent = (
<>
<div className='chapter-button-left'>
<span className='checkmark-wrap chapter-checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
</span>
<ChapterIcon className='map-icon' chapter={dashedName as FsdChapters} />
{chapterLabel}
{isLinkChapter && examSlug && (
<BlockLabelComponent blockLabel={BlockLabel.exam} />
)}
</div>
<div className='chapter-button-right'>
{!comingSoon && !isLinkChapter && (
<span className='chapter-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
</span>
)}
<span className='dropdown-wrap'>{!isLinkChapter && <DropDown />}</span>
</div>
</>
);
if (isLinkChapter && examSlug) {
return (
<li className='chapter'>
<Link
className='chapter-button'
data-playwright-test-label='chapter-button'
to={examSlug}
>
{chapterButtonContent}
</Link>
</li>
);
}
return (
<li className='chapter'>
<button
aria-controls={panelId}
aria-expanded={open}
className='chapter-button'
data-playwright-test-label='chapter-button'
onClick={() => setOpen(o => !o)}
type='button'
>
{chapterButtonContent}
</button>
{open && (
<ul className='chapter-panel' id={panelId}>
{children}
</ul>
)}
</li>
);
};
const Module = ({
dashedName,
children,
isExpanded,
totalSteps,
completedSteps,
superBlock,
comingSoon
}: ModuleProps) => {
const { t } = useTranslation();
const isComplete = totalSteps === 0 ? false : completedSteps === totalSteps;
const { note, intro } = t(`intro:${superBlock}.module-intros.${dashedName}`, {
returnObjects: true
}) as {
note: string;
intro: string[];
};
const showModuleContent = !(comingSoon && !showUpcomingChanges);
const [open, setOpen] = useState(isExpanded);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `module-panel-${dashedName}`;
return (
<li>
<button
aria-controls={panelId}
aria-expanded={open}
className='module-button'
onClick={() => setOpen(o => !o)}
type='button'
>
<div className='module-button-left'>
<span className='dropdown-wrap'>
<DropDown />
</span>
<span className='checkmark-wrap'>
<CheckMark isCompleted={isComplete} />
</span>
{t(`intro:${superBlock}.modules.${dashedName}`)}
</div>
<div className='module-button-right' data-testid='module-button-right'>
{!comingSoon && !!totalSteps && (
<span className='module-steps'>
{t('learn.steps-completed', {
totalSteps,
completedSteps
})}
</span>
)}
</div>
</button>
{open && (
<ul className='module-panel' id={panelId}>
{comingSoon && (
<div className='module-intro'>
{note && (
<p>
<b>{note}</b>
</p>
)}
{intro?.length && intro.map(ntro => <p key={ntro}>{ntro}</p>)}
</div>
)}
{showModuleContent && children}
</ul>
)}
</li>
);
};
const LinkModule = ({
superBlock,
challenges,
accordion,
moduleType,
expandAll
}: {
superBlock: SuperBlocks;
challenges?: Challenge[];
accordion: boolean;
moduleType?: Module['moduleType'];
expandAll?: boolean;
}) => {
if (!challenges?.length) return null;
const label = moduleType ?? challenges[0].blockLabel;
return (
<li className='link-block'>
<Block
block={challenges[0].block}
blockLabel={label || null}
challenges={challenges}
superBlock={superBlock}
accordion={accordion}
expandAll={expandAll}
/>
</li>
);
};
export const SuperBlockAccordion = ({
challenges,
superBlock,
structure,
chosenBlock,
completedChallengeIds,
expandAll = false
}: 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 === BlockLabel.review ||
module?.moduleType === BlockLabel.exam ||
module?.moduleType === BlockLabel.quiz ||
module?.moduleType === BlockLabel.certProject
);
};
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 allChapters = useMemo<PopulatedChapter[]>(() => {
const populateBlocks = (blocks: string[]): PopulatedBlock[] =>
blocks.map(block => {
const blockChallenges = challenges.filter(
({ block: blockName }) => blockName === block
);
return {
name: block,
blockLabel: blockChallenges[0]?.blockLabel ?? null,
challenges: blockChallenges
};
});
return superBlockStructure.chapters.map((chapter: 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)
}))
}));
}, [challenges, superBlockStructure.chapters]);
// Expand the outer layers in order to reveal the chosen block.
const expandedChapter = blockToChapterMap.get(chosenBlock);
const expandedModule = blockToModuleMap.get(chosenBlock);
const accordion = true;
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))
);
});
if (expandAll && chapterStepIds.length === 0) return null;
const chapterStepIdsSet = new Set(chapterStepIds);
const completedStepsInChapter = new Set(
completedChallengeIds.filter(id => chapterStepIdsSet.has(id))
).size;
const [firstChapterModule] = chapter.modules;
const [firstModuleBlock] = firstChapterModule?.blocks ?? [];
const isLinkChapter =
chapter.modules.length === 1 &&
firstChapterModule?.blocks.length === 1 &&
firstModuleBlock?.blockLabel === BlockLabel.exam &&
firstModuleBlock.challenges.length === 1;
const examSlug = isLinkChapter
? firstModuleBlock?.challenges[0]?.fields.slug
: undefined;
return (
<Chapter
key={chapter.name}
dashedName={chapter.name}
isExpanded={
expandAll ||
expandedChapter === chapter.name ||
allChapters.length === 1
}
comingSoon={chapter.comingSoon}
totalSteps={chapterStepIds.length}
completedSteps={completedStepsInChapter}
superBlock={superBlock}
isLinkChapter={isLinkChapter}
examSlug={examSlug}
>
{chapter.modules.map(module => {
if (module.comingSoon && !showUpcomingChanges) {
if (module.moduleType === BlockLabel.review) {
return null;
}
}
if (isLinkModule(module.name)) {
return (
<LinkModule
key={module.name}
superBlock={superBlock}
moduleType={module.moduleType}
challenges={module.blocks[0]?.challenges}
accordion={accordion}
expandAll={expandAll}
/>
);
}
const moduleStepIds: string[] = [];
module.blocks.forEach(block =>
moduleStepIds.push(...block.challenges.map(c => c.id))
);
if (expandAll && moduleStepIds.length === 0) return null;
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={expandAll || expandedModule === module.name}
totalSteps={moduleStepIds.length}
completedSteps={completedStepsInModule}
superBlock={superBlock}
comingSoon={!!module.comingSoon}
>
{module.blocks.map(block => (
// maybe TODO: allow blocks to be "coming soon"
<li key={block.name}>
<Block
block={block.name}
blockLabel={block.blockLabel}
challenges={block.challenges}
superBlock={superBlock}
accordion={accordion}
expandAll={expandAll}
/>
</li>
))}
</Module>
);
})}
</Chapter>
);
})}
</ul>
);
};