From ff958051e4e5d05aed4407f3386427f2f90ac2bd Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 28 Apr 2026 14:36:39 -0700 Subject: [PATCH 01/32] Updated React Dependency Versions This stops a 'Conflicting Peer Dependency' error from occurring when you run npm install while react is currently below 18.3.1 --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2deb6d09..18625c56d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", - "react": "^18.2.0", + "react": "^18.3.1", "react-data-table-component": "7.5.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-multi-select-component": "4.3.4", "react-table": "^7.8.0", "react-tabs": "4.3.0", diff --git a/package.json b/package.json index b475a58b8..a1637107d 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "json-server": "0.17.1", "next": "^15.5.12", "next-auth": "4.24.5", - "react": "^18.2.0", + "react": "^18.3.1", "react-data-table-component": "7.5.3", - "react-dom": "^18.2.0", + "react-dom": "^18.3.1", "react-multi-select-component": "4.3.4", "react-table": "^7.8.0", "react-tabs": "4.3.0", From b6c3a3f87145ede4db6c4ffe7687b69c5e4f417c Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Sat, 13 Jun 2026 12:21:09 -0700 Subject: [PATCH 02/32] style: align globals.css with main freeCodeCamp platform --- styles/globals.css | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/styles/globals.css b/styles/globals.css index e8e93627b..50de5dd87 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -3,36 +3,49 @@ @tailwind utilities; :root { - /* Colors */ - /* Light Gray */ + /* Colors */ + /* Light Gray */ --fcc-gray-00: #ffffff; --fcc-gray-05: #f5f6f7; --fcc-gray-10: #dfdfe2; --fcc-gray-15: #d0d0d5; - /* Dark Gray */ - --fcc-gray-80: #2a2a40; + --fcc-gray-45: #858591; + /* Dark Gray */ --fcc-gray-75: #3b3b4f; + --fcc-gray-80: #2a2a40; --fcc-gray-85: #1b1b32; --fcc-gray-90: #0a0a23; - /* Primary Colors */ + /* Semantic alias (matches --theme-color on main fCC platform) */ + --theme-color: #0a0a23; + + /* Primary Colors */ --fcc-primary-purple: #dbb8ff; - --fcc-primary-yellow: #f1be32; + --fcc-primary-yellow: #ffbf00; --fcc-primary-blue: #99c9ff; --fcc-primary-lightGreen: #acd157; - /* Secondary Colors */ + /* Secondary / Dark Colors */ --fcc-secondary-darkPurple: #5a01a7; --fcc-secondary-darkYellow: #4d3800; --fcc-secondary-darkBlue: #002ead; --fcc-secondary-darkGreen: #00471b; + + /* Mid Colors */ + --fcc-blue-mid: #198eee; + --fcc-purple-mid: #9400d3; + + /* Red */ + --fcc-red-light: #ffadad; + --fcc-red-dark: #850000; } html, body { padding: 0; margin: 0; - font-family: 'Roboto Mono', monospace; + font-family: 'Lato', sans-serif; + font-size: 18px; } a { @@ -44,9 +57,15 @@ a { box-sizing: border-box; } +.btn { + border-radius: 0; + border: 3px solid var(--fcc-gray-90); + font-size: 18px; +} + .toast-message { background: var(--fcc-gray-90); color: var(--fcc-gray-00); font-size: 20px; - --toastify-font-family: 'Roboto Mono', monospace; + --toastify-font-family: 'Lato', sans-serif; } From 704401407e1ec8f0bbd290b0dace4f62399c750a Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Sat, 13 Jun 2026 12:21:44 -0700 Subject: [PATCH 03/32] style: align tailwind color tokens with main freeCodeCamp platform --- tailwind.config.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tailwind.config.js b/tailwind.config.js index 0ef18f55a..9f07a798e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -29,6 +29,7 @@ module.exports = { 0o5: '#f5f6f7', 10: '#dfdfe2', 15: '#d0d0d5', + 45: '#858591', // DARK GRAYs 75: '#3b3b4f', 80: '#2a2a40', @@ -38,7 +39,7 @@ module.exports = { primary: { purple: '#dbb8ff', - yellow: '#f1be32', + yellow: '#ffbf00', blue: '#99c9ff', lightGreen: '#acd157' }, @@ -48,6 +49,16 @@ module.exports = { darkYellow: '#4d3800', darkBlue: '#002ead', darkGreen: '#00471b' + }, + + mid: { + blue: '#198eee', + purple: '#9400d3' + }, + + red: { + light: '#ffadad', + dark: '#850000' } }, ...colors From 761e1070dec8f22b3edff312dfc0268f83a6a4bb Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sun, 14 Jun 2026 18:28:12 -0700 Subject: [PATCH 04/32] Synchronized Classroom CSS with FCC Proper Created a navButton class for navigation button instances Added responsive design to make web app more mobile-friendly --- components/modal.js | 2 +- components/navbar.js | 2 +- pages/admin/index.js | 4 +- pages/classes/index.js | 4 +- pages/dashboard/[id].js | 4 +- pages/dashboard/v2/[id].js | 4 +- .../v2/details/[id]/[studentEmail].js | 6 +-- pages/index.js | 10 ++--- styles/Home.module.css | 1 + styles/globals.css | 43 +++---------------- 10 files changed, 24 insertions(+), 56 deletions(-) diff --git a/components/modal.js b/components/modal.js index 97238a3af..de8c12021 100644 --- a/components/modal.js +++ b/components/modal.js @@ -71,7 +71,7 @@ export default function Modal({
Create Class diff --git a/components/navbar.js b/components/navbar.js index 9d4c44adb..0f4cea554 100644 --- a/components/navbar.js +++ b/components/navbar.js @@ -6,7 +6,7 @@ import AuthButton from '../components/authButton'; export default function Navbar({ children }) { return (
-
+
-
+
Classes
-
+
Menu
diff --git a/pages/classes/index.js b/pages/classes/index.js index 598f38d68..88bf2c996 100644 --- a/pages/classes/index.js +++ b/pages/classes/index.js @@ -116,10 +116,10 @@ export default function Classes({ {userSession && ( <> -
+
Classes
-
+
Menu
diff --git a/pages/dashboard/[id].js b/pages/dashboard/[id].js index 95e003192..87f5e4678 100644 --- a/pages/dashboard/[id].js +++ b/pages/dashboard/[id].js @@ -91,10 +91,10 @@ export default function Home({ {userSession && ( <> -
+
Classes
-
+
Menu
diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 0de21d81b..3d52eb04e 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -123,10 +123,10 @@ export default function Home({ {userSession && ( <> -
+
Classes
-
+
Menu
diff --git a/pages/dashboard/v2/details/[id]/[studentEmail].js b/pages/dashboard/v2/details/[id]/[studentEmail].js index 3ff38d353..6f737aa58 100644 --- a/pages/dashboard/v2/details/[id]/[studentEmail].js +++ b/pages/dashboard/v2/details/[id]/[studentEmail].js @@ -113,13 +113,13 @@ export default function StudentDetails({ {userSession && ( <> -
+
<
-
+
Classes
-
+
Menu
diff --git a/pages/index.js b/pages/index.js index 20d336074..21e64e1c6 100644 --- a/pages/index.js +++ b/pages/index.js @@ -13,22 +13,22 @@ export default function Home() { -
+
Classes
-
+
Menu
-

+

Welcome to FreeCodeCamp Classroom Mode

-
+

A self-hosted solution to help teachers plan and manage diff --git a/styles/Home.module.css b/styles/Home.module.css index 69dd3d1db..281bb08f9 100644 --- a/styles/Home.module.css +++ b/styles/Home.module.css @@ -131,4 +131,5 @@ display: flex; align-items: center; justify-content: center; + margin-top: 2rem; } diff --git a/styles/globals.css b/styles/globals.css index 50de5dd87..abb6289c7 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -3,41 +3,8 @@ @tailwind utilities; :root { - /* Colors */ - /* Light Gray */ --fcc-gray-00: #ffffff; - --fcc-gray-05: #f5f6f7; - --fcc-gray-10: #dfdfe2; - --fcc-gray-15: #d0d0d5; - --fcc-gray-45: #858591; - /* Dark Gray */ - --fcc-gray-75: #3b3b4f; - --fcc-gray-80: #2a2a40; - --fcc-gray-85: #1b1b32; --fcc-gray-90: #0a0a23; - - /* Semantic alias (matches --theme-color on main fCC platform) */ - --theme-color: #0a0a23; - - /* Primary Colors */ - --fcc-primary-purple: #dbb8ff; - --fcc-primary-yellow: #ffbf00; - --fcc-primary-blue: #99c9ff; - --fcc-primary-lightGreen: #acd157; - - /* Secondary / Dark Colors */ - --fcc-secondary-darkPurple: #5a01a7; - --fcc-secondary-darkYellow: #4d3800; - --fcc-secondary-darkBlue: #002ead; - --fcc-secondary-darkGreen: #00471b; - - /* Mid Colors */ - --fcc-blue-mid: #198eee; - --fcc-purple-mid: #9400d3; - - /* Red */ - --fcc-red-light: #ffadad; - --fcc-red-dark: #850000; } html, @@ -45,7 +12,7 @@ body { padding: 0; margin: 0; font-family: 'Lato', sans-serif; - font-size: 18px; + font-size: clamp(18px, 1.5vw, 21.0625px); } a { @@ -57,10 +24,10 @@ a { box-sizing: border-box; } -.btn { - border-radius: 0; - border: 3px solid var(--fcc-gray-90); - font-size: 18px; +.navButton { + border: 1px solid; + padding-left: 1rem; + padding-right: 1rem; } .toast-message { From 3c21a1ebc914b0800d89d7108bdba9ae80145b70 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 16 Jun 2026 14:28:50 -0700 Subject: [PATCH 05/32] Updated Test and Snapshots --- __tests__/components/__snapshots__/error.test.jsx.snap | 2 +- __tests__/components/__snapshots__/modal.test.jsx.snap | 4 ++-- __tests__/components/__snapshots__/navbar.test.jsx.snap | 2 +- __tests__/components/modal.test.jsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/__tests__/components/__snapshots__/error.test.jsx.snap b/__tests__/components/__snapshots__/error.test.jsx.snap index e804bff57..9c62f9ae0 100644 --- a/__tests__/components/__snapshots__/error.test.jsx.snap +++ b/__tests__/components/__snapshots__/error.test.jsx.snap @@ -6,7 +6,7 @@ exports[`ErrorComponet displays error cause and error message properly 1`] = ` className="h-[38px]" >
Create Class @@ -31,7 +31,7 @@ exports[`Modal Component renders whole form after header clicked 1`] = ` className="flex justify-center" >
Create Class diff --git a/__tests__/components/__snapshots__/navbar.test.jsx.snap b/__tests__/components/__snapshots__/navbar.test.jsx.snap index c583d7f59..cc17d97d0 100644 --- a/__tests__/components/__snapshots__/navbar.test.jsx.snap +++ b/__tests__/components/__snapshots__/navbar.test.jsx.snap @@ -5,7 +5,7 @@ exports[`Navbar rendering correctly renders correctly 1`] = ` className="h-[38px]" >
{ it('renders header correctly', () => { From ce920bbdfe0c9dfdb3e13c4f92fa3aa692dbf2fd Mon Sep 17 00:00:00 2001 From: Newton Ly Chung Date: Tue, 16 Jun 2026 21:26:32 -0700 Subject: [PATCH 06/32] feat: improve teacher feedback for fetch failures and empty classrooms (#606) - fetchStudentData now returns { error, data } instead of a bare array, distinguishing FETCH_FAILED, NETWORK_ERROR, and MISSING_URL cases - [id].js selects fccUserIds from Prisma, handles structured fetch result, and passes fetchError, fccUserIds, and joinLink as props - DashTabs shows an empty-classroom state with the join link and copy button when no students are enrolled, and a user-facing error message for runtime fetch failures --- components/dashtabs.js | 54 ++++++++++++++++++++++++++++++-- pages/dashboard/[id].js | 24 +++++++++++--- util/student/fetchStudentData.js | 22 +++++++------ 3 files changed, 83 insertions(+), 17 deletions(-) diff --git a/components/dashtabs.js b/components/dashtabs.js index 4a8e2fa22..66f83981d 100644 --- a/components/dashtabs.js +++ b/components/dashtabs.js @@ -5,9 +5,9 @@ import { useState } from 'react'; export default function DashTabs(props) { const [tabIndex, setTabIndex] = useState(0); - // This sets our selected tab to our first index of certification module names const [tabIndexName, setTabIndexName] = useState(props.certificationNames[0]); - // Here we are copying the columns array (which is now immutable) in order to be able to add the Student Name column to it + const [copied, setCopied] = useState(false); + var columnNames = [...props.columns]; const presetColumns = [ { @@ -26,11 +26,59 @@ export default function DashTabs(props) { return finalColumns; }); - // This function sets the tab name which later gives our selected tab selected styling function determineItemStyle(x) { setTabIndexName(x); } + function handleCopy() { + navigator.clipboard.writeText(props.joinLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (props.fccUserIds.length === 0) { + return ( +
+

No students have joined this classroom yet.

+

Share this link with your students to invite them:

+
+ + {props.joinLink} + + +
+
+ ); + } + + if (props.fetchError && props.fetchError !== 'MISSING_URL') { + return ( +
+

+ We couldn't load your students. Please try refreshing, or contact + support at{' '} + support@freecodecamp.org{' '} + if the problem persists. +

+
+ ); + } + return ( <> setTabIndex(index)}> diff --git a/pages/dashboard/[id].js b/pages/dashboard/[id].js index 95e003192..1437e9d63 100644 --- a/pages/dashboard/[id].js +++ b/pages/dashboard/[id].js @@ -46,7 +46,8 @@ export async function getServerSideProps(context) { classroomId: context.params.id }, select: { - fccCertifications: true + fccCertifications: true, + fccUserIds: true } }); let superblockURLS = await getDashedNamesURLs( @@ -59,14 +60,21 @@ export async function getServerSideProps(context) { let superBlockJsons = await getSuperBlockJsons(superblockURLS); let dashboardObjs = await createSuperblockDashboardObject(superBlockJsons); - let currStudentData = await fetchStudentData(); + const { error: fetchError, data: currStudentData } = await fetchStudentData(); + + const protocol = context.req.headers['x-forwarded-proto'] ?? 'http'; + const host = context.req.headers.host; + const joinLink = `${protocol}://${host}/join/${context.params.id}`; return { props: { userSession, columns: dashboardObjs, certificationNames: nonDashedNames, - data: currStudentData + data: currStudentData ?? [], + fetchError: fetchError ?? null, + fccUserIds: certificationNumbers.fccUserIds, + joinLink } }; } @@ -75,7 +83,10 @@ export default function Home({ userSession, columns, certificationNames, - data + data, + fetchError, + fccUserIds, + joinLink }) { let tabNames = certificationNames; let columnNames = columns; @@ -102,7 +113,10 @@ export default function Home({ columns={columnNames} certificationNames={tabNames} studentData={studentData} - > + fetchError={fetchError} + fccUserIds={fccUserIds} + joinLink={joinLink} + /> )} diff --git a/util/student/fetchStudentData.js b/util/student/fetchStudentData.js index 9eaee14e7..4c2ff35de 100644 --- a/util/student/fetchStudentData.js +++ b/util/student/fetchStudentData.js @@ -1,11 +1,15 @@ -/** - * Fetches student data from the mock data URL - * @returns {Promise} Array of student objects - * - * NOTE: This is a mock data function used for testing. - * In production, use FCC Proper API with fccProperUserIds. - */ export async function fetchStudentData() { - let data = await fetch(process.env.MOCK_USER_DATA_URL); - return data.json(); + if (!process.env.MOCK_USER_DATA_URL) { + console.warn('MOCK_USER_DATA_URL is not defined.'); + return { error: 'MISSING_URL', data: null }; + } + try { + const response = await fetch(process.env.MOCK_USER_DATA_URL); + if (!response.ok) { + return { error: 'FETCH_FAILED', status: response.status, data: null }; + } + return { error: null, data: await response.json() }; + } catch { + return { error: 'NETWORK_ERROR', data: null }; + } } From 1da354f693e7cf77e58f6974210d0c5b95b8f7c5 Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Sat, 6 Jun 2026 19:37:43 +0530 Subject: [PATCH 07/32] fix(navbar): show Dashboard instead of Classes label for admin users --- __tests__/components/navbar.test.jsx | 37 ++++++++++++++++++++++++++++ components/navbar.js | 34 ++++++++++++++++++++++++- pages/api/auth/[...nextauth].js | 7 ++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/__tests__/components/navbar.test.jsx b/__tests__/components/navbar.test.jsx index 33f684deb..96aa572e3 100644 --- a/__tests__/components/navbar.test.jsx +++ b/__tests__/components/navbar.test.jsx @@ -2,6 +2,7 @@ import Navbar from '../../components/navbar'; import React from 'react'; import { SessionProvider } from 'next-auth/react'; import renderer from 'react-test-renderer'; +import Link from 'next/link'; describe('Navbar rendering correctly', () => { it('renders correctly', () => { @@ -14,4 +15,40 @@ describe('Navbar rendering correctly', () => { .toJSON(); expect(tree).toMatchSnapshot(); }); + + it('renders Classes link as "Classes" for non-admin session', () => { + const tree = renderer + .create( + + +
+ Classes +
+
+
+ ) + .toJSON(); + + const jsonString = JSON.stringify(tree); + expect(jsonString).toContain('Classes'); + expect(jsonString).not.toContain('Dashboard'); + }); + + it('renders Classes link as "Dashboard" for ADMIN session', () => { + const tree = renderer + .create( + + +
+ Classes +
+
+
+ ) + .toJSON(); + + const jsonString = JSON.stringify(tree); + expect(jsonString).toContain('Dashboard'); + expect(jsonString).not.toContain('Classes'); + }); }); diff --git a/components/navbar.js b/components/navbar.js index 9d4c44adb..f73de3f73 100644 --- a/components/navbar.js +++ b/components/navbar.js @@ -1,9 +1,41 @@ import Image from 'next/legacy/image'; import Link from 'next/link'; import React from 'react'; +import { useSession } from 'next-auth/react'; import AuthButton from '../components/authButton'; +function updateClassesLinkLabel(child, isAdmin) { + if (!child) return child; + + if (React.isValidElement(child)) { + const isClassesLink = + child.props.href === '/classes' && child.props.children === 'Classes'; + + if (isClassesLink) { + return React.cloneElement(child, { + children: isAdmin ? 'Dashboard' : 'Classes' + }); + } + + if (child.props.children) { + const newChildren = React.Children.map(child.props.children, c => + updateClassesLinkLabel(c, isAdmin) + ); + return React.cloneElement(child, { children: newChildren }); + } + } + + return child; +} + export default function Navbar({ children }) { + const { data: session } = useSession(); + const isAdmin = session?.user?.role === 'ADMIN'; + + const processedChildren = React.Children.toArray(children).map(child => + updateClassesLinkLabel(child, isAdmin) + ); + return (
@@ -20,7 +52,7 @@ export default function Navbar({ children }) { >
- {React.Children.toArray(children).map(child => ( + {processedChildren.map(child => (
{child}
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 4895ac83c..27376ddf1 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -28,6 +28,13 @@ export const authOptions = { // Allows callback URLs on the same origin else if (new URL(url).origin === baseUrl) return url; return baseUrl; + }, + async session({ session, user }) { + if (session?.user && user) { + session.user.role = user.role; + session.user.id = user.id; + } + return session; } } }; From cbb16171dccd436bddb41ccdf2612e99e0f716dc Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 8 Jun 2026 09:01:19 +0530 Subject: [PATCH 08/32] fix(navbar): hide classes link for unauthenticated and non-admin/teacher users --- __tests__/components/navbar.test.jsx | 50 +++++++++++++++++++++++++--- components/navbar.js | 28 +++++++++++++--- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/__tests__/components/navbar.test.jsx b/__tests__/components/navbar.test.jsx index 96aa572e3..81cb405a6 100644 --- a/__tests__/components/navbar.test.jsx +++ b/__tests__/components/navbar.test.jsx @@ -19,10 +19,12 @@ describe('Navbar rendering correctly', () => { it('renders Classes link as "Classes" for non-admin session', () => { const tree = renderer .create( - +
- Classes + Classes
@@ -37,10 +39,12 @@ describe('Navbar rendering correctly', () => { it('renders Classes link as "Dashboard" for ADMIN session', () => { const tree = renderer .create( - +
- Classes + Classes
@@ -51,4 +55,42 @@ describe('Navbar rendering correctly', () => { expect(jsonString).toContain('Dashboard'); expect(jsonString).not.toContain('Classes'); }); + + it('hides Classes link for STUDENT session', () => { + const tree = renderer + .create( + + +
+ Classes +
+
+
+ ) + .toJSON(); + + const jsonString = JSON.stringify(tree); + expect(jsonString).not.toContain('Classes'); + expect(jsonString).not.toContain('Dashboard'); + }); + + it('hides Classes link for unauthenticated session', () => { + const tree = renderer + .create( + + +
+ Classes +
+
+
+ ) + .toJSON(); + + const jsonString = JSON.stringify(tree); + expect(jsonString).not.toContain('Classes'); + expect(jsonString).not.toContain('Dashboard'); + }); }); diff --git a/components/navbar.js b/components/navbar.js index f73de3f73..079949256 100644 --- a/components/navbar.js +++ b/components/navbar.js @@ -28,13 +28,33 @@ function updateClassesLinkLabel(child, isAdmin) { return child; } +function hasClassesLink(child) { + if (!child) return false; + if (React.isValidElement(child)) { + if (child.props.href === '/classes') { + return true; + } + if (child.props.children) { + return React.Children.toArray(child.props.children).some(hasClassesLink); + } + } + return false; +} + export default function Navbar({ children }) { const { data: session } = useSession(); - const isAdmin = session?.user?.role === 'ADMIN'; + const role = session?.user?.role; + const hasAccess = role === 'ADMIN' || role === 'TEACHER'; + const isAdmin = role === 'ADMIN'; - const processedChildren = React.Children.toArray(children).map(child => - updateClassesLinkLabel(child, isAdmin) - ); + const processedChildren = React.Children.toArray(children) + .filter(child => { + if (hasClassesLink(child)) { + return hasAccess; + } + return true; + }) + .map(child => updateClassesLinkLabel(child, isAdmin)); return (
From f0d814726dffe1bb4a9feedcfa1a2c831454bc0d Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Wed, 10 Jun 2026 21:18:33 +0530 Subject: [PATCH 09/32] fix: prevent page crashes when mock-fcc-data server is offline --- pages/dashboard/v2/[id].js | 37 ++++++------ util/student/calculateProgress.js | 13 +++- ...ressDataForSuperblocksSelectedByTeacher.js | 37 ++++++++---- util/student/extractTimestamps.js | 59 ++++++++++++------- 4 files changed, 96 insertions(+), 50 deletions(-) diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 0de21d81b..2b754ebef 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -74,26 +74,29 @@ export async function getServerSideProps(context) { studentData, dashboardObjs ); - studentData.forEach(studentJSON => { - let indexToCheckProgress = studentData.indexOf(studentJSON); - let isStudentEnrolledInAtLeastOneSuperblock = - studentsAreEnrolledInSuperblocks[indexToCheckProgress].some( + if (Array.isArray(studentData)) { + studentData.forEach(studentJSON => { + let indexToCheckProgress = studentData.indexOf(studentJSON); + let enrollStatus = + studentsAreEnrolledInSuperblocks[indexToCheckProgress] || []; + let isStudentEnrolledInAtLeastOneSuperblock = enrollStatus.some( val => val === true ); - if (!isStudentEnrolledInAtLeastOneSuperblock) { - studentData[indexToCheckProgress].certifications = []; - } else { - // Filter out certifications that are not selected by the teacher - studentJSON.certifications = studentJSON.certifications.filter( - (certification, certIndex) => { - return studentsAreEnrolledInSuperblocks[indexToCheckProgress][ - certIndex - ]; - } - ); - } - }); + if (!isStudentEnrolledInAtLeastOneSuperblock) { + studentJSON.certifications = []; + } else if (Array.isArray(studentJSON.certifications)) { + // Filter out certifications that are not selected by the teacher + studentJSON.certifications = studentJSON.certifications.filter( + (certification, certIndex) => { + return enrollStatus[certIndex]; + } + ); + } else { + studentJSON.certifications = []; + } + }); + } return { props: { diff --git a/util/student/calculateProgress.js b/util/student/calculateProgress.js index 6cd4644fd..34ce8bd3e 100644 --- a/util/student/calculateProgress.js +++ b/util/student/calculateProgress.js @@ -26,11 +26,22 @@ export function getStudentProgressInSuperblock( ) { let blockProgressDetails = []; + if ( + !studentSuperblocksJSON || + !Array.isArray(studentSuperblocksJSON.certifications) + ) { + return blockProgressDetails; + } + studentSuperblocksJSON.certifications.forEach(superblockProgressJSON => { + if (!superblockProgressJSON) return; // the keys are dynamic which is why we have to use Object.keys(obj) let superblockDashedName = Object.keys(superblockProgressJSON)[0]; if (specificSuperblockDashedName === superblockDashedName) { - blockProgressDetails = Object.values(superblockProgressJSON)[0].blocks; + const val = Object.values(superblockProgressJSON)[0]; + if (val && val.blocks) { + blockProgressDetails = val.blocks; + } } }); diff --git a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js index 0c90f2d1f..fd11812d4 100644 --- a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js +++ b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js @@ -9,27 +9,40 @@ * only provide student data on the specified superblocks selected by the teacher */ export function checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher( - studentJSON, - superblockDashboardObj + studentJSON = [], + superblockDashboardObj = [] ) { // Returns a boolean matrix which checks to see enrollment in at least 1 superblock (at least 1 because in the GlobalDashboard component we calculate the cumulative progress) let superblockTitlesSelectedByTeacher = []; - superblockDashboardObj.forEach(superblockObj => { - superblockTitlesSelectedByTeacher.push(superblockObj[0].superblock); - }); + if (Array.isArray(superblockDashboardObj)) { + superblockDashboardObj.forEach(superblockObj => { + if (superblockObj && superblockObj[0]) { + superblockTitlesSelectedByTeacher.push(superblockObj[0].superblock); + } + }); + } let studentResponseDataHasSuperblockBooleanArray = []; + if (!Array.isArray(studentJSON)) { + return studentResponseDataHasSuperblockBooleanArray; + } + studentJSON.forEach(studentDetails => { let individualStudentEnrollmentStatus = []; - studentDetails.certifications.forEach(certObj => { - let studentIsEnrolledSuperblock = false; - if (superblockTitlesSelectedByTeacher.includes(Object.keys(certObj)[0])) { - studentIsEnrolledSuperblock = true; - } - individualStudentEnrollmentStatus.push(studentIsEnrolledSuperblock); - }); + if (studentDetails && Array.isArray(studentDetails.certifications)) { + studentDetails.certifications.forEach(certObj => { + if (!certObj) return; + let studentIsEnrolledSuperblock = false; + if ( + superblockTitlesSelectedByTeacher.includes(Object.keys(certObj)[0]) + ) { + studentIsEnrolledSuperblock = true; + } + individualStudentEnrollmentStatus.push(studentIsEnrolledSuperblock); + }); + } studentResponseDataHasSuperblockBooleanArray.push( individualStudentEnrollmentStatus ); diff --git a/util/student/extractTimestamps.js b/util/student/extractTimestamps.js index 245887f2d..5c20da93e 100644 --- a/util/student/extractTimestamps.js +++ b/util/student/extractTimestamps.js @@ -8,18 +8,28 @@ export function extractStudentCompletionTimestamps( ) { let completedTimestampsArray = []; + if (!Array.isArray(studentSuperblockProgressJSONArray)) { + return completedTimestampsArray; + } + studentSuperblockProgressJSONArray.forEach(superblockProgressJSON => { + if (!superblockProgressJSON) return; // since the keys are dynamic we have to use Object.values(obj) - let superblockProgressJSONArray = Object.values(superblockProgressJSON)[0] - .blocks; - superblockProgressJSONArray.forEach(blockProgressJSON => { - let blockKey = Object.keys(blockProgressJSON)[0]; - let allCompletedChallengesArrayWithTimestamps = - blockProgressJSON[blockKey].completedChallenges; - allCompletedChallengesArrayWithTimestamps.forEach(completionDetails => { - completedTimestampsArray.push(completionDetails.completedDate); + const val = Object.values(superblockProgressJSON)[0]; + if (val && Array.isArray(val.blocks)) { + val.blocks.forEach(blockProgressJSON => { + if (!blockProgressJSON) return; + let blockKey = Object.keys(blockProgressJSON)[0]; + let blockData = blockProgressJSON[blockKey]; + if (blockData && Array.isArray(blockData.completedChallenges)) { + blockData.completedChallenges.forEach(completionDetails => { + if (completionDetails && completionDetails.completedDate) { + completedTimestampsArray.push(completionDetails.completedDate); + } + }); + } }); - }); + } }); return completedTimestampsArray; } @@ -32,11 +42,16 @@ export function extractStudentCompletionTimestamps( */ export function extractFilteredCompletionTimestamps( studentSuperblockProgressJSONArray, - selectedSuperblocks + selectedSuperblocks = [] ) { let completedTimestampsArray = []; + if (!Array.isArray(studentSuperblockProgressJSONArray)) { + return completedTimestampsArray; + } + studentSuperblockProgressJSONArray.forEach(superblockProgressJSON => { + if (!superblockProgressJSON) return; let superblockDashedName = Object.keys(superblockProgressJSON)[0]; // Only include selected superblocks @@ -44,17 +59,21 @@ export function extractFilteredCompletionTimestamps( return; } - let superblockProgressJSONArray = Object.values(superblockProgressJSON)[0] - .blocks; - superblockProgressJSONArray.forEach(blockProgressJSON => { - let blockKey = Object.keys(blockProgressJSON)[0]; - let allCompletedChallengesArrayWithTimestamps = - blockProgressJSON[blockKey].completedChallenges; - - allCompletedChallengesArrayWithTimestamps.forEach(completionDetails => { - completedTimestampsArray.push(completionDetails.completedDate); + const val = Object.values(superblockProgressJSON)[0]; + if (val && Array.isArray(val.blocks)) { + val.blocks.forEach(blockProgressJSON => { + if (!blockProgressJSON) return; + let blockKey = Object.keys(blockProgressJSON)[0]; + let blockData = blockProgressJSON[blockKey]; + if (blockData && Array.isArray(blockData.completedChallenges)) { + blockData.completedChallenges.forEach(completionDetails => { + if (completionDetails && completionDetails.completedDate) { + completedTimestampsArray.push(completionDetails.completedDate); + } + }); + } }); - }); + } }); return completedTimestampsArray; From 59f3a4d873b37f63563720df54b40eaf48831c6a Mon Sep 17 00:00:00 2001 From: AbiramiR-27 Date: Mon, 15 Jun 2026 12:20:21 +0530 Subject: [PATCH 10/32] fix: address review comments on offline crash prevention --- __tests__/components/navbar.test.jsx | 79 ------------------- components/navbar.js | 54 +------------ pages/api/auth/[...nextauth].js | 7 -- pages/dashboard/v2/[id].js | 3 +- ...ressDataForSuperblocksSelectedByTeacher.js | 7 +- 5 files changed, 7 insertions(+), 143 deletions(-) diff --git a/__tests__/components/navbar.test.jsx b/__tests__/components/navbar.test.jsx index 81cb405a6..33f684deb 100644 --- a/__tests__/components/navbar.test.jsx +++ b/__tests__/components/navbar.test.jsx @@ -2,7 +2,6 @@ import Navbar from '../../components/navbar'; import React from 'react'; import { SessionProvider } from 'next-auth/react'; import renderer from 'react-test-renderer'; -import Link from 'next/link'; describe('Navbar rendering correctly', () => { it('renders correctly', () => { @@ -15,82 +14,4 @@ describe('Navbar rendering correctly', () => { .toJSON(); expect(tree).toMatchSnapshot(); }); - - it('renders Classes link as "Classes" for non-admin session', () => { - const tree = renderer - .create( - - -
- Classes -
-
-
- ) - .toJSON(); - - const jsonString = JSON.stringify(tree); - expect(jsonString).toContain('Classes'); - expect(jsonString).not.toContain('Dashboard'); - }); - - it('renders Classes link as "Dashboard" for ADMIN session', () => { - const tree = renderer - .create( - - -
- Classes -
-
-
- ) - .toJSON(); - - const jsonString = JSON.stringify(tree); - expect(jsonString).toContain('Dashboard'); - expect(jsonString).not.toContain('Classes'); - }); - - it('hides Classes link for STUDENT session', () => { - const tree = renderer - .create( - - -
- Classes -
-
-
- ) - .toJSON(); - - const jsonString = JSON.stringify(tree); - expect(jsonString).not.toContain('Classes'); - expect(jsonString).not.toContain('Dashboard'); - }); - - it('hides Classes link for unauthenticated session', () => { - const tree = renderer - .create( - - -
- Classes -
-
-
- ) - .toJSON(); - - const jsonString = JSON.stringify(tree); - expect(jsonString).not.toContain('Classes'); - expect(jsonString).not.toContain('Dashboard'); - }); }); diff --git a/components/navbar.js b/components/navbar.js index 079949256..9d4c44adb 100644 --- a/components/navbar.js +++ b/components/navbar.js @@ -1,61 +1,9 @@ import Image from 'next/legacy/image'; import Link from 'next/link'; import React from 'react'; -import { useSession } from 'next-auth/react'; import AuthButton from '../components/authButton'; -function updateClassesLinkLabel(child, isAdmin) { - if (!child) return child; - - if (React.isValidElement(child)) { - const isClassesLink = - child.props.href === '/classes' && child.props.children === 'Classes'; - - if (isClassesLink) { - return React.cloneElement(child, { - children: isAdmin ? 'Dashboard' : 'Classes' - }); - } - - if (child.props.children) { - const newChildren = React.Children.map(child.props.children, c => - updateClassesLinkLabel(c, isAdmin) - ); - return React.cloneElement(child, { children: newChildren }); - } - } - - return child; -} - -function hasClassesLink(child) { - if (!child) return false; - if (React.isValidElement(child)) { - if (child.props.href === '/classes') { - return true; - } - if (child.props.children) { - return React.Children.toArray(child.props.children).some(hasClassesLink); - } - } - return false; -} - export default function Navbar({ children }) { - const { data: session } = useSession(); - const role = session?.user?.role; - const hasAccess = role === 'ADMIN' || role === 'TEACHER'; - const isAdmin = role === 'ADMIN'; - - const processedChildren = React.Children.toArray(children) - .filter(child => { - if (hasClassesLink(child)) { - return hasAccess; - } - return true; - }) - .map(child => updateClassesLinkLabel(child, isAdmin)); - return (
@@ -72,7 +20,7 @@ export default function Navbar({ children }) { >
- {processedChildren.map(child => ( + {React.Children.toArray(children).map(child => (
{child}
diff --git a/pages/api/auth/[...nextauth].js b/pages/api/auth/[...nextauth].js index 27376ddf1..4895ac83c 100644 --- a/pages/api/auth/[...nextauth].js +++ b/pages/api/auth/[...nextauth].js @@ -28,13 +28,6 @@ export const authOptions = { // Allows callback URLs on the same origin else if (new URL(url).origin === baseUrl) return url; return baseUrl; - }, - async session({ session, user }) { - if (session?.user && user) { - session.user.role = user.role; - session.user.id = user.id; - } - return session; } } }; diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 2b754ebef..9c398e8be 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -75,8 +75,7 @@ export async function getServerSideProps(context) { dashboardObjs ); if (Array.isArray(studentData)) { - studentData.forEach(studentJSON => { - let indexToCheckProgress = studentData.indexOf(studentJSON); + studentData.forEach((studentJSON, indexToCheckProgress) => { let enrollStatus = studentsAreEnrolledInSuperblocks[indexToCheckProgress] || []; let isStudentEnrolledInAtLeastOneSuperblock = enrollStatus.some( diff --git a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js index fd11812d4..d70a8d03a 100644 --- a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js +++ b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js @@ -9,9 +9,12 @@ * only provide student data on the specified superblocks selected by the teacher */ export function checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher( - studentJSON = [], - superblockDashboardObj = [] + studentJSON, + superblockDashboardObj ) { + studentJSON = studentJSON ?? []; + superblockDashboardObj = superblockDashboardObj ?? []; + // Returns a boolean matrix which checks to see enrollment in at least 1 superblock (at least 1 because in the GlobalDashboard component we calculate the cumulative progress) let superblockTitlesSelectedByTeacher = []; From 512f1e56c68fd6e3b4de1c0fafcced62be834d24 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 16 Jun 2026 12:57:54 -0700 Subject: [PATCH 11/32] Removed redundant null catch code --- ...kIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js index d70a8d03a..e917ec115 100644 --- a/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js +++ b/util/student/checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher.js @@ -12,9 +12,6 @@ export function checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher( studentJSON, superblockDashboardObj ) { - studentJSON = studentJSON ?? []; - superblockDashboardObj = superblockDashboardObj ?? []; - // Returns a boolean matrix which checks to see enrollment in at least 1 superblock (at least 1 because in the GlobalDashboard component we calculate the cumulative progress) let superblockTitlesSelectedByTeacher = []; From 2b76083ffbd38e35526c4bc5039276c3bdbb92f1 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Mon, 22 Jun 2026 00:09:05 -0700 Subject: [PATCH 12/32] Improve UI Feedback for Failing Student Fetches and Empty Classrooms Updated fetchStudentData to change the return shape from empty arrays to specific errors Updated dashboard/v2/[id].js to take that return shape and render the UI accordingly --- pages/dashboard/v2/[id].js | 85 ++++++++++++++++++++++++++------ util/student/fetchStudentData.js | 7 +++ 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/pages/dashboard/v2/[id].js b/pages/dashboard/v2/[id].js index 9c398e8be..c6c9b61f0 100644 --- a/pages/dashboard/v2/[id].js +++ b/pages/dashboard/v2/[id].js @@ -4,7 +4,7 @@ import Link from 'next/link'; import Navbar from '../../../components/navbar'; import { getSession } from 'next-auth/react'; import GlobalDashboardTable from '../../../components/dashtable_v2'; -import React from 'react'; +import React, { useState } from 'react'; import { createSuperblockDashboardObject } from '../../../util/dashboard/createSuperblockDashboardObject'; import { getTotalChallengesForSuperblocks } from '../../../util/student/calculateProgress'; import { fetchStudentData } from '../../../util/student/fetchStudentData'; @@ -53,7 +53,8 @@ export async function getServerSideProps(context) { classroomId: context.params.id }, select: { - fccCertifications: true + fccCertifications: true, + fccUserIds: true } }); @@ -66,16 +67,17 @@ export async function getServerSideProps(context) { let totalChallenges = getTotalChallengesForSuperblocks(dashboardObjs); - let studentData = await fetchStudentData(); + const { error: fetchError, data: studentData } = await fetchStudentData(); + const safeStudentData = studentData ?? []; // Temporary check to map/accomodate hard-coded mock student data progress in unselected superblocks by teacher let studentsAreEnrolledInSuperblocks = checkIfStudentHasProgressDataForSuperblocksSelectedByTeacher( - studentData, + safeStudentData, dashboardObjs ); - if (Array.isArray(studentData)) { - studentData.forEach((studentJSON, indexToCheckProgress) => { + if (Array.isArray(safeStudentData)) { + safeStudentData.forEach((studentJSON, indexToCheckProgress) => { let enrollStatus = studentsAreEnrolledInSuperblocks[indexToCheckProgress] || []; let isStudentEnrolledInAtLeastOneSuperblock = enrollStatus.some( @@ -97,13 +99,20 @@ export async function getServerSideProps(context) { }); } + const protocol = context.req.headers['x-forwarded-proto'] ?? 'http'; + const host = context.req.headers.host; + const joinLink = `${protocol}://${host}/join/${context.params.id}`; + return { props: { userSession, classroomId: context.params.id, - studentData, + studentData: safeStudentData, totalChallenges: totalChallenges, - studentsAreEnrolledInSuperblocks + studentsAreEnrolledInSuperblocks, + fetchError: fetchError ?? null, + isEmpty: certificationNumbers.fccUserIds.length === 0, + joinLink } }; } @@ -113,8 +122,20 @@ export default function Home({ classroomId, totalChallenges, studentData, - studentsAreEnrolledInSuperblocks + studentsAreEnrolledInSuperblocks, + fetchError, + isEmpty, + joinLink }) { + const [copied, setCopied] = useState(false); + + function handleCopy() { + navigator.clipboard.writeText(joinLink).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + return ( @@ -132,12 +153,46 @@ export default function Home({ Menu
- + {isEmpty ? ( +
+

+ There are no students in this class yet. +

+

+ Share this link with your students so they can join: +

+

{joinLink}

+ +
+ ) : fetchError && fetchError !== 'MISSING_URL' ? ( +
+

+ We couldn't load your students. Please try refreshing, or + contact support at{' '} + + support@freecodecamp.org + {' '} + if the problem persists. +

+
+ ) : ( + + )} )} diff --git a/util/student/fetchStudentData.js b/util/student/fetchStudentData.js index 4c2ff35de..b40045d01 100644 --- a/util/student/fetchStudentData.js +++ b/util/student/fetchStudentData.js @@ -1,3 +1,10 @@ +/** + * Fetches student data from the mock data URL + * @returns {Promise<{error: string|null, data: Array|null, status?: number}>} + * + * NOTE: This is a mock data function used for testing. + * In production, use FCC Proper API with fccProperUserIds. + */ export async function fetchStudentData() { if (!process.env.MOCK_USER_DATA_URL) { console.warn('MOCK_USER_DATA_URL is not defined.'); From 19964962d899d8f5d650506f760db1ff9a729cda Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Mon, 22 Jun 2026 00:34:21 -0700 Subject: [PATCH 13/32] Reversed changes on dead files --- components/dashtabs.js | 54 +++-------------------------------------- pages/dashboard/[id].js | 24 ++++-------------- 2 files changed, 8 insertions(+), 70 deletions(-) diff --git a/components/dashtabs.js b/components/dashtabs.js index 66f83981d..4a8e2fa22 100644 --- a/components/dashtabs.js +++ b/components/dashtabs.js @@ -5,9 +5,9 @@ import { useState } from 'react'; export default function DashTabs(props) { const [tabIndex, setTabIndex] = useState(0); + // This sets our selected tab to our first index of certification module names const [tabIndexName, setTabIndexName] = useState(props.certificationNames[0]); - const [copied, setCopied] = useState(false); - + // Here we are copying the columns array (which is now immutable) in order to be able to add the Student Name column to it var columnNames = [...props.columns]; const presetColumns = [ { @@ -26,59 +26,11 @@ export default function DashTabs(props) { return finalColumns; }); + // This function sets the tab name which later gives our selected tab selected styling function determineItemStyle(x) { setTabIndexName(x); } - function handleCopy() { - navigator.clipboard.writeText(props.joinLink); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - if (props.fccUserIds.length === 0) { - return ( -
-

No students have joined this classroom yet.

-

Share this link with your students to invite them:

-
- - {props.joinLink} - - -
-
- ); - } - - if (props.fetchError && props.fetchError !== 'MISSING_URL') { - return ( -
-

- We couldn't load your students. Please try refreshing, or contact - support at{' '} - support@freecodecamp.org{' '} - if the problem persists. -

-
- ); - } - return ( <> setTabIndex(index)}> diff --git a/pages/dashboard/[id].js b/pages/dashboard/[id].js index 1437e9d63..95e003192 100644 --- a/pages/dashboard/[id].js +++ b/pages/dashboard/[id].js @@ -46,8 +46,7 @@ export async function getServerSideProps(context) { classroomId: context.params.id }, select: { - fccCertifications: true, - fccUserIds: true + fccCertifications: true } }); let superblockURLS = await getDashedNamesURLs( @@ -60,21 +59,14 @@ export async function getServerSideProps(context) { let superBlockJsons = await getSuperBlockJsons(superblockURLS); let dashboardObjs = await createSuperblockDashboardObject(superBlockJsons); - const { error: fetchError, data: currStudentData } = await fetchStudentData(); - - const protocol = context.req.headers['x-forwarded-proto'] ?? 'http'; - const host = context.req.headers.host; - const joinLink = `${protocol}://${host}/join/${context.params.id}`; + let currStudentData = await fetchStudentData(); return { props: { userSession, columns: dashboardObjs, certificationNames: nonDashedNames, - data: currStudentData ?? [], - fetchError: fetchError ?? null, - fccUserIds: certificationNumbers.fccUserIds, - joinLink + data: currStudentData } }; } @@ -83,10 +75,7 @@ export default function Home({ userSession, columns, certificationNames, - data, - fetchError, - fccUserIds, - joinLink + data }) { let tabNames = certificationNames; let columnNames = columns; @@ -113,10 +102,7 @@ export default function Home({ columns={columnNames} certificationNames={tabNames} studentData={studentData} - fetchError={fetchError} - fccUserIds={fccUserIds} - joinLink={joinLink} - /> + > )} From f5d7444028d7fa3e15934844e571093f39e02e0f Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 4 Aug 2026 14:34:36 -0700 Subject: [PATCH 14/32] Added Certification List tooltip to Classes page Teachers can now hover over it to easily see which certifications are part of their class on the Classes page. Previously, they needed to press the Edit Class button and scroll through the certifications list. --- .../classInviteTable.test.jsx.snap | 40 ++++++++++++++++++- components/ClassInviteTable.js | 38 +++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap index 3763d8aa7..fc8506352 100644 --- a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap +++ b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap @@ -12,11 +12,46 @@ exports[`ClassInviteTable displays invites in a table 1`] = ` className="group flex items-center" >

Classroom: how to build a website

+
+ + + + +
+

+ Certifications +

+

+ No certifications assigned +

+
+
@@ -53,7 +88,8 @@ exports[`ClassInviteTable displays invites in a table 1`] = `

learn how to build a website in a jiffy

diff --git a/components/ClassInviteTable.js b/components/ClassInviteTable.js index 8809f47b0..d85a9d5db 100644 --- a/components/ClassInviteTable.js +++ b/components/ClassInviteTable.js @@ -134,9 +134,40 @@ export default function ClassInviteTable({ className='group block max-w-xl mx-auto p-6 bg-fcc-gray-15 border-2 border-fcc-gray-90 ring-1 ring-slate-900/5 shadow-lg space-y-3 hover:bg-fcc-gray-90 hover:ring-sky-500' >
-

+

Classroom: {currentClass.classroomName}

+
+ + + + +
+

Certifications

+ {getSelectedCerts().length > 0 ? ( +
    + {getSelectedCerts().map(cert => ( +
  • + {cert.displayName} +
  • + ))} +
+ ) : ( +

No certifications assigned

+ )} +
+
{/* <-------Menu Item Selection -----> */}
@@ -356,7 +387,10 @@ export default function ClassInviteTable({ )}
-

+

{currentClass.description}

From 3fb1372a5d8c77eccf2dc90f83ce594da619b9b8 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Tue, 4 Aug 2026 15:11:53 -0700 Subject: [PATCH 15/32] Add Client-side CSS Text-Wrapping and Character Count Caps Users can only write class titles of up to 100 characters and descriptions of up to 500 characters. The Class card truncates after 2 lines of the class title and 4 lines of the description. --- .../__snapshots__/classInviteTable.test.jsx.snap | 2 +- .../components/__snapshots__/modal.test.jsx.snap | 14 ++++++++++++++ components/ClassInviteTable.js | 10 +++++++++- components/modal.js | 8 ++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap index fc8506352..bbe11f051 100644 --- a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap +++ b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap @@ -12,7 +12,7 @@ exports[`ClassInviteTable displays invites in a table 1`] = ` className="group flex items-center" >

Classroom: how to build a website diff --git a/__tests__/components/__snapshots__/modal.test.jsx.snap b/__tests__/components/__snapshots__/modal.test.jsx.snap index 43a6cdab4..ced4b5e4c 100644 --- a/__tests__/components/__snapshots__/modal.test.jsx.snap +++ b/__tests__/components/__snapshots__/modal.test.jsx.snap @@ -78,11 +78,18 @@ exports[`Modal Component renders whole form after header clicked 1`] = ` +

+ 0 + /100 +

+

+ 0 + /500 +

-

+

Classroom: {currentClass.classroomName}

@@ -322,9 +322,13 @@ export default function ClassInviteTable({ } id='class-name' name='classname' + maxLength={100} className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' placeholder={currentClass.classroomName} > +

+ {formData.className?.length ?? 0}/100 +

@@ -342,9 +346,13 @@ export default function ClassInviteTable({ } id='description-text' name='description' + maxLength={500} className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' placeholder={currentClass.description} > +

+ {formData.description?.length ?? 0}/500 +

diff --git a/components/modal.js b/components/modal.js index 97238a3af..97d7266a4 100644 --- a/components/modal.js +++ b/components/modal.js @@ -105,9 +105,13 @@ export default function Modal({ id='class-name' name='classname' required + maxLength={100} className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' placeholder='Class Name' > +

+ {formData.classroomName?.length ?? 0}/100 +

@@ -126,9 +130,13 @@ export default function Modal({ id='description-text' name='description' required + maxLength={500} className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' placeholder='Description' > +

+ {formData.description?.length ?? 0}/500 +

From e8ccbd52402b1e2be1218070b81db57b1025b5d2 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Mon, 10 Aug 2026 14:35:05 -0700 Subject: [PATCH 16/32] Redesign Create and Edit Class Modals Refactor the code in modal.js and ClassInviteTable.js into a shared component in ClassModal.js Create the FloatingMultiSelect.js modal to make the Select Certification dropdown appear on top of the backdrop Switch from overlaying JSX in place with ReactDOM.createPortal() to make the modal appear fully over the page Updated test files and snapshots, added temporary comments to explain the changes. --- .../__snapshots__/modal.test.jsx.snap | 168 ----------------- .../components/classInviteTable.test.jsx | 56 ++++++ __tests__/components/modal.test.jsx | 43 +++-- components/ClassInviteTable.js | 140 +++----------- components/ClassModal.js | 173 ++++++++++++++++++ components/FloatingMultiSelect.js | 72 ++++++++ components/modal.js | 127 ++----------- 7 files changed, 368 insertions(+), 411 deletions(-) create mode 100644 components/ClassModal.js create mode 100644 components/FloatingMultiSelect.js diff --git a/__tests__/components/__snapshots__/modal.test.jsx.snap b/__tests__/components/__snapshots__/modal.test.jsx.snap index 43a6cdab4..28c68b5d1 100644 --- a/__tests__/components/__snapshots__/modal.test.jsx.snap +++ b/__tests__/components/__snapshots__/modal.test.jsx.snap @@ -19,171 +19,3 @@ exports[`Modal Component renders header correctly 1`] = `
`; - -exports[`Modal Component renders whole form after header clicked 1`] = ` -
-
-
-
-
-
- Create Class -
-
-
-
-
-
- Create Class -
-
- -
-
-

- Class Name: -

- - -
-
-
-
-

- Description: -

- - -
-
-
-
-

- Edit Select Certifications: -

- ({ - value: x['value'], - label: x['displayName'] - }))} - value={selected} - onChange={setSelected} - labelledBy='Select' - /> -
-
- -
-
- - -
-
-
-
-
- - )} +

diff --git a/components/ClassModal.js b/components/ClassModal.js new file mode 100644 index 000000000..4fb953a49 --- /dev/null +++ b/components/ClassModal.js @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import FloatingMultiSelect from './FloatingMultiSelect'; +import { getStoredSuperblocks } from '../util/curriculum/constants'; + +/** + * Shared Create/Edit Class modal. + * + * Used by both the "Create Class" trigger (components/modal.js) and the + * "Edit" menu item (components/ClassInviteTable.js) so the two flows share + * one implementation instead of two hand-copied ones. + * + * Renders via a portal straight to document.body so the overlay/panel are + * never subject to layout quirks from wherever the trigger happens to sit + * in the component tree. + */ +export default function ClassModal({ + mode, + isOpen, + onClose, + userId, + certificationNames, + initialValues, + onSubmit +}) { + const isEdit = mode === 'edit'; + + const getSelectedCerts = () => { + if (!isEdit || !initialValues?.fccCertifications) { + return []; + } + return certificationNames + .filter(cert => initialValues.fccCertifications.includes(cert.value)) + .map(cert => ({ value: cert.value, label: cert.displayName })); + }; + + const [className, setClassName] = useState(''); + const [description, setDescription] = useState(''); + const [selected, setSelected] = useState([]); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + // Re-sync local form state to the current class every time the modal + // opens. The component instance persists across open/close (only its + // rendered output is conditional), so this can't rely on remount to reset. + useEffect(() => { + if (isOpen) { + setClassName(isEdit ? (initialValues?.classroomName ?? '') : ''); + setDescription(isEdit ? (initialValues?.description ?? '') : ''); + setSelected(getSelectedCerts()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const handleSubmit = async e => { + e.preventDefault(); + const fccCertificationsSet = new Set(); + selected.forEach(cert => + getStoredSuperblocks(cert.value).forEach(req => + fccCertificationsSet.add(req) + ) + ); + + const payload = { + classroomName: className, + description, + fccCertifications: [...fccCertificationsSet].sort() + }; + if (isEdit) { + payload.classroomId = initialValues.classroomId; + } else { + payload.classroomTeacherId = userId; + } + + await onSubmit(payload); + onClose(); + }; + + if (!isOpen || !mounted) { + return null; + } + + return createPortal( +
+
+
+
+ {isEdit ? 'Edit Class' : 'Create Class'} +
+ +
+ +
+
+

+ {isEdit ? 'Edit Class Name:' : 'Class Name:'} +

+ + setClassName(e.target.value)} + value={className} + id='class-name' + name='classname' + required + className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' + placeholder='Class Name' + > +
+
+
+
+

+ {isEdit ? 'Edit Description:' : 'Description:'} +

+ + +
+
+
+
+

+ {isEdit + ? 'Edit Select Certifications:' + : 'Select Certifications:'} +

+ ({ + value: cert.value, + label: cert.displayName + }))} + value={selected} + onChange={setSelected} + labelledBy='Select' + /> +
+
+ +
+
+ + +
+
+
+
+
, + document.body + ); +} diff --git a/components/FloatingMultiSelect.js b/components/FloatingMultiSelect.js new file mode 100644 index 000000000..b2c43ee63 --- /dev/null +++ b/components/FloatingMultiSelect.js @@ -0,0 +1,72 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { MultiSelect } from 'react-multi-select-component'; + +/** + * Wraps react-multi-select-component and renders it into a portal on + * document.body, positioned over an in-flow anchor element. + * + * react-multi-select-component has no built-in floating/portal option, and + * its dropdown panel is absolutely positioned relative to its own wrapper. + * When that wrapper sits inside a scrollable ancestor (e.g. a modal panel + * with `overflow-auto`), the open panel gets clipped by that boundary. + * Portaling the whole widget out to document.body sidesteps the clipping + * entirely, at the cost of manually tracking the anchor's position. + * + * This is a deliberately lightweight fix (no new dependency). If positioning + * needs get more advanced later (e.g. flipping above the anchor near the + * viewport edge), consider swapping to a library with native floating + * support (e.g. react-select's menuPortalTarget, or @floating-ui/react). + */ +export default function FloatingMultiSelect(props) { + const anchorRef = useRef(null); + const [rect, setRect] = useState(null); + const [mounted, setMounted] = useState(false); + + const updateRect = () => { + if (anchorRef.current) { + setRect(anchorRef.current.getBoundingClientRect()); + } + }; + + useLayoutEffect(() => { + setMounted(true); + updateRect(); + }, []); + + useEffect(() => { + if (!mounted) { + return undefined; + } + window.addEventListener('resize', updateRect); + // 'scroll' doesn't bubble, so listen on the capture phase to also catch + // scrolling inside the modal panel, not just the window itself. + window.addEventListener('scroll', updateRect, true); + return () => { + window.removeEventListener('resize', updateRect); + window.removeEventListener('scroll', updateRect, true); + }; + }, [mounted]); + + return ( + <> +
+ {mounted && + rect && + createPortal( +
+ +
, + document.body + )} + + ); +} diff --git a/components/modal.js b/components/modal.js index 97238a3af..129109e10 100644 --- a/components/modal.js +++ b/components/modal.js @@ -1,45 +1,30 @@ import { useState } from 'react'; -import { MultiSelect } from 'react-multi-select-component'; +import ClassModal from './ClassModal'; import DisplayNotification from './displayNotification'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import { getStoredSuperblocks } from '../util/curriculum/constants'; export default function Modal({ userId, certificationNames, setCurrentClassrooms }) { - const handleCancelClick = () => { - setSelected([]); - setModalOn(false); - }; - - const [formData, setFormData] = useState({}); - const [selected, setSelected] = useState([]); - const [modalOn, setModalOn] = useState(false); const clicked = () => { setModalOn(true); }; - async function saveClass(e) { + const closeModal = () => { setModalOn(false); - e.preventDefault(); - const fccCertificationsSet = new Set(); - selected.forEach(x => - getStoredSuperblocks(x.value).forEach(req => - fccCertificationsSet.add(req) - ) - ); - formData.fccCertifications = [...fccCertificationsSet].sort(); + }; + const createClass = async payload => { const response = await fetch(`/api/create_class_teacher`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) + body: JSON.stringify(payload) }); if (response.ok) { @@ -56,12 +41,11 @@ export default function Modal({ ...currentClassrooms, newClassroom ]); - setSelected([]); DisplayNotification('Success', 'Class Created!'); } else { DisplayNotification('Error', 'Class could not be created!'); } - } + }; return ( <> @@ -77,97 +61,14 @@ export default function Modal({ Create Class

- {modalOn && ( - <> -
-
-
-
- Create Class -
- -
- -
-
-

Class Name:

- - - setFormData({ - ...formData, - classroomName: e.target.value, - classroomTeacherId: userId - }) - } - id='class-name' - name='classname' - required - className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' - placeholder='Class Name' - > -
-
-
-
-

Description:

- - -
-
-
-
-

Select Certifications:

- ({ - value: x['value'], - label: x['displayName'] - }))} - value={selected} - onChange={setSelected} - labelledBy='Select' - /> -
-
- -
-
- - -
-
-
-
-
- - )} +
); From df5451a4cbf3107eec5fcdc70b9bde629a6e9623 Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 26 Sep 2026 00:52:48 -0700 Subject: [PATCH 17/32] chore(ui): install @freecodecamp/ui and adopt its design tokens - Add @freecodecamp/ui@6.1.0 (pinned) and load its base.css before globals.css - Set the light-palette class on - Mirror the library's Tailwind config (semantic colors, palette, fonts, font sizes); legacy fcc-* aliases now point at the same CSS variables - Disable Classroom's own preflight since base.css already ships it - Load Lato 300/400/700/italic weights - Require Node >=24 (the package's engine requirement; CI already uses 24) Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 437 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- pages/_app.js | 1 + pages/_document.js | 7 +- styles/globals.css | 30 +--- tailwind.config.js | 182 +++++++++++++++---- 6 files changed, 599 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index ccb498e7b..1c4f8488b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "BSD-3-Clause", "dependencies": { + "@freecodecamp/ui": "6.1.0", "@next-auth/prisma-adapter": "1.0.7", "@prisma/client": "^6.19.2", "json-server": "0.17.1", @@ -797,6 +798,93 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.3.0.tgz", + "integrity": "sha512-EHmHeTf8WgO29sdY3iX/7ekE3gNUdlc2RW6mm/FzELlHFKfTrA9S4MlyquRR+RRCRCn8+jXfLFpLGB2l7wCWyw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~6 || ~7", + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@freecodecamp/ui": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@freecodecamp/ui/-/ui-6.1.0.tgz", + "integrity": "sha512-EXIOk762EP18onNOtLxujfmZbpUN1vuSzEQp5Tq4jciNp2a+kRUDdxQxK5YidsqGKW/ELjbQxAXtXVV5IORIdQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "7.2.0", + "@fortawesome/free-solid-svg-icons": "7.2.0", + "@fortawesome/react-fontawesome": "3.3.0", + "@headlessui/react": "1.7.19", + "@radix-ui/react-tabs": "1.1.13", + "babel-plugin-prismjs": "2.1.0", + "dompurify": "3.4.7", + "prismjs": "1.30.0" + }, + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@headlessui/react": { + "version": "1.7.19", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.19.tgz", + "integrity": "sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==", + "license": "MIT", + "dependencies": { + "@tanstack/react-virtual": "^3.0.0-beta.60", + "client-only": "^0.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2118,6 +2206,294 @@ "@prisma/debug": "6.19.2" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2175,6 +2551,33 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz", + "integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz", + "integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2424,6 +2827,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3529,6 +3939,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-prismjs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-prismjs/-/babel-plugin-prismjs-2.1.0.tgz", + "integrity": "sha512-ehzSKYfeAz4U78zi/sfwsjDPlq0LvDKxNefcZTJ/iKBu+plsHsLqZhUeGf1+82LAcA35UZGbU6ksEx2Utphc/g==", + "license": "MIT", + "peerDependencies": { + "prismjs": "^1.18.0" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", @@ -4692,6 +5111,15 @@ "node": ">=12" } }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -10266,6 +10694,15 @@ } } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index 4211d9d91..e35085456 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "Classroom mode for freeCodeCamp", "engines": { - "node": ">=18.18.0", + "node": ">=24", "npm": ">=9" }, "main": "index.js", @@ -32,6 +32,7 @@ "mock-fcc-data": "npx json-server --watch mock-json-server/fccdata.json --port 3002" }, "dependencies": { + "@freecodecamp/ui": "6.1.0", "@next-auth/prisma-adapter": "1.0.7", "@prisma/client": "^6.19.2", "json-server": "0.17.1", diff --git a/pages/_app.js b/pages/_app.js index 18866dd7f..50959c2ab 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,3 +1,4 @@ +import '@freecodecamp/ui/dist/base.css'; import '../styles/globals.css'; import { SessionProvider } from 'next-auth/react'; diff --git a/pages/_document.js b/pages/_document.js index ba5d5d461..c50b1ee0d 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -11,12 +11,15 @@ class MyDocument extends Document { href='https://fonts.gstatic.com' crossOrigin='anonymous' /> + {/* @freecodecamp/ui sets the font stacks but doesn't bundle the font files. */} - + {/* Classroom is light-theme only for now. fCC UI's getThemingClass() + reads window, so the palette class is set statically instead. */} +
diff --git a/styles/globals.css b/styles/globals.css index abb6289c7..4638a9041 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1,27 +1,15 @@ +/* + @freecodecamp/ui/dist/base.css (imported in pages/_app.js before this file) + provides the reset, the color variables, fonts and base element styles. + Only Classroom-specific additions belong here. +*/ @tailwind base; @tailwind components; @tailwind utilities; -:root { - --fcc-gray-00: #ffffff; - --fcc-gray-90: #0a0a23; -} - -html, body { - padding: 0; - margin: 0; - font-family: 'Lato', sans-serif; - font-size: clamp(18px, 1.5vw, 21.0625px); -} - -a { - color: inherit; - text-decoration: none; -} - -* { - box-sizing: border-box; + background-color: var(--background-primary); + color: var(--foreground-primary); } .navButton { @@ -31,8 +19,8 @@ a { } .toast-message { - background: var(--fcc-gray-90); - color: var(--fcc-gray-00); + background: var(--gray90); + color: var(--gray00); font-size: 20px; --toastify-font-family: 'Lato', sans-serif; } diff --git a/tailwind.config.js b/tailwind.config.js index 9f07a798e..81e8b69db 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,7 @@ const colors = require('tailwindcss/colors'); +const plugin = require('tailwindcss/plugin'); -/* +/* Without these, we get warnings in our console regarding these colors being deprecated. Changes were implemented based on this GitHub discussion: https://github.com/tailwindlabs/tailwindcss/issues/4690 */ delete colors['lightBlue']; @@ -9,65 +10,172 @@ delete colors['coolGray']; delete colors['trueGray']; delete colors['blueGray']; +/* + The palette comes from @freecodecamp/ui. Its base.css defines the raw color + variables (--gray90, --yellow45, ...) on :root and the semantic ones + (--foreground-primary, --background-secondary, ...) on .light-palette / + .dark-palette. The `extend` block below mirrors the library's own Tailwind + config (https://github.com/freeCodeCamp/ui/blob/main/tailwind.config.js) + so Classroom's utility classes use the same names as freeCodeCamp. +*/ module.exports = { content: [ './pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}' ], + corePlugins: { + // @freecodecamp/ui/dist/base.css already ships Tailwind's preflight. + preflight: false + }, theme: { - /*These colors are taken from FreeCodeCamp's design guide - refer to https://design-style-guide.freecodecamp.org/ to have a clear understanding*/ colors: { transparent: 'transparent', current: 'currentColor', + ...colors, + // Legacy Classroom aliases, kept until every usage is migrated. fcc: { gray: { /*It's supposed to be 00 instead of 0o0 but due to an error with writting 00, "Octal numbers are not allowed. Use the syntax '0o0'" */ - //LIGHT GRAYs - 0o0: '#ffffff', - 0o5: '#f5f6f7', - 10: '#dfdfe2', - 15: '#d0d0d5', - 45: '#858591', - // DARK GRAYs - 75: '#3b3b4f', - 80: '#2a2a40', - 85: '#1b1b32', - 90: '#0a0a23' + 0o0: 'var(--gray00)', + 0o5: 'var(--gray05)', + 10: 'var(--gray10)', + 15: 'var(--gray15)', + 45: 'var(--gray45)', + 75: 'var(--gray75)', + 80: 'var(--gray80)', + 85: 'var(--gray85)', + 90: 'var(--gray90)' }, - primary: { - purple: '#dbb8ff', - yellow: '#ffbf00', - blue: '#99c9ff', - lightGreen: '#acd157' + purple: 'var(--purple10)', + yellow: 'var(--yellow45)', + blue: 'var(--blue30)', + lightGreen: 'var(--green40)' }, - secondary: { - darkPurple: '#5a01a7', - darkYellow: '#4d3800', - darkBlue: '#002ead', - darkGreen: '#00471b' + darkPurple: 'var(--purple90)', + darkYellow: 'var(--yellow90)', + darkBlue: 'var(--blue90)', + darkGreen: 'var(--green90)' }, - mid: { - blue: '#198eee', - purple: '#9400d3' + blue: 'var(--blue50)', + purple: 'var(--purple50)' + }, + red: { + light: 'var(--red15)', + dark: 'var(--red90)' + } + } + }, + extend: { + colors: { + 'dark-theme-background': 'var(--gray90)', + 'light-theme-background': 'var(--gray00)', + // Foreground + 'foreground-primary': 'var(--foreground-primary)', + 'foreground-secondary': 'var(--foreground-secondary)', + 'foreground-tertiary': 'var(--foreground-tertiary)', + 'foreground-quaternary': 'var(--foreground-quaternary)', + 'foreground-muted': 'var(--foreground-muted)', + 'foreground-danger': 'var(--foreground-danger)', + 'foreground-danger-disabled': 'var(--foreground-danger-disabled)', + 'foreground-success': 'var(--foreground-success)', + 'foreground-info': 'var(--foreground-info)', + 'foreground-warning': 'var(--foreground-warning)', + // Background + 'background-primary': 'var(--background-primary)', + 'background-secondary': 'var(--background-secondary)', + 'background-tertiary': 'var(--background-tertiary)', + 'background-quaternary': 'var(--background-quaternary)', + 'background-danger': 'var(--background-danger)', + 'background-danger-disabled': 'var(--background-danger-disabled)', + 'background-success': 'var(--background-success)', + 'background-info': 'var(--background-info)', + // Focus outline + 'focus-outline-color': 'var(--focus-outline-color)', + gray: { + 0: 'var(--gray00)', + 50: 'var(--gray05)', + 100: 'var(--gray10)', + 150: 'var(--gray15)', + 400: 'var(--gray40)', + 450: 'var(--gray45)', + 500: 'var(--gray50)', + 750: 'var(--gray75)', + 800: 'var(--gray80)', + 850: 'var(--gray85)', + 900: 'var(--gray90)' + }, + green: { + 50: 'var(--green05)', + 100: 'var(--green10)', + 400: 'var(--green40)', + 700: 'var(--green70)', + 800: 'var(--green80)', + 900: 'var(--green90)' + }, + blue: { + 50: 'var(--blue05)', + 100: 'var(--blue10)', + 300: 'var(--blue30)', + 500: 'var(--blue50)', + 700: 'var(--blue70)', + 900: 'var(--blue90)' + }, + yellow: { + 50: 'var(--yellow05)', + 100: 'var(--yellow10)', + 400: 'var(--yellow40)', + 450: 'var(--yellow45)', + 500: 'var(--yellow50)', + 700: 'var(--yellow70)', + 800: 'var(--yellow80)', + 900: 'var(--yellow90)' }, - red: { - light: '#ffadad', - dark: '#850000' + 50: 'var(--red05)', + 100: 'var(--red10)', + 150: 'var(--red15)', + 300: 'var(--red30)', + 700: 'var(--red70)', + 800: 'var(--red80)', + 900: 'var(--red90)', + 1000: 'var(--red100)' + }, + orange: { + 300: 'var(--orange30)' + }, + purple: { + 100: 'var(--purple10)', + 500: 'var(--purple50)', + 900: 'var(--purple90)' } }, - ...colors - } - }, - extend: { - colors: { - 'gray-90': '#0a0a23' + borderWidth: { + 1: '1px', + 3: '3px' + }, + outlineWidth: { + 3: '3px' + }, + fontFamily: { + sans: ['Lato', 'sans-serif'], + mono: ['Hack-ZeroSlash', 'monospace'] + }, + fontSize: { + // [fontSize, lineHeight] + sm: ['16px', '1.5'], + md: ['18px', '1.42857143'], + lg: ['24px', '1.3333333'] + } } }, - plugins: [] + plugins: [ + plugin(({ addVariant }) => { + addVariant('aria-disabled', '&[aria-disabled="true"]'); + addVariant('aria-expanded', '&[aria-expanded="true"]'); + }) + ] }; From c413fc7c355d8c6cd09830459f81544b55db0a0f Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 26 Sep 2026 17:23:50 -0700 Subject: [PATCH 18/32] feat(ui): use @freecodecamp/ui Button and fCC typography - Add Link and ButtonLink helpers mirroring freeCodeCamp's client helpers (internal links use Next's router, external links open in a new tab) - Replace hand-styled buttons across Classroom with @freecodecamp/ui Button - Port fCC's .btn-cta for main actions and style the navbar auth button like fCC's header Sign in (fits the 38px bar) - Port fCC's heading, paragraph, and .big-heading rules into globals.css - Rebuild the error and join pages with one consistent layout and page titles - Turn non-heading

s (modal labels, card description, details rows) into text - Remove button CSS made obsolete by the library Co-Authored-By: Claude Opus 5.5 --- .../classInviteTable.test.jsx.snap | 15 +- .../__snapshots__/dashtable_v2.test.jsx.snap | 4 - .../__snapshots__/error.test.jsx.snap | 23 +-- .../__snapshots__/modal.test.jsx.snap | 7 +- .../__snapshots__/navbar.test.jsx.snap | 3 +- .../updateUserForm.test.jsx.snap | 9 +- __tests__/components/modal.test.jsx | 8 +- components/ClassInviteTable.js | 20 +-- components/ClassModal.js | 31 ++-- components/DetailsCSS.module.css | 12 -- components/DetailsDashboardList.js | 13 +- components/TeacherInvitesPanel.js | 19 ++- components/TeacherInvitesPanel.module.css | 34 ---- components/authButton.js | 41 ++--- components/dashtable_v2.js | 11 +- components/error.js | 8 +- components/helpers/button-link.js | 58 +++++++ components/helpers/link.js | 28 ++++ components/modal.js | 8 +- components/updateUserForm.js | 8 +- pages/admin/index.js | 15 +- pages/error.js | 25 ++- pages/join/[...joinCode].js | 147 +++++++----------- pages/join/index.js | 53 +++---- .../invite/TeacherInviteAccept.module.css | 48 +----- pages/teacher/invite/[inviteToken].js | 69 ++++---- styles/Home.module.css | 8 - styles/globals.css | 85 ++++++++++ 28 files changed, 409 insertions(+), 401 deletions(-) create mode 100644 components/helpers/button-link.js create mode 100644 components/helpers/link.js diff --git a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap index bbe11f051..19f4f41ad 100644 --- a/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap +++ b/__tests__/components/__snapshots__/classInviteTable.test.jsx.snap @@ -87,22 +87,21 @@ exports[`ClassInviteTable displays invites in a table 1`] = `

-

learn how to build a website in a jiffy -

+

- +
`; diff --git a/__tests__/components/__snapshots__/dashtable_v2.test.jsx.snap b/__tests__/components/__snapshots__/dashtable_v2.test.jsx.snap index 4f8743c96..6ff38f22e 100644 --- a/__tests__/components/__snapshots__/dashtable_v2.test.jsx.snap +++ b/__tests__/components/__snapshots__/dashtable_v2.test.jsx.snap @@ -88,9 +88,7 @@ exports[`GlobalDashboardTable renders dashtable with correct data format 1`] = ` - details - @@ -139,9 +137,7 @@ exports[`GlobalDashboardTable renders dashtable with correct data format 1`] = ` - details - diff --git a/__tests__/components/__snapshots__/error.test.jsx.snap b/__tests__/components/__snapshots__/error.test.jsx.snap index 9c62f9ae0..5074f9033 100644 --- a/__tests__/components/__snapshots__/error.test.jsx.snap +++ b/__tests__/components/__snapshots__/error.test.jsx.snap @@ -76,8 +76,9 @@ exports[`ErrorComponet displays error cause and error message properly 1`] = ` className="pl-2" > @@ -85,15 +86,17 @@ exports[`ErrorComponet displays error cause and error message properly 1`] = `
, -

- 404 error -

, -

- Page not found -

, +

+ 404 error +

+

+ Page not found +

+ , ] `; diff --git a/__tests__/components/__snapshots__/modal.test.jsx.snap b/__tests__/components/__snapshots__/modal.test.jsx.snap index 20195d171..7e8dc8fec 100644 --- a/__tests__/components/__snapshots__/modal.test.jsx.snap +++ b/__tests__/components/__snapshots__/modal.test.jsx.snap @@ -10,12 +10,13 @@ exports[`Modal Component renders header correctly 1`] = `
-
Create Class -
+
`; diff --git a/__tests__/components/__snapshots__/navbar.test.jsx.snap b/__tests__/components/__snapshots__/navbar.test.jsx.snap index cc17d97d0..19f8dbef9 100644 --- a/__tests__/components/__snapshots__/navbar.test.jsx.snap +++ b/__tests__/components/__snapshots__/navbar.test.jsx.snap @@ -75,8 +75,9 @@ exports[`Navbar rendering correctly renders correctly 1`] = ` className="pl-2" > diff --git a/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap b/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap index a4314eb80..c61da0136 100644 --- a/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap +++ b/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap @@ -88,7 +88,8 @@ exports[`updateUserForm doesn't show other roles if user role is "ADMIN" 1`] = `

- + + View Class +
diff --git a/components/ClassModal.js b/components/ClassModal.js index 69c585cee..c3aa25e89 100644 --- a/components/ClassModal.js +++ b/components/ClassModal.js @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { Button } from '@freecodecamp/ui'; import FloatingMultiSelect from './FloatingMultiSelect'; import { getStoredSuperblocks } from '../util/curriculum/constants'; @@ -93,9 +94,9 @@ export default function ClassModal({
-

+

{isEdit ? 'Edit Class Name:' : 'Class Name:'} -

+

@@ -116,9 +117,9 @@ export default function ClassModal({
-

+

{isEdit ? 'Edit Description:' : 'Description:'} -

+

@@ -139,11 +140,11 @@ export default function ClassModal({
-

+

{isEdit ? 'Edit Select Certifications:' : 'Select Certifications:'} -

+

({ value: cert.value, @@ -156,21 +157,11 @@ export default function ClassModal({
-
-
- - + +
diff --git a/components/DetailsCSS.module.css b/components/DetailsCSS.module.css index 0a93b1d32..d0a53c22e 100644 --- a/components/DetailsCSS.module.css +++ b/components/DetailsCSS.module.css @@ -29,19 +29,7 @@ font-weight: bold; font-size: 1.2rem; } -.list_container button { - font-size: 0.7rem; - text-transform: uppercase; - border: 1px solid navy; - padding: 5px 10px; - background-color: orange; -} - -.list_container button:hover { - background-color: rgb(89, 103, 174); - color: white; -} .inner_comp { background-color: grey; color: white; diff --git a/components/DetailsDashboardList.js b/components/DetailsDashboardList.js index cfca73d2f..59505332d 100644 --- a/components/DetailsDashboardList.js +++ b/components/DetailsDashboardList.js @@ -1,5 +1,6 @@ import React from 'react'; import { useState } from 'react'; +import { Button } from '@freecodecamp/ui'; import styles from './DetailsCSS.module.css'; import { getStudentTotalChallengesCompletedInBlock } from '../util/student/calculateProgress'; @@ -37,7 +38,9 @@ export default function DetailsDashboardList(props) {

{props.superblockTitle}

- +
{hideDetails ? ( @@ -49,14 +52,14 @@ export default function DetailsDashboardList(props) { {props.blockData.map((blockDetails, idx) => { return (
-

+ {blockDetails.blockName} -

-

+ + {getStudentsProgressInBlock(blockDetails.selector) + '/' + blockDetails.allChallenges.length} -

+
); })} diff --git a/components/TeacherInvitesPanel.js b/components/TeacherInvitesPanel.js index e8059a53a..cbfded518 100644 --- a/components/TeacherInvitesPanel.js +++ b/components/TeacherInvitesPanel.js @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import DisplayNotification from './displayNotification'; import { ToastContainer } from 'react-toastify'; +import { Button } from '@freecodecamp/ui'; import styles from './TeacherInvitesPanel.module.css'; const normalizeInvitedEmail = value => value.trim().toLowerCase(); @@ -205,9 +206,9 @@ export default function TeacherInvitesPanel() { placeholder='teacher@example.org' required /> - +
@@ -276,26 +277,24 @@ export default function TeacherInvitesPanel() {
- - +
diff --git a/components/TeacherInvitesPanel.module.css b/components/TeacherInvitesPanel.module.css index 8e6fcf619..47ad9946b 100644 --- a/components/TeacherInvitesPanel.module.css +++ b/components/TeacherInvitesPanel.module.css @@ -16,23 +16,6 @@ border-radius: 0.25rem; } -.primaryButton { - flex: 0 0 12rem; - padding: 0.5rem 1rem; - border: 2px solid #000; - border-radius: 0.25rem; - background-color: #feac32; - color: #000; - font-weight: 600; - cursor: pointer; - white-space: nowrap; -} - -.primaryButton:disabled { - cursor: not-allowed; - opacity: 0.6; -} - .controlsRow { margin-top: 10px; display: flex; @@ -108,19 +91,6 @@ gap: 0.5rem; } -.secondaryButton { - padding: 0.25rem 0.5rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; - background-color: #fff; - cursor: pointer; -} - -.secondaryButton:disabled { - cursor: not-allowed; - opacity: 0.6; -} - .footer { text-align: right; } @@ -161,10 +131,6 @@ flex-direction: column; } - .primaryButton { - width: 100%; - } - .controlsRow { align-items: stretch; justify-content: flex-start; diff --git a/components/authButton.js b/components/authButton.js index 96bd2fa65..bba3875f7 100644 --- a/components/authButton.js +++ b/components/authButton.js @@ -1,27 +1,32 @@ import { useSession, signIn, signOut } from 'next-auth/react'; +import { Button } from '@freecodecamp/ui'; -export default function AuthButton({ callbackUrl = '/' }) { +/* + Mirrors freeCodeCamp's header Login button (Header/components/login.tsx + + universal-nav.css `.signup-btn`): the yellow CTA style, sized to fit the + 38px navbar. Pass `size` to render a regular-sized CTA outside the navbar. +*/ +const navClassName = + 'btn-cta flex items-center justify-center max-h-[28px] min-w-[28px] px-1 sm:px-3 text-md no-underline'; + +export default function AuthButton({ callbackUrl = '/', size }) { const { data: session } = useSession(); - if (session) { + const onClick = session + ? () => signOut({ callbackUrl: '/' }) + : () => signIn(null, { callbackUrl }); + const label = session ? 'Sign out' : 'Sign in'; + + if (size) { return ( - <> - - + ); } + return ( - <> - - + ); } diff --git a/components/dashtable_v2.js b/components/dashtable_v2.js index a196afff4..cd6b95bb9 100644 --- a/components/dashtable_v2.js +++ b/components/dashtable_v2.js @@ -1,6 +1,7 @@ import { useTable } from 'react-table'; import React from 'react'; import getStudentActivity from './studentActivity'; +import Link from './helpers/link'; import { extractStudentCompletionTimestamps } from '../util/student/extractTimestamps'; export default function GlobalDashboardTable(props) { @@ -40,13 +41,9 @@ export default function GlobalDashboardTable(props) { activity: studentActivity, progress: percentageCompletion, detail: ( - - {' '} - details{' '} - + + details + ) }; diff --git a/components/error.js b/components/error.js index 1dccd0593..b1ffb1fb6 100644 --- a/components/error.js +++ b/components/error.js @@ -4,10 +4,10 @@ export default function ErrorComponent(props) { <> -

- {props.errorCause} -

-

{props.errorMessage}

+
+

{props.errorCause}

+

{props.errorMessage}

+
); } diff --git a/components/helpers/button-link.js b/components/helpers/button-link.js new file mode 100644 index 000000000..15b172c5b --- /dev/null +++ b/components/helpers/button-link.js @@ -0,0 +1,58 @@ +import { useRouter } from 'next/router'; +import { Button } from '@freecodecamp/ui'; + +const isInternal = href => /^\/(?!\/)/.test(href); + +const isModifiedClick = event => + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey; + +/** + * A link that looks like a button, based on freeCodeCamp's + * client/src/components/helpers/button-link.tsx. + * + * fCC renders internal links through Gatsby's Link with legacy `.btn` classes + * that aren't part of @freecodecamp/ui. Here, @freecodecamp/ui's Button always + * renders the `` (so styles come from the library), and plain left-clicks on + * internal links are handed to Next's router for client-side navigation. + * Modified clicks (ctrl/cmd/middle) keep the browser's default behavior. + */ +export default function ButtonLink({ + href, + onClick, + target, + size = 'medium', + ...rest +}) { + const router = useRouter(); + + const handleClick = event => { + if (onClick) { + onClick(event); + } + if ( + event.defaultPrevented || + !isInternal(href) || + target === '_blank' || + isModifiedClick(event) + ) { + return; + } + event.preventDefault(); + router.push(href); + }; + + return ( +
-
+
+
- +
); diff --git a/pages/admin/index.js b/pages/admin/index.js index e40d90817..5d2b22a0f 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -1,4 +1,5 @@ import Head from 'next/head'; +import { Button } from '@freecodecamp/ui'; import styles from '../../styles/Home.module.css'; import Navbar from '../../components/navbar'; import Link from 'next/link'; @@ -99,15 +100,14 @@ export default function Home(props) {

Teacher Invitations

- +

Invite management and invitation history. @@ -122,13 +122,12 @@ export default function Home(props) {

User Database

- +

Current users and role management actions. diff --git a/pages/error.js b/pages/error.js index 8579d2f6d..96e1baeab 100644 --- a/pages/error.js +++ b/pages/error.js @@ -1,5 +1,6 @@ +import Head from 'next/head'; import Navbar from '../components/navbar'; -import Link from 'next/link'; +import ButtonLink from '../components/helpers/button-link'; import { getSession } from 'next-auth/react'; const getGuidance = ({ hasSession, hasUser, role, inviteStatus, reason }) => { @@ -164,22 +165,18 @@ export default function ErrorPage(props) { return ( <> + + {`${guidance.heading} | freeCodeCamp Classroom`} + -

- {guidance.heading} -

-
+
+

{guidance.heading}

{guidance.body}

-
- - {guidance.actionLabel} - -
-
+ + {guidance.actionLabel} + + ); } diff --git a/pages/join/[...joinCode].js b/pages/join/[...joinCode].js index 2b8d1a7d9..70923cce5 100644 --- a/pages/join/[...joinCode].js +++ b/pages/join/[...joinCode].js @@ -1,5 +1,6 @@ import Head from 'next/head'; -import Link from 'next/link'; +import { Button } from '@freecodecamp/ui'; +import ButtonLink from '../../components/helpers/button-link'; import Navbar from '../../components/navbar'; import { useState } from 'react'; import { useRouter } from 'next/router'; @@ -117,102 +118,66 @@ export default function JoinWithCode({
- Create Next App - + Join a Classroom | freeCodeCamp Classroom + {!userSession ? ( - <> -
-
-
-

- Sign In with FreeCodeCamp -

-
-
- -
-
-
- +
+

Sign in with freeCodeCamp

+

Sign in to join your teacher's classroom.

+ +
) : classroom === null ? ( -
-
-

- Classroom Not Found -

-

- We could not find a classroom for this invite link. Please check - the link or ask your teacher to resend the invite. -

-
- - Back to Home - -
-
-
+
+

Classroom Not Found

+

+ We could not find a classroom for this invite link. Please check + the link or ask your teacher to resend the invite. +

+ Back to Home +
) : ( - <> -
-
-
-

- Join Classroom - {(userRole === 'TEACHER' || userRole === 'ADMIN') && ( - - (as a Student) - - )} -

- {classroom && ( -

- Joining: {classroom.classroomName} -

- )} -
-
-
- -
-
- {isConnected && ( -

- You're connected to{' '} - {classroom.classroomName}! -

- )} -
-

- If you have not enabled Classroom access on freeCodeCamp, - please open your settings and enable it before connecting. -

- -
-
-
- +
+

+ {userRole === 'TEACHER' || userRole === 'ADMIN' + ? 'Join Classroom (as a Student)' + : 'Join Classroom'} +

+ {classroom && ( +

+ Joining: {classroom.classroomName} +

+ )} +
+ +
+ {isConnected && ( +

+ You're connected to{' '} + {classroom.classroomName}! +

+ )} +

+ If you have not enabled Classroom access on freeCodeCamp, please + open your settings and enable it before connecting. +

+ + Open freeCodeCamp Settings + +
)}
diff --git a/pages/join/index.js b/pages/join/index.js index 1db6a02e8..4579cd2f0 100644 --- a/pages/join/index.js +++ b/pages/join/index.js @@ -1,7 +1,7 @@ import Layout from '../../components/layout'; import Head from 'next/head'; import Navbar from '../../components/navbar'; -import Link from 'next/link'; +import ButtonLink from '../../components/helpers/button-link'; import AuthButton from '../../components/authButton'; import { getSession } from 'next-auth/react'; @@ -9,42 +9,31 @@ export default function Join({ userSession }) { return ( - Join a Classroom + Join a Classroom | freeCodeCamp Classroom -
-
- {!userSession ? ( -
-
-

- Sign In with FreeCodeCamp -

-
- -
-
-
- ) : ( -
-

No join code provided

-

- To join a Classroom, you must open the unique join link provided - by your instructor. The link should look like{' '} - /join/<classroomId>. -

- - - Return to Home - - -
- )} -
-
+
+ {!userSession ? ( + <> +

Sign in with freeCodeCamp

+

Sign in to join your teacher's classroom.

+ + + ) : ( + <> +

No join code provided

+

+ To join a Classroom, you must open the unique join link provided + by your instructor. The link should look like{' '} + /join/<classroomId>. +

+ Return to Home + + )} +
); } diff --git a/pages/teacher/invite/TeacherInviteAccept.module.css b/pages/teacher/invite/TeacherInviteAccept.module.css index 2b376308c..c0594ba54 100644 --- a/pages/teacher/invite/TeacherInviteAccept.module.css +++ b/pages/teacher/invite/TeacherInviteAccept.module.css @@ -119,50 +119,6 @@ align-items: center; } -.primaryButton, -.secondaryButton, -.continueLink { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 48px; - padding: 0 20px; - font-size: 1rem; - font-weight: 700; - text-decoration: none; - transition: - transform 120ms ease, - box-shadow 120ms ease, - background-color 120ms ease, - color 120ms ease; -} - -.primaryButton { - border: 2px solid #0a0a23; - background: #fecc4c; - color: #0a0a23; - cursor: pointer; -} - -.primaryButton:hover:not(:disabled), -.secondaryButton:hover, -.continueLink:hover { - transform: translateY(-1px); - box-shadow: 0 8px 18px rgba(10, 10, 35, 0.12); -} - -.primaryButton:disabled { - cursor: not-allowed; - opacity: 0.65; -} - -.secondaryButton, -.continueLink { - border: 2px solid #0a0a23; - background: #ffffff; - color: #0a0a23; -} - .successTitle { margin: 0 0 16px; color: #0a7f52; @@ -186,9 +142,7 @@ padding: 24px 20px; } - .primaryButton, - .secondaryButton, - .continueLink { + .actions > * { width: 100%; } } diff --git a/pages/teacher/invite/[inviteToken].js b/pages/teacher/invite/[inviteToken].js index 323fd7a37..198e45ed3 100644 --- a/pages/teacher/invite/[inviteToken].js +++ b/pages/teacher/invite/[inviteToken].js @@ -1,5 +1,7 @@ import Head from 'next/head'; -import Link from 'next/link'; +import { Button } from '@freecodecamp/ui'; +import ButtonLink from '../../../components/helpers/button-link'; +import Link from '../../../components/helpers/link'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { getSession, signIn } from 'next-auth/react'; @@ -125,30 +127,29 @@ export default function TeacherInviteAccept({ inviteToken, userSession }) { progress tracking tools.

- + ) : success ? ( <> @@ -160,9 +161,13 @@ export default function TeacherInviteAccept({ inviteToken, userSession }) { shortly to the site. If that does not happen, use the link below.

- + {successDestinationLabel} - + ) : ( <> @@ -194,43 +199,39 @@ export default function TeacherInviteAccept({ inviteToken, userSession }) { curriculum here before continuing.

)}
- + {error && ( - + )}
diff --git a/styles/Home.module.css b/styles/Home.module.css index 417a010e6..d1c5d9c03 100644 --- a/styles/Home.module.css +++ b/styles/Home.module.css @@ -159,14 +159,6 @@ color: #4b5563; } -.sectionToggleButton { - padding: 0.35rem 0.75rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; - background-color: #fff; - cursor: pointer; -} - .sectionDivider { margin: 1.5rem; border: 0; diff --git a/styles/globals.css b/styles/globals.css index 4638a9041..ec5162d9e 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -12,6 +12,91 @@ body { color: var(--foreground-primary); } +/* + Typography, ported from freeCodeCamp's client/src/components/layouts/global.css. + @freecodecamp/ui's reset makes headings plain text, so fCC's own heading and + paragraph rules live in the site CSS, not the library. +*/ +@layer base { + h1 { + color: var(--foreground-secondary); + font-weight: 700; + font-size: 1.5rem; + margin: 0.6rem 0; + } + h2 { + color: var(--foreground-secondary); + font-weight: 700; + font-size: 1.25rem; + margin: 0.6rem 0; + } + h3 { + color: var(--foreground-secondary); + font-weight: 700; + font-size: 1.1rem; + margin: 0 0 0.6rem; + } + h4, + h5, + h6 { + color: var(--foreground-secondary); + font-weight: 400; + font-size: 1rem; + margin: 0 0 1.2rem; + } + p { + line-height: 1.5rem; + font-weight: 400; + font-size: 1rem; + margin: 0 0 1.2rem; + } + + @media (max-width: 500px) { + h1 { + font-size: 1.3rem; + } + h2 { + font-size: 1.2rem; + } + } +} + +/* Page titles (fCC's .big-heading) */ +.big-heading { + font-size: 2rem; + overflow-wrap: break-word; +} +@media (max-width: 500px) { + .big-heading { + font-size: 1.5rem; + } +} + +/* + fCC's yellow call-to-action button (global.css `.btn-cta`). It isn't part of + @freecodecamp/ui, so it's layered on top of the library's Button via + className. !important mirrors fCC and beats the Button's hover utilities. +*/ +.btn-cta { + background-color: #feac32; + background-image: linear-gradient(#fecc4c, #ffac33); + border: 3px solid #feac32; + color: #0a0a23 !important; + text-align: center; +} +.btn-cta:hover, +.btn-cta:focus, +.btn-cta:active:hover { + background-color: #fecc4c !important; + border: 3px solid #f1a02a; + background-image: none; + color: #0a0a23 !important; +} +.btn-cta:active { + background-image: none; + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); +} + .navButton { border: 1px solid; padding-left: 1rem; From 31210226a0fd6e7393858a6c1a888117d9b5e7cc Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 26 Sep 2026 17:43:33 -0700 Subject: [PATCH 19/32] feat(ui): rebuild Create/Edit Class modal on @freecodecamp/ui Modal - Use Modal (Headless UI Dialog) with Header/Body/Footer: backdrop and Escape close, focus trap, dialog semantics, close button - Use FormGroup/ControlLabel/FormControl/HelpBlock for the fields; stacked CTA + Cancel footer buttons - Render the certification MultiSelect inline and theme it with fCC tokens; remove FloatingMultiSelect (the fCC panel doesn't clip overflow) - Close the modal on Escape from the closed certification dropdown - Cap the library's fixed-width modal panel at the viewport width on phones - Stub ResizeObserver in modal tests, as freeCodeCamp's own tests do Co-Authored-By: Claude Opus 5.5 --- .../components/classInviteTable.test.jsx | 11 + __tests__/components/modal.test.jsx | 23 ++- components/ClassModal.js | 188 +++++++++--------- components/FloatingMultiSelect.js | 72 ------- styles/globals.css | 26 +++ 5 files changed, 142 insertions(+), 178 deletions(-) delete mode 100644 components/FloatingMultiSelect.js diff --git a/__tests__/components/classInviteTable.test.jsx b/__tests__/components/classInviteTable.test.jsx index cff5cbcfb..092a593e1 100644 --- a/__tests__/components/classInviteTable.test.jsx +++ b/__tests__/components/classInviteTable.test.jsx @@ -45,6 +45,17 @@ const sampleCurrentClassrooms = [ const sampleClassroom = sampleCurrentClassrooms[0]; describe('ClassInviteTable', () => { + // Headless UI's Dialog (used by @freecodecamp/ui's Modal) needs + // ResizeObserver, which jsdom doesn't implement. Stubbed the same way as + // freeCodeCamp's own modal tests. + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + }; + }); + it('displays invites in a table', () => { const tree = renderer .create( diff --git a/__tests__/components/modal.test.jsx b/__tests__/components/modal.test.jsx index d210764a9..b09982f3f 100644 --- a/__tests__/components/modal.test.jsx +++ b/__tests__/components/modal.test.jsx @@ -35,6 +35,17 @@ const sampleData = [ const sampleUser = 'Ayomide'; describe('Modal Component', () => { + // Headless UI's Dialog (used by @freecodecamp/ui's Modal) needs + // ResizeObserver, which jsdom doesn't implement. Stubbed the same way as + // freeCodeCamp's own modal tests. + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + }; + }); + it('renders header correctly', () => { const tree = renderer .create() @@ -42,19 +53,15 @@ describe('Modal Component', () => { expect(tree).toMatchSnapshot(); }); - // The Create Class form renders through a React portal straight to - // document.body (see components/ClassModal.js), so it's verified with - // Testing Library against the real jsdom document instead of - // react-test-renderer's toJSON(), which can't reconcile a portal target - // that isn't one of its own fake instances. + // The Create Class form renders in @freecodecamp/ui's Modal, which portals + // to document.body, so it's verified with Testing Library against the real + // jsdom document instead of react-test-renderer's toJSON(). it('renders whole form after header clicked', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Create Class' })); - expect( - screen.getByText('Create Class', { selector: 'div.text-lg' }) - ).toBeVisible(); + expect(screen.getByRole('dialog', { name: 'Create Class' })).toBeVisible(); expect(screen.getByLabelText('Class Name')).toBeVisible(); expect(screen.getByLabelText('Description')).toBeVisible(); expect(screen.getByRole('button', { name: 'Create' })).toBeVisible(); diff --git a/components/ClassModal.js b/components/ClassModal.js index c3aa25e89..572e80e2e 100644 --- a/components/ClassModal.js +++ b/components/ClassModal.js @@ -1,9 +1,19 @@ import { useState, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { Button } from '@freecodecamp/ui'; -import FloatingMultiSelect from './FloatingMultiSelect'; +import { MultiSelect } from 'react-multi-select-component'; +import { + Button, + ControlLabel, + FormControl, + FormGroup, + HelpBlock, + Modal, + Spacer +} from '@freecodecamp/ui'; import { getStoredSuperblocks } from '../util/curriculum/constants'; +export const CLASS_NAME_MAX_LENGTH = 100; +export const DESCRIPTION_MAX_LENGTH = 500; + /** * Shared Create/Edit Class modal. * @@ -11,9 +21,11 @@ import { getStoredSuperblocks } from '../util/curriculum/constants'; * "Edit" menu item (components/ClassInviteTable.js) so the two flows share * one implementation instead of two hand-copied ones. * - * Renders via a portal straight to document.body so the overlay/panel are - * never subject to layout quirks from wherever the trigger happens to sit - * in the component tree. + * Built on @freecodecamp/ui's Modal (a Headless UI Dialog), which renders in + * its own portal over the whole page, traps focus, and closes on Escape or a + * click on the backdrop. The certification dropdown renders inline: the modal + * panel doesn't clip overflow (its full-screen container scrolls instead), and + * a dropdown portaled outside the panel would count as an "outside" click. */ export default function ClassModal({ mode, @@ -38,9 +50,7 @@ export default function ClassModal({ const [className, setClassName] = useState(''); const [description, setDescription] = useState(''); const [selected, setSelected] = useState([]); - const [mounted, setMounted] = useState(false); - - useEffect(() => setMounted(true), []); + const [certMenuOpen, setCertMenuOpen] = useState(false); // Re-sync local form state to the current class every time the modal // opens. The component instance persists across open/close (only its @@ -78,95 +88,77 @@ export default function ClassModal({ onClose(); }; - if (!isOpen || !mounted) { - return null; - } - - return createPortal( -
-
-
-
- {isEdit ? 'Edit Class' : 'Create Class'} -
- -
- -
-
-

- {isEdit ? 'Edit Class Name:' : 'Class Name:'} -

- - setClassName(e.target.value)} - value={className} - id='class-name' - name='classname' - required - maxLength={100} - className='appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm' - placeholder='Class Name' - > -

- {className.length}/100 -

-
-
-
-
-

- {isEdit ? 'Edit Description:' : 'Description:'} -

- - -

- {description.length}/500 -

-
-
-
-
-

- {isEdit - ? 'Edit Select Certifications:' - : 'Select Certifications:'} -

- ({ - value: cert.value, - label: cert.displayName - }))} - value={selected} - onChange={setSelected} - labelledBy='Select' - /> -
-
- -
- - + return ( + + {isEdit ? 'Edit Class' : 'Create Class'} + + + + Class Name + setClassName(e.target.value)} + value={className} + name='classname' + required + maxLength={CLASS_NAME_MAX_LENGTH} + /> + + {className.length}/{CLASS_NAME_MAX_LENGTH} + + + + Description + setDescription(e.target.value)} + value={description} + name='description' + required + maxLength={DESCRIPTION_MAX_LENGTH} + /> + + {description.length}/{DESCRIPTION_MAX_LENGTH} + + + + + Certifications + + {/* react-multi-select-component treats Escape on its closed menu as + "open", so the Modal never sees it. Close the modal instead. */} +
{ + if (e.key === 'Escape' && !certMenuOpen) { + e.stopPropagation(); + onClose(); + } + }} + > + ({ + value: cert.value, + label: cert.displayName + }))} + value={selected} + onChange={setSelected} + labelledBy='certifications-label' + onMenuToggle={setCertMenuOpen} + />
- -
-
-
, - document.body + + + + + + + + + ); } diff --git a/components/FloatingMultiSelect.js b/components/FloatingMultiSelect.js deleted file mode 100644 index b2c43ee63..000000000 --- a/components/FloatingMultiSelect.js +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { MultiSelect } from 'react-multi-select-component'; - -/** - * Wraps react-multi-select-component and renders it into a portal on - * document.body, positioned over an in-flow anchor element. - * - * react-multi-select-component has no built-in floating/portal option, and - * its dropdown panel is absolutely positioned relative to its own wrapper. - * When that wrapper sits inside a scrollable ancestor (e.g. a modal panel - * with `overflow-auto`), the open panel gets clipped by that boundary. - * Portaling the whole widget out to document.body sidesteps the clipping - * entirely, at the cost of manually tracking the anchor's position. - * - * This is a deliberately lightweight fix (no new dependency). If positioning - * needs get more advanced later (e.g. flipping above the anchor near the - * viewport edge), consider swapping to a library with native floating - * support (e.g. react-select's menuPortalTarget, or @floating-ui/react). - */ -export default function FloatingMultiSelect(props) { - const anchorRef = useRef(null); - const [rect, setRect] = useState(null); - const [mounted, setMounted] = useState(false); - - const updateRect = () => { - if (anchorRef.current) { - setRect(anchorRef.current.getBoundingClientRect()); - } - }; - - useLayoutEffect(() => { - setMounted(true); - updateRect(); - }, []); - - useEffect(() => { - if (!mounted) { - return undefined; - } - window.addEventListener('resize', updateRect); - // 'scroll' doesn't bubble, so listen on the capture phase to also catch - // scrolling inside the modal panel, not just the window itself. - window.addEventListener('scroll', updateRect, true); - return () => { - window.removeEventListener('resize', updateRect); - window.removeEventListener('scroll', updateRect, true); - }; - }, [mounted]); - - return ( - <> -
- {mounted && - rect && - createPortal( -
- -
, - document.body - )} - - ); -} diff --git a/styles/globals.css b/styles/globals.css index ec5162d9e..1a995d4aa 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -97,6 +97,32 @@ body { box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.3); } +/* + react-multi-select-component isn't covered by @freecodecamp/ui, so it's + themed through its CSS variables to match the library's FormControl. +*/ +.fcc-multi-select { + --rmsc-main: var(--focus-outline-color); + --rmsc-primary: var(--foreground-primary); + --rmsc-hover: var(--background-tertiary); + --rmsc-selected: var(--background-secondary); + --rmsc-border: var(--background-quaternary); + --rmsc-gray: var(--foreground-quaternary); + --rmsc-bg: var(--background-primary); + --rmsc-p: 10px; + --rmsc-radius: 0; + --rmsc-h: 36px; + color: var(--foreground-primary); +} + +/* + @freecodecamp/ui's Modal panel has a fixed width (600px for "medium"), which + overflows phone screens. Cap it at the width of its container. +*/ +[id^='headlessui-dialog-panel'] { + max-width: 100%; +} + .navButton { border: 1px solid; padding-left: 1rem; From f75c3425e945cb539b14a1c18355536d9c1c456f Mon Sep 17 00:00:00 2001 From: Newton Chung Date: Sat, 26 Sep 2026 17:52:26 -0700 Subject: [PATCH 20/32] feat(ui): use @freecodecamp/ui form controls for admin forms - Add FormSelect helper: a native select styled like the library's FormControl - Rebuild the update-user form with FormGroup/ControlLabel/FormControl, add a page title and blank-field hint, and fix the Role label pointing at Name - Use FormControl/ControlLabel/FormSelect for the teacher invite email and filters; add an accessible label to the invite email input - Keep layout classes on wrappers since FormControl drops its own classes when given a className Co-Authored-By: Claude Opus 5.5 --- .../updateUserForm.test.jsx.snap | 404 +++++++++--------- components/TeacherInvitesPanel.js | 63 +-- components/TeacherInvitesPanel.module.css | 31 +- components/helpers/form-select.js | 16 + components/updateUserForm.js | 88 ++-- 5 files changed, 299 insertions(+), 303 deletions(-) create mode 100644 components/helpers/form-select.js diff --git a/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap b/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap index c61da0136..5dbb3e2d1 100644 --- a/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap +++ b/__tests__/components/__snapshots__/updateUserForm.test.jsx.snap @@ -1,11 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`updateUserForm doesn't show other roles if user role is "ADMIN" 1`] = ` -
+

+ Edit User +

You are currently editing: Hamzat Victor @@ -13,97 +18,97 @@ exports[`updateUserForm doesn't show other roles if user role is "ADMIN" 1`] = ` oluwaborihamzat@gmail.com )

+

+ Leave a field blank to keep its value. +

-
- - -
+ Name + +
-
- - -
+ Email + +
-
- - + - -
+ ADMIN + +
-
+ `; exports[`updateUserForm shows a form for user to update details 1`] = ` -
+

+ Edit User +

You are currently editing: Ayomide onifade @@ -111,112 +116,112 @@ exports[`updateUserForm shows a form for user to update details 1`] = ` Jangulabi@gmail.com )

+

+ Leave a field blank to keep its value. +

-
- - -
+ Name + +
-
- - -
+ Email + +
-
+ Role + + + - - - - -
+ ADMIN + +
-
+ `; exports[`updateUserForm shows all available roles if user role is not "ADMIN" 1`] = ` -
+

+ Edit User +

You are currently editing: Alade Christopher @@ -224,102 +229,97 @@ exports[`updateUserForm shows all available roles if user role is not "ADMIN" 1` aladechristoph@gmail.com )

+

+ Leave a field blank to keep its value. +

-
- - -
+ Name + +
-
- - -
+ Email + +
-
-
+ NONE + +
-
+ `; diff --git a/components/TeacherInvitesPanel.js b/components/TeacherInvitesPanel.js index cbfded518..49aef14a4 100644 --- a/components/TeacherInvitesPanel.js +++ b/components/TeacherInvitesPanel.js @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import DisplayNotification from './displayNotification'; import { ToastContainer } from 'react-toastify'; -import { Button } from '@freecodecamp/ui'; +import { Button, ControlLabel, FormControl } from '@freecodecamp/ui'; +import FormSelect from './helpers/form-select'; import styles from './TeacherInvitesPanel.module.css'; const normalizeInvitedEmail = value => value.trim().toLowerCase(); @@ -198,38 +199,48 @@ export default function TeacherInvitesPanel() {
- setInvitedTeacherEmail(event.target.value)} - className={styles.emailInput} - placeholder='teacher@example.org' - required - /> + {/* FormControl overwrites its own classes with a passed className, + so layout classes go on wrappers instead. */} + + Teacher email + +
+ setInvitedTeacherEmail(event.target.value)} + placeholder='teacher@example.org' + required + /> +
- - + +
Showing {totalEntries}
diff --git a/components/TeacherInvitesPanel.module.css b/components/TeacherInvitesPanel.module.css index 47ad9946b..26ced31ac 100644 --- a/components/TeacherInvitesPanel.module.css +++ b/components/TeacherInvitesPanel.module.css @@ -5,50 +5,36 @@ .form { margin-top: 1rem; display: flex; + align-items: center; gap: 0.5rem; } .emailInput { flex: 1; min-width: 0; - padding: 0.5rem 0.75rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; } .controlsRow { - margin-top: 10px; + margin-top: 1rem; display: flex; - align-items: center; + align-items: flex-end; justify-content: flex-end; - color: #757575; - gap: 0.5rem; + gap: 1rem; flex-wrap: wrap; } -.controlLabel { - display: inline-flex; - align-items: center; +.control { + display: flex; + flex-direction: column; gap: 0.25rem; - font-size: inherit; - white-space: nowrap; } .filterInput { min-width: 15rem; - padding: 0.35rem 0.5rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; - font-size: inherit; } .filterSelect { min-width: 12rem; - padding: 0.35rem 0.5rem; - border: 1px solid #6b7280; - border-radius: 0.25rem; - background-color: #fff; - font-size: inherit; } .tableWrapper { @@ -137,9 +123,8 @@ gap: 0.5rem; } - .controlLabel { + .control { width: 100%; - justify-content: space-between; } .filterInput, diff --git a/components/helpers/form-select.js b/components/helpers/form-select.js new file mode 100644 index 000000000..25a71140e --- /dev/null +++ b/components/helpers/form-select.js @@ -0,0 +1,16 @@ +/** + * A native and