feat(client): add settings side nav (#63034)

Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
Co-authored-by: ahmad abdolsaheb <ahmad.abdolsaheb@gmail.com>
This commit is contained in:
Sem Bauke
2025-12-03 10:28:20 +01:00
committed by GitHub
parent ebf5a8463e
commit e056608d82
12 changed files with 546 additions and 135 deletions

View File

@@ -281,7 +281,7 @@
"disabled": "Your certifications will be disabled, if set to private.",
"private-name": "Your name will not appear on your certifications, if this is set to private.",
"claim-legacy": "Once you've earned the following freeCodeCamp certifications, you'll be able to claim the {{cert}}:",
"for": "Account Settings for {{username}}",
"for": "Settings for {{username}}",
"sound-mode": "This adds the pleasant sound of acoustic guitar throughout the website. You'll get musical feedback as you type in the editor, complete challenges, claim certifications, and more.",
"sound-volume": "Campfire Volume:",
"scrollbar-width": "Editor Scrollbar Width",
@@ -328,12 +328,13 @@
"keyboard-shortcuts": "Enable Keyboard Shortcuts"
},
"headings": {
"account": "Account",
"certs": "Certifications",
"legacy-certs": "Legacy Certifications",
"honesty": "Academic Honesty Policy",
"internet": "Your Internet Presence",
"portfolio": "Portfolio Settings",
"privacy": "Privacy Settings",
"privacy": "Privacy",
"personal-info": "Personal Information"
},
"danger": {
@@ -362,7 +363,7 @@
},
"email": {
"missing": "You do not have an email associated with this account.",
"heading": "Email Settings",
"heading": "Email",
"not-verified": "Your email has not been verified.",
"check": "Please check your email, or <0>request a new verification email here</0>.",
"current": "Current Email",

View File

@@ -0,0 +1,71 @@
.settings-container {
display: flex;
min-height: calc(100vh - var(--header-height));
}
.settings-main {
flex: 4;
padding: 1rem;
}
.settings-sidebar-nav ul {
list-style-type: none;
padding-left: 0;
}
.settings-sidebar-nav ul li {
margin-bottom: 0.5rem;
}
.settings-sidebar-nav {
flex: 1;
position: sticky;
top: var(--header-height);
padding: 1rem 0;
overflow-y: scroll;
height: calc(100vh - var(--header-height));
border-right: 3px solid var(--tertiary-background);
}
.settings-sidebar-nav .sidebar-nav-section-heading {
display: block;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
padding-left: 1rem;
text-decoration: none;
cursor: pointer;
}
.settings-sidebar-nav .sidebar-nav-anchor-btn {
display: inline-block;
width: 100%;
text-decoration: none;
cursor: pointer;
padding: 0 1rem 0rem 2rem;
}
.settings-sidebar-nav .sidebar-nav-section-heading.active,
.settings-sidebar-nav .sidebar-nav-anchor-btn.active {
box-shadow: inset 3px 0 0 0 var(--highlight-color);
background-color: var(--tertiary-background);
}
@media (max-width: 980px) {
.settings-sidebar-nav {
display: none;
}
.settings-container {
display: block;
}
}
.settings-user-token-heading,
.settings-danger-zone-heading,
.settings-exam-token-heading {
color: inherit;
font-size: inherit;
margin: 0;
text-align: center;
}

View File

@@ -3,22 +3,22 @@ import Helmet from 'react-helmet';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { scroller } from 'react-scroll';
import { Container, Spacer } from '@freecodecamp/ui';
import { Spacer } from '@freecodecamp/ui';
import store from 'store';
import { scroller, Element as ScrollElement } from 'react-scroll';
import envData from '../../config/env.json';
import { createFlashMessage } from '../components/Flash/redux';
import { Loader } from '../components/helpers';
import Certification from '../components/settings/certification';
import MiscSettings from '../components/settings/misc-settings';
import Account from '../components/settings/account';
import DangerZone from '../components/settings/danger-zone';
import Email from '../components/settings/email';
import Honesty from '../components/settings/honesty';
import Privacy from '../components/settings/privacy';
import UserToken from '../components/settings/user-token';
import ExamToken from '../components/settings/exam-token';
import SettingsSidebarNav from '../components/settings/settings-sidebar-nav';
import { hardGoTo as navigate } from '../redux/actions';
import {
signInLoadingSelector,
@@ -37,6 +37,8 @@ import {
resetMyEditorLayout
} from '../redux/settings/actions';
import './show-settings.css';
const { apiLocation } = envData;
// TODO: update types for actions
@@ -171,9 +173,11 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
return (
<>
<Helmet title={`${t('buttons.settings')} | freeCodeCamp.org`} />
<Container>
<main>
<div className='settings-container' id='settings-container'>
<SettingsSidebarNav userToken={userToken} />
<main className='settings-main'>
<Spacer size='l' />
<ScrollElement name='username'>
<h1
id='content-start'
className='text-center'
@@ -182,7 +186,10 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
>
{t('settings.for', { username: username })}
</h1>
<MiscSettings
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='account'>
<Account
keyboardShortcuts={keyboardShortcuts}
sound={sound}
editorLayout={editorLayout}
@@ -190,19 +197,30 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
toggleSoundMode={toggleSoundMode}
/>
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='privacy'>
<Privacy />
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='email'>
<Email
email={email}
isEmailVerified={isEmailVerified}
sendQuincyEmail={sendQuincyEmail}
updateQuincyEmail={updateQuincyEmail}
/>
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='honesty'>
<Honesty isHonest={isHonest} updateIsHonest={updateIsHonest} />
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='exam-token'>
<ExamToken email={email} />
</ScrollElement>
<Spacer size='m' />
<ScrollElement name='certifications'>
<Certification
completedChallenges={completedChallenges}
createFlashMessage={createFlashMessage}
@@ -233,16 +251,21 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
verifyCert={verifyCert}
isEmailVerified={isEmailVerified}
/>
<Spacer size='m' />
</ScrollElement>
{userToken && (
<>
<Spacer size='m' />
<ScrollElement name='user-token'>
<UserToken />
</ScrollElement>
<Spacer size='m' />
</>
)}
<Spacer size='m' />
<ScrollElement name='danger-zone'>
<DangerZone />
</ScrollElement>
</main>
</Container>
</div>
</>
);
}

View File

@@ -3,9 +3,10 @@ import { useTranslation } from 'react-i18next';
import { Button, Spacer } from '@freecodecamp/ui';
import { FullWidthRow } from '../helpers';
import SoundSettings from '../../components/settings/sound';
import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts';
import ScrollbarWidthSettings from '../../components/settings/scrollbar-width';
import SoundSettings from './sound';
import KeyboardShortcutsSettings from './keyboard-shortcuts';
import ScrollbarWidthSettings from './scrollbar-width';
import SectionHeader from './section-header';
type MiscSettingsProps = {
keyboardShortcuts: boolean;
@@ -27,8 +28,8 @@ const MiscSettings = ({
const { t } = useTranslation();
return (
<>
<Spacer size='m' />
<div className='account-settings'>
<SectionHeader>{t('settings.headings.account')}</SectionHeader>
<FullWidthRow>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
@@ -51,7 +52,7 @@ const MiscSettings = ({
{t('settings.reset-editor-layout')}
</Button>
</FullWidthRow>
</>
</div>
);
};

View File

@@ -155,20 +155,25 @@ const LegacyFullStack = (props: CertificationSettingsProps) => {
};
return (
<Element name={`cert-${certSlug}`}>
<FullWidthRow key={certSlug}>
<Spacer size='m' />
<h3 className='text-center'>
{t(`certification.title.${Certification.LegacyFullStack}-cert`)}
{t(`certification.title.${Certification.LegacyFullStack}`)}
</h3>
<div>
<p>
{t('settings.claim-legacy', {
cert: t(`certification.title.${Certification.LegacyFullStack}-cert`)
cert: t(
`certification.title.${Certification.LegacyFullStack}-cert`
)
})}
</p>
<ul>
<li>{t(`certification.title.${Certification.RespWebDesign}`)}</li>
<li>{t(`certification.title.${Certification.JsAlgoDataStruct}`)}</li>
<li>
{t(`certification.title.${Certification.JsAlgoDataStruct}`)}
</li>
<li>{t(`certification.title.${Certification.FrontEndDevLibs}`)}</li>
<li>{t(`certification.title.${Certification.DataVis}`)}</li>
<li>{t(`certification.title.${Certification.BackEndDevApis}`)}</li>
@@ -209,6 +214,7 @@ const LegacyFullStack = (props: CertificationSettingsProps) => {
</div>
<Spacer size='m' />
</FullWidthRow>
</Element>
);
};
@@ -385,7 +391,9 @@ function CertificationSettings(props: CertificationSettingsProps) {
<Certification key={cert} certSlug={cert} t={t} />
))}
<Spacer size='m' />
<Element name='legacy-certifications'>
<SectionHeader>{t('settings.headings.legacy-certs')}</SectionHeader>
</Element>
<LegacyFullStack {...props} />
{legacyCertifications.map(cert => (
<Certification key={cert} certSlug={cert} t={t} />

View File

@@ -43,7 +43,11 @@ function DangerZone({ deleteAccount, resetProgress, t }: DangerZoneProps) {
return (
<FullWidthRow className='text-center'>
<Panel variant='danger' id='danger-zone'>
<Panel.Heading>{t('settings.danger.heading')}</Panel.Heading>
<Panel.Heading>
<h2 className='settings-danger-zone-heading'>
{t('settings.danger.heading')}
</h2>
</Panel.Heading>
<Spacer size='m' />
<p>{t('settings.danger.be-careful')}</p>
<FullWidthRow>

View File

@@ -95,7 +95,11 @@ function ExamToken({ email }: ExamTokenProps) {
</Modal.Footer>
</Modal>
<Panel variant='info' id='exam-environment-authorization-token'>
<Panel.Heading>{t('exam-token.exam-token')}</Panel.Heading>
<Panel.Heading>
<h2 className='settings-exam-token-heading'>
{t('exam-token.exam-token')}
</h2>
</Panel.Heading>
<Panel.Body>
<p>{t('exam-token.note')}</p>
<strong>{t('exam-token.invalidation-2')}</strong>

View File

@@ -0,0 +1,232 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link as ScrollLink } from 'react-scroll';
import {
currentCertifications,
legacyCertifications,
legacyFullStackCertification,
upcomingCertifications
} from '../../../../shared-dist/config/certification-settings';
import env from '../../../config/env.json';
type SettingsSidebarNavProps = {
userToken: string | null;
};
const { showUpcomingChanges } = env;
function SettingsSidebarNav({
userToken
}: SettingsSidebarNavProps): JSX.Element {
const { t } = useTranslation();
const allLegacyCertifications = [
...legacyFullStackCertification,
...legacyCertifications
];
return (
<aside className='settings-sidebar-nav'>
<ul>
<li>
<ScrollLink
to='account'
href='#account'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.account')}
</ScrollLink>
</li>
<li>
<ScrollLink
to='privacy'
href='#privacy'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.privacy')}
</ScrollLink>
</li>
<li>
<ScrollLink
to='email'
href='#email'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.email.heading')}
</ScrollLink>
</li>
<li>
<ScrollLink
to='honesty'
href='#honesty'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.honesty')}
</ScrollLink>
</li>
<li>
<ScrollLink
to='exam-token'
href='#exam-token'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('exam-token.exam-token')}
</ScrollLink>
</li>
<li>
<ScrollLink
to='certifications'
href='#certifications'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.certs')}
</ScrollLink>
<ul>
{currentCertifications.map(slug => (
<li key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
))}
</ul>
</li>
<li>
<ScrollLink
to='legacy-certifications'
href='#legacy-certifications'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.legacy-certs')}
</ScrollLink>
<ul>
{allLegacyCertifications.map(slug => (
<li key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
))}
</ul>
<ul>
{showUpcomingChanges &&
upcomingCertifications.map(slug => (
<li key={slug}>
<ScrollLink
to={`cert-${slug}`}
href={`#cert-${slug}`}
className={'sidebar-nav-anchor-btn'}
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t(`certification.title.${slug}`, slug)}
</ScrollLink>
</li>
))}
</ul>
</li>
{userToken && (
<li>
<ScrollLink
to='user-token'
href='#user-token'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('user-token.title')}
</ScrollLink>
</li>
)}
<li>
<ScrollLink
to='danger-zone'
href='#danger-zone'
className='sidebar-nav-section-heading'
smooth={true}
offset={-48}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.danger.heading')}
</ScrollLink>
</li>
</ul>
</aside>
);
}
SettingsSidebarNav.displayName = 'SettingsSidebarNav';
export default SettingsSidebarNav;

View File

@@ -29,7 +29,11 @@ class UserToken extends Component<UserTokenProps> {
return (
<FullWidthRow>
<Panel variant='info' className='text-center'>
<Panel.Heading>{t('user-token.title')}</Panel.Heading>
<Panel.Heading>
<h2 className='settings-user-token-heading'>
{t('user-token.title')}
</h2>
</Panel.Heading>
<Spacer size='m' />
<Panel.Body>
<p>{t('user-token.delete-p1')}</p>

View File

@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/certified-user.json' });
test.beforeEach(async ({ page }) => {
// Set viewport to desktop size to ensure sideNav is visible
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto('/settings');
// Wait for the main heading to appear
await expect(
page.getByRole('heading', { level: 1, name: 'Settings for certifieduser' })
).toBeVisible();
});
test.describe('Settings SideNav Component', () => {
test('should display the settings sideNav with links to all main sections', async ({
page,
isMobile
}) => {
test.setTimeout(30000);
test.skip(isMobile, 'Sidebar is hidden on mobile');
const sideNav = page.getByRole('complementary');
const main = page.getByRole('main');
// Get all h2 and h3 heading in the main section
const h2Texts = await main
.getByRole('heading', { level: 2 })
.allTextContents();
const h3Texts = await main
.getByRole('heading', { level: 3 })
.allTextContents();
const headingTexts = [...h2Texts, ...h3Texts];
// Make sure the sideNav contains the same number of links as headings
const sideNavLinks = sideNav.getByRole('link');
await expect(sideNavLinks).toHaveCount(headingTexts.length);
// For each heading text, find the link by accessible name and test click behavior
for (const headingText of headingTexts) {
const link = sideNav.getByRole('link', {
name: headingText,
exact: true
});
await expect(link).toBeVisible();
// Get the href and assert the URL ends with it after click
const href = await link.getAttribute('href');
await link.click();
// Wait for scroll animation
// Playwright performs click very fast, which could lead to URL check before scroll ends
await page.waitForTimeout(300);
await expect(page).toHaveURL(new RegExp(href + '$'));
}
});
});

View File

@@ -232,7 +232,7 @@ test.describe('Settings - Certified User', () => {
}
// Danger Zone
await expect(page.getByText('Danger Zone')).toBeVisible();
await expect(page.getByRole('main').getByText('Danger Zone')).toBeVisible();
await expect(
page.getByText(
'Please be careful. Changes in this section are permanent.'

View File

@@ -18,7 +18,7 @@ test.describe('Initially', () => {
test('should not render', async ({ page }) => {
await page.goto('/settings');
await expect(
page.getByText('User Token', { exact: true })
page.getByRole('main').getByText('User Token', { exact: true })
).not.toBeVisible();
});
});
@@ -32,7 +32,9 @@ test.describe('After creating token', () => {
await page.goto('/settings');
// Set `exact` to `true` to only match the panel heading
await expect(page.getByText('User Token', { exact: true })).toBeVisible();
await expect(
page.getByRole('main').getByText('User Token', { exact: true })
).toBeVisible();
await expect(
page.getByText(
'Your user token is used to save your progress on curriculum sections that use a virtual machine. If you suspect it has been compromised, you can delete it without losing any progress. A new one will be created automatically the next time you open a project.'
@@ -42,7 +44,7 @@ test.describe('After creating token', () => {
await alertToBeVisible(page, translations.flash['token-deleted']);
await expect(
page.getByText('User Token', { exact: true })
page.getByRole('main').getByText('User Token', { exact: true })
).not.toBeVisible();
});
});