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:
Sem Bauke
2025-12-17 20:36:23 +01:00
committed by GitHub
parent 4d8b38489c
commit 8034515276
5 changed files with 213 additions and 69 deletions

View File

@@ -34,6 +34,7 @@ describe('<BlockHeader />', () => {
{...defaultProps} {...defaultProps}
accordion={true} accordion={true}
blockUrl='/learn/test-block' blockUrl='/learn/test-block'
onLinkClick={() => {}}
/> />
); );

View File

@@ -10,7 +10,7 @@ import CheckMark from './check-mark';
import BlockLabel from './block-label'; import BlockLabel from './block-label';
import BlockIntros from './block-intros'; import BlockIntros from './block-intros';
interface BlockHeaderProps { interface BaseBlockHeaderProps {
blockDashed: string; blockDashed: string;
blockTitle: string; blockTitle: string;
blockLabel: BlockLabelType | null; blockLabel: BlockLabelType | null;
@@ -22,9 +22,19 @@ interface BlockHeaderProps {
percentageCompleted: number; percentageCompleted: number;
blockIntroArr?: string[]; blockIntroArr?: string[];
accordion?: boolean; 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({ function BlockHeader({
blockDashed, blockDashed,
blockTitle, blockTitle,
@@ -37,7 +47,8 @@ function BlockHeader({
percentageCompleted, percentageCompleted,
blockIntroArr, blockIntroArr,
accordion, accordion,
blockUrl blockUrl,
onLinkClick
}: BlockHeaderProps): JSX.Element { }: BlockHeaderProps): JSX.Element {
const InnerBlockHeader = () => ( const InnerBlockHeader = () => (
<> <>
@@ -68,7 +79,7 @@ function BlockHeader({
<> <>
<h3 className='block-grid-title'> <h3 className='block-grid-title'>
{accordion && blockUrl ? ( {accordion && blockUrl ? (
<Link className='block-header' to={blockUrl}> <Link className='block-header' to={blockUrl} onClick={onLinkClick}>
<InnerBlockHeader /> <InnerBlockHeader />
</Link> </Link>
) : ( ) : (

View File

@@ -82,7 +82,7 @@ export class Block extends Component<BlockProps> {
super(props); super(props);
this.handleBlockClick = this.handleBlockClick.bind(this); this.handleBlockClick = this.handleBlockClick.bind(this);
this.handleBlockHover = this.handleBlockHover.bind(this); this.handleChallengeClick = this.handleChallengeClick.bind(this);
} }
handleBlockClick = (): void => { handleBlockClick = (): void => {
@@ -91,13 +91,8 @@ export class Block extends Component<BlockProps> {
toggleBlock(block); toggleBlock(block);
}; };
/* handleChallengeClick = (): void => {
* This function handles the block hover event.
* It also updates the URL hash to reflect the current block.
*/
handleBlockHover = (): void => {
const { block } = this.props; const { block } = this.props;
// Convert block to dashed format
const dashedBlock = block const dashedBlock = block
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
@@ -181,11 +176,7 @@ export class Block extends Component<BlockProps> {
*/ */
const LegacyChallengeListBlock = ( const LegacyChallengeListBlock = (
<Element name={block}> <Element name={block}>
<div <div className={`block ${isExpanded ? 'open' : ''}`}>
className={`block ${isExpanded ? 'open' : ''}`}
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
>
<div className='block-header'> <div className='block-header'>
<h3 className='big-block-title'>{blockTitle}</h3> <h3 className='big-block-title'>{blockTitle}</h3>
{blockLabel && <BlockLabel blockLabel={blockLabel} />} {blockLabel && <BlockLabel blockLabel={blockLabel} />}
@@ -224,7 +215,12 @@ export class Block extends Component<BlockProps> {
</span> </span>
</div> </div>
</button> </button>
{isExpanded && <ChallengesList challenges={extendedChallenges} />} {isExpanded && (
<ChallengesList
challenges={extendedChallenges}
onChallengeClick={this.handleChallengeClick}
/>
)}
</div> </div>
</Element> </Element>
); );
@@ -236,11 +232,7 @@ export class Block extends Component<BlockProps> {
*/ */
const ProjectListBlock = ( const ProjectListBlock = (
<Element name={block}> <Element name={block}>
<div <div className='block'>
className='block'
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
>
<div className='block-header'> <div className='block-header'>
<h3 className='big-block-title'>{blockTitle}</h3> <h3 className='big-block-title'>{blockTitle}</h3>
{blockLabel && <BlockLabel blockLabel={blockLabel} />} {blockLabel && <BlockLabel blockLabel={blockLabel} />}
@@ -255,7 +247,10 @@ export class Block extends Component<BlockProps> {
</div> </div>
)} )}
</div> </div>
<ChallengesList challenges={extendedChallenges} /> <ChallengesList
challenges={extendedChallenges}
onChallengeClick={this.handleChallengeClick}
/>
</div> </div>
</Element> </Element>
); );
@@ -267,11 +262,7 @@ export class Block extends Component<BlockProps> {
*/ */
const LegacyChallengeGridBlock = ( const LegacyChallengeGridBlock = (
<Element name={block}> <Element name={block}>
<div <div className={`block block-grid ${isExpanded ? 'open' : ''}`}>
className={`block block-grid ${isExpanded ? 'open' : ''}`}
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
>
<BlockHeader <BlockHeader
blockDashed={block} blockDashed={block}
blockTitle={blockTitle} blockTitle={blockTitle}
@@ -305,6 +296,7 @@ export class Block extends Component<BlockProps> {
challenges={extendedChallenges} challenges={extendedChallenges}
isProjectBlock={isProjectBlock} isProjectBlock={isProjectBlock}
blockTitle={blockTitle} blockTitle={blockTitle}
onChallengeClick={this.handleChallengeClick}
/> />
</div> </div>
</> </>
@@ -353,6 +345,7 @@ export class Block extends Component<BlockProps> {
challenges={extendedChallenges} challenges={extendedChallenges}
blockTitle={blockTitle} blockTitle={blockTitle}
jumpLink={!accordion} jumpLink={!accordion}
onChallengeClick={this.handleChallengeClick}
/> />
</div> </div>
</> </>
@@ -368,11 +361,7 @@ export class Block extends Component<BlockProps> {
*/ */
const LegacyLinkBlock = ( const LegacyLinkBlock = (
<Element name={block}> <Element name={block}>
<div <div className='block block-grid grid-project-block'>
className='block block-grid grid-project-block'
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
>
<div className='tags-wrapper'> <div className='tags-wrapper'>
<span className='cert-tag' aria-hidden='true'> <span className='cert-tag' aria-hidden='true'>
{t('misc.certification-project')} {t('misc.certification-project')}
@@ -394,9 +383,7 @@ export class Block extends Component<BlockProps> {
<h3 className='block-grid-title'> <h3 className='block-grid-title'>
<Link <Link
className='block-header' className='block-header'
onClick={() => { onClick={this.handleChallengeClick}
this.handleBlockClick();
}}
to={link} to={link}
> >
<CheckMark isCompleted={isBlockCompleted} /> <CheckMark isCompleted={isBlockCompleted} />
@@ -423,8 +410,6 @@ export class Block extends Component<BlockProps> {
</Element> </Element>
<div <div
className={`block block-grid block-grid-no-border challenge-grid-block ${isExpanded ? 'open' : ''}`} className={`block block-grid block-grid-no-border challenge-grid-block ${isExpanded ? 'open' : ''}`}
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
> >
<BlockHeader <BlockHeader
blockDashed={block} blockDashed={block}
@@ -461,9 +446,13 @@ export class Block extends Component<BlockProps> {
blockTitle={blockTitle} blockTitle={blockTitle}
isProjectBlock={isProjectBlock} isProjectBlock={isProjectBlock}
jumpLink={false} jumpLink={false}
onChallengeClick={this.handleChallengeClick}
/> />
) : ( ) : (
<ChallengesList challenges={extendedChallenges} /> <ChallengesList
challenges={extendedChallenges}
onChallengeClick={this.handleChallengeClick}
/>
)} )}
</div> </div>
</div> </div>
@@ -477,8 +466,6 @@ export class Block extends Component<BlockProps> {
</Element> </Element>
<div <div
className={`block block-grid challenge-grid-block ${isExpanded ? 'open' : ''}`} className={`block block-grid challenge-grid-block ${isExpanded ? 'open' : ''}`}
onMouseOver={this.handleBlockHover}
onFocus={this.handleBlockHover}
> >
<BlockHeader <BlockHeader
blockDashed={block} blockDashed={block}
@@ -516,9 +503,13 @@ export class Block extends Component<BlockProps> {
challenges={extendedChallenges} challenges={extendedChallenges}
blockTitle={blockTitle} blockTitle={blockTitle}
isProjectBlock={isProjectBlock} isProjectBlock={isProjectBlock}
onChallengeClick={this.handleChallengeClick}
/> />
) : ( ) : (
<ChallengesList challenges={extendedChallenges} /> <ChallengesList
challenges={extendedChallenges}
onChallengeClick={this.handleChallengeClick}
/>
)} )}
</div> </div>
</div> </div>
@@ -539,6 +530,7 @@ export class Block extends Component<BlockProps> {
completedCount={completedCount} completedCount={completedCount}
courseCompletionStatus={courseCompletionStatus()} courseCompletionStatus={courseCompletionStatus()}
handleClick={this.handleBlockClick} handleClick={this.handleBlockClick}
onLinkClick={this.handleChallengeClick}
isCompleted={isBlockCompleted} isCompleted={isBlockCompleted}
isExpanded={isExpanded} isExpanded={isExpanded}
percentageCompleted={percentageCompleted} percentageCompleted={percentageCompleted}

View File

@@ -20,6 +20,7 @@ interface ChallengeInfo {
interface ChallengesProps { interface ChallengesProps {
challenges: ChallengeInfo[]; challenges: ChallengeInfo[];
onChallengeClick: () => void;
} }
interface JumpLinkProps { interface JumpLinkProps {
@@ -37,25 +38,44 @@ interface IsProjectBlockProps {
const CheckMark = ({ isCompleted }: { isCompleted: boolean }) => const CheckMark = ({ isCompleted }: { isCompleted: boolean }) =>
isCompleted ? <GreenPass /> : <GreenNotCompleted />; isCompleted ? <GreenPass /> : <GreenNotCompleted />;
const ListChallenge = ({ challenge }: { challenge: ChallengeInfo }) => ( const ListChallenge = ({
<Link to={challenge.fields.slug}> challenge,
<span> onChallengeClick
<CheckMark isCompleted={challenge.isCompleted} /> }: {
</span> challenge: ChallengeInfo;
{challenge.title} onChallengeClick: () => void;
</Link> }) => {
); return (
<Link to={challenge.fields.slug} onClick={onChallengeClick}>
<span>
<CheckMark isCompleted={challenge.isCompleted} />
</span>
{challenge.title}
</Link>
);
};
const CertChallenge = ({ challenge }: { challenge: ChallengeInfo }) => ( const CertChallenge = ({
<Link to={challenge.fields.slug}> challenge,
{challenge.title} onChallengeClick
<span className='map-project-checkmark'> }: {
<CheckMark isCompleted={challenge.isCompleted} /> challenge: ChallengeInfo;
</span> onChallengeClick: () => void;
</Link> }) => {
); 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 ( return (
<ul className={`map-challenges-ul`}> <ul className={`map-challenges-ul`}>
{challenges.map(challenge => ( {challenges.map(challenge => (
@@ -64,7 +84,10 @@ export function ChallengesList({ challenges }: ChallengesProps): JSX.Element {
id={challenge.dashedName} id={challenge.dashedName}
key={'map-challenge' + challenge.fields.slug} key={'map-challenge' + challenge.fields.slug}
> >
<ListChallenge challenge={challenge} /> <ListChallenge
challenge={challenge}
onChallengeClick={onChallengeClick}
/>
</li> </li>
))} ))}
</ul> </ul>
@@ -73,16 +96,19 @@ export function ChallengesList({ challenges }: ChallengesProps): JSX.Element {
// Step or Task challenge // Step or Task challenge
const GridChallenge = ({ const GridChallenge = ({
challenge, challenge,
isTask = false isTask = false,
onChallengeClick
}: { }: {
challenge: ChallengeInfo; challenge: ChallengeInfo;
isTask?: boolean; isTask?: boolean;
onChallengeClick: () => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Link <Link
to={challenge.fields.slug} to={challenge.fields.slug}
onClick={onChallengeClick}
className={`map-grid-item ${ className={`map-grid-item ${
challenge.isCompleted ? 'challenge-completed' : '' challenge.isCompleted ? 'challenge-completed' : ''
}`} }`}
@@ -100,7 +126,8 @@ const GridChallenge = ({
const LinkToFirstIncompleteChallenge = ({ const LinkToFirstIncompleteChallenge = ({
challenges, challenges,
blockTitle blockTitle,
onChallengeClick
}: ChallengesProps & BlockTitleProps) => { }: ChallengesProps & BlockTitleProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -113,7 +140,11 @@ const LinkToFirstIncompleteChallenge = ({
); );
return firstIncompleteChallenge ? ( return firstIncompleteChallenge ? (
<div className='challenge-jump-link'> <div className='challenge-jump-link'>
<ButtonLink size='small' href={firstIncompleteChallenge.fields.slug}> <ButtonLink
size='small'
href={firstIncompleteChallenge.fields.slug}
onClick={onChallengeClick}
>
{!isChallengeStarted {!isChallengeStarted
? t('buttons.start-project') ? t('buttons.start-project')
: t('buttons.resume-project')}{' '} : t('buttons.resume-project')}{' '}
@@ -127,7 +158,8 @@ export const GridMapChallenges = ({
challenges, challenges,
blockTitle, blockTitle,
isProjectBlock, isProjectBlock,
jumpLink jumpLink,
onChallengeClick
}: ChallengesProps & BlockTitleProps & IsProjectBlockProps & JumpLinkProps) => { }: ChallengesProps & BlockTitleProps & IsProjectBlockProps & JumpLinkProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -136,6 +168,7 @@ export const GridMapChallenges = ({
<LinkToFirstIncompleteChallenge <LinkToFirstIncompleteChallenge
challenges={challenges} challenges={challenges}
blockTitle={blockTitle} blockTitle={blockTitle}
onChallengeClick={onChallengeClick}
/> />
)} )}
<nav aria-label={t('aria.steps-for', { blockTitle })}> <nav aria-label={t('aria.steps-for', { blockTitle })}>
@@ -153,9 +186,15 @@ export const GridMapChallenges = ({
key={`map-challenge ${challenge.fields.slug}`} key={`map-challenge ${challenge.fields.slug}`}
> >
{!isProjectBlock ? ( {!isProjectBlock ? (
<GridChallenge challenge={challenge} /> <GridChallenge
challenge={challenge}
onChallengeClick={onChallengeClick}
/>
) : ( ) : (
<CertChallenge challenge={challenge} /> <CertChallenge
challenge={challenge}
onChallengeClick={onChallengeClick}
/>
)} )}
</li> </li>
))} ))}
@@ -168,7 +207,8 @@ export const GridMapChallenges = ({
export const ChallengesWithDialogues = ({ export const ChallengesWithDialogues = ({
challenges, challenges,
blockTitle, blockTitle,
jumpLink jumpLink,
onChallengeClick
}: ChallengesProps & BlockTitleProps & JumpLinkProps) => { }: ChallengesProps & BlockTitleProps & JumpLinkProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -177,6 +217,7 @@ export const ChallengesWithDialogues = ({
<LinkToFirstIncompleteChallenge <LinkToFirstIncompleteChallenge
challenges={challenges} challenges={challenges}
blockTitle={blockTitle} blockTitle={blockTitle}
onChallengeClick={onChallengeClick}
/> />
)} )}
@@ -193,9 +234,16 @@ export const ChallengesWithDialogues = ({
key={`map-challenge ${challenge.fields.slug}`} key={`map-challenge ${challenge.fields.slug}`}
> >
{challenge.challengeType === challengeTypes.dialogue ? ( {challenge.challengeType === challengeTypes.dialogue ? (
<ListChallenge challenge={challenge} /> <ListChallenge
challenge={challenge}
onChallengeClick={onChallengeClick}
/>
) : ( ) : (
<GridChallenge challenge={challenge} isTask /> <GridChallenge
challenge={challenge}
isTask
onChallengeClick={onChallengeClick}
/>
)} )}
</li> </li>
))} ))}

View 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$/);
});
});