feat(client): coming soon chapters, modules and blocks (#57462)

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
This commit is contained in:
Oliver Eyton-Williams
2024-12-13 20:10:33 +01:00
committed by GitHub
parent 3418f3dad5
commit 996d76ef20
7 changed files with 220 additions and 82 deletions

View File

@@ -1704,7 +1704,8 @@
"javascript": "JavaScript",
"frontend-libraries": "Front End Libraries",
"relational-databases": "Relational Databases",
"security-and-privacy": "Security and Privacy"
"backend-javascript": "Backend JavaScript",
"python": "Python"
},
"modules": {
"getting-started-with-freecodecamp": "Getting Started with freeCodeCamp",

View File

@@ -749,6 +749,7 @@
"result-list": "Search results"
},
"misc": {
"coming-soon": "Coming Soon",
"offline": "You appear to be offline, your progress may not be saved",
"server-offline": "The server could not be reached and your progress may not be saved. Please contact <0>support</0> if this message persists",
"unsubscribed": "You have successfully been unsubscribed",

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React, { Component, ReactNode } from 'react';
import type { DefaultTFuncReturn, TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
@@ -72,7 +72,7 @@ class Block extends Component<BlockProps> {
toggleBlock(block);
};
render(): JSX.Element {
render(): ReactNode {
const {
block,
blockType,
@@ -129,6 +129,13 @@ class Block extends Component<BlockProps> {
(completedCount / extendedChallenges.length) * 100
);
// since the Blocks are not components, we need link to exist even if it's
// not being used to render anything
const link = challenges[0]?.fields.slug || '';
const blockLayout = challenges[0]?.blockLayout;
const isEmptyBlock = !challenges.length;
const courseCompletionStatus = () => {
if (completedCount === 0) {
return t('learn.not-started');
@@ -306,7 +313,7 @@ class Block extends Component<BlockProps> {
onClick={() => {
this.handleBlockClick();
}}
to={extendedChallenges[0].fields.slug}
to={link}
>
<CheckMark isCompleted={isBlockCompleted} />
{blockTitle}{' '}
@@ -391,7 +398,7 @@ class Block extends Component<BlockProps> {
onClick={() => {
this.handleBlockClick();
}}
to={extendedChallenges[0].fields.slug}
to={link}
>
<CheckMark isCompleted={isBlockCompleted} />
{blockType && <BlockLabel blockType={blockType} />}
@@ -460,11 +467,15 @@ class Block extends Component<BlockProps> {
};
return (
<>
{layoutToComponent[challenges[0].blockLayout]}
{(!isGridBlock || isProjectBlock) &&
superBlock !== SuperBlocks.FullStackDeveloper && <Spacer size='m' />}
</>
!isEmptyBlock && (
<>
{layoutToComponent[blockLayout]}
{(!isGridBlock || isProjectBlock) &&
superBlock !== SuperBlocks.FullStackDeveloper && (
<Spacer size='m' />
)}
</>
)
);
}
}

View File

@@ -18,8 +18,7 @@
}
.super-block-accordion .chapter,
.super-block-accordion .link-chapter,
.super-block-accordion .link-module {
.super-block-accordion .link-block {
background-color: var(--primary-background);
}
@@ -75,6 +74,40 @@
padding: 0 24px;
}
.super-block-accordion .chapter-panel > li:last-child .block-grid {
.super-block-accordion .chapter-panel > li {
border-bottom: 4px solid var(--secondary-background);
}
.super-block-accordion .chapter-panel > li:last-child {
border-bottom: none;
}
.super-block-accordion .chapter-panel > li:last-child .block-grid,
.super-block-accordion .module-panel > li:last-child .block-grid {
border-bottom: 0;
}
.super-block-accordion .badge {
color: var(--quaternary-color);
border: 1px solid var(--quaternary-color);
align-self: center;
height: fit-content;
padding: 1px 6px 2px;
font-size: 0.85rem;
position: relative;
top: 1px;
}
.super-block-accordion .coming-soon {
background-color: var(--primary-background);
width: 100%;
padding: 20px;
display: flex;
align-items: center;
column-gap: 20px;
font-size: 1.5em;
}
.super-block-accordion .coming-soon-module {
font-size: 1.25rem;
}

View File

@@ -1,5 +1,4 @@
import React, { ReactNode, useMemo } from 'react';
import { uniqBy } from 'lodash-es';
import { useTranslation } from 'react-i18next';
// TODO: Add this component to freecodecamp/ui and remove this dependency
import { Disclosure } from '@headlessui/react';
@@ -12,10 +11,13 @@ import DropDown from '../../../assets/icons/dropdown';
import superBlockStructure from '../../../../../curriculum/superblock-structure/full-stack.json';
import { ChapterIcon } from '../../../assets/chapter-icon';
import { FsdChapters } from '../../../../../shared/config/chapters';
import envData from '../../../../config/env.json';
import Block from './block';
import './super-block-accordion.css';
const { showUpcomingChanges } = envData;
interface ChapterProps {
dashedName: string;
children: ReactNode;
@@ -33,9 +35,16 @@ interface SuperBlockTreeViewProps {
chosenBlock: string;
}
const modules = superBlockStructure.chapters.flatMap(
chapter => chapter.modules
);
type Module = {
dashedName: string;
comingSoon?: boolean;
blocks: {
dashedName: string;
}[];
};
const modules = superBlockStructure.chapters.flatMap(({ modules }) => modules);
const chapters = superBlockStructure.chapters;
const isLinkModule = (name: string) => {
const module = modules.find(module => module.dashedName === name);
@@ -44,13 +53,38 @@ const isLinkModule = (name: string) => {
};
const isLinkChapter = (name: string) => {
const chapter = superBlockStructure.chapters.find(
chapter => chapter.dashedName === name
);
const chapter = chapters.find(chapter => chapter.dashedName === name);
return chapter?.chapterType === 'exam';
};
const getBlockToChapterMap = () => {
const blockToChapterMap = new Map<string, string>();
chapters.forEach(chapter => {
chapter.modules.forEach(module => {
module.blocks.forEach(block => {
blockToChapterMap.set(block.dashedName, chapter.dashedName);
});
});
});
return blockToChapterMap;
};
const getBlockToModuleMap = () => {
const blockToModuleMap = new Map<string, string>();
modules.forEach(module => {
module.blocks.forEach(block => {
blockToModuleMap.set(block.dashedName, module.dashedName);
});
});
return blockToModuleMap;
};
const blockToChapterMap = getBlockToChapterMap();
const blockToModuleMap = getBlockToModuleMap();
const Chapter = ({ dashedName, children, isExpanded }: ChapterProps) => {
const { t } = useTranslation();
@@ -89,64 +123,89 @@ const Module = ({ dashedName, children, isExpanded }: ModuleProps) => {
);
};
const ComingSoon = ({ children }: { children: ReactNode }) => {
const { t } = useTranslation();
return (
<li className='coming-soon'>
<span className='badge'>{t('misc.coming-soon')}</span> {children}
</li>
);
};
const LinkBlock = ({
superBlock,
challenges
}: {
superBlock: SuperBlocks;
challenges?: ChallengeNode['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,
chosenBlock
}: SuperBlockTreeViewProps) => {
const { allChapters, allBlocks } = useMemo(() => {
const allBlocks = uniqBy(challenges, 'block').map(
({ block, blockType, chapter, module }) => ({
name: block,
blockType,
chapter: chapter as string,
module: module as string,
challenges: challenges.filter(({ block: b }) => b === block)
})
);
const { t } = useTranslation();
const { allChapters } = useMemo(() => {
const populateBlocks = (blocks: { dashedName: string }[]) =>
blocks.map(block => {
const blockChallenges = challenges.filter(
({ block: blockName }) => blockName === block.dashedName
);
const allModules = uniqBy(allBlocks, 'module').map(
({ module, chapter }) => ({
name: module,
chapter,
blocks: allBlocks.filter(({ module: m }) => m === module)
})
);
return {
name: block.dashedName,
blockType: blockChallenges[0]?.blockType ?? null,
challenges: blockChallenges
};
});
const allChapters = uniqBy(allModules, 'chapter').map(({ chapter }) => ({
name: chapter,
modules: allModules.filter(({ chapter: c }) => c === chapter)
const allChapters = chapters.map(chapter => ({
name: chapter.dashedName,
comingSoon: chapter.comingSoon,
modules: chapter.modules.map((module: Module) => ({
name: module.dashedName,
comingSoon: module.comingSoon,
blocks: populateBlocks(module.blocks)
}))
}));
return {
allChapters,
allModules,
allBlocks
};
return { allChapters };
}, [challenges]);
// Expand the outer layers in order to reveal the chosen block.
const expandedChapter = allBlocks.find(
({ name }) => chosenBlock === name
)?.chapter;
const expandedModule = allBlocks.find(
({ name }) => chosenBlock === name
)?.module;
const expandedChapter = blockToChapterMap.get(chosenBlock);
const expandedModule = blockToModuleMap.get(chosenBlock);
return (
<ul className='super-block-accordion'>
{allChapters.map(chapter => {
if (isLinkChapter(chapter.name)) {
const linkedChallenge = chapter.modules[0].blocks[0].challenges[0];
// show coming soon on production, and all the challenges in dev
if (chapter.comingSoon && !showUpcomingChanges) {
return (
<li key={chapter.name} className='link-chapter'>
<Block
block={linkedChallenge.block}
blockType={linkedChallenge.blockType}
challenges={[linkedChallenge]}
superBlock={superBlock}
/>
</li>
<ComingSoon key={chapter.name}>
{t(`intro:full-stack-developer.chapters.${chapter.name}`)}
</ComingSoon>
);
}
if (isLinkChapter(chapter.name)) {
return (
<LinkBlock
key={chapter.name}
superBlock={superBlock}
challenges={chapter.modules[0]?.blocks[0]?.challenges}
/>
);
}
@@ -157,17 +216,24 @@ export const SuperBlockAccordion = ({
isExpanded={expandedChapter === chapter.name}
>
{chapter.modules.map(module => {
if (isLinkModule(module.name)) {
const linkedChallenge = module.blocks[0].challenges[0];
// show coming soon on production, and all the challenges in dev
if (module.comingSoon && !showUpcomingChanges) {
return (
<li key={module.name} className='link-module'>
<Block
block={linkedChallenge.block}
blockType={linkedChallenge.blockType}
challenges={[linkedChallenge]}
superBlock={superBlock}
/>
</li>
<ComingSoon key={chapter.name}>
<span className='coming-soon-module'>
{t(`intro:full-stack-developer.modules.${module.name}`)}
</span>
</ComingSoon>
);
}
if (isLinkModule(module.name)) {
return (
<LinkBlock
key={module.name}
superBlock={superBlock}
challenges={module.blocks[0]?.challenges}
/>
);
}
@@ -178,6 +244,7 @@ export const SuperBlockAccordion = ({
isExpanded={expandedModule === module.name}
>
{module.blocks.map(block => (
// maybe TODO: allow blocks to be "coming soon"
<li key={block.name}>
<Block
block={block.name}

View File

@@ -7,18 +7,22 @@ const schema = Joi.object()
chapters: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required(),
comingSoon: Joi.boolean().optional(),
chapterType: Joi.valid('exam').optional(),
modules: Joi.array().items(
Joi.object().keys({
moduleType: Joi.valid('review', 'exam').optional(),
dashedName: Joi.string().regex(slugRE).required(),
blocks: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required()
})
)
})
)
modules: Joi.array()
.items(
Joi.object().keys({
moduleType: Joi.valid('review', 'exam').optional(),
comingSoon: Joi.boolean().optional(),
dashedName: Joi.string().regex(slugRE).required(),
blocks: Joi.array().items(
Joi.object().keys({
dashedName: Joi.string().regex(slugRE).required()
})
)
})
)
.required()
})
)
})

View File

@@ -2,6 +2,7 @@
"chapters": [
{
"dashedName": "freecodecamp",
"comingSoon": true,
"modules": [
{
"dashedName": "getting-started-with-freecodecamp",
@@ -11,6 +12,7 @@
},
{
"dashedName": "html",
"comingSoon": true,
"modules": [
{
"dashedName": "basic-html",
@@ -71,6 +73,7 @@
},
{
"dashedName": "css",
"comingSoon": true,
"modules": [
{
"dashedName": "computer-basics",
@@ -276,6 +279,7 @@
},
{
"dashedName": "javascript",
"comingSoon": true,
"modules": [
{
"dashedName": "code-editors",
@@ -546,6 +550,7 @@
{
"dashedName": "frontend-libraries",
"comingSoon": true,
"modules": [
{
"dashedName": "react-fundamentals",
@@ -564,6 +569,7 @@
},
{
"dashedName": "react-state-hooks-and-routing",
"comingSoon": true,
"blocks": [
{ "dashedName": "quiz-react-state-and-hooks" },
{ "dashedName": "quiz-advanced-react" }
@@ -571,6 +577,7 @@
},
{
"dashedName": "performance",
"comingSoon": true,
"blocks": [
{ "dashedName": "review-web-performance" },
{ "dashedName": "quiz-web-performance" }
@@ -578,6 +585,7 @@
},
{
"dashedName": "css-libraries-and-frameworks",
"comingSoon": true,
"blocks": [
{ "dashedName": "review-css-libraries-and-frameworks" },
{ "dashedName": "quiz-css-libraries-and-frameworks" }
@@ -585,6 +593,7 @@
},
{
"dashedName": "testing",
"comingSoon": true,
"blocks": [
{ "dashedName": "review-testing" },
{ "dashedName": "quiz-testing" }
@@ -592,6 +601,7 @@
},
{
"dashedName": "typescript-fundamentals",
"comingSoon": true,
"blocks": [
{ "dashedName": "review-typescript" },
{ "dashedName": "quiz-typescript" }
@@ -606,6 +616,7 @@
},
{
"dashedName": "relational-databases",
"comingSoon": true,
"modules": [
{
"dashedName": "bash-fundamentals",
@@ -670,6 +681,16 @@
}
]
},
{
"dashedName": "backend-javascript",
"comingSoon": true,
"modules": []
},
{
"dashedName": "python",
"comingSoon": true,
"modules": []
},
{
"chapterType": "exam",
"dashedName": "exam-certified-full-stack-developer",