diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index a29f95eb6ad..70fb537928a 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", "current": "Current Email", diff --git a/client/src/client-only-routes/show-settings.css b/client/src/client-only-routes/show-settings.css new file mode 100644 index 00000000000..37f6d3f70ba --- /dev/null +++ b/client/src/client-only-routes/show-settings.css @@ -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; +} diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index ac563c692b7..e84bd0b798d 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -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,78 +173,99 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { return ( <> - -
+
+ +
-

- {t('settings.for', { username: username })} -

- + +

+ {t('settings.for', { username: username })} +

+
- + + + - + + + - + + + - - + + + + + + + + + + + + {userToken && ( <> + + + - )} - - + + +
- +
); } diff --git a/client/src/components/settings/misc-settings.tsx b/client/src/components/settings/account.tsx similarity index 82% rename from client/src/components/settings/misc-settings.tsx rename to client/src/components/settings/account.tsx index ca83fe77b5e..34cd6ca9a39 100644 --- a/client/src/components/settings/misc-settings.tsx +++ b/client/src/components/settings/account.tsx @@ -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 ( - <> - +
+ {t('settings.headings.account')} - +
); }; diff --git a/client/src/components/settings/certification.tsx b/client/src/components/settings/certification.tsx index f25cc723dd1..9a938f549fc 100644 --- a/client/src/components/settings/certification.tsx +++ b/client/src/components/settings/certification.tsx @@ -155,60 +155,66 @@ const LegacyFullStack = (props: CertificationSettingsProps) => { }; return ( - - -

- {t(`certification.title.${Certification.LegacyFullStack}-cert`)} -

-
-

- {t('settings.claim-legacy', { - cert: t(`certification.title.${Certification.LegacyFullStack}-cert`) - })} -

-
    -
  • {t(`certification.title.${Certification.RespWebDesign}`)}
  • -
  • {t(`certification.title.${Certification.JsAlgoDataStruct}`)}
  • -
  • {t(`certification.title.${Certification.FrontEndDevLibs}`)}
  • -
  • {t(`certification.title.${Certification.DataVis}`)}
  • -
  • {t(`certification.title.${Certification.BackEndDevApis}`)}
  • -
  • {t(`certification.title.${Certification.LegacyInfoSecQa}`)}
  • -
-
+ + + +

+ {t(`certification.title.${Certification.LegacyFullStack}`)} +

+
+

+ {t('settings.claim-legacy', { + cert: t( + `certification.title.${Certification.LegacyFullStack}-cert` + ) + })} +

+
    +
  • {t(`certification.title.${Certification.RespWebDesign}`)}
  • +
  • + {t(`certification.title.${Certification.JsAlgoDataStruct}`)} +
  • +
  • {t(`certification.title.${Certification.FrontEndDevLibs}`)}
  • +
  • {t(`certification.title.${Certification.DataVis}`)}
  • +
  • {t(`certification.title.${Certification.BackEndDevApis}`)}
  • +
  • {t(`certification.title.${Certification.LegacyInfoSecQa}`)}
  • +
+
-
- {isFullStackCert ? ( - - ) : ( - - )} -
- -
+
+ {isFullStackCert ? ( + + ) : ( + + )} +
+ +
+ ); }; @@ -385,7 +391,9 @@ function CertificationSettings(props: CertificationSettingsProps) { ))} - {t('settings.headings.legacy-certs')} + + {t('settings.headings.legacy-certs')} + {legacyCertifications.map(cert => ( diff --git a/client/src/components/settings/danger-zone.tsx b/client/src/components/settings/danger-zone.tsx index 55edc5386ec..4248bc6ca1a 100644 --- a/client/src/components/settings/danger-zone.tsx +++ b/client/src/components/settings/danger-zone.tsx @@ -43,7 +43,11 @@ function DangerZone({ deleteAccount, resetProgress, t }: DangerZoneProps) { return ( - {t('settings.danger.heading')} + +

+ {t('settings.danger.heading')} +

+

{t('settings.danger.be-careful')}

diff --git a/client/src/components/settings/exam-token.tsx b/client/src/components/settings/exam-token.tsx index a9cfee7ae7b..ca47575d6b1 100644 --- a/client/src/components/settings/exam-token.tsx +++ b/client/src/components/settings/exam-token.tsx @@ -95,7 +95,11 @@ function ExamToken({ email }: ExamTokenProps) { - {t('exam-token.exam-token')} + +

+ {t('exam-token.exam-token')} +

+

{t('exam-token.note')}

{t('exam-token.invalidation-2')} diff --git a/client/src/components/settings/settings-sidebar-nav.tsx b/client/src/components/settings/settings-sidebar-nav.tsx new file mode 100644 index 00000000000..68d326620b4 --- /dev/null +++ b/client/src/components/settings/settings-sidebar-nav.tsx @@ -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 ( + + ); +} + +SettingsSidebarNav.displayName = 'SettingsSidebarNav'; + +export default SettingsSidebarNav; diff --git a/client/src/components/settings/user-token.tsx b/client/src/components/settings/user-token.tsx index 0ef7a0da632..8973f82eee6 100644 --- a/client/src/components/settings/user-token.tsx +++ b/client/src/components/settings/user-token.tsx @@ -29,7 +29,11 @@ class UserToken extends Component { return ( - {t('user-token.title')} + +

+ {t('user-token.title')} +

+

{t('user-token.delete-p1')}

diff --git a/e2e/settings-sidenav.spec.ts b/e2e/settings-sidenav.spec.ts new file mode 100644 index 00000000000..a830a231990 --- /dev/null +++ b/e2e/settings-sidenav.spec.ts @@ -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 + '$')); + } + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 1b1e94bd9d9..9abcfe52502 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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.' diff --git a/e2e/user-token.spec.ts b/e2e/user-token.spec.ts index 6e7c03f9efe..ea1387fd7f8 100644 --- a/e2e/user-token.spec.ts +++ b/e2e/user-token.spec.ts @@ -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(); }); });