From dfe42dd6f4cfa4f09210839cb785927d70bd71cf Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Wed, 20 Nov 2024 10:23:29 +0700 Subject: [PATCH] feat(client): display FSD in multi-level accordion (#56941) Co-authored-by: Oliver Eyton-Williams --- client/i18n/locales/english/intro.json | 67 ++++++++ client/package.json | 1 + client/src/assets/icons/dropdown.tsx | 1 + client/src/redux/prop-types.ts | 2 + .../components/super-block-accordion.css | 69 ++++++++ .../components/super-block-accordion.tsx | 152 ++++++++++++++++++ client/src/templates/Introduction/intro.css | 20 +-- .../Introduction/super-block-intro.tsx | 72 ++++++--- pnpm-lock.yaml | 3 + 9 files changed, 348 insertions(+), 39 deletions(-) create mode 100644 client/src/templates/Introduction/components/super-block-accordion.css create mode 100644 client/src/templates/Introduction/components/super-block-accordion.tsx diff --git a/client/i18n/locales/english/intro.json b/client/i18n/locales/english/intro.json index 17243aebb83..ef4d696ab40 100644 --- a/client/i18n/locales/english/intro.json +++ b/client/i18n/locales/english/intro.json @@ -1704,6 +1704,73 @@ "Through a blend of interactive lessons, coding exercises, and real-world projects, you will master both frontend and backend development. You'll work with HTML, CSS, and JavaScript to build responsive user interfaces, explore React and TypeScript for advanced web applications, and learn to manage data with relational databases - and on the backend, you'll use Git, Npm, Node.js, and Python to create powerful server-side solutions.", "By the end of this course, you'll have the practical skills and experience to confidently develop complete web applications, preparing you for a successful career as a Full Stack Developer." ], + "chapters": { + "freecodecamp": "freeCodeCamp", + "html": "HTML", + "css": "CSS", + "javascript": "JavaScript", + "frontend-libraries": "Front End Libraries", + "relational-databases": "Relational Databases", + "security-and-privacy": "Security and Privacy" + }, + "modules": { + "getting-started-with-freecodecamp": "Getting Started with freeCodeCamp", + "basic-html": "Basic HTML", + "semantic-html": "Semantic HTML", + "html-forms-and-tables": "HTML Forms and Tables", + "html-and-accessibility": "HTML and Accessibility", + "computer-basics": "Computer Basics", + "basic-css": "Basic CSS", + "design-for-developers": "Designer for Developers", + "absolute-and-relative-units": "Absolute and Relative Units", + "pseudo-classes-and-elements": "Pseudo Classes and Elements", + "css-colors": "CSS Colors", + "styling-forms": "Styling Forms", + "css-box-model": "CSS Box Model", + "css-flexbox": "CSS Flexbox", + "css-typography": "CSS Typography", + "css-and-accessibility": "CSS and Accessibility", + "attribute-selectors": "Attribute Selectors", + "css-positioning": "CSS Positioning", + "responsive-design": "Responsive Design", + "css-variables": "CSS Variables", + "css-grid": "CSS Grid", + "css-animations": "CSS Animations", + "code-editors": "Code editors", + "javascript-variables-and-strings": "JavaScript Variables and Strings", + "javascript-booleans-and-numbers": "JavaScript Booleans and Numbers", + "javascript-functions": "JavaScript Functions", + "javascript-arrays": "JavaScript Arrays", + "javascript-objects": "JavaScript Objects", + "javascript-loops": "JavaScript Loops", + "review-javascript-fundamentals": "Review JavaScript Fundamentals", + "higher-order-functions-and-callbacks": "Higher Order Functions and Callbacks", + "dom-manipulation-and-events": "DOM Manipulation and Events", + "debugging-javascript": "Debugging JavaScript", + "basic-regex": "Basic Regex", + "form-validation": "Form Validation", + "javascript-dates": "JavaScript Dates", + "audio-and-video-events": "Audio and Video Events", + "maps-sets-and-json": "Maps, Sets, and JSON", + "localstorage-and-crud-operations": "localStorage and CRUD Operations", + "classes-and-the-this-keyword": "Classes and the this keyword", + "recursion": "Recursion", + "functional-programming": "Functional Programming", + "asynchronous-javascript": "Asynchronous JavaScript", + "react-fundamentals": "React Fundamentals", + "react-state-hooks-and-routing": "React state, hooks, and routing", + "performance": "Performance", + "css-libraries-and-frameworks": "CSS Libraries and Frameworks", + "testing": "Testing", + "typescript-fundamentals": "TypeScript Fundamentals", + "bash-fundamentals": "Bash Fundamentals", + "relational-databases": "Relational Databases", + "bash-scripting": "Bash Scripting", + "sql-and-bash": "SQL and Bash", + "nano": "Nano", + "git": "Git", + "security-and-privacy": "Security and Privacy" + }, "blocks": { "lecture-welcome-to-freecodecamp": { "title": "Welcome to freeCodeCamp", diff --git a/client/package.json b/client/package.json index c89ac6a1fbe..411246d8ce0 100644 --- a/client/package.json +++ b/client/package.json @@ -52,6 +52,7 @@ "@freecodecamp/react-calendar-heatmap": "1.1.0", "@freecodecamp/ui": "3.1.0", "@growthbook/growthbook-react": "0.20.0", + "@headlessui/react": "1.7.19", "@loadable/component": "5.16.3", "@reach/router": "1.3.4", "@redux-devtools/extension": "3.3.0", diff --git a/client/src/assets/icons/dropdown.tsx b/client/src/assets/icons/dropdown.tsx index f7326016aff..5b2f9333aaa 100644 --- a/client/src/assets/icons/dropdown.tsx +++ b/client/src/assets/icons/dropdown.tsx @@ -9,6 +9,7 @@ function DropDown(): JSX.Element { height='10' viewBox='0 0 389 254' fill='none' + className='dropdown-icon' > { + const { t } = useTranslation(); + + return ( + + + {t(`intro:full-stack-developer.chapters.${dashedName}`)} + + + + {children} + + + ); +}; + +const Module = ({ dashedName, children, isExpanded }: ModuleProps) => { + const { t } = useTranslation(); + + return ( + + + + {t(`intro:full-stack-developer.modules.${dashedName}`)} + + + {children} + + + ); +}; + +export const SuperBlockAccordion = ({ + challenges, + superBlock, + chosenBlock +}: SuperBlockTreeViewProps) => { + const { allChapters, allBlocks, examChallenge } = 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 allModules = uniqBy(allBlocks, 'module').map( + ({ module, chapter }) => ({ + name: module, + chapter, + blocks: allBlocks.filter(({ module: m }) => m === module) + }) + ); + + const allChapters = uniqBy(allModules, 'chapter').map(({ chapter }) => ({ + name: chapter, + modules: allModules.filter(({ chapter: c }) => c === chapter) + })); + + const examChallenge = challenges.find( + ({ blockType }) => blockType === BlockTypes.exam + ); + + return { allChapters, allModules, allBlocks, examChallenge }; + }, [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; + + return ( + + ); +}; diff --git a/client/src/templates/Introduction/intro.css b/client/src/templates/Introduction/intro.css index 75f22cbd9b5..b79a61a52ce 100644 --- a/client/src/templates/Introduction/intro.css +++ b/client/src/templates/Introduction/intro.css @@ -599,21 +599,15 @@ a.map-grid-item.challenge-completed { background: var(--primary-background); } -/* Override the `.block-description` padding (25px) - to align the description with the content in the panel. */ -.block-ui .challenge-list-block .block-header .block-description, -.block-ui .challenge-grid-block .block-header .block-description { - padding: 10px; -} - -.block-ui .challenge-grid-block .progress-wrapper { - /* Make the progress bar align with the description above */ - padding-inline-start: 10px; -} - -.block-ui .challenge-grid-block .challenge-grid-block-panel { +.challenge-grid-block .challenge-grid-block-panel { /* Add some space between panel content and the top edge of the container. The value (18px) is the same value `.map-challenges-grid` uses to create space between the grid and the bottom edge of the container. */ margin-top: 18px; } + +.chapter-button .dropdown-icon, +.module-button .dropdown-icon { + width: 14px; + height: 14px; +} diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index e5299d12bdb..dc5e64764de 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -1,7 +1,7 @@ import { WindowLocation } from '@reach/router'; import { graphql } from 'gatsby'; import { uniq } from 'lodash-es'; -import React, { Fragment, useEffect, memo } from 'react'; +import React, { Fragment, useEffect, memo, useMemo } from 'react'; import Helmet from 'react-helmet'; import { useTranslation, withTranslation } from 'react-i18next'; import { connect } from 'react-redux'; @@ -31,6 +31,7 @@ import CertChallenge from './components/cert-challenge'; import LegacyLinks from './components/legacy-links'; import HelpTranslate from './components/help-translate'; import SuperBlockIntro from './components/super-block-intro'; +import { SuperBlockAccordion } from './components/super-block-accordion'; import { resetExpansion, toggleBlock } from './redux'; import './intro.css'; @@ -174,8 +175,14 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => { pageContext: { superBlock, title, certification } } = props; - const allChallenges = nodes.map(({ challenge }) => challenge); - const challenges = allChallenges.filter(c => c.superBlock === superBlock); + const allChallenges = useMemo( + () => nodes.map(({ challenge }) => challenge), + [nodes] + ); + const challenges = useMemo( + () => allChallenges.filter(c => c.superBlock === superBlock), + [allChallenges, superBlock] + ); const blocks = uniq(challenges.map(({ block }) => block)); const i18nTitle = getSuperBlockTitleForMap(superBlock); @@ -190,6 +197,9 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => { SuperBlocks.PythonForEverybody ]; + const superBlockWithAccordionView = [SuperBlocks.FullStackDeveloper]; + const chosenBlock = getChosenBlock(); + const onCertificationDonationAlertClick = () => { callGA({ event: 'donation_related', @@ -221,32 +231,40 @@ const SuperBlockIntroductionPage = (props: SuperBlockProp) => { {t(`intro:misc-text.courses`)} -
- {blocks.map(block => { - const blockChallenges = challenges.filter( - c => c.block === block - ); - const blockType = blockChallenges[0].blockType; + {superBlockWithAccordionView.includes(superBlock) ? ( + + ) : ( +
+ {blocks.map(block => { + const blockChallenges = challenges.filter( + c => c.block === block + ); + const blockType = blockChallenges[0].blockType; - return ( - + ); + })} + {!superblockWithoutCert.includes(superBlock) && ( + - ); - })} - {!superblockWithoutCert.includes(superBlock) && ( - - )} -
+ )} +
+ )} {!isSignedIn && !signInLoading && ( <> @@ -305,6 +323,8 @@ export const query = graphql` superBlock dashedName blockLayout + chapter + module } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b850185e1d..7c1e9334f4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,9 @@ importers: '@growthbook/growthbook-react': specifier: 0.20.0 version: 0.20.0(react@16.14.0) + '@headlessui/react': + specifier: 1.7.19 + version: 1.7.19(react-dom@16.14.0(react@16.14.0))(react@16.14.0) '@loadable/component': specifier: 5.16.3 version: 5.16.3(react@16.14.0)