mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 10:22:16 +00:00
feat(client): search functionality for curriculum lessons (#66514)
This commit is contained in:
@@ -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</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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -93,6 +93,27 @@ describe('<Block />', () => {
|
||||
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', () => {
|
||||
render(<Block {...defaultProps} />);
|
||||
expect(
|
||||
|
||||
@@ -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<BlockProps> {
|
||||
@@ -106,12 +111,15 @@ export class Block extends Component<BlockProps> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
<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';
|
||||
// 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 (
|
||||
<Disclosure as='li' className='chapter' defaultOpen={isExpanded}>
|
||||
<Disclosure.Button
|
||||
<li className='chapter'>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={open}
|
||||
className='chapter-button'
|
||||
data-playwright-test-label='chapter-button'
|
||||
onClick={() => setOpen(o => !o)}
|
||||
type='button'
|
||||
>
|
||||
{chapterButtonContent}
|
||||
</Disclosure.Button>
|
||||
{!isLinkChapter && !examSlug && (
|
||||
<Disclosure.Panel as='ul' className='chapter-panel'>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className='chapter-panel' id={panelId}>
|
||||
{children}
|
||||
</Disclosure.Panel>
|
||||
</ul>
|
||||
)}
|
||||
</Disclosure>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Disclosure as='li' defaultOpen={isExpanded}>
|
||||
<Disclosure.Button className='module-button'>
|
||||
<li>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={open}
|
||||
className='module-button'
|
||||
onClick={() => setOpen(o => !o)}
|
||||
type='button'
|
||||
>
|
||||
<div className='module-button-left'>
|
||||
<span className='dropdown-wrap'>
|
||||
<DropDown />
|
||||
@@ -196,21 +225,23 @@ const Module = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel as='ul' className='module-panel'>
|
||||
{comingSoon && (
|
||||
<div className='module-intro'>
|
||||
{note && (
|
||||
<p>
|
||||
<b>{note}</b>
|
||||
</p>
|
||||
)}
|
||||
{intro?.length && intro.map(ntro => <p key={ntro}>{ntro}</p>)}
|
||||
</div>
|
||||
)}
|
||||
{showModuleContent && children}
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className='module-panel' id={panelId}>
|
||||
{comingSoon && (
|
||||
<div className='module-intro'>
|
||||
{note && (
|
||||
<p>
|
||||
<b>{note}</b>
|
||||
</p>
|
||||
)}
|
||||
{intro?.length && intro.map(ntro => <p key={ntro}>{ntro}</p>)}
|
||||
</div>
|
||||
)}
|
||||
{showModuleContent && children}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
@@ -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 = ({
|
||||
<Module
|
||||
key={module.name}
|
||||
dashedName={module.name}
|
||||
isExpanded={expandedModule === module.name}
|
||||
isExpanded={expandAll || expandedModule === module.name}
|
||||
totalSteps={moduleStepIds.length}
|
||||
completedSteps={completedStepsInModule}
|
||||
superBlock={superBlock}
|
||||
@@ -416,6 +458,7 @@ export const SuperBlockAccordion = ({
|
||||
challenges={block.challenges}
|
||||
superBlock={superBlock}
|
||||
accordion={accordion}
|
||||
expandAll={expandAll}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<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) && (
|
||||
<>
|
||||
<ul className='super-block-accordion requirement-list'>
|
||||
@@ -174,24 +204,37 @@ export const SuperBlockMap = ({
|
||||
)}
|
||||
|
||||
<SuperBlockAccordion
|
||||
challenges={superBlockChallenges}
|
||||
challenges={filteredChallenges}
|
||||
superBlock={superBlock}
|
||||
structure={structure}
|
||||
chosenBlock={initialExpandedBlock}
|
||||
completedChallengeIds={completedChallengeIds}
|
||||
expandAll={isSearching}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockList
|
||||
disabledBlocks={disabledBlocks}
|
||||
showCertification={showCertification}
|
||||
superBlock={superBlock}
|
||||
superBlockChallenges={superBlockChallenges}
|
||||
user={user}
|
||||
/>
|
||||
<>
|
||||
<Row>
|
||||
<Col sm={10} smOffset={1} xs={12}>
|
||||
<SuperBlockSearch
|
||||
onSearch={setSearchTerm}
|
||||
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
|
||||
}));
|
||||
|
||||
vi.mock('./components/super-block-search', () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
const translationMap: Record<string, unknown> = {
|
||||
'intro:full-stack-developer': {
|
||||
title: 'Full-Stack Developer',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Generated
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user