feat(client): archive page (#62450)

This commit is contained in:
Tom
2025-10-02 14:30:33 -05:00
committed by GitHub
parent 8704883aeb
commit 0b71e8779d
13 changed files with 206 additions and 125 deletions

View File

@@ -214,6 +214,7 @@
"next-heading": "Try our beta curriculum:",
"upcoming-heading": "Upcoming curriculum:",
"catalog-heading": "Explore our Catalog:",
"archive-link": "Looking for older coursework? Check out <0>our archive page</0>.",
"faq": "Frequently asked questions:",
"faqs": [
{
@@ -662,6 +663,10 @@
"warm-up": "Warm-up",
"learn": "Learn",
"practice": "Practice"
},
"archive": {
"title": "Archived Coursework",
"content-not-updated": "<0>Warning:</0> The content in this section is not being updated, but is still available for you to further your learning. We recommend trying <1>our current curriculum</1>."
}
},
"donate": {

View File

@@ -1,17 +1,18 @@
import i18next from 'i18next';
import React, { Fragment } from 'react';
import { Spacer } from '@freecodecamp/ui';
import { useTranslation } from 'react-i18next';
import { useTranslation, Trans } from 'react-i18next';
import {
type SuperBlocks,
SuperBlockStage,
getStageOrder,
superBlockStages
superBlockStages,
archivedSuperBlocks
} from '../../../../shared-dist/config/curriculum';
import { SuperBlockIcon } from '../../assets/superblock-icon';
import LinkButton from '../../assets/icons/link-button';
import { ButtonLink } from '../helpers';
import { ButtonLink, Link } from '../helpers';
import { showUpcomingChanges } from '../../../config/env.json';
import DailyCodingChallengeWidget from '../daily-coding-challenge/widget';
@@ -68,6 +69,20 @@ function MapLi({
);
}
// used on /learn/archive
export function ArchiveMap() {
return (
<div className='map-ui' data-test-label='curriculum-map'>
<ul>
{archivedSuperBlocks.map(superblock => (
<MapLi key={superblock} superBlock={superblock} landing={false} />
))}
</ul>
</div>
);
}
// used on /learn and landing page
function Map({ forLanding = false }: MapProps) {
const { t } = useTranslation();
@@ -75,39 +90,48 @@ function Map({ forLanding = false }: MapProps) {
<div className='map-ui' data-test-label='curriculum-map'>
{getStageOrder({
showUpcomingChanges
}).map(stage => {
const superblocks = superBlockStages[stage];
if (superblocks.length === 0) {
return null;
}
})
// remove legacy superblocks from main maps - shown in archive map only
.filter(stage => stage !== SuperBlockStage.Legacy)
.map(stage => {
const superblocks = superBlockStages[stage];
if (superblocks.length === 0) {
return null;
}
return (
<Fragment key={stage}>
{
/* Show the daily coding challenge before the "English" curriculum */
stage === SuperBlockStage.English && (
<>
<DailyCodingChallengeWidget forLanding={forLanding} />
<Spacer size='m' />
</>
)
}
<h2 className={forLanding ? 'big-heading' : ''}>
{t(superBlockHeadings[stage])}
</h2>
<ul key={stage}>
{superblocks.map(superblock => (
<MapLi
key={superblock}
superBlock={superblock}
landing={forLanding}
/>
))}
</ul>
<Spacer size='m' />
</Fragment>
);
})}
return (
<Fragment key={stage}>
{
/* Show the daily coding challenge before the "English" curriculum */
stage === SuperBlockStage.English && (
<>
<DailyCodingChallengeWidget forLanding={forLanding} />
<Spacer size='m' />
</>
)
}
<h2 className={forLanding ? 'big-heading' : ''}>
{t(superBlockHeadings[stage])}
</h2>
<ul key={stage}>
{superblocks.map(superblock => (
<MapLi
key={superblock}
superBlock={superblock}
landing={forLanding}
/>
))}
</ul>
<Spacer size='m' />
</Fragment>
);
})}
<Spacer size='m' />
<p className='archive-link'>
<Trans i18nKey='landing.archive-link'>
<Link to={'/learn/archive'}>placeholder</Link>
</Trans>
</p>
</div>
);
}

View File

@@ -8,6 +8,10 @@
padding: 0;
}
.map-ui .archive-link {
font-size: 1.25rem;
}
@media (max-width: 640px) {
.map-ui .block ul {
padding-inline-start: 6rem;

View File

@@ -0,0 +1,8 @@
.archived-warning {
margin-bottom: 0;
font-size: 1rem;
}
.archived-warning strong {
color: var(--blue-dark);
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { Callout } from '@freecodecamp/ui';
import { Link } from '../helpers';
import './index.css';
const ArchivedWarning = () => {
return (
<Callout variant='info'>
<p className='text-center archived-warning'>
<Trans i18nKey='learn.archive.content-not-updated'>
<strong>placeholder</strong>
<Link to={'/learn/full-stack-developer'}>placeholder</Link>
</Trans>
</p>
</Callout>
);
};
export default ArchivedWarning;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Container, Row, Col, Spacer } from '@freecodecamp/ui';
import Helmet from 'react-helmet';
import LearnLayout from '../../components/layouts/learn';
import { ArchiveMap } from '../../components/Map';
import CalendarIcon from '../../assets/icons/calendar';
import ArchivedWarning from '../../components/archived-warning';
const ArchivePage = () => {
const { t } = useTranslation();
return (
<LearnLayout>
<Helmet title={t('metaTags:title')} />
<Container>
<Row>
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='l' />
<h1 className='text-center big-heading'>
{t('learn.archive.title')}
</h1>
<Spacer size='m' />
<CalendarIcon className='cert-header-icon' />
<Spacer size='l' />
<ArchivedWarning />
<Spacer size='m' />
<h2>{t('landing.legacy-curriculum-heading')}</h2>
<ArchiveMap />
<Spacer size='l' />
</Col>
</Row>
</Container>
</LearnLayout>
);
};
export default ArchivePage;

View File

@@ -2,12 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Alert } from '@freecodecamp/ui';
import { SuperBlocks } from '../../../../../shared-dist/config/curriculum';
import {
isOldRespCert,
isRelationalDbCert,
isExamCert
} from '../../../utils/is-a-cert';
import { Link } from '../../../components/helpers';
import { isRelationalDbCert, isExamCert } from '../../../utils/is-a-cert';
import { CodeAllyDown } from '../../../components/growth-book/codeally-down';
import envData from '../../../../config/env.json';
@@ -22,18 +17,7 @@ interface LegacyLinksProps {
function LegacyLinks({ superBlock }: LegacyLinksProps): JSX.Element {
const { t } = useTranslation();
if (isOldRespCert(superBlock)) {
return (
<Alert variant='info'>
<p>
{t('intro:misc-text.legacy-desc')}{' '}
<Link sameTab={false} to={`/learn/2022/responsive-web-design`}>
{t('intro:misc-text.legacy-go-back')}
</Link>
</p>
</Alert>
);
} else if (isRelationalDbCert(superBlock)) {
if (isRelationalDbCert(superBlock)) {
return (
<>
<CodeAllyDown />

View File

@@ -4,7 +4,10 @@ import { useTranslation, Trans } from 'react-i18next';
import { Alert, Spacer, Container, Row, Col, Callout } from '@freecodecamp/ui';
import { ConnectedProps, connect } from 'react-redux';
import { useFeatureIsOn } from '@growthbook/growthbook-react';
import { SuperBlocks } from '../../../../../shared-dist/config/curriculum';
import {
archivedSuperBlocks,
SuperBlocks
} from '../../../../../shared-dist/config/curriculum';
import { SuperBlockIcon } from '../../../assets/superblock-icon';
import { Link } from '../../../components/helpers';
import CapIcon from '../../../assets/icons/cap';
@@ -12,6 +15,7 @@ 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: {
@@ -142,6 +146,8 @@ function SuperBlockIntro({
const introTopA = (
<>
{archivedSuperBlocks.includes(superBlock) && <ArchivedWarning />}
<Spacer size='s' />
<h1 id='content-start' className='text-center big-heading'>
{i18nSuperBlock}
</h1>

View File

@@ -1,9 +1,5 @@
import { SuperBlocks } from '../../../shared-dist/config/curriculum';
export function isOldRespCert(superBlock: string): boolean {
return superBlock === String(SuperBlocks.RespWebDesign);
}
export function isRelationalDbCert(superBlock: string): boolean {
return superBlock === String(SuperBlocks.RelationalDb);
}

44
e2e/archive.spec.ts Normal file
View File

@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test';
import intro from '../client/i18n/locales/english/intro.json';
import { SuperBlocks } from '../shared/config/curriculum';
const archivedSuperBlocks = [
intro[SuperBlocks.RespWebDesignNew].title,
intro[SuperBlocks.JsAlgoDataStructNew].title,
intro[SuperBlocks.FrontEndDevLibs].title,
intro[SuperBlocks.DataVis].title,
intro[SuperBlocks.RelationalDb].title,
intro[SuperBlocks.BackEndDevApis].title,
intro[SuperBlocks.QualityAssurance].title,
intro[SuperBlocks.SciCompPy].title,
intro[SuperBlocks.DataAnalysisPy].title,
intro[SuperBlocks.InfoSec].title,
intro[SuperBlocks.MachineLearningPy].title,
intro[SuperBlocks.CollegeAlgebraPy].title,
intro[SuperBlocks.RespWebDesign].title,
intro[SuperBlocks.JsAlgoDataStruct].title,
intro[SuperBlocks.PythonForEverybody].title
];
test.describe('Archive Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learn/archive');
});
test('Links to new curriculum', async ({ page }) => {
const newCurriculumLink = page.locator(
'a[href="/learn/full-stack-developer"]'
);
await expect(newCurriculumLink).toBeVisible();
});
test('Links to all archived superblocks in order', async ({ page }) => {
const curriculumBtns = page.getByTestId('curriculum-map-button');
await expect(curriculumBtns).toHaveCount(archivedSuperBlocks.length);
for (let index = 0; index < archivedSuperBlocks.length; index++) {
const btn = curriculumBtns.nth(index);
const link = btn.getByRole('link', { name: archivedSuperBlocks[index] });
await expect(link).toBeVisible();
}
});
});

View File

@@ -15,7 +15,7 @@ const landingPageElements = {
jobs: 'More than <strong>100,000</strong> freeCodeCamp.org graduates have gotten <strong>jobs</strong> at tech companies including:'
} as const;
const superBlocks = [
const nonArchivedSuperBlocks = [
intro[SuperBlocks.FullStackDeveloper].title,
intro[SuperBlocks.A2English].title,
intro[SuperBlocks.B1English].title,
@@ -23,21 +23,6 @@ const superBlocks = [
intro[SuperBlocks.CodingInterviewPrep].title,
intro[SuperBlocks.ProjectEuler].title,
intro[SuperBlocks.RosettaCode].title,
intro[SuperBlocks.RespWebDesignNew].title,
intro[SuperBlocks.JsAlgoDataStructNew].title,
intro[SuperBlocks.FrontEndDevLibs].title,
intro[SuperBlocks.DataVis].title,
intro[SuperBlocks.RelationalDb].title,
intro[SuperBlocks.BackEndDevApis].title,
intro[SuperBlocks.QualityAssurance].title,
intro[SuperBlocks.SciCompPy].title,
intro[SuperBlocks.DataAnalysisPy].title,
intro[SuperBlocks.InfoSec].title,
intro[SuperBlocks.MachineLearningPy].title,
intro[SuperBlocks.CollegeAlgebraPy].title,
intro[SuperBlocks.RespWebDesign].title,
intro[SuperBlocks.JsAlgoDataStruct].title,
intro[SuperBlocks.PythonForEverybody].title,
intro[SuperBlocks.FoundationalCSharp].title
];
@@ -209,16 +194,23 @@ test.describe('Landing Page', () => {
}
});
test('Links to all superblocks in order', async ({ page }) => {
test('Links to all non-archived superblocks in order', async ({ page }) => {
const curriculumBtns = page.getByTestId(landingPageElements.curriculumBtns);
await expect(curriculumBtns).toHaveCount(superBlocks.length);
for (let index = 0; index < superBlocks.length; index++) {
await expect(curriculumBtns).toHaveCount(nonArchivedSuperBlocks.length);
for (let index = 0; index < nonArchivedSuperBlocks.length; index++) {
const btn = curriculumBtns.nth(index);
const link = btn.getByRole('link', { name: superBlocks[index] });
const link = btn.getByRole('link', {
name: nonArchivedSuperBlocks[index]
});
await expect(link).toBeVisible();
}
});
test('Links to the archive page', async ({ page }) => {
const archiveLink = page.locator('a[href="/learn/archive"]');
await expect(archiveLink).toBeVisible();
});
test('Has FAQ section', async ({ page }) => {
const faqs = page.getByTestId(landingPageElements.faq);
await expect(faqs).toHaveCount(9);

View File

@@ -6,42 +6,6 @@ test.beforeEach(async ({ page }) => {
});
const LANDING_PAGE_LINKS = [
{
slug: '2022/responsive-web-design',
name: 'Responsive Web Design'
},
{
slug: 'javascript-algorithms-and-data-structures-v8',
name: 'JavaScript Algorithms and Data Structures'
},
{
slug: 'front-end-development-libraries',
name: 'Front End Development Libraries'
},
{ slug: 'data-visualization', name: 'Data Visualization' },
{ slug: 'relational-database', name: 'Relational Database' },
{
slug: 'back-end-development-and-apis',
name: 'Back End Development and APIs'
},
{ slug: 'quality-assurance', name: 'Quality Assurance' },
{
slug: 'scientific-computing-with-python',
name: 'Scientific Computing with Python'
},
{
slug: 'data-analysis-with-python',
name: 'Data Analysis with Python'
},
{ slug: 'information-security', name: 'Information Security' },
{
slug: 'machine-learning-with-python',
name: 'Machine Learning with Python'
},
{
slug: 'college-algebra-with-python',
name: 'College Algebra with Python'
},
{
slug: 'full-stack-developer',
name: 'Certified Full Stack Developer Curriculum'
@@ -61,16 +25,7 @@ const LANDING_PAGE_LINKS = [
{ slug: 'the-odin-project', name: 'The Odin Project - freeCodeCamp Remix' },
{ slug: 'coding-interview-prep', name: 'Coding Interview Prep' },
{ slug: 'project-euler', name: 'Project Euler' },
{ slug: 'rosetta-code', name: 'Rosetta Code' },
{
slug: 'responsive-web-design',
name: 'Legacy Responsive Web Design Challenges'
},
{
slug: 'javascript-algorithms-and-data-structures',
name: 'Legacy JavaScript Algorithms and Data Structures'
},
{ slug: 'python-for-everybody', name: 'Legacy Python for Everybody' }
{ slug: 'rosetta-code', name: 'Rosetta Code' }
];
test.describe('Map Component', () => {
@@ -85,7 +40,7 @@ test.describe('Map Component', () => {
page.getByText(translations.landing['interview-prep-heading'])
).toBeVisible();
const curriculumBtns = page.getByTestId('curriculum-map-button');
await expect(curriculumBtns).toHaveCount(23);
await expect(curriculumBtns).toHaveCount(8);
for (const { name, slug } of LANDING_PAGE_LINKS) {
const superblockLink = page.getByRole('link', {

View File

@@ -133,6 +133,8 @@ export const superBlockStages: StageMap = {
Object.freeze(superBlockStages);
export const archivedSuperBlocks = superBlockStages[SuperBlockStage.Legacy];
export const catalogSuperBlocks = superBlockStages[SuperBlockStage.Catalog];
type NotAuditedSuperBlocks = {