feat(client): add start/continue superblock buttons (#63495)

This commit is contained in:
Ahmad Abdolsaheb
2025-11-20 19:59:26 +03:00
committed by GitHub
parent 14fc339502
commit 03b4a86127
5 changed files with 548 additions and 122 deletions
@@ -894,6 +894,7 @@
"navigation-warning": "If you leave this page, you will lose your progress. Are you sure?",
"fsd-b-description": "This comprehensive course prepares you to become a Certified Full Stack Developer. You'll learn to build complete web applications using HTML, CSS, JavaScript, React, TypeScript, Node.js, Python, and more.",
"fsd-b-cta": "Start Learning",
"continue-learning": "Continue Learning",
"fsd-b-benefit-1-title": "100k+ Students",
"fsd-b-benefit-1-description": "Join more than 100k students taking this certification.",
"fsd-b-benefit-2-title": "Professional Certification",
@@ -310,8 +310,9 @@ button .block-header-button-text {
padding: 20px;
}
.super-block-intro-page .btn-cta-big {
max-width: 350px;
.super-block-intro-page .intro-top-cta {
width: fit-content;
padding: 5px 30px;
}
.super-block-benefits > div {
@@ -1,9 +1,6 @@
import React from 'react';
import { graphql, useStaticQuery } from 'gatsby';
import { useTranslation, Trans } from 'react-i18next';
import { Callout, Spacer, Container, Row, Col } from '@freecodecamp/ui';
import { ConnectedProps, connect } from 'react-redux';
import { useFeatureIsOn } from '@growthbook/growthbook-react';
import {
archivedSuperBlocks,
SuperBlocks
@@ -13,39 +10,18 @@ import { Link } from '../../../components/helpers';
import CapIcon from '../../../assets/icons/cap';
import DumbbellIcon from '../../../assets/icons/dumbbell';
import CommunityIcon from '../../../assets/icons/community';
import { CompletedChallenge } from '../../../redux/prop-types';
import { completedChallengesSelector } from '../../../redux/selectors';
import ArchivedWarning from '../../../components/archived-warning';
interface SuperBlockIntroQueryData {
challengeNode: {
challenge: {
fields: {
slug: string;
};
};
} | null;
}
type ReduxProps = ConnectedProps<typeof connector>;
interface ConditionalDonationAlertProps {
superBlock: SuperBlocks;
onCertificationDonationAlertClick: () => void;
isDonating: boolean;
}
interface SuperBlockIntroProps
extends ConditionalDonationAlertProps,
ReduxProps {}
const mapStateToProps = (state: unknown) => ({
completedChallenges: completedChallengesSelector(
state
) as CompletedChallenge[]
});
const connector = connect(mapStateToProps);
interface SuperBlockIntroProps extends ConditionalDonationAlertProps {
hasNotstarted: boolean;
nextChallengeSlug: string | null;
}
export const ConditionalDonationAlert = ({
superBlock,
@@ -104,10 +80,10 @@ function SuperBlockIntro({
superBlock,
onCertificationDonationAlertClick,
isDonating,
completedChallenges
hasNotstarted,
nextChallengeSlug
}: SuperBlockIntroProps): JSX.Element {
const { t } = useTranslation();
const superBlockIntroObj: {
title: string;
intro: string[];
@@ -118,41 +94,68 @@ function SuperBlockIntro({
note: string;
};
const { challengeNode } = useStaticQuery<SuperBlockIntroQueryData>(graphql`
query SuperBlockIntroQuery {
challengeNode(
challenge: {
superOrder: { eq: 0 }
order: { eq: 0 }
challengeOrder: { eq: 0 }
}
) {
challenge {
fields {
slug
}
}
}
}
`);
const firstChallengeSlug = challengeNode?.challenge?.fields?.slug || '';
const {
title: i18nSuperBlock,
intro: superBlockIntroText,
note: superBlockNoteText
} = superBlockIntroObj;
const introTopA = (
const IntroTopDefault = ({ fsd }: { fsd: boolean }) => (
<>
{archivedSuperBlocks.includes(superBlock) && <ArchivedWarning />}
<Spacer size='s' />
<SuperBlockIcon className='cert-header-icon' superBlock={superBlock} />
<Spacer size='m' />
<h1 id='content-start' className='text-center big-heading'>
{i18nSuperBlock}
</h1>
<Spacer size='m' />
<SuperBlockIcon className='cert-header-icon' superBlock={superBlock} />
<Spacer size='m' />
<Spacer size='l' />
{fsd && (
<Container
fluid={true}
className='full-width-container super-benefits-container'
>
<Container>
<Row>
<Col xs={12} className='super-block-benefits'>
<div>
<CommunityIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-1-title')}</h3>
<p>{t('misc.fsd-b-benefit-1-description')}</p>
</div>
</div>
<div>
<CapIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-2-title')}</h3>
<p>{t('misc.fsd-b-benefit-2-description')}</p>
</div>
</div>
<div>
<DumbbellIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-3-title')}</h3>
<p>{t('misc.fsd-b-benefit-3-description')}</p>
</div>
</div>
</Col>
</Row>
</Container>
</Container>
)}
{nextChallengeSlug && !fsd && (
<Link
className={'btn-cta-big btn-block signup-btn btn-cta intro-top-cta'}
to={nextChallengeSlug}
data-test-label={
hasNotstarted ? 'start-learning' : 'continue-learning'
}
>
{hasNotstarted ? t('misc.fsd-b-cta') : t('misc.continue-learning')}
</Link>
)}
<Spacer size='l' />
{superBlockIntroText.map((str, i) => (
<p dangerouslySetInnerHTML={{ __html: str }} key={i} />
))}
@@ -165,75 +168,13 @@ function SuperBlockIntro({
</>
);
const introTopB = (
<>
<SuperBlockIcon className='cert-header-icon' superBlock={superBlock} />
<Spacer size='m' />
<h1 id='content-start' className='text-center big-heading'>
{i18nSuperBlock}
</h1>
<Spacer size='m' />
<p>{t('misc.fsd-b-description')}</p>
{superBlockNoteText && (
<>
<Spacer size='m' />
<Callout variant='info'>{superBlockNoteText}</Callout>
</>
)}
<Spacer size='s' />
<a
className={'btn-cta-big btn-block signup-btn btn-cta'}
href={firstChallengeSlug}
>
{t('misc.fsd-b-cta')}
</a>
<Spacer size='l' />
<Container
fluid={true}
className='full-width-container super-benefits-container'
>
<Container>
<Row>
<Col xs={12} className='super-block-benefits'>
<div>
<CommunityIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-1-title')}</h3>
<p>{t('misc.fsd-b-benefit-1-description')}</p>
</div>
</div>
<div>
<CapIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-2-title')}</h3>
<p>{t('misc.fsd-b-benefit-2-description')}</p>
</div>
</div>
<div>
<DumbbellIcon />
<div className='benefit-text'>
<h3>{t('misc.fsd-b-benefit-3-title')}</h3>
<p>{t('misc.fsd-b-benefit-3-description')}</p>
</div>
</div>
</Col>
</Row>
</Container>
</Container>
<Spacer size='l' />
</>
);
const showFSDnewIntro = useFeatureIsOn('fsd-new-intro');
const showIntroTopB =
completedChallenges.length === 0 &&
superBlock === SuperBlocks.FullStackDeveloper &&
showFSDnewIntro;
const isFullStackDeveloper =
superBlock === SuperBlocks.FullStackDeveloper ||
superBlock === SuperBlocks.FullStackDeveloperV9;
return (
<>
{showIntroTopB ? introTopB : introTopA}
<IntroTopDefault fsd={isFullStackDeveloper} />
<ConditionalDonationAlert
superBlock={superBlock}
onCertificationDonationAlertClick={onCertificationDonationAlertClick}
@@ -245,4 +186,4 @@ function SuperBlockIntro({
SuperBlockIntro.displayName = 'SuperBlockIntro';
export default connector(SuperBlockIntro);
export default SuperBlockIntro;
@@ -0,0 +1,453 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import i18next from 'i18next';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { WindowLocation } from '@gatsbyjs/reach-router';
vi.mock('react-redux', () => ({
connect: () => (Component: React.ComponentType<unknown>) => Component,
useDispatch: () => vi.fn(),
useSelector: () => ({})
}));
vi.mock('react-helmet', () => ({
__esModule: true,
default: ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
)
}));
vi.mock('gatsby', () => ({
graphql: vi.fn()
}));
vi.mock('@growthbook/growthbook-react', () => ({
useFeatureValue: () => []
}));
vi.mock('react-scroll', () => ({
scroller: { scrollTo: vi.fn() }
}));
vi.mock('../../components/Donation/donation-modal', () => ({
default: () => null
}));
vi.mock('../../components/Header/components/login', () => ({
default: ({ children }: { children?: React.ReactNode }) => (
<span>{children}</span>
)
}));
vi.mock('../../components/Map', () => ({
default: () => null
}));
vi.mock('./components/block', () => ({
default: () => null
}));
vi.mock('./components/cert-challenge', () => ({
default: () => null
}));
vi.mock('./components/help-translate', () => ({
default: () => null
}));
vi.mock('./components/legacy-links', () => ({
default: () => null
}));
vi.mock('./components/super-block-accordion', () => ({
SuperBlockAccordion: () => null
}));
const translationMap: Record<string, unknown> = {
'intro:full-stack-developer': {
title: 'Full Stack Developer',
intro: ['<strong>Build</strong> and deploy full stack apps.'],
note: 'Stay curious.'
},
'intro:full-stack-developer-v9': {
title: 'Certified Full Stack Developer Curriculum',
intro: [
'This certification represents the culmination of your full stack developer journey.',
'Pass the exam to earn your Full Stack Developer Certification.'
],
note: 'Coming soon.'
},
'intro:responsive-web-design': {
title: 'Responsive Web Design',
intro: ['Create responsive layouts across devices.'],
note: ''
},
'misc.fsd-b-cta': 'Start Learning',
'misc.continue-learning': 'Continue Learning'
};
const mockT = vi.fn((key: string, options?: { returnObjects?: boolean }) => {
const value = translationMap[key];
if (options?.returnObjects && typeof value === 'object') {
return value;
}
return value ?? key;
});
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: mockT
}),
Trans: ({ children }: { children: React.ReactNode }) => (
<span>{children}</span>
),
withTranslation: () => (Component: React.ComponentType<unknown>) => Component
}));
vi.mock('@freecodecamp/ui', () => ({
Callout: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
Spacer: () => null,
Container: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
Row: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Col: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}));
vi.mock('../../assets/superblock-icon', () => ({
SuperBlockIcon: () => <div />
}));
vi.mock('../../assets/icons/cap', () => ({ default: () => <div /> }));
vi.mock('../../assets/icons/dumbbell', () => ({ default: () => <div /> }));
vi.mock('../../assets/icons/community', () => ({ default: () => <div /> }));
vi.mock('../../components/archived-warning', () => ({
default: () => <div />
}));
vi.mock('../../components/helpers', () => ({
Link: ({
children,
to,
...rest
}: {
children: React.ReactNode;
to: string;
}) => (
<a href={to} {...rest}>
{children}
</a>
)
}));
import {
BlockLabel,
BlockLayouts
} from '../../../../shared-dist/config/blocks';
import { SuperBlocks } from '../../../../shared-dist/config/curriculum';
import SuperBlockIntroductionPage from './super-block-intro';
type ChallengeNode = {
challenge: {
id: string;
fields: { slug: string; blockName: string };
block: string;
blockLabel: BlockLabel;
challengeType: number;
title: string;
order: number;
superBlock: SuperBlocks;
dashedName: string;
blockLayout: BlockLayouts;
chapter: string;
module: string;
};
};
type TestSetup = {
challengeNodes: ChallengeNode[];
challengeByOrder: Map<number, ChallengeNode['challenge']>;
structureNode: {
superBlock: SuperBlocks;
chapters: Array<{
dashedName: string;
comingSoon: boolean;
modules: Array<{
dashedName: string;
comingSoon: boolean;
moduleType: string;
blocks: string[];
}>;
}>;
};
};
const createSetup = (superBlock: SuperBlocks): TestSetup => {
const makeChallengeNode = (order: number): ChallengeNode => ({
challenge: {
id: `${superBlock}-challenge-${order}`,
fields: {
slug: `/learn/${superBlock}/challenge-${order}`,
blockName: 'Block One'
},
block: 'block-one',
blockLabel: BlockLabel.learn,
challengeType: 0,
title: `Challenge ${order}`,
order,
superBlock,
dashedName: `${superBlock}-challenge-${order}`,
blockLayout: BlockLayouts.LegacyChallengeList,
chapter: 'chapter-one',
module: 'module-one'
}
});
const challengeNodes = [1, 2, 3].map(makeChallengeNode);
const challengeByOrder = new Map(
challengeNodes.map(node => [node.challenge.order, node.challenge])
);
return {
challengeNodes,
challengeByOrder,
structureNode: {
superBlock,
chapters: [
{
dashedName: 'chapter-one',
comingSoon: false,
modules: [
{
dashedName: 'module-one',
comingSoon: false,
moduleType: 'core',
blocks: ['block-one']
}
]
}
]
}
};
};
const createLocation = () =>
({
hash: '',
pathname: '/learn/super-block',
search: '',
state: undefined
}) as unknown as WindowLocation<{ breadcrumbBlockClick: string }>;
const createPageProps = (
setup: TestSetup,
superBlock: SuperBlocks,
overrides: Record<string, unknown> = {}
) =>
({
currentChallengeId: setup.challengeNodes[0].challenge.id,
data: {
allChallengeNode: { nodes: setup.challengeNodes.slice() },
allSuperBlockStructure: { nodes: [setup.structureNode] }
},
expandedState: {},
fetchState: { pending: false, complete: true, errored: false },
isSignedIn: true,
signInLoading: false,
location: createLocation(),
pageContext: {
superBlock,
title: `${superBlock} certification`,
certification: superBlock
},
resetExpansion: vi.fn(),
toggleBlock: vi.fn(),
tryToShowDonationModal: vi.fn(),
user: {
completedChallenges: [],
isDonating: false
},
...overrides
}) as unknown as React.ComponentProps<typeof SuperBlockIntroductionPage>;
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const i18nSpy = vi.spyOn(i18next, 't');
i18nSpy.mockImplementation(((
key: unknown,
options?: { returnObjects?: boolean }
) => {
if (typeof key !== 'string') return '';
if (options?.returnObjects) {
const value = translationMap[key];
if (typeof value === 'object') {
return value;
}
}
const titleKeySuffix = '.title';
if (key.endsWith(titleKeySuffix)) {
const baseKey = key.slice(0, -titleKeySuffix.length);
const entry = translationMap[baseKey];
if (
entry &&
typeof entry === 'object' &&
'title' in (entry as Record<string, unknown>)
) {
return (entry as { title: string }).title;
}
}
const value = translationMap[key];
return typeof value === 'string' ? value : key;
}) as unknown as typeof i18next.t);
afterAll(() => {
consoleSpy.mockRestore();
i18nSpy.mockRestore();
});
beforeEach(() => {
mockT.mockClear();
});
type Scenario = {
description: string;
superBlock: SuperBlocks;
completedOrders: number[];
expected: {
labelKey: string | null;
dataLabel: 'start-learning' | 'continue-learning' | null;
nextOrder: number | null;
};
};
const scenarios: Scenario[] = [
{
description:
'For a non full stack certification with progress it should render the continue button and slug.',
superBlock: SuperBlocks.RespWebDesign,
completedOrders: [1],
expected: {
labelKey: 'misc.continue-learning',
dataLabel: 'continue-learning',
nextOrder: 2
}
},
{
description:
'For a non full stack certification without progress it should render the start button and slug.',
superBlock: SuperBlocks.RespWebDesign,
completedOrders: [],
expected: {
labelKey: 'misc.fsd-b-cta',
dataLabel: 'start-learning',
nextOrder: 1
}
},
{
description:
'For a non full stack certification with full progress it should not render the button.',
superBlock: SuperBlocks.RespWebDesign,
completedOrders: [1, 2, 3],
expected: {
labelKey: null,
dataLabel: null,
nextOrder: null
}
},
{
description:
'For the full stack certification with progress it should not render the start or continue button.',
superBlock: SuperBlocks.FullStackDeveloperV9,
completedOrders: [1],
expected: {
labelKey: null,
dataLabel: null,
nextOrder: null
}
},
{
description:
'For the full stack certification without progress it should not render the start or continue button.',
superBlock: SuperBlocks.FullStackDeveloperV9,
completedOrders: [],
expected: {
labelKey: null,
dataLabel: null,
nextOrder: null
}
},
{
description:
'For the full stack certification with full progress it should not render the button.',
superBlock: SuperBlocks.FullStackDeveloperV9,
completedOrders: [1, 2, 3],
expected: {
labelKey: null,
dataLabel: null,
nextOrder: null
}
}
];
describe('SuperBlockIntroductionPage', () => {
it.each(scenarios)('%s', async scenario => {
const { superBlock, completedOrders, expected } = scenario;
const setup = createSetup(superBlock);
const completedChallenges = completedOrders.map(order => {
const challenge = setup.challengeByOrder.get(order);
if (!challenge) {
throw new Error(`Missing challenge for order ${order}`);
}
return {
id: challenge.id,
completedDate: order * 100
};
});
const props = createPageProps(setup, superBlock, {
user: {
completedChallenges,
isDonating: false
}
});
render(<SuperBlockIntroductionPage {...props} />);
if (expected.labelKey) {
const expectedText = translationMap[expected.labelKey] as string;
const cta = await screen.findByRole('link', {
name: expectedText
});
expect(cta).toHaveAttribute('data-test-label', expected.dataLabel);
const nextChallenge = setup.challengeByOrder.get(expected.nextOrder!);
expect(nextChallenge).toBeDefined();
expect(cta).toHaveAttribute('href', nextChallenge?.fields.slug ?? '');
} else {
await waitFor(() =>
expect(
screen.queryByRole('link', {
name: translationMap['misc.fsd-b-cta'] as string
})
).toBeNull()
);
expect(
screen.queryByRole('link', {
name: translationMap['misc.continue-learning'] as string
})
).toBeNull();
}
});
});
@@ -256,6 +256,34 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
});
};
const hasNotstarted = completedChallenges.length === 0;
const nextChallengeSlug = useMemo(() => {
if (hasNotstarted) return superBlockChallenges[0]?.fields.slug || null;
const lastCompletedChallenge = completedChallenges.reduce<
(typeof completedChallenges)[number] | null
>((latest, challenge) => {
if (!challenge?.completedDate) return latest;
if (
!latest?.completedDate ||
challenge.completedDate > latest.completedDate
) {
return challenge;
}
return latest;
}, null);
const nextChallenge = () => {
if (!lastCompletedChallenge?.id) return null;
const lastCompletedIndex = superBlockChallenges.findIndex(
({ id }) => id === lastCompletedChallenge?.id
);
if (lastCompletedIndex === -1) return null;
return superBlockChallenges[lastCompletedIndex + 1] ?? null;
};
return nextChallenge()?.fields.slug || null;
}, [completedChallenges, superBlockChallenges, hasNotstarted]);
return (
<>
<Helmet>
@@ -273,6 +301,8 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
onCertificationDonationAlertClick
}
isDonating={user?.isDonating ?? false}
hasNotstarted={hasNotstarted}
nextChallengeSlug={nextChallengeSlug}
/>
<HelpTranslate superBlock={superBlock} />
<Spacer size='l' />