mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): search functionality for curriculum lessons (#66514)
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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,8 +225,9 @@ const Module = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Disclosure.Button>
|
</button>
|
||||||
<Disclosure.Panel as='ul' className='module-panel'>
|
{open && (
|
||||||
|
<ul className='module-panel' id={panelId}>
|
||||||
{comingSoon && (
|
{comingSoon && (
|
||||||
<div className='module-intro'>
|
<div className='module-intro'>
|
||||||
{note && (
|
{note && (
|
||||||
@@ -209,8 +239,9 @@ const Module = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showModuleContent && children}
|
{showModuleContent && children}
|
||||||
</Disclosure.Panel>
|
</ul>
|
||||||
</Disclosure>
|
)}
|
||||||
|
</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 (
|
||||||
|
<>
|
||||||
|
<Row>
|
||||||
|
<Col sm={10} smOffset={1} xs={12}>
|
||||||
|
<SuperBlockSearch
|
||||||
|
onSearch={setSearchTerm}
|
||||||
|
resultCount={filteredChallenges.length}
|
||||||
|
isSearching={isSearching}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<BlockList
|
<BlockList
|
||||||
disabledBlocks={disabledBlocks}
|
disabledBlocks={disabledBlocks}
|
||||||
showCertification={showCertification}
|
showCertification={showCertification}
|
||||||
superBlock={superBlock}
|
superBlock={superBlock}
|
||||||
superBlockChallenges={superBlockChallenges}
|
superBlockChallenges={filteredChallenges}
|
||||||
user={user}
|
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',
|
||||||
|
|||||||
@@ -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':
|
'@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)
|
||||||
|
|||||||
Reference in New Issue
Block a user