diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 83bc72dbb3c..8965e8b6527 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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", diff --git a/client/src/templates/Introduction/components/super-block-accordion.css b/client/src/templates/Introduction/components/super-block-accordion.css index b394ad59e11..0e5b89f5c5f 100644 --- a/client/src/templates/Introduction/components/super-block-accordion.css +++ b/client/src/templates/Introduction/components/super-block-accordion.css @@ -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 { diff --git a/client/src/templates/Introduction/components/super-block-intro.tsx b/client/src/templates/Introduction/components/super-block-intro.tsx index fce06ccf214..efb1e446c68 100644 --- a/client/src/templates/Introduction/components/super-block-intro.tsx +++ b/client/src/templates/Introduction/components/super-block-intro.tsx @@ -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; - 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(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) && } + +

{i18nSuperBlock}

- - - + + {fsd && ( + + + + +
+ +
+

{t('misc.fsd-b-benefit-1-title')}

+

{t('misc.fsd-b-benefit-1-description')}

+
+
+
+ +
+

{t('misc.fsd-b-benefit-2-title')}

+

{t('misc.fsd-b-benefit-2-description')}

+
+
+
+ +
+

{t('misc.fsd-b-benefit-3-title')}

+

{t('misc.fsd-b-benefit-3-description')}

+
+
+ +
+
+
+ )} + {nextChallengeSlug && !fsd && ( + + {hasNotstarted ? t('misc.fsd-b-cta') : t('misc.continue-learning')} + + )} + {superBlockIntroText.map((str, i) => (

))} @@ -165,75 +168,13 @@ function SuperBlockIntro({ ); - const introTopB = ( - <> - - -

- {i18nSuperBlock} -

- -

{t('misc.fsd-b-description')}

- {superBlockNoteText && ( - <> - - {superBlockNoteText} - - )} - - - {t('misc.fsd-b-cta')} - - - - - - -
- -
-

{t('misc.fsd-b-benefit-1-title')}

-

{t('misc.fsd-b-benefit-1-description')}

-
-
-
- -
-

{t('misc.fsd-b-benefit-2-title')}

-

{t('misc.fsd-b-benefit-2-description')}

-
-
-
- -
-

{t('misc.fsd-b-benefit-3-title')}

-

{t('misc.fsd-b-benefit-3-description')}

-
-
- -
-
-
- - - ); - - 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} + ({ + connect: () => (Component: React.ComponentType) => Component, + useDispatch: () => vi.fn(), + useSelector: () => ({}) +})); + +vi.mock('react-helmet', () => ({ + __esModule: true, + default: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ) +})); + +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 }) => ( + {children} + ) +})); + +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 = { + 'intro:full-stack-developer': { + title: 'Full Stack Developer', + intro: ['Build 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 }) => ( + {children} + ), + withTranslation: () => (Component: React.ComponentType) => Component +})); + +vi.mock('@freecodecamp/ui', () => ({ + Callout: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Spacer: () => null, + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Row: ({ children }: { children: React.ReactNode }) =>
{children}
, + Col: ({ children }: { children: React.ReactNode }) =>
{children}
+})); + +vi.mock('../../assets/superblock-icon', () => ({ + SuperBlockIcon: () =>
+})); + +vi.mock('../../assets/icons/cap', () => ({ default: () =>
})); +vi.mock('../../assets/icons/dumbbell', () => ({ default: () =>
})); +vi.mock('../../assets/icons/community', () => ({ default: () =>
})); +vi.mock('../../components/archived-warning', () => ({ + default: () =>
+})); + +vi.mock('../../components/helpers', () => ({ + Link: ({ + children, + to, + ...rest + }: { + children: React.ReactNode; + to: string; + }) => ( + + {children} + + ) +})); + +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; + 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 = {} +) => + ({ + 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; + +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) + ) { + 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(); + + 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(); + } + }); +}); diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index 8c1f90b1c66..6b28bfcb550 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -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 ( <> @@ -273,6 +301,8 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { onCertificationDonationAlertClick } isDonating={user?.isDonating ?? false} + hasNotstarted={hasNotstarted} + nextChallengeSlug={nextChallengeSlug} />