mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-19 10:07:46 -05:00
fix(client): add better navigation for navigating back to blocks (#64027)
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,7 @@ describe('<BlockHeader />', () => {
|
||||
{...defaultProps}
|
||||
accordion={true}
|
||||
blockUrl='/learn/test-block'
|
||||
onLinkClick={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import CheckMark from './check-mark';
|
||||
import BlockLabel from './block-label';
|
||||
import BlockIntros from './block-intros';
|
||||
|
||||
interface BlockHeaderProps {
|
||||
interface BaseBlockHeaderProps {
|
||||
blockDashed: string;
|
||||
blockTitle: string;
|
||||
blockLabel: BlockLabelType | null;
|
||||
@@ -22,9 +22,19 @@ interface BlockHeaderProps {
|
||||
percentageCompleted: number;
|
||||
blockIntroArr?: string[];
|
||||
accordion?: boolean;
|
||||
blockUrl?: string;
|
||||
}
|
||||
|
||||
interface BlockHeaderButtonProps extends BaseBlockHeaderProps {
|
||||
blockUrl?: never;
|
||||
onLinkClick?: never;
|
||||
}
|
||||
|
||||
interface BlockHeaderLinkProps extends BaseBlockHeaderProps {
|
||||
blockUrl: string;
|
||||
onLinkClick: () => void;
|
||||
}
|
||||
|
||||
type BlockHeaderProps = BlockHeaderButtonProps | BlockHeaderLinkProps;
|
||||
function BlockHeader({
|
||||
blockDashed,
|
||||
blockTitle,
|
||||
@@ -37,7 +47,8 @@ function BlockHeader({
|
||||
percentageCompleted,
|
||||
blockIntroArr,
|
||||
accordion,
|
||||
blockUrl
|
||||
blockUrl,
|
||||
onLinkClick
|
||||
}: BlockHeaderProps): JSX.Element {
|
||||
const InnerBlockHeader = () => (
|
||||
<>
|
||||
@@ -68,7 +79,7 @@ function BlockHeader({
|
||||
<>
|
||||
<h3 className='block-grid-title'>
|
||||
{accordion && blockUrl ? (
|
||||
<Link className='block-header' to={blockUrl}>
|
||||
<Link className='block-header' to={blockUrl} onClick={onLinkClick}>
|
||||
<InnerBlockHeader />
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -82,7 +82,7 @@ export class Block extends Component<BlockProps> {
|
||||
super(props);
|
||||
|
||||
this.handleBlockClick = this.handleBlockClick.bind(this);
|
||||
this.handleBlockHover = this.handleBlockHover.bind(this);
|
||||
this.handleChallengeClick = this.handleChallengeClick.bind(this);
|
||||
}
|
||||
|
||||
handleBlockClick = (): void => {
|
||||
@@ -91,13 +91,8 @@ export class Block extends Component<BlockProps> {
|
||||
toggleBlock(block);
|
||||
};
|
||||
|
||||
/*
|
||||
* This function handles the block hover event.
|
||||
* It also updates the URL hash to reflect the current block.
|
||||
*/
|
||||
handleBlockHover = (): void => {
|
||||
handleChallengeClick = (): void => {
|
||||
const { block } = this.props;
|
||||
// Convert block to dashed format
|
||||
const dashedBlock = block
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
@@ -181,11 +176,7 @@ export class Block extends Component<BlockProps> {
|
||||
*/
|
||||
const LegacyChallengeListBlock = (
|
||||
<Element name={block}>
|
||||
<div
|
||||
className={`block ${isExpanded ? 'open' : ''}`}
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<div className={`block ${isExpanded ? 'open' : ''}`}>
|
||||
<div className='block-header'>
|
||||
<h3 className='big-block-title'>{blockTitle}</h3>
|
||||
{blockLabel && <BlockLabel blockLabel={blockLabel} />}
|
||||
@@ -224,7 +215,12 @@ export class Block extends Component<BlockProps> {
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && <ChallengesList challenges={extendedChallenges} />}
|
||||
{isExpanded && (
|
||||
<ChallengesList
|
||||
challenges={extendedChallenges}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Element>
|
||||
);
|
||||
@@ -236,11 +232,7 @@ export class Block extends Component<BlockProps> {
|
||||
*/
|
||||
const ProjectListBlock = (
|
||||
<Element name={block}>
|
||||
<div
|
||||
className='block'
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<div className='block'>
|
||||
<div className='block-header'>
|
||||
<h3 className='big-block-title'>{blockTitle}</h3>
|
||||
{blockLabel && <BlockLabel blockLabel={blockLabel} />}
|
||||
@@ -255,7 +247,10 @@ export class Block extends Component<BlockProps> {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChallengesList challenges={extendedChallenges} />
|
||||
<ChallengesList
|
||||
challenges={extendedChallenges}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
</div>
|
||||
</Element>
|
||||
);
|
||||
@@ -267,11 +262,7 @@ export class Block extends Component<BlockProps> {
|
||||
*/
|
||||
const LegacyChallengeGridBlock = (
|
||||
<Element name={block}>
|
||||
<div
|
||||
className={`block block-grid ${isExpanded ? 'open' : ''}`}
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<div className={`block block-grid ${isExpanded ? 'open' : ''}`}>
|
||||
<BlockHeader
|
||||
blockDashed={block}
|
||||
blockTitle={blockTitle}
|
||||
@@ -305,6 +296,7 @@ export class Block extends Component<BlockProps> {
|
||||
challenges={extendedChallenges}
|
||||
isProjectBlock={isProjectBlock}
|
||||
blockTitle={blockTitle}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -353,6 +345,7 @@ export class Block extends Component<BlockProps> {
|
||||
challenges={extendedChallenges}
|
||||
blockTitle={blockTitle}
|
||||
jumpLink={!accordion}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -368,11 +361,7 @@ export class Block extends Component<BlockProps> {
|
||||
*/
|
||||
const LegacyLinkBlock = (
|
||||
<Element name={block}>
|
||||
<div
|
||||
className='block block-grid grid-project-block'
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<div className='block block-grid grid-project-block'>
|
||||
<div className='tags-wrapper'>
|
||||
<span className='cert-tag' aria-hidden='true'>
|
||||
{t('misc.certification-project')}
|
||||
@@ -394,9 +383,7 @@ export class Block extends Component<BlockProps> {
|
||||
<h3 className='block-grid-title'>
|
||||
<Link
|
||||
className='block-header'
|
||||
onClick={() => {
|
||||
this.handleBlockClick();
|
||||
}}
|
||||
onClick={this.handleChallengeClick}
|
||||
to={link}
|
||||
>
|
||||
<CheckMark isCompleted={isBlockCompleted} />
|
||||
@@ -423,8 +410,6 @@ export class Block extends Component<BlockProps> {
|
||||
</Element>
|
||||
<div
|
||||
className={`block block-grid block-grid-no-border challenge-grid-block ${isExpanded ? 'open' : ''}`}
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<BlockHeader
|
||||
blockDashed={block}
|
||||
@@ -461,9 +446,13 @@ export class Block extends Component<BlockProps> {
|
||||
blockTitle={blockTitle}
|
||||
isProjectBlock={isProjectBlock}
|
||||
jumpLink={false}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
) : (
|
||||
<ChallengesList challenges={extendedChallenges} />
|
||||
<ChallengesList
|
||||
challenges={extendedChallenges}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -477,8 +466,6 @@ export class Block extends Component<BlockProps> {
|
||||
</Element>
|
||||
<div
|
||||
className={`block block-grid challenge-grid-block ${isExpanded ? 'open' : ''}`}
|
||||
onMouseOver={this.handleBlockHover}
|
||||
onFocus={this.handleBlockHover}
|
||||
>
|
||||
<BlockHeader
|
||||
blockDashed={block}
|
||||
@@ -516,9 +503,13 @@ export class Block extends Component<BlockProps> {
|
||||
challenges={extendedChallenges}
|
||||
blockTitle={blockTitle}
|
||||
isProjectBlock={isProjectBlock}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
) : (
|
||||
<ChallengesList challenges={extendedChallenges} />
|
||||
<ChallengesList
|
||||
challenges={extendedChallenges}
|
||||
onChallengeClick={this.handleChallengeClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -539,6 +530,7 @@ export class Block extends Component<BlockProps> {
|
||||
completedCount={completedCount}
|
||||
courseCompletionStatus={courseCompletionStatus()}
|
||||
handleClick={this.handleBlockClick}
|
||||
onLinkClick={this.handleChallengeClick}
|
||||
isCompleted={isBlockCompleted}
|
||||
isExpanded={isExpanded}
|
||||
percentageCompleted={percentageCompleted}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ChallengeInfo {
|
||||
|
||||
interface ChallengesProps {
|
||||
challenges: ChallengeInfo[];
|
||||
onChallengeClick: () => void;
|
||||
}
|
||||
|
||||
interface JumpLinkProps {
|
||||
@@ -37,25 +38,44 @@ interface IsProjectBlockProps {
|
||||
const CheckMark = ({ isCompleted }: { isCompleted: boolean }) =>
|
||||
isCompleted ? <GreenPass /> : <GreenNotCompleted />;
|
||||
|
||||
const ListChallenge = ({ challenge }: { challenge: ChallengeInfo }) => (
|
||||
<Link to={challenge.fields.slug}>
|
||||
<span>
|
||||
<CheckMark isCompleted={challenge.isCompleted} />
|
||||
</span>
|
||||
{challenge.title}
|
||||
</Link>
|
||||
);
|
||||
const ListChallenge = ({
|
||||
challenge,
|
||||
onChallengeClick
|
||||
}: {
|
||||
challenge: ChallengeInfo;
|
||||
onChallengeClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Link to={challenge.fields.slug} onClick={onChallengeClick}>
|
||||
<span>
|
||||
<CheckMark isCompleted={challenge.isCompleted} />
|
||||
</span>
|
||||
{challenge.title}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const CertChallenge = ({ challenge }: { challenge: ChallengeInfo }) => (
|
||||
<Link to={challenge.fields.slug}>
|
||||
{challenge.title}
|
||||
<span className='map-project-checkmark'>
|
||||
<CheckMark isCompleted={challenge.isCompleted} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
const CertChallenge = ({
|
||||
challenge,
|
||||
onChallengeClick
|
||||
}: {
|
||||
challenge: ChallengeInfo;
|
||||
onChallengeClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Link to={challenge.fields.slug} onClick={onChallengeClick}>
|
||||
{challenge.title}
|
||||
<span className='map-project-checkmark'>
|
||||
<CheckMark isCompleted={challenge.isCompleted} />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export function ChallengesList({ challenges }: ChallengesProps): JSX.Element {
|
||||
export function ChallengesList({
|
||||
challenges,
|
||||
onChallengeClick
|
||||
}: ChallengesProps): JSX.Element {
|
||||
return (
|
||||
<ul className={`map-challenges-ul`}>
|
||||
{challenges.map(challenge => (
|
||||
@@ -64,7 +84,10 @@ export function ChallengesList({ challenges }: ChallengesProps): JSX.Element {
|
||||
id={challenge.dashedName}
|
||||
key={'map-challenge' + challenge.fields.slug}
|
||||
>
|
||||
<ListChallenge challenge={challenge} />
|
||||
<ListChallenge
|
||||
challenge={challenge}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -73,16 +96,19 @@ export function ChallengesList({ challenges }: ChallengesProps): JSX.Element {
|
||||
// Step or Task challenge
|
||||
const GridChallenge = ({
|
||||
challenge,
|
||||
isTask = false
|
||||
isTask = false,
|
||||
onChallengeClick
|
||||
}: {
|
||||
challenge: ChallengeInfo;
|
||||
isTask?: boolean;
|
||||
onChallengeClick: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={challenge.fields.slug}
|
||||
onClick={onChallengeClick}
|
||||
className={`map-grid-item ${
|
||||
challenge.isCompleted ? 'challenge-completed' : ''
|
||||
}`}
|
||||
@@ -100,7 +126,8 @@ const GridChallenge = ({
|
||||
|
||||
const LinkToFirstIncompleteChallenge = ({
|
||||
challenges,
|
||||
blockTitle
|
||||
blockTitle,
|
||||
onChallengeClick
|
||||
}: ChallengesProps & BlockTitleProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -113,7 +140,11 @@ const LinkToFirstIncompleteChallenge = ({
|
||||
);
|
||||
return firstIncompleteChallenge ? (
|
||||
<div className='challenge-jump-link'>
|
||||
<ButtonLink size='small' href={firstIncompleteChallenge.fields.slug}>
|
||||
<ButtonLink
|
||||
size='small'
|
||||
href={firstIncompleteChallenge.fields.slug}
|
||||
onClick={onChallengeClick}
|
||||
>
|
||||
{!isChallengeStarted
|
||||
? t('buttons.start-project')
|
||||
: t('buttons.resume-project')}{' '}
|
||||
@@ -127,7 +158,8 @@ export const GridMapChallenges = ({
|
||||
challenges,
|
||||
blockTitle,
|
||||
isProjectBlock,
|
||||
jumpLink
|
||||
jumpLink,
|
||||
onChallengeClick
|
||||
}: ChallengesProps & BlockTitleProps & IsProjectBlockProps & JumpLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -136,6 +168,7 @@ export const GridMapChallenges = ({
|
||||
<LinkToFirstIncompleteChallenge
|
||||
challenges={challenges}
|
||||
blockTitle={blockTitle}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
)}
|
||||
<nav aria-label={t('aria.steps-for', { blockTitle })}>
|
||||
@@ -153,9 +186,15 @@ export const GridMapChallenges = ({
|
||||
key={`map-challenge ${challenge.fields.slug}`}
|
||||
>
|
||||
{!isProjectBlock ? (
|
||||
<GridChallenge challenge={challenge} />
|
||||
<GridChallenge
|
||||
challenge={challenge}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
) : (
|
||||
<CertChallenge challenge={challenge} />
|
||||
<CertChallenge
|
||||
challenge={challenge}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
@@ -168,7 +207,8 @@ export const GridMapChallenges = ({
|
||||
export const ChallengesWithDialogues = ({
|
||||
challenges,
|
||||
blockTitle,
|
||||
jumpLink
|
||||
jumpLink,
|
||||
onChallengeClick
|
||||
}: ChallengesProps & BlockTitleProps & JumpLinkProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -177,6 +217,7 @@ export const ChallengesWithDialogues = ({
|
||||
<LinkToFirstIncompleteChallenge
|
||||
challenges={challenges}
|
||||
blockTitle={blockTitle}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -193,9 +234,16 @@ export const ChallengesWithDialogues = ({
|
||||
key={`map-challenge ${challenge.fields.slug}`}
|
||||
>
|
||||
{challenge.challengeType === challengeTypes.dialogue ? (
|
||||
<ListChallenge challenge={challenge} />
|
||||
<ListChallenge
|
||||
challenge={challenge}
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
) : (
|
||||
<GridChallenge challenge={challenge} isTask />
|
||||
<GridChallenge
|
||||
challenge={challenge}
|
||||
isTask
|
||||
onChallengeClick={onChallengeClick}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
92
e2e/block-navigation.spec.ts
Normal file
92
e2e/block-navigation.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Block Navigation - Hash Updates', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('should update URL hash when clicking on a challenge link in a grid layout', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(
|
||||
'/learn/javascript-algorithms-and-data-structures-v8/#learn-introductory-javascript-by-building-a-pyramid-generator'
|
||||
);
|
||||
|
||||
// Verify the hash is set correctly
|
||||
await expect(page).toHaveURL(
|
||||
/#learn-introductory-javascript-by-building-a-pyramid-generator$/
|
||||
);
|
||||
|
||||
// Click on step 1 in the grid - the accessible name includes the sr-only text
|
||||
const step1Link = page.getByRole('link', { name: 'Step 1 Not Passed' });
|
||||
await expect(step1Link).toBeVisible();
|
||||
await step1Link.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/step-1$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(
|
||||
/#learn-introductory-javascript-by-building-a-pyramid-generator$/
|
||||
);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a certification project', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-algorithms-and-data-structures-v8');
|
||||
|
||||
// Click on the certification project link
|
||||
const projectLink = page.getByRole('link', {
|
||||
name: 'Build a Palindrome Checker Project Certification Project, Not completed'
|
||||
});
|
||||
await expect(projectLink).toBeVisible();
|
||||
await projectLink.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/build-a-palindrome-checker$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#build-a-palindrome-checker-project$/);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a challenge in accordion layout (v9)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-v9');
|
||||
|
||||
await page.getByRole('button', { name: 'Build a Greeting Bot' }).click();
|
||||
|
||||
// Click on step 1 in the accordion
|
||||
const step1Link = page.getByRole('link', { name: 'Step 1 Not Passed' });
|
||||
await expect(step1Link).toBeVisible();
|
||||
await step1Link.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/step-1$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#workshop-greeting-bot$/);
|
||||
});
|
||||
|
||||
test('should update URL hash when clicking on a certification project in accordion layout (v9)', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/learn/javascript-v9');
|
||||
|
||||
// Click on the certification project link
|
||||
const projectLink = page.getByRole('link', {
|
||||
name: 'Build a Markdown to HTML Converter'
|
||||
});
|
||||
await expect(projectLink).toBeVisible();
|
||||
await projectLink.click();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForURL(/build-a-markdown-to-html-converter$/);
|
||||
|
||||
// Go back to verify the hash persists in history
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/#lab-markdown-to-html-converter$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user