From 996d76ef20df5fca9fb0e7f6ce5e1ee5bbb57fea Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Fri, 13 Dec 2024 20:10:33 +0100 Subject: [PATCH] feat(client): coming soon chapters, modules and blocks (#57462) Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com> --- client/i18n/locales/english/intro.json | 3 +- client/i18n/locales/english/translations.json | 1 + .../Introduction/components/block.tsx | 29 ++- .../components/super-block-accordion.css | 39 +++- .../components/super-block-accordion.tsx | 183 ++++++++++++------ .../schema/superblock-structure-schema.js | 26 +-- .../superblock-structure/full-stack.json | 21 ++ 7 files changed, 220 insertions(+), 82 deletions(-) diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 5510d03d162..98c45969e4c 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -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", diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 9825d391fa4..948629d2582 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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 if this message persists", "unsubscribed": "You have successfully been unsubscribed", diff --git a/client/src/templates/Introduction/components/block.tsx b/client/src/templates/Introduction/components/block.tsx index a86f40d704b..854e952273c 100644 --- a/client/src/templates/Introduction/components/block.tsx +++ b/client/src/templates/Introduction/components/block.tsx @@ -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 { toggleBlock(block); }; - render(): JSX.Element { + render(): ReactNode { const { block, blockType, @@ -129,6 +129,13 @@ class Block extends Component { (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 { onClick={() => { this.handleBlockClick(); }} - to={extendedChallenges[0].fields.slug} + to={link} > {blockTitle}{' '} @@ -391,7 +398,7 @@ class Block extends Component { onClick={() => { this.handleBlockClick(); }} - to={extendedChallenges[0].fields.slug} + to={link} > {blockType && } @@ -460,11 +467,15 @@ class Block extends Component { }; return ( - <> - {layoutToComponent[challenges[0].blockLayout]} - {(!isGridBlock || isProjectBlock) && - superBlock !== SuperBlocks.FullStackDeveloper && } - + !isEmptyBlock && ( + <> + {layoutToComponent[blockLayout]} + {(!isGridBlock || isProjectBlock) && + superBlock !== SuperBlocks.FullStackDeveloper && ( + + )} + + ) ); } } diff --git a/client/src/templates/Introduction/components/super-block-accordion.css b/client/src/templates/Introduction/components/super-block-accordion.css index 66db203648e..84edccb4b7d 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.css +++ b/client/src/templates/Introduction/components/super-block-accordion.css @@ -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; +} diff --git a/client/src/templates/Introduction/components/super-block-accordion.tsx b/client/src/templates/Introduction/components/super-block-accordion.tsx index cc3a6b54dc1..641591f935c 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.tsx +++ b/client/src/templates/Introduction/components/super-block-accordion.tsx @@ -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(); + 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(); + 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 ( +
  • + {t('misc.coming-soon')} {children} +
  • + ); +}; + +const LinkBlock = ({ + superBlock, + challenges +}: { + superBlock: SuperBlocks; + challenges?: ChallengeNode['challenge'][]; +}) => + challenges?.length ? ( +
  • + +
  • + ) : 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 (
      {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 ( -
    • - -
    • + + {t(`intro:full-stack-developer.chapters.${chapter.name}`)} + + ); + } + + if (isLinkChapter(chapter.name)) { + return ( + ); } @@ -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 ( -
    • - -
    • + + + {t(`intro:full-stack-developer.modules.${module.name}`)} + + + ); + } + + if (isLinkModule(module.name)) { + return ( + ); } @@ -178,6 +244,7 @@ export const SuperBlockAccordion = ({ isExpanded={expandedModule === module.name} > {module.blocks.map(block => ( + // maybe TODO: allow blocks to be "coming soon"