From 9a0086e2a605c575d307f69ba4e0ae30b9743331 Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Mon, 30 Mar 2026 22:12:07 +0300 Subject: [PATCH] feat: add catalog (#66680) Co-authored-by: jdwilkin4 --- client/i18n/locales/english/translations.json | 2 + .../Header/components/nav-links.tsx | 5 +++ client/src/components/catalog-item.tsx | 10 +++-- .../components/landing-catalog.test.tsx | 44 +++++++++++++++++++ client/src/pages/catalog.test.tsx | 40 +++++++++++++++++ client/src/pages/catalog.tsx | 6 +-- client/src/pages/index.tsx | 3 +- e2e/header.spec.ts | 6 ++- 8 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 client/src/components/landing/components/landing-catalog.test.tsx create mode 100644 client/src/pages/catalog.test.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 3c0d7ca801f..42de1c1047e 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -29,6 +29,7 @@ "sign-in": "Sign in", "sign-up-email-list": "Sign up for Quincy's weekly emails", "sign-out": "Sign out", + "catalog": "Catalog", "curriculum": "Curriculum", "contribute": "Contribute", "podcast": "Podcast", @@ -1500,6 +1501,7 @@ "intermediate": "Intermediate", "advanced": "Advanced" }, + "duration-singular": "{{duration}} hour", "duration": "{{duration}} hours", "no-results": "No courses found. Try adjusting your filters to see more results.", "topic": { diff --git a/client/src/components/Header/components/nav-links.tsx b/client/src/components/Header/components/nav-links.tsx index 9697138f919..9212130c4b2 100644 --- a/client/src/components/Header/components/nav-links.tsx +++ b/client/src/components/Header/components/nav-links.tsx @@ -152,6 +152,11 @@ function NavLinks({ {t('buttons.curriculum')} +
  • + + {t('buttons.catalog')} + +
  • {currentUserName && ( <>
  • diff --git a/client/src/components/catalog-item.tsx b/client/src/components/catalog-item.tsx index 0a3a9a4e08b..17cb402b13c 100644 --- a/client/src/components/catalog-item.tsx +++ b/client/src/components/catalog-item.tsx @@ -28,6 +28,11 @@ const CatalogItem: React.FC = ({ summary: string[]; }; + const duration = + hours === 1 + ? t('curriculum.catalog.duration-singular', { duration: hours }) + : t('curriculum.catalog.duration', { duration: hours }); + return (
    @@ -48,10 +53,7 @@ const CatalogItem: React.FC = ({
    -  {' '} - {showAllSummaries - ? t('curriculum.catalog.duration', { duration: hours }) - : `${hours} hours`} +   {duration}
    diff --git a/client/src/components/landing/components/landing-catalog.test.tsx b/client/src/components/landing/components/landing-catalog.test.tsx new file mode 100644 index 00000000000..adc1ad00cc7 --- /dev/null +++ b/client/src/components/landing/components/landing-catalog.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { SuperBlocks } from '@freecodecamp/shared/config/curriculum'; +import { catalog } from '@freecodecamp/shared/config/catalog'; +import LandingCatalog from './landing-catalog'; + +const featuredSuperBlocks = [ + SuperBlocks.LearnPythonForBeginners, + SuperBlocks.ComputerBasics, + SuperBlocks.BasicHtml +]; + +describe('LandingCatalog', () => { + test('renders the catalog heading', () => { + render(); + expect(screen.getByText('landing.catalog.heading')).toBeInTheDocument(); + }); + + test('renders three featured course items and a see all link', () => { + render(); + // 3 featured courses + 1 "See All" link + expect(screen.getAllByRole('link')).toHaveLength(4); + }); + + test('featured courses link to their superblock learn pages', () => { + render(); + for (const superBlock of featuredSuperBlocks) { + const course = catalog.find(c => c.superBlock === superBlock); + const link = screen.getByRole('link', { + name: new RegExp(`topic\\.${course!.topic}`) + }); + expect(link).toHaveAttribute('href', `/learn/${superBlock}`); + } + }); + + test('has a "See All Courses" link to /catalog', () => { + render(); + const seeAllLink = screen.getByRole('link', { + name: 'landing.catalog.seeAll' + }); + expect(seeAllLink).toHaveAttribute('href', '/catalog'); + }); +}); diff --git a/client/src/pages/catalog.test.tsx b/client/src/pages/catalog.test.tsx new file mode 100644 index 00000000000..924ca3ea55e --- /dev/null +++ b/client/src/pages/catalog.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { catalog } from '@freecodecamp/shared/config/catalog'; +import CatalogPage from './catalog'; + +vi.mock('../components/catalog-item', () => ({ + default: ({ superBlock }: { superBlock: string }) => ( + + {superBlock} + + ) +})); + +describe('CatalogPage', () => { + test('renders the catalog page title', () => { + render(); + expect(screen.getByText('curriculum.catalog.title')).toBeInTheDocument(); + }); + + test('renders a catalog item for each entry', () => { + render(); + const items = screen.getAllByTestId('catalog-item'); + expect(items).toHaveLength(catalog.length); + }); + + test('catalog items link to their superblock learn pages', () => { + render(); + for (const course of catalog) { + const item = screen.getByRole('link', { name: course.superBlock }); + expect(item).toHaveAttribute('href', `/learn/${course.superBlock}`); + } + }); + + test('renders level and topic filter dropdowns', () => { + render(); + expect(screen.getByText(/Level:/)).toBeInTheDocument(); + expect(screen.getByText(/Topic:/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/catalog.tsx b/client/src/pages/catalog.tsx index 5cd54b35ede..b6b2a1cad53 100644 --- a/client/src/pages/catalog.tsx +++ b/client/src/pages/catalog.tsx @@ -2,8 +2,6 @@ import React, { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Col, Spacer, Dropdown, MenuItem, Alert } from '@freecodecamp/ui'; import { catalog } from '@freecodecamp/shared/config/catalog'; -import { showUpcomingChanges } from '../../config/env.json'; -import FourOhFour from '../components/FourOhFour'; import CatalogItem from '../components/catalog-item'; import './catalog.css'; @@ -69,7 +67,7 @@ const CatalogPage = () => { }); }, [selectedLevels, selectedTopics]); - return showUpcomingChanges ? ( + return (

    {t('curriculum.catalog.title')}

    @@ -164,8 +162,6 @@ const CatalogPage = () => {
    - ) : ( - ); }; diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index 5e0be95d5cc..67f76173e1c 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -10,7 +10,6 @@ import LandingCatalog from '../components/landing/components/landing-catalog'; import Faq from '../components/landing/components/faq'; import Benefits from '../components/landing/components/benefits'; import { useClaimableCertsNotification } from '../components/helpers/use-claimable-certs-notification'; -import { showUpcomingChanges } from '../../config/env.json'; import '../components/landing/landing.css'; @@ -24,7 +23,7 @@ const Landing = () => ( - {showUpcomingChanges && } + ); diff --git a/e2e/header.spec.ts b/e2e/header.spec.ts index 8543c1c0da3..c3615224e6a 100644 --- a/e2e/header.spec.ts +++ b/e2e/header.spec.ts @@ -138,7 +138,7 @@ test.describe('Header', () => { await expect(menuButton).toBeFocused(); }); - test('The menu should contain links to: donate, curriculum, forum, news, radio, contribute, and podcast', async ({ + test('The menu should contain links to: donate, curriculum, catalog, forum, news, radio, contribute, and podcast', async ({ page }) => { const menuButton = page.getByTestId(headerComponentElements.menuButton); @@ -157,6 +157,10 @@ test.describe('Header', () => { name: translations.buttons.curriculum, href: '/learn' }, + { + name: translations.buttons.catalog, + href: '/catalog' + }, { name: translations.buttons.forum, href: links.nav.forum