diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 516a970ec26..97d0fb29548 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -759,6 +759,11 @@ "archive": { "title": "Archived Coursework", "content-not-updated": "The content in this section is not being updated, but is still available for you to further your learning. We recommend trying <0>our current curriculum." + }, + "search": { + "search-challenges-in-curriculum": "Search lessons in the curriculum", + "search-challenges-results": "Showing {{resultCount}} matching lessons for \"{{term}}\".", + "search-challenges-no-results": "No results found for \"{{term}}\"." } }, "donate": { diff --git a/client/package.json b/client/package.json index 67c458f3f4a..cf5ea0a6245 100644 --- a/client/package.json +++ b/client/package.json @@ -55,7 +55,6 @@ "@freecodecamp/ui": "6.0.1", "@gatsbyjs/reach-router": "1.3.9", "@growthbook/growthbook-react": "1.6.5", - "@headlessui/react": "1.7.19", "@loadable/component": "5.16.7", "@redux-devtools/extension": "3.3.0", "@redux-saga/core": "^1.4.2", diff --git a/client/src/templates/Introduction/components/block.test.tsx b/client/src/templates/Introduction/components/block.test.tsx index ae8f85761f0..e71d452a403 100644 --- a/client/src/templates/Introduction/components/block.test.tsx +++ b/client/src/templates/Introduction/components/block.test.tsx @@ -93,6 +93,27 @@ describe('', () => { vi.clearAllMocks(); }); + it('should expand the block when isExpanded is true and expandAll is false', () => { + (isAuditedSuperBlock as Mock).mockReturnValue(true); + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should expand the block when expandAll is true and isExpanded is false', () => { + (isAuditedSuperBlock as Mock).mockReturnValue(true); + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should not expand the block when both expandAll and isExpanded are false', () => { + (isAuditedSuperBlock as Mock).mockReturnValue(true); + render(); + expect(screen.getByRole('button')).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); + it('The "Help us translate" badge does not appear on any English blocks', () => { render(); expect( diff --git a/client/src/templates/Introduction/components/block.tsx b/client/src/templates/Introduction/components/block.tsx index 1b4fa731094..5a4dea1ab1e 100644 --- a/client/src/templates/Introduction/components/block.tsx +++ b/client/src/templates/Introduction/components/block.tsx @@ -74,6 +74,11 @@ interface BlockProps { t: TFunction; toggleBlock: typeof toggleBlock; accordion?: boolean; + /** + * When true, expands all chapters and modules and hides those with no matching challenges. + * Used during search/filter. + */ + expandAll?: boolean; } export class Block extends Component { @@ -106,12 +111,15 @@ export class Block extends Component { blockLabel, completedChallengeIds, challenges, - isExpanded, + isExpanded: isExpandedProp, superBlock, t, - accordion = false + accordion = false, + expandAll = false } = this.props; + const isExpanded = expandAll || isExpandedProp; + let completedCount = 0; let stepNumber = 0; diff --git a/client/src/templates/Introduction/components/super-block-accordion.test.tsx b/client/src/templates/Introduction/components/super-block-accordion.test.tsx index 0e8bed52e96..5e224018ec6 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.test.tsx +++ b/client/src/templates/Introduction/components/super-block-accordion.test.tsx @@ -1,11 +1,16 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; import { SuperBlocks } from '@freecodecamp/shared/config/curriculum'; import { SuperBlockAccordion } from './super-block-accordion'; import { BlockLabel, BlockLayouts } from '@freecodecamp/shared/config/blocks'; +vi.mock('./block', () => ({ + default: ({ block }: { block: string }) => + React.createElement('div', { 'data-testid': `block-${block}` }) +})); + const mockStructure = { superBlock: SuperBlocks.RespWebDesign, chapters: [ @@ -176,4 +181,73 @@ describe('SuperBlockAccordion', () => { const moduleRight = within(moduleButton).getByTestId('module-button-right'); expect(within(moduleRight).queryByText(/steps/i)).not.toBeInTheDocument(); }); + + it('should expand all chapters when expandAll is true', () => { + const multiChapterStructure = { + superBlock: SuperBlocks.RespWebDesign, + chapters: [ + { + dashedName: 'chapter-one', + modules: [{ dashedName: 'mod-one', blocks: ['block-one'] }] + }, + { + dashedName: 'chapter-two', + modules: [{ dashedName: 'mod-two', blocks: ['block-two'] }] + } + ] + }; + + render( + + ); + + // When expandAll=true, both chapters are open so their module buttons are visible + const moduleButtons = screen.getAllByRole('button', { name: /mod/i }); + expect(moduleButtons).toHaveLength(2); + }); + + it('should not render a module when all its challenges are filtered out', () => { + render( + + ); + + // mod-one has a challenge — its button should render + expect( + screen.getByRole('button', { name: /mod-one/i }) + ).toBeInTheDocument(); + + // mod-two has no challenges — its button should not render + expect( + screen.queryByRole('button', { name: /mod-two/i }) + ).not.toBeInTheDocument(); + }); }); diff --git a/client/src/templates/Introduction/components/super-block-accordion.tsx b/client/src/templates/Introduction/components/super-block-accordion.tsx index 9534fa80253..aff51254c68 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.tsx +++ b/client/src/templates/Introduction/components/super-block-accordion.tsx @@ -1,7 +1,5 @@ -import React, { ReactNode, useMemo } from 'react'; +import React, { ReactNode, useEffect, useMemo, useState } 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 '@freecodecamp/shared/config/curriculum'; import DropDown from '../../../assets/icons/dropdown'; @@ -80,6 +78,11 @@ interface SuperBlockAccordionProps { 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 = ({ @@ -94,6 +97,14 @@ const Chapter = ({ 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}`); @@ -138,19 +149,23 @@ const Chapter = ({ } return ( - - + + {open && ( +
    {children} - +
)} -
+ ); }; @@ -174,9 +189,23 @@ const Module = ({ const showModuleContent = !(comingSoon && !showUpcomingChanges); + const [open, setOpen] = useState(isExpanded); + + useEffect(() => { + setOpen(isExpanded); + }, [isExpanded]); + + const panelId = `module-panel-${dashedName}`; + return ( - - +
  • + + {open && ( +
      + {comingSoon && ( +
      + {note && ( +

      + {note} +

      + )} + {intro?.length && intro.map(ntro =>

      {ntro}

      )} +
      + )} + {showModuleContent && children} +
    + )} +
  • ); }; @@ -218,12 +249,14 @@ const LinkModule = ({ superBlock, challenges, accordion, - moduleType + moduleType, + expandAll }: { superBlock: SuperBlocks; challenges?: Challenge[]; accordion: boolean; moduleType?: Module['moduleType']; + expandAll?: boolean; }) => { if (!challenges?.length) return null; @@ -237,6 +270,7 @@ const LinkModule = ({ challenges={challenges} superBlock={superBlock} accordion={accordion} + expandAll={expandAll} /> ); @@ -247,7 +281,8 @@ export const SuperBlockAccordion = ({ superBlock, structure, chosenBlock, - completedChallengeIds + completedChallengeIds, + expandAll = false }: SuperBlockAccordionProps) => { const superBlockStructure = structure; @@ -334,6 +369,8 @@ export const SuperBlockAccordion = ({ ); }); + if (expandAll && chapterStepIds.length === 0) return null; + const chapterStepIdsSet = new Set(chapterStepIds); const completedStepsInChapter = new Set( @@ -359,7 +396,9 @@ export const SuperBlockAccordion = ({ key={chapter.name} dashedName={chapter.name} isExpanded={ - expandedChapter === chapter.name || allChapters.length === 1 + expandAll || + expandedChapter === chapter.name || + allChapters.length === 1 } comingSoon={chapter.comingSoon} totalSteps={chapterStepIds.length} @@ -383,6 +422,7 @@ export const SuperBlockAccordion = ({ moduleType={module.moduleType} challenges={module.blocks[0]?.challenges} accordion={accordion} + expandAll={expandAll} /> ); } @@ -392,6 +432,8 @@ export const SuperBlockAccordion = ({ 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)) @@ -401,7 +443,7 @@ export const SuperBlockAccordion = ({ ))} diff --git a/client/src/templates/Introduction/components/super-block-map.tsx b/client/src/templates/Introduction/components/super-block-map.tsx index f6b44d35226..b9443e7fb04 100644 --- a/client/src/templates/Introduction/components/super-block-map.tsx +++ b/client/src/templates/Introduction/components/super-block-map.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Spacer } from '@freecodecamp/ui'; +import { Col, Row, Spacer } from '@freecodecamp/ui'; import { certificationCollectionSuperBlocks, @@ -28,11 +28,13 @@ import Block from './block'; import CertChallenge from './cert-challenge'; import { SuperBlockAccordion } from './super-block-accordion'; import './super-block-accordion.css'; +import SuperBlockSearch from './super-block-search'; type Challenge = { block: string; blockLabel?: BlockLabel; blockLayout: BlockLayouts; + chapter: string; challengeType: number; dashedName: string; fields: { slug: string }; @@ -59,13 +61,15 @@ const BlockList = ({ showCertification, superBlock, superBlockChallenges, - user + user, + expandAll = false }: { disabledBlocks: string[]; showCertification: boolean; superBlock: SuperBlocks; superBlockChallenges: Challenge[]; user: User | null; + expandAll?: boolean; }) => { const visibleBlocks = useMemo(() => { const uniqueBlocks = Array.from( @@ -92,6 +96,7 @@ const BlockList = ({ blockLabel={blockLabel} challenges={blockChallenges} superBlock={superBlock} + expandAll={expandAll} /> ); })} @@ -113,6 +118,22 @@ export const SuperBlockMap = ({ user }: SuperBlockMapProps) => { const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(''); + const isSearching = searchTerm.length > 0; + + const filteredChallenges = useMemo(() => { + if (!isSearching) return superBlockChallenges; + return superBlockChallenges.filter(challenge => { + const challengeTitle = challenge.title.toLowerCase(); + const blockTitle = t( + `intro:${superBlock}.blocks.${challenge.block}.title` + ).toLowerCase(); + return ( + challengeTitle.includes(searchTerm) || blockTitle.includes(searchTerm) + ); + }); + }, [isSearching, searchTerm, superBlockChallenges, superBlock, t]); + if (chapterBasedSuperBlocks.includes(superBlock)) { if (!structure) return null; @@ -160,6 +181,15 @@ export const SuperBlockMap = ({ return ( <> + + + + + {certificationCollectionSuperBlocks.includes(superBlock) && ( <>
      @@ -174,24 +204,37 @@ export const SuperBlockMap = ({ )} ); } return ( - + <> + + + + + + + ); }; diff --git a/client/src/templates/Introduction/components/super-block-search.css b/client/src/templates/Introduction/components/super-block-search.css new file mode 100644 index 00000000000..568e88b8bc6 --- /dev/null +++ b/client/src/templates/Introduction/components/super-block-search.css @@ -0,0 +1,50 @@ +.super-block-search-container { + position: relative; + display: flex; + align-items: center; + margin-bottom: 12px; +} + +#super-block-search-input { + padding-inline: 30px; + width: 100%; +} + +#super-block-search-input::-webkit-search-cancel-button { + display: none; +} + +.super-block-search-magnifier { + position: absolute; + left: 10px; + pointer-events: none; + display: flex; + align-items: center; +} + +.super-block-search-magnifier path, +.super-block-search-reset-btn path { + fill: var(--foreground-quaternary); +} + +.super-block-search-reset-btn { + position: absolute; + right: 6px; + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + border-radius: 50%; +} + +.super-block-search-reset-btn:hover, +.super-block-search-reset-btn:focus { + background-color: var(--tertiary-background); +} + +.super-block-search-form p.super-block-search-status { + font-size: 1rem; + height: 1.4rem; +} diff --git a/client/src/templates/Introduction/components/super-block-search.test.tsx b/client/src/templates/Introduction/components/super-block-search.test.tsx new file mode 100644 index 00000000000..f605b3e4fc7 --- /dev/null +++ b/client/src/templates/Introduction/components/super-block-search.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import SuperBlockSearch from './super-block-search'; + +describe('SuperBlockSearch', () => { + it('should render a search input', () => { + render( + {}} + resultCount={0} + isSearching={false} + /> + ); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + }); + + it('should not show a clear button when input is empty', () => { + render( + {}} + resultCount={0} + isSearching={false} + /> + ); + expect( + screen.queryByRole('button', { name: /icons.input-reset/i }) + ).not.toBeInTheDocument(); + }); + + it('should show a clear button when input has a value', async () => { + const user = userEvent.setup(); + render( + {}} + resultCount={0} + isSearching={false} + /> + ); + await user.type(screen.getByRole('searchbox'), 'html'); + expect( + screen.getByRole('button', { name: /icons.input-reset/i }) + ).toBeInTheDocument(); + }); + + it('should clear the input and call onSearch with empty string when clear button is clicked', async () => { + const onSearch = vi.fn(); + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole('searchbox'), 'html'); + await user.click( + screen.getByRole('button', { name: /icons.input-reset/i }) + ); + expect(screen.getByRole('searchbox')).toHaveValue(''); + expect(onSearch).toHaveBeenLastCalledWith(''); + }); + + it('should show a result count status message when searching with results', () => { + render( + {}} + resultCount={5} + isSearching={true} + /> + ); + const status = screen.getByText(/search-challenges-results/i); + expect(status).toBeInTheDocument(); + expect(status).toHaveAttribute('aria-live', 'polite'); + }); + + it('should show a no-results status message when searching with no results', () => { + render( + {}} + resultCount={0} + isSearching={true} + /> + ); + expect( + screen.getByText(/search-challenges-no-results/i) + ).toBeInTheDocument(); + }); + + it('should show no status message when not searching', () => { + render( + {}} + resultCount={0} + isSearching={false} + /> + ); + expect(screen.queryByText(/search-challenges/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/templates/Introduction/components/super-block-search.tsx b/client/src/templates/Introduction/components/super-block-search.tsx new file mode 100644 index 00000000000..6ca6dfb90ec --- /dev/null +++ b/client/src/templates/Introduction/components/super-block-search.tsx @@ -0,0 +1,85 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { debounce } from 'lodash-es'; +import { FormControl } from '@freecodecamp/ui'; +import Magnifier from '../../../assets/icons/magnifier'; +import InputReset from '../../../assets/icons/input-reset'; + +import './super-block-search.css'; + +interface SuperBlockSearchProps { + onSearch: (term: string) => void; + resultCount: number; + isSearching: boolean; +} + +const SuperBlockSearch = ({ + onSearch, + resultCount, + isSearching +}: SuperBlockSearchProps): JSX.Element => { + const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(''); + + const debouncedOnSearch = useMemo(() => debounce(onSearch, 300), [onSearch]); + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchTerm(value); + debouncedOnSearch(value.trim().toLowerCase()); + }; + + const handleReset = () => { + debouncedOnSearch.cancel(); + setSearchTerm(''); + onSearch(''); + }; + + const statusMessage = isSearching + ? resultCount > 0 + ? t('learn.search.search-challenges-results', { + resultCount, + term: searchTerm + }) + : t('learn.search.search-challenges-no-results', { term: searchTerm }) + : ''; + + return ( +
      e.preventDefault()} + role='search' + aria-label={t('learn.search.search-challenges-in-curriculum')} + > +
      + + + {searchTerm && ( + + )} +
      +

      + {statusMessage} +

      +
      + ); +}; + +SuperBlockSearch.displayName = 'SuperBlockSearch'; + +export default SuperBlockSearch; diff --git a/client/src/templates/Introduction/super-block-intro.test.tsx b/client/src/templates/Introduction/super-block-intro.test.tsx index a8fa8cced13..ad65d207483 100644 --- a/client/src/templates/Introduction/super-block-intro.test.tsx +++ b/client/src/templates/Introduction/super-block-intro.test.tsx @@ -63,6 +63,10 @@ vi.mock('./components/super-block-accordion', () => ({ SuperBlockAccordion: () => null })); +vi.mock('./components/super-block-search', () => ({ + default: () => null +})); + const translationMap: Record = { 'intro:full-stack-developer': { title: 'Full-Stack Developer', diff --git a/e2e/super-block-page.spec.ts b/e2e/super-block-page.spec.ts index 3a47f2cf361..e65f98bc17a 100644 --- a/e2e/super-block-page.spec.ts +++ b/e2e/super-block-page.spec.ts @@ -277,3 +277,73 @@ test.describe('Super Block Page - Unauthenticated User', () => { }); }); }); + +test.describe('Super Block Page - Search Lessons', () => { + test('should filter and restore blocks on a block-based superblock', async ({ + page + }) => { + const searchTerm = '401'; + + await page.goto('/learn/project-euler/'); + + await expect( + page.getByRole('heading', { name: 'Project Euler Problems 1 to 100' }) + ).toBeVisible(); + + await page + .getByRole('searchbox', { name: /Search lessons/i }) + .fill(searchTerm); + + await expect( + page.getByRole('heading', { name: 'Project Euler Problems 401 to 480' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Project Euler Problems 1 to 100' }) + ).not.toBeVisible(); + await expect( + page.getByText( + new RegExp(`showing .+ matching lessons for "${searchTerm}"`, 'i') + ) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Clear search terms' }).click(); + + await expect( + page.getByRole('heading', { name: 'Project Euler Problems 1 to 100' }) + ).toBeVisible(); + }); + + test('should filter blocks and auto-expand matching modules on a chapter-based superblock', async ({ + page + }) => { + const searchTerm = 'Greeting Bot'; + + await page.goto('/learn/javascript-v9/'); + + await expect( + page.getByRole('button', { name: 'Booleans and Numbers' }) + ).toBeVisible(); + + await page + .getByRole('searchbox', { name: /Search lessons/i }) + .fill(searchTerm); + + await expect( + page.getByRole('heading', { name: 'Build a Greeting Bot' }) + ).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Booleans and Numbers' }) + ).not.toBeVisible(); + await expect( + page.getByText( + new RegExp(`showing .+ matching lessons for "${searchTerm}"`, 'i') + ) + ).toBeVisible(); + + await page.getByRole('button', { name: 'Clear search terms' }).click(); + + await expect( + page.getByRole('button', { name: 'Booleans and Numbers' }) + ).toBeVisible(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a21309f2a77..9d9c2e992fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,9 +297,6 @@ importers: '@growthbook/growthbook-react': specifier: 1.6.5 version: 1.6.5(react@18.3.1) - '@headlessui/react': - specifier: 1.7.19 - version: 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@loadable/component': specifier: 5.16.7 version: 5.16.7(react@18.3.1)