feat(client): search functionality for curriculum lessons (#66514)

This commit is contained in:
Huyen Nguyen
2026-05-23 19:56:31 +07:00
committed by GitHub
parent f9f699e52b
commit d6abf68d1c
13 changed files with 549 additions and 49 deletions
@@ -759,6 +759,11 @@
"archive": { "archive": {
"title": "Archived Coursework", "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</0>." "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</0>."
},
"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": { "donate": {
-1
View File
@@ -55,7 +55,6 @@
"@freecodecamp/ui": "6.0.1", "@freecodecamp/ui": "6.0.1",
"@gatsbyjs/reach-router": "1.3.9", "@gatsbyjs/reach-router": "1.3.9",
"@growthbook/growthbook-react": "1.6.5", "@growthbook/growthbook-react": "1.6.5",
"@headlessui/react": "1.7.19",
"@loadable/component": "5.16.7", "@loadable/component": "5.16.7",
"@redux-devtools/extension": "3.3.0", "@redux-devtools/extension": "3.3.0",
"@redux-saga/core": "^1.4.2", "@redux-saga/core": "^1.4.2",
@@ -93,6 +93,27 @@ describe('<Block />', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should expand the block when isExpanded is true and expandAll is false', () => {
(isAuditedSuperBlock as Mock).mockReturnValue(true);
render(<Block {...defaultProps} isExpanded={true} expandAll={false} />);
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(<Block {...defaultProps} isExpanded={false} expandAll={true} />);
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(<Block {...defaultProps} isExpanded={false} expandAll={false} />);
expect(screen.getByRole('button')).toHaveAttribute(
'aria-expanded',
'false'
);
});
it('The "Help us translate" badge does not appear on any English blocks', () => { it('The "Help us translate" badge does not appear on any English blocks', () => {
render(<Block {...defaultProps} />); render(<Block {...defaultProps} />);
expect( expect(
@@ -74,6 +74,11 @@ interface BlockProps {
t: TFunction; t: TFunction;
toggleBlock: typeof toggleBlock; toggleBlock: typeof toggleBlock;
accordion?: boolean; 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<BlockProps> { export class Block extends Component<BlockProps> {
@@ -106,12 +111,15 @@ export class Block extends Component<BlockProps> {
blockLabel, blockLabel,
completedChallengeIds, completedChallengeIds,
challenges, challenges,
isExpanded, isExpanded: isExpandedProp,
superBlock, superBlock,
t, t,
accordion = false accordion = false,
expandAll = false
} = this.props; } = this.props;
const isExpanded = expandAll || isExpandedProp;
let completedCount = 0; let completedCount = 0;
let stepNumber = 0; let stepNumber = 0;
@@ -1,11 +1,16 @@
import React from 'react'; import React from 'react';
import { render, screen, within } from '@testing-library/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 '@testing-library/jest-dom/vitest';
import { SuperBlocks } from '@freecodecamp/shared/config/curriculum'; import { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import { SuperBlockAccordion } from './super-block-accordion'; import { SuperBlockAccordion } from './super-block-accordion';
import { BlockLabel, BlockLayouts } from '@freecodecamp/shared/config/blocks'; import { BlockLabel, BlockLayouts } from '@freecodecamp/shared/config/blocks';
vi.mock('./block', () => ({
default: ({ block }: { block: string }) =>
React.createElement('div', { 'data-testid': `block-${block}` })
}));
const mockStructure = { const mockStructure = {
superBlock: SuperBlocks.RespWebDesign, superBlock: SuperBlocks.RespWebDesign,
chapters: [ chapters: [
@@ -176,4 +181,73 @@ describe('SuperBlockAccordion', () => {
const moduleRight = within(moduleButton).getByTestId('module-button-right'); const moduleRight = within(moduleButton).getByTestId('module-button-right');
expect(within(moduleRight).queryByText(/steps/i)).not.toBeInTheDocument(); 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(
<SuperBlockAccordion
challenges={[
{ ...mockChallenge, block: 'block-one', id: 'id-1' },
{ ...mockChallenge, block: 'block-two', id: 'id-2' }
]}
superBlock={SuperBlocks.RespWebDesign}
structure={multiChapterStructure}
chosenBlock={''}
completedChallengeIds={[]}
expandAll={true}
/>
);
// 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(
<SuperBlockAccordion
// Only challenges for block-one are passed; mod-two has no challenges
challenges={[{ ...mockChallenge, block: 'block-one', id: 'id-1' }]}
superBlock={SuperBlocks.RespWebDesign}
structure={{
superBlock: SuperBlocks.RespWebDesign,
chapters: [
{
dashedName: 'test-chapter',
modules: [
{ dashedName: 'mod-one', blocks: ['block-one'] },
{ dashedName: 'mod-two', blocks: ['block-two'] }
]
}
]
}}
chosenBlock={'block-one'}
completedChallengeIds={[]}
expandAll={true}
/>
);
// 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();
});
}); });
@@ -1,7 +1,5 @@
import React, { ReactNode, useMemo } from 'react'; import React, { ReactNode, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { SuperBlocks } from '@freecodecamp/shared/config/curriculum';
import DropDown from '../../../assets/icons/dropdown'; import DropDown from '../../../assets/icons/dropdown';
@@ -80,6 +78,11 @@ interface SuperBlockAccordionProps {
structure: ChapterBasedSuperBlockStructure; structure: ChapterBasedSuperBlockStructure;
chosenBlock: string; chosenBlock: string;
completedChallengeIds: 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 = ({ const Chapter = ({
@@ -94,6 +97,14 @@ const Chapter = ({
examSlug examSlug
}: ChapterProps) => { }: ChapterProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(isExpanded);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `chapter-panel-${dashedName}`;
const isComplete = completedSteps === totalSteps && totalSteps > 0; const isComplete = completedSteps === totalSteps && totalSteps > 0;
const chapterLabel = t(`intro:${superBlock}.chapters.${dashedName}`); const chapterLabel = t(`intro:${superBlock}.chapters.${dashedName}`);
@@ -138,19 +149,23 @@ const Chapter = ({
} }
return ( return (
<Disclosure as='li' className='chapter' defaultOpen={isExpanded}> <li className='chapter'>
<Disclosure.Button <button
aria-controls={panelId}
aria-expanded={open}
className='chapter-button' className='chapter-button'
data-playwright-test-label='chapter-button' data-playwright-test-label='chapter-button'
onClick={() => setOpen(o => !o)}
type='button'
> >
{chapterButtonContent} {chapterButtonContent}
</Disclosure.Button> </button>
{!isLinkChapter && !examSlug && ( {open && (
<Disclosure.Panel as='ul' className='chapter-panel'> <ul className='chapter-panel' id={panelId}>
{children} {children}
</Disclosure.Panel> </ul>
)} )}
</Disclosure> </li>
); );
}; };
@@ -174,9 +189,23 @@ const Module = ({
const showModuleContent = !(comingSoon && !showUpcomingChanges); const showModuleContent = !(comingSoon && !showUpcomingChanges);
const [open, setOpen] = useState(isExpanded);
useEffect(() => {
setOpen(isExpanded);
}, [isExpanded]);
const panelId = `module-panel-${dashedName}`;
return ( return (
<Disclosure as='li' defaultOpen={isExpanded}> <li>
<Disclosure.Button className='module-button'> <button
aria-controls={panelId}
aria-expanded={open}
className='module-button'
onClick={() => setOpen(o => !o)}
type='button'
>
<div className='module-button-left'> <div className='module-button-left'>
<span className='dropdown-wrap'> <span className='dropdown-wrap'>
<DropDown /> <DropDown />
@@ -196,21 +225,23 @@ const Module = ({
</span> </span>
)} )}
</div> </div>
</Disclosure.Button> </button>
<Disclosure.Panel as='ul' className='module-panel'> {open && (
{comingSoon && ( <ul className='module-panel' id={panelId}>
<div className='module-intro'> {comingSoon && (
{note && ( <div className='module-intro'>
<p> {note && (
<b>{note}</b> <p>
</p> <b>{note}</b>
)} </p>
{intro?.length && intro.map(ntro => <p key={ntro}>{ntro}</p>)} )}
</div> {intro?.length && intro.map(ntro => <p key={ntro}>{ntro}</p>)}
)} </div>
{showModuleContent && children} )}
</Disclosure.Panel> {showModuleContent && children}
</Disclosure> </ul>
)}
</li>
); );
}; };
@@ -218,12 +249,14 @@ const LinkModule = ({
superBlock, superBlock,
challenges, challenges,
accordion, accordion,
moduleType moduleType,
expandAll
}: { }: {
superBlock: SuperBlocks; superBlock: SuperBlocks;
challenges?: Challenge[]; challenges?: Challenge[];
accordion: boolean; accordion: boolean;
moduleType?: Module['moduleType']; moduleType?: Module['moduleType'];
expandAll?: boolean;
}) => { }) => {
if (!challenges?.length) return null; if (!challenges?.length) return null;
@@ -237,6 +270,7 @@ const LinkModule = ({
challenges={challenges} challenges={challenges}
superBlock={superBlock} superBlock={superBlock}
accordion={accordion} accordion={accordion}
expandAll={expandAll}
/> />
</li> </li>
); );
@@ -247,7 +281,8 @@ export const SuperBlockAccordion = ({
superBlock, superBlock,
structure, structure,
chosenBlock, chosenBlock,
completedChallengeIds completedChallengeIds,
expandAll = false
}: SuperBlockAccordionProps) => { }: SuperBlockAccordionProps) => {
const superBlockStructure = structure; const superBlockStructure = structure;
@@ -334,6 +369,8 @@ export const SuperBlockAccordion = ({
); );
}); });
if (expandAll && chapterStepIds.length === 0) return null;
const chapterStepIdsSet = new Set(chapterStepIds); const chapterStepIdsSet = new Set(chapterStepIds);
const completedStepsInChapter = new Set( const completedStepsInChapter = new Set(
@@ -359,7 +396,9 @@ export const SuperBlockAccordion = ({
key={chapter.name} key={chapter.name}
dashedName={chapter.name} dashedName={chapter.name}
isExpanded={ isExpanded={
expandedChapter === chapter.name || allChapters.length === 1 expandAll ||
expandedChapter === chapter.name ||
allChapters.length === 1
} }
comingSoon={chapter.comingSoon} comingSoon={chapter.comingSoon}
totalSteps={chapterStepIds.length} totalSteps={chapterStepIds.length}
@@ -383,6 +422,7 @@ export const SuperBlockAccordion = ({
moduleType={module.moduleType} moduleType={module.moduleType}
challenges={module.blocks[0]?.challenges} challenges={module.blocks[0]?.challenges}
accordion={accordion} accordion={accordion}
expandAll={expandAll}
/> />
); );
} }
@@ -392,6 +432,8 @@ export const SuperBlockAccordion = ({
moduleStepIds.push(...block.challenges.map(c => c.id)) moduleStepIds.push(...block.challenges.map(c => c.id))
); );
if (expandAll && moduleStepIds.length === 0) return null;
const moduleStepIdsSet = new Set(moduleStepIds); const moduleStepIdsSet = new Set(moduleStepIds);
const completedStepsInModule = new Set( const completedStepsInModule = new Set(
completedChallengeIds.filter(id => moduleStepIdsSet.has(id)) completedChallengeIds.filter(id => moduleStepIdsSet.has(id))
@@ -401,7 +443,7 @@ export const SuperBlockAccordion = ({
<Module <Module
key={module.name} key={module.name}
dashedName={module.name} dashedName={module.name}
isExpanded={expandedModule === module.name} isExpanded={expandAll || expandedModule === module.name}
totalSteps={moduleStepIds.length} totalSteps={moduleStepIds.length}
completedSteps={completedStepsInModule} completedSteps={completedStepsInModule}
superBlock={superBlock} superBlock={superBlock}
@@ -416,6 +458,7 @@ export const SuperBlockAccordion = ({
challenges={block.challenges} challenges={block.challenges}
superBlock={superBlock} superBlock={superBlock}
accordion={accordion} accordion={accordion}
expandAll={expandAll}
/> />
</li> </li>
))} ))}
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui'; import { Col, Row, Spacer } from '@freecodecamp/ui';
import { import {
certificationCollectionSuperBlocks, certificationCollectionSuperBlocks,
@@ -28,11 +28,13 @@ import Block from './block';
import CertChallenge from './cert-challenge'; import CertChallenge from './cert-challenge';
import { SuperBlockAccordion } from './super-block-accordion'; import { SuperBlockAccordion } from './super-block-accordion';
import './super-block-accordion.css'; import './super-block-accordion.css';
import SuperBlockSearch from './super-block-search';
type Challenge = { type Challenge = {
block: string; block: string;
blockLabel?: BlockLabel; blockLabel?: BlockLabel;
blockLayout: BlockLayouts; blockLayout: BlockLayouts;
chapter: string;
challengeType: number; challengeType: number;
dashedName: string; dashedName: string;
fields: { slug: string }; fields: { slug: string };
@@ -59,13 +61,15 @@ const BlockList = ({
showCertification, showCertification,
superBlock, superBlock,
superBlockChallenges, superBlockChallenges,
user user,
expandAll = false
}: { }: {
disabledBlocks: string[]; disabledBlocks: string[];
showCertification: boolean; showCertification: boolean;
superBlock: SuperBlocks; superBlock: SuperBlocks;
superBlockChallenges: Challenge[]; superBlockChallenges: Challenge[];
user: User | null; user: User | null;
expandAll?: boolean;
}) => { }) => {
const visibleBlocks = useMemo(() => { const visibleBlocks = useMemo(() => {
const uniqueBlocks = Array.from( const uniqueBlocks = Array.from(
@@ -92,6 +96,7 @@ const BlockList = ({
blockLabel={blockLabel} blockLabel={blockLabel}
challenges={blockChallenges} challenges={blockChallenges}
superBlock={superBlock} superBlock={superBlock}
expandAll={expandAll}
/> />
); );
})} })}
@@ -113,6 +118,22 @@ export const SuperBlockMap = ({
user user
}: SuperBlockMapProps) => { }: SuperBlockMapProps) => {
const { t } = useTranslation(); 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 (chapterBasedSuperBlocks.includes(superBlock)) {
if (!structure) return null; if (!structure) return null;
@@ -160,6 +181,15 @@ export const SuperBlockMap = ({
return ( return (
<> <>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<SuperBlockSearch
onSearch={setSearchTerm}
resultCount={filteredChallenges.length}
isSearching={isSearching}
/>
</Col>
</Row>
{certificationCollectionSuperBlocks.includes(superBlock) && ( {certificationCollectionSuperBlocks.includes(superBlock) && (
<> <>
<ul className='super-block-accordion requirement-list'> <ul className='super-block-accordion requirement-list'>
@@ -174,24 +204,37 @@ export const SuperBlockMap = ({
)} )}
<SuperBlockAccordion <SuperBlockAccordion
challenges={superBlockChallenges} challenges={filteredChallenges}
superBlock={superBlock} superBlock={superBlock}
structure={structure} structure={structure}
chosenBlock={initialExpandedBlock} chosenBlock={initialExpandedBlock}
completedChallengeIds={completedChallengeIds} completedChallengeIds={completedChallengeIds}
expandAll={isSearching}
/> />
</> </>
); );
} }
return ( return (
<BlockList <>
disabledBlocks={disabledBlocks} <Row>
showCertification={showCertification} <Col sm={10} smOffset={1} xs={12}>
superBlock={superBlock} <SuperBlockSearch
superBlockChallenges={superBlockChallenges} onSearch={setSearchTerm}
user={user} resultCount={filteredChallenges.length}
/> isSearching={isSearching}
/>
</Col>
</Row>
<BlockList
disabledBlocks={disabledBlocks}
showCertification={showCertification}
superBlock={superBlock}
superBlockChallenges={filteredChallenges}
user={user}
expandAll={isSearching}
/>
</>
); );
}; };
@@ -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;
}
@@ -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(
<SuperBlockSearch
onSearch={() => {}}
resultCount={0}
isSearching={false}
/>
);
expect(screen.getByRole('searchbox')).toBeInTheDocument();
});
it('should not show a clear button when input is empty', () => {
render(
<SuperBlockSearch
onSearch={() => {}}
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(
<SuperBlockSearch
onSearch={() => {}}
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(
<SuperBlockSearch
onSearch={onSearch}
resultCount={0}
isSearching={false}
/>
);
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(
<SuperBlockSearch
onSearch={() => {}}
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(
<SuperBlockSearch
onSearch={() => {}}
resultCount={0}
isSearching={true}
/>
);
expect(
screen.getByText(/search-challenges-no-results/i)
).toBeInTheDocument();
});
it('should show no status message when not searching', () => {
render(
<SuperBlockSearch
onSearch={() => {}}
resultCount={0}
isSearching={false}
/>
);
expect(screen.queryByText(/search-challenges/i)).not.toBeInTheDocument();
});
});
@@ -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<HTMLInputElement>) => {
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 (
<form
className='super-block-search-form'
onSubmit={e => e.preventDefault()}
role='search'
aria-label={t('learn.search.search-challenges-in-curriculum')}
>
<div className='super-block-search-container'>
<span aria-hidden='true' className='super-block-search-magnifier'>
<Magnifier />
</span>
<FormControl
id='super-block-search-input'
type='search'
aria-label={t('learn.search.search-challenges-in-curriculum')}
value={searchTerm}
onChange={handleChange}
placeholder={t('learn.search.search-challenges-in-curriculum')}
/>
{searchTerm && (
<button
className='super-block-search-reset-btn'
onClick={handleReset}
type='button'
>
<InputReset />
</button>
)}
</div>
<p aria-live='polite' className='super-block-search-status'>
{statusMessage}
</p>
</form>
);
};
SuperBlockSearch.displayName = 'SuperBlockSearch';
export default SuperBlockSearch;
@@ -63,6 +63,10 @@ vi.mock('./components/super-block-accordion', () => ({
SuperBlockAccordion: () => null SuperBlockAccordion: () => null
})); }));
vi.mock('./components/super-block-search', () => ({
default: () => null
}));
const translationMap: Record<string, unknown> = { const translationMap: Record<string, unknown> = {
'intro:full-stack-developer': { 'intro:full-stack-developer': {
title: 'Full-Stack Developer', title: 'Full-Stack Developer',
+70
View File
@@ -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();
});
});
-3
View File
@@ -297,9 +297,6 @@ importers:
'@growthbook/growthbook-react': '@growthbook/growthbook-react':
specifier: 1.6.5 specifier: 1.6.5
version: 1.6.5(react@18.3.1) 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': '@loadable/component':
specifier: 5.16.7 specifier: 5.16.7
version: 5.16.7(react@18.3.1) version: 5.16.7(react@18.3.1)