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}
accordion={true}
blockUrl='/learn/test-block'
onLinkClick={() => {}}
/>
);

View File

@@ -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>
) : (

View File

@@ -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}

View File

@@ -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}>
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}>
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>
))}

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