feat(client): update profile ui (#66889)

This commit is contained in:
Ahmad Abdolsaheb
2026-04-21 13:09:39 +03:00
committed by GitHub
parent a381d7f20f
commit 40b5550e96
29 changed files with 1605 additions and 291 deletions
@@ -339,6 +339,7 @@
"keyboard-shortcuts": "Enable Keyboard Shortcuts"
},
"headings": {
"personal": "Personal",
"account": "Account",
"certs": "Certifications",
"legacy-certs": "Legacy Certifications",
@@ -469,13 +470,16 @@
},
"completeness": {
"heading": "Profile {{percentage}}% complete",
"title": "Profile Completion",
"progress": "{{percentage}}% complete",
"name": "Add your name",
"location": "Add your location",
"picture": "Upload a profile picture",
"about": "Write an about section",
"social": "Add a social link",
"portfolio": "Add a portfolio project",
"experience": "Add your experience"
"experience": "Add your experience",
"privacy": "Make your profile public"
}
},
"footer": {
@@ -1027,6 +1031,10 @@
"rsa-checkbox": "I have tried the Read-Search-Ask method",
"similar-questions-checkbox": "I have searched for similar questions that have already been answered on the forum",
"edit-my-profile": "Edit my profile",
"add-portfolio": "Add portfolio project",
"edit-portfolio": "Edit portfolio project",
"add-experience": "Add experience",
"edit-experience": "Edit experience",
"editor-a11y-off-macos": "{{editorName}} editor content. Press Option+F1 for accessibility options.",
"editor-a11y-off-non-macos": "{{editorName}} editor content. Press Alt+F1 for accessibility options.",
"editor-a11y-on-macos": "{{editorName}} editor content. Accessibility mode set to 'on'. Press Command+E to disable or press Option+F1 for more options.",
@@ -1,5 +1,5 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { Provider } from 'react-redux';
import envData from '../../config/env.json';
@@ -84,4 +84,58 @@ describe('<ShowSettings />', () => {
const profileLink = container.querySelector(`a[href="/${testUsername}"]`);
expect(profileLink).toBeInTheDocument();
});
it('renders the Personal section with About form', () => {
const store = createStore({
app: {
...initialState,
user: {
sessionUser: {
username: testUsername,
email: 'test@example.com',
completedChallenges: []
}
},
userFetchState: { pending: false, complete: true, errored: false }
}
});
render(
<Provider store={store}>
<ShowSettings />
</Provider>
);
// The About component renders a heading using the sectionTitle prop
expect(
screen.getByRole('heading', { name: 'settings.headings.personal' })
).toBeInTheDocument();
});
it('renders a Personal link in the settings sidebar navigation', () => {
const store = createStore({
app: {
...initialState,
user: {
sessionUser: {
username: testUsername,
email: 'test@example.com',
completedChallenges: []
}
},
userFetchState: { pending: false, complete: true, errored: false }
}
});
const { container } = render(
<Provider store={store}>
<ShowSettings />
</Provider>
);
// SettingsSidebarNav renders a ScrollLink with href="#personal"
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const personalLink = container.querySelector('a[href="#personal"]');
expect(personalLink).toBeInTheDocument();
});
});
+21 -16
View File
@@ -4,12 +4,12 @@ import { Trans, useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Callout, 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 { FullWidthRow, Loader, Link } from '../components/helpers';
import { Loader } from '../components/helpers';
import Certification from '../components/settings/certification';
import Account from '../components/settings/account';
import DangerZone from '../components/settings/danger-zone';
@@ -19,6 +19,7 @@ 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 About from '../components/profile/components/about';
import { hardGoTo as navigate } from '../redux/actions';
import {
signInLoadingSelector,
@@ -183,15 +184,19 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
>
{t('settings.for', { username: username })}
</h1>
</ScrollElement>
<FullWidthRow>
<Callout variant='note' label={t('misc.note')}>
<Trans i18nKey='settings.profile-note'>
<Link to={`/${username}`} />
<a href={`/${username}`}>your profile</a>
</Trans>
</Callout>
</FullWidthRow>
<Spacer size='m' />
</ScrollElement>
<Spacer size='l' />
<ScrollElement name='personal'>
<About
user={user}
setIsEditing={() => {}}
sectionTitle={t('settings.headings.personal')}
/>
</ScrollElement>
<Spacer size='l' />
<ScrollElement name='account'>
<Account
keyboardShortcuts={keyboardShortcuts}
@@ -203,11 +208,11 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
socrates={socrates}
/>
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
<ScrollElement name='privacy'>
<Privacy />
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
<ScrollElement name='email'>
<Email
email={email}
@@ -216,15 +221,15 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
updateQuincyEmail={updateQuincyEmail}
/>
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
<ScrollElement name='honesty'>
<Honesty isHonest={isHonest} updateIsHonest={updateIsHonest} />
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
<ScrollElement name='exam-token'>
<ExamToken email={email} />
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
<ScrollElement name='certifications'>
<Certification
completedChallenges={completedChallenges}
@@ -264,14 +269,14 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
username={username}
verifyCert={verifyCert}
/>
<Spacer size='m' />
<Spacer size='l' />
</ScrollElement>
{userToken && (
<>
<ScrollElement name='user-token'>
<UserToken />
</ScrollElement>
<Spacer size='m' />
<Spacer size='l' />
</>
)}
<ScrollElement name='danger-zone'>
@@ -22,6 +22,7 @@ type AboutProps = {
t: TFunction;
submitNewAbout: (formValues: FormValues) => void;
setIsEditing: (isEditing: boolean) => void;
sectionTitle?: string;
};
type FormValues = {
@@ -53,7 +54,8 @@ const AboutSettings = ({
user,
t,
submitNewAbout,
setIsEditing
setIsEditing,
sectionTitle
}: AboutProps) => {
const { name = '', location = '', picture = '', about = '' } = user;
@@ -163,7 +165,9 @@ const AboutSettings = ({
return (
<>
<SectionHeader>{t('settings.headings.personal-info')}</SectionHeader>
<SectionHeader>
{sectionTitle ?? t('settings.headings.personal-info')}
</SectionHeader>
<FullWidthRow>
<form
id='camper-identity'
@@ -22,11 +22,24 @@ function Camper({
const {
isDonating,
yearsTopContributor,
profileUI: { showDonation }
profileUI: { showDonation, isLocked }
} = user;
const { t } = useTranslation();
const isTopContributor = yearsTopContributor.filter(Boolean).length > 0;
const hasBadges = isDonating || isTopContributor;
// Visible to non-session visitors (isLocked is already handled at the
// profile level — if locked, visitors never reach this component)
const sectionVisibleToVisitors =
(isDonating && showDonation) || isTopContributor;
const showBadgesSection = isSessionUser
? hasBadges
: sectionVisibleToVisitors;
const badgesSectionIsPrivate =
isSessionUser && hasBadges && (isLocked || !sectionVisibleToVisitors);
return (
<>
<div className='bio-container'>
@@ -37,12 +50,19 @@ function Camper({
isSessionUser={isSessionUser}
/>
</div>
{((isDonating && showDonation) || isTopContributor) && (
{showBadgesSection && (
<FullWidthRow>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.badges')}</h2>
{badgesSectionIsPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
</div>
<div className='badge-card-container'>
{isDonating && (
{isDonating && (showDonation || isSessionUser) && (
<div className='badge-card'>
<div className='camper-badge'>
<SupporterBadgeEmblem />
@@ -10,6 +10,7 @@ import './certifications.css';
interface CertificationProps {
user: User;
isPrivate?: boolean;
}
interface CertButtonProps {
@@ -35,7 +36,7 @@ function CertButton({ username, cert }: CertButtonProps): JSX.Element {
);
}
function Certificates({ user }: CertificationProps): JSX.Element {
function Certificates({ user, isPrivate }: CertificationProps): JSX.Element {
const { username } = user;
const { currentCerts, legacyCerts, hasLegacyCert, hasModernCert } =
@@ -45,7 +46,14 @@ function Certificates({ user }: CertificationProps): JSX.Element {
return (
<FullWidthRow className='profile-certifications'>
<section className='card'>
<div className='profile-section-heading'>
<h2 id='fcc-certifications'>{t('profile.fcc-certs')}</h2>
{isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
</div>
<br />
{hasModernCert && currentCerts ? (
<ul aria-labelledby='fcc-certifications'>
@@ -1,12 +1,23 @@
.experience-item-wrapper {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.experience-item-wrapper .experience-item {
flex: 1;
min-width: 0;
}
.experience-company {
font-weight: normal;
margin-top: 0.5rem;
margin-bottom: 0rem;
}
.experience-date {
color: #858591;
font-size: 0.9rem;
margin-top: 0.25rem;
margin-bottom: 0rem;
}
.experience-description {
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { ExperienceDisplay } from './experience-display';
// Mock only Modal from @freecodecamp/ui to avoid ResizeObserver dependency
vi.mock('@freecodecamp/ui', async () => {
const actual =
await vi.importActual<Record<string, unknown>>('@freecodecamp/ui');
const MockModal = Object.assign(
({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid='modal-container'>{children}</div> : null,
{
Header: ({ children }: { children: React.ReactNode }) => (
<div data-testid='modal-header'>{children}</div>
),
Body: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
)
}
);
return { ...actual, Modal: MockModal };
});
// Capture the onSave callback passed from ExperienceDisplay to Experience
let capturedOnSave: (() => void) | undefined;
vi.mock('./experience', () => ({
default: ({ onSave }: { onSave?: () => void }) => {
capturedOnSave = onSave;
return <div data-testid='experience-form'>Experience Form</div>;
}
}));
const sampleExperience = [
{
id: 'exp-1',
title: 'Software Engineer',
company: 'Tech Corp',
location: 'San Francisco',
startDate: '01/2020',
endDate: '12/2022',
description: 'Built cool things'
},
{
id: 'exp-2',
title: 'Senior Engineer',
company: 'Startup Inc',
location: '',
startDate: '01/2023',
endDate: '',
description: 'Leading projects'
}
];
describe('<ExperienceDisplay />', () => {
beforeEach(() => {
capturedOnSave = undefined;
});
it('returns null for non-session users with no experience', () => {
const { container } = render(
<ExperienceDisplay experience={[]} isSessionUser={false} />
);
expect(container).toBeEmptyDOMElement();
});
it('renders the experience section for session users even with no experience', () => {
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
expect(screen.getByText('profile.experience.heading')).toBeInTheDocument();
});
it('renders experience items for visitors', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={false} />
);
expect(screen.getByText('Software Engineer')).toBeInTheDocument();
expect(screen.getByText(/Tech Corp/)).toBeInTheDocument();
});
it('renders multiple experience items', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={false} />
);
expect(screen.getByText('Software Engineer')).toBeInTheDocument();
expect(screen.getByText('Senior Engineer')).toBeInTheDocument();
});
it('shows "present" for experiences without an end date', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={true} />
);
expect(
screen.getByText(/profile\.experience\.present/)
).toBeInTheDocument();
});
it('shows add button only for session users', () => {
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
expect(screen.getByLabelText('aria.add-experience')).toBeInTheDocument();
});
it('does not show add button for non-session users', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={false} />
);
expect(
screen.queryByLabelText('aria.add-experience')
).not.toBeInTheDocument();
});
it('shows edit button for each experience item for session users', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={true} />
);
const editButtons = screen.getAllByLabelText('aria.edit-experience');
expect(editButtons).toHaveLength(sampleExperience.length);
});
it('does not show edit buttons for non-session users', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={false} />
);
expect(
screen.queryByLabelText('aria.edit-experience')
).not.toBeInTheDocument();
});
it('does not render the experience form by default (modal closed)', () => {
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
expect(screen.queryByTestId('experience-form')).not.toBeInTheDocument();
});
it('opens the modal when the add button is clicked', async () => {
const user = userEvent.setup();
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-experience'));
expect(screen.getByTestId('experience-form')).toBeInTheDocument();
});
it('shows add heading in the modal when adding a new experience', async () => {
const user = userEvent.setup();
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-experience'));
expect(screen.getByTestId('modal-header')).toHaveTextContent(
'aria.add-experience'
);
});
it('opens the modal when an edit button is clicked', async () => {
const user = userEvent.setup();
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={true} />
);
await user.click(screen.getAllByLabelText('aria.edit-experience')[0]);
expect(screen.getByTestId('experience-form')).toBeInTheDocument();
});
it('shows edit heading in the modal when editing an experience', async () => {
const user = userEvent.setup();
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={true} />
);
await user.click(screen.getAllByLabelText('aria.edit-experience')[0]);
expect(screen.getByTestId('modal-header')).toHaveTextContent(
'aria.edit-experience'
);
});
it('passes onSave callback to Experience and closes modal when called', async () => {
const user = userEvent.setup();
render(<ExperienceDisplay experience={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-experience'));
expect(screen.getByTestId('experience-form')).toBeInTheDocument();
expect(capturedOnSave).toBeDefined();
act(() => {
capturedOnSave?.();
});
expect(screen.queryByTestId('experience-form')).not.toBeInTheDocument();
});
it('shows private badge when experience is private', () => {
render(
<ExperienceDisplay
experience={sampleExperience}
isPrivate={true}
isSessionUser={true}
/>
);
expect(screen.getByText('buttons.private')).toBeInTheDocument();
});
it('does not show private badge when experience is not private', () => {
render(
<ExperienceDisplay
experience={sampleExperience}
isPrivate={false}
isSessionUser={true}
/>
);
expect(screen.queryByText('buttons.private')).not.toBeInTheDocument();
});
it('formats start and end dates for display', () => {
render(
<ExperienceDisplay experience={sampleExperience} isSessionUser={false} />
);
// startDate '01/2020' → 'Jan 2020', endDate '12/2022' → 'Dec 2022'
expect(screen.getByText(/Jan 2020/)).toBeInTheDocument();
expect(screen.getByText(/Dec 2022/)).toBeInTheDocument();
});
});
@@ -1,13 +1,18 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faPen } from '@fortawesome/free-solid-svg-icons';
import { parse, format, isValid } from 'date-fns';
import type { ExperienceData } from '../../../redux/prop-types';
import { FullWidthRow, interleave } from '../../helpers';
import Experience from './experience';
import './experience-display.css';
interface ExperienceDisplayProps {
experience: ExperienceData[];
isPrivate?: boolean;
isSessionUser?: boolean;
}
const formatDate = (dateString: string): string => {
@@ -18,16 +23,37 @@ const formatDate = (dateString: string): string => {
};
export const ExperienceDisplay = ({
experience
experience,
isPrivate,
isSessionUser
}: ExperienceDisplayProps): JSX.Element | null => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
const [autoAdd, setAutoAdd] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [modalKey, setModalKey] = useState(0);
if (!experience.length) {
if (!experience.length && !isSessionUser) {
return null;
}
const openAddModal = () => {
setAutoAdd(true);
setEditingId(null);
setModalKey(k => k + 1);
setIsModalOpen(true);
};
const openEditModal = (id: string) => {
setAutoAdd(false);
setEditingId(id);
setModalKey(k => k + 1);
setIsModalOpen(true);
};
const experienceItems = experience.map(exp => (
<div key={exp.id} className='experience-item'>
<div key={exp.id} className='experience-item-wrapper'>
<div className='experience-item'>
<h3>{exp.title}</h3>
<h4 className='experience-company'>
{exp.company}
@@ -44,16 +70,69 @@ export const ExperienceDisplay = ({
<p className='experience-description'>{exp.description}</p>
)}
</div>
{isSessionUser && (
<Button
size='small'
className='button-fit'
onClick={() => openEditModal(exp.id)}
aria-label={t('aria.edit-experience')}
type='button'
>
<FontAwesomeIcon icon={faPen} />
</Button>
)}
</div>
));
return (
<FullWidthRow>
<Modal
onClose={() => setIsModalOpen(false)}
open={isModalOpen}
size='large'
>
<Modal.Header>
{autoAdd ? t('aria.add-experience') : t('aria.edit-experience')}
</Modal.Header>
<Modal.Body alignment='left'>
<Experience
key={modalKey}
experience={experience}
autoAdd={autoAdd}
editItemId={editingId}
onSave={() => setIsModalOpen(false)}
/>
</Modal.Body>
</Modal>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.experience.heading')}</h2>
{isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
{isSessionUser && (
<Button
size='small'
className='button-fit'
onClick={openAddModal}
aria-label={t('aria.add-experience')}
type='button'
style={{ marginLeft: 'auto' }}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
)}
</div>
{experience.length > 0 && (
<>
<Spacer size='s' />
{interleave(experienceItems, index => (
<hr key={`separator-${index}`} />
))}
</>
)}
<Spacer size='m' />
</section>
</FullWidthRow>
@@ -22,7 +22,10 @@ import BlockSaveButton from '../../helpers/form/block-save-button';
import SectionHeader from '../../settings/section-header';
type ExperienceProps = {
autoAdd?: boolean;
editItemId?: string | null;
experience: ExperienceData[];
onSave?: () => void;
t: TFunction;
updateMyExperience: (obj: { experience: ExperienceData[] }) => void;
};
@@ -96,9 +99,29 @@ const byId = (id: string) => (exp: ExperienceData) => exp.id === id;
const notById = (id: string) => (exp: ExperienceData) => exp.id !== id;
const ExperienceSettings = (props: ExperienceProps) => {
const { t, experience: initialExperience = [], updateMyExperience } = props;
const [experience, setExperience] = useState(initialExperience);
const [newItemId, setNewItemId] = useState<string | null>(null);
const {
t,
experience: initialExperience = [],
updateMyExperience,
autoAdd,
editItemId
} = props;
const isSingleItemMode = autoAdd || editItemId != null;
const getInitialState = () => {
if (autoAdd) {
const newItem = createEmptyExperienceItem();
return {
experience: [newItem, ...initialExperience],
newItemId: newItem.id
};
}
return { experience: initialExperience, newItemId: null };
};
const initial = getInitialState();
const [experience, setExperience] = useState(initial.experience);
const [newItemId, setNewItemId] = useState<string | null>(initial.newItemId);
const createOnChangeHandler =
(
@@ -134,6 +157,7 @@ const ExperienceSettings = (props: ExperienceProps) => {
? props.experience.map(item => (byId(id)(item) ? itemToSave : item))
: [itemToSave, ...props.experience];
updateMyExperience({ experience: updatedExperience });
props.onSave?.();
}
};
@@ -150,6 +174,7 @@ const ExperienceSettings = (props: ExperienceProps) => {
}
const filteredExperience = props.experience.filter(notById(id));
updateMyExperience({ experience: filteredExperience });
props.onSave?.();
};
const isFormPristine = (id: string) => {
@@ -446,8 +471,16 @@ const ExperienceSettings = (props: ExperienceProps) => {
);
};
const itemsToRender = autoAdd
? experience.filter(item => item.id === newItemId)
: editItemId != null
? experience.filter(item => item.id === editItemId)
: experience;
return (
<section id='experience-settings'>
{!isSingleItemMode && (
<>
<SectionHeader>{t('profile.experience.heading')}</SectionHeader>
<FullWidthRow>
<p>{t('profile.experience.share-experience')}</p>
@@ -464,7 +497,9 @@ const ExperienceSettings = (props: ExperienceProps) => {
</Button>
</FullWidthRow>
<Spacer size='l' />
{interleave(experience.map(renderExperience), () => (
</>
)}
{interleave(itemsToRender.map(renderExperience), () => (
<>
<Spacer size='m' />
<hr />
@@ -23,6 +23,7 @@ const localeCode = getLangCode(clientLocale);
interface HeatMapProps {
calendar: User['calendar'];
isPrivate?: boolean;
}
interface PageData {
@@ -37,6 +38,7 @@ interface CalendarData {
interface HeatMapInnerProps {
calendarData: CalendarData[];
isPrivate?: boolean;
pages: PageData[];
points?: number;
t: TFunction;
@@ -93,7 +95,14 @@ class HeatMapInner extends Component<HeatMapInnerProps, HeatMapInnerState> {
return (
<FullWidthRow>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.activity')}</h2>
{this.props.isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
</div>
<Spacer size='m' />
<CalendarHeatMap
@@ -170,7 +179,7 @@ class HeatMapInner extends Component<HeatMapInnerProps, HeatMapInnerState> {
const HeatMap = (props: HeatMapProps): JSX.Element => {
const { t } = useTranslation();
const { calendar } = props;
const { calendar, isPrivate } = props;
/**
* the following logic creates the data for the heatmap
@@ -230,7 +239,14 @@ const HeatMap = (props: HeatMapProps): JSX.Element => {
}
});
return <HeatMapInner calendarData={calendarData} pages={pages} t={t} />;
return (
<HeatMapInner
calendarData={calendarData}
isPrivate={isPrivate}
pages={pages}
t={t}
/>
);
};
HeatMap.displayName = 'HeatMap';
@@ -1,3 +1,14 @@
.portfolio-item-wrapper {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.portfolio-item-wrapper .portfolio-card {
flex: 1;
min-width: 0;
}
/* Base styles for mobile devices */
.portfolio-card {
display: flex;
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { PortfolioProjects } from './portfolio-projects';
// Mock only Modal from @freecodecamp/ui to avoid ResizeObserver dependency
vi.mock('@freecodecamp/ui', async () => {
const actual =
await vi.importActual<Record<string, unknown>>('@freecodecamp/ui');
const MockModal = Object.assign(
({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-testid='modal-container'>{children}</div> : null,
{
Header: ({ children }: { children: React.ReactNode }) => (
<div data-testid='modal-header'>{children}</div>
),
Body: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
)
}
);
return { ...actual, Modal: MockModal };
});
// Capture the onSave callback passed from PortfolioProjects to Portfolio
let capturedOnSave: (() => void) | undefined;
vi.mock('./portfolio', () => ({
default: ({ onSave }: { onSave?: () => void }) => {
capturedOnSave = onSave;
return <div data-testid='portfolio-form'>Portfolio Form</div>;
}
}));
const samplePortfolio = [
{
id: 'proj-1',
title: 'My Website',
url: 'https://example.com',
image: '',
description: 'A personal website'
},
{
id: 'proj-2',
title: 'Open Source Tool',
url: 'https://github.com/user/tool',
image: '',
description: 'A useful tool'
}
];
describe('<PortfolioProjects />', () => {
beforeEach(() => {
capturedOnSave = undefined;
});
it('returns null for non-session users with no portfolio projects', () => {
const { container } = render(
<PortfolioProjects portfolioProjects={[]} isSessionUser={false} />
);
expect(container).toBeEmptyDOMElement();
});
it('renders the section for session users even with no portfolio projects', () => {
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
expect(screen.getByText('profile.projects')).toBeInTheDocument();
});
it('renders portfolio project titles for visitors', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={false}
/>
);
expect(screen.getByText('My Website')).toBeInTheDocument();
expect(screen.getByText('Open Source Tool')).toBeInTheDocument();
});
it('renders portfolio project descriptions', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={false}
/>
);
expect(screen.getByText('A personal website')).toBeInTheDocument();
});
it('shows add button only for session users', () => {
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
expect(screen.getByLabelText('aria.add-portfolio')).toBeInTheDocument();
});
it('does not show add button for non-session users', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={false}
/>
);
expect(
screen.queryByLabelText('aria.add-portfolio')
).not.toBeInTheDocument();
});
it('shows edit button for each portfolio item for session users', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={true}
/>
);
const editButtons = screen.getAllByLabelText('aria.edit-portfolio');
expect(editButtons).toHaveLength(samplePortfolio.length);
});
it('does not show edit buttons for non-session users', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={false}
/>
);
expect(
screen.queryByLabelText('aria.edit-portfolio')
).not.toBeInTheDocument();
});
it('does not render the portfolio form by default (modal closed)', () => {
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
expect(screen.queryByTestId('portfolio-form')).not.toBeInTheDocument();
});
it('opens the modal when the add button is clicked', async () => {
const user = userEvent.setup();
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-portfolio'));
expect(screen.getByTestId('portfolio-form')).toBeInTheDocument();
});
it('shows add heading in the modal when adding a new portfolio project', async () => {
const user = userEvent.setup();
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-portfolio'));
expect(screen.getByTestId('modal-header')).toHaveTextContent(
'aria.add-portfolio'
);
});
it('opens the modal when an edit button is clicked', async () => {
const user = userEvent.setup();
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={true}
/>
);
await user.click(screen.getAllByLabelText('aria.edit-portfolio')[0]);
expect(screen.getByTestId('portfolio-form')).toBeInTheDocument();
});
it('shows edit heading in the modal when editing a portfolio project', async () => {
const user = userEvent.setup();
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={true}
/>
);
await user.click(screen.getAllByLabelText('aria.edit-portfolio')[0]);
expect(screen.getByTestId('modal-header')).toHaveTextContent(
'aria.edit-portfolio'
);
});
it('passes onSave callback to Portfolio and closes modal when called', async () => {
const user = userEvent.setup();
render(<PortfolioProjects portfolioProjects={[]} isSessionUser={true} />);
await user.click(screen.getByLabelText('aria.add-portfolio'));
expect(screen.getByTestId('portfolio-form')).toBeInTheDocument();
expect(capturedOnSave).toBeDefined();
act(() => {
capturedOnSave?.();
});
expect(screen.queryByTestId('portfolio-form')).not.toBeInTheDocument();
});
it('shows private badge when portfolio is private', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isPrivate={true}
isSessionUser={true}
/>
);
expect(screen.getByText('buttons.private')).toBeInTheDocument();
});
it('does not show private badge when portfolio is not private', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isPrivate={false}
isSessionUser={true}
/>
);
expect(screen.queryByText('buttons.private')).not.toBeInTheDocument();
});
it('renders project links with correct href', () => {
render(
<PortfolioProjects
portfolioProjects={samplePortfolio}
isSessionUser={false}
/>
);
const link = screen.getByRole('link', { name: /My Website/ });
expect(link).toHaveAttribute('href', 'https://example.com');
});
});
@@ -1,29 +1,96 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui';
import { Button, Modal, Spacer } from '@freecodecamp/ui';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faPen } from '@fortawesome/free-solid-svg-icons';
import type { PortfolioProjectData } from '../../../redux/prop-types';
import Portfolio from './portfolio';
import './portfolio-projects.css';
import { FullWidthRow } from '../../helpers';
interface PortfolioProjectsProps {
portfolioProjects: PortfolioProjectData[];
isPrivate?: boolean;
isSessionUser?: boolean;
}
export const PortfolioProjects = ({
portfolioProjects
portfolioProjects,
isPrivate,
isSessionUser
}: PortfolioProjectsProps): JSX.Element | null => {
const { t } = useTranslation();
if (!portfolioProjects.length) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [autoAdd, setAutoAdd] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [modalKey, setModalKey] = useState(0);
if (!portfolioProjects.length && !isSessionUser) {
return null;
}
const openAddModal = () => {
setAutoAdd(true);
setEditingId(null);
setModalKey(k => k + 1);
setIsModalOpen(true);
};
const openEditModal = (id: string) => {
setAutoAdd(false);
setEditingId(id);
setModalKey(k => k + 1);
setIsModalOpen(true);
};
return (
<FullWidthRow>
<Modal
onClose={() => setIsModalOpen(false)}
open={isModalOpen}
size='large'
>
<Modal.Header>
{autoAdd ? t('aria.add-portfolio') : t('aria.edit-portfolio')}
</Modal.Header>
<Modal.Body alignment='left'>
<Portfolio
key={modalKey}
portfolio={portfolioProjects}
autoAdd={autoAdd}
editItemId={editingId}
onSave={() => setIsModalOpen(false)}
/>
</Modal.Body>
</Modal>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.projects')}</h2>
{isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
{isSessionUser && (
<Button
size='small'
className='button-fit'
onClick={openAddModal}
aria-label={t('aria.add-portfolio')}
type='button'
style={{ marginLeft: 'auto' }}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
)}
</div>
{portfolioProjects.length > 0 && (
<>
<Spacer size='s' />
{portfolioProjects.map(
({ title, url, image, description, id }, index) => (
<React.Fragment key={id}>
<div className='portfolio-item-wrapper'>
<a
href={url}
rel='nofollow noopener noreferrer'
@@ -53,10 +120,24 @@ export const PortfolioProjects = ({
</div>
</div>
</a>
{isSessionUser && (
<Button
size='small'
className='button-fit'
onClick={() => openEditModal(id)}
aria-label={t('aria.edit-portfolio')}
type='button'
>
<FontAwesomeIcon icon={faPen} />
</Button>
)}
</div>
{index < portfolioProjects.length - 1 && <hr />}
</React.Fragment>
)
)}
</>
)}
<Spacer size='m' />
</section>
</FullWidthRow>
@@ -24,6 +24,9 @@ import SectionHeader from '../../settings/section-header';
import { updateMyPortfolio } from '../../../redux/settings/actions';
type PortfolioProps = {
autoAdd?: boolean;
editItemId?: string | null;
onSave?: () => void;
portfolio: PortfolioProjectData[];
t: TFunction;
updateMyPortfolio: (obj: { portfolio: PortfolioProjectData[] }) => void;
@@ -54,9 +57,31 @@ const byId = (id: string) => (p: PortfolioProjectData) => p.id === id;
const notById = (id: string) => (p: PortfolioProjectData) => p.id !== id;
const PortfolioSettings = (props: PortfolioProps) => {
const { t, portfolio: initialPortfolio = [], updateMyPortfolio } = props;
const [portfolio, setPortfolio] = useState(initialPortfolio);
const [unsavedItemId, setUnsavedItemId] = useState<string | null>(null);
const {
t,
portfolio: initialPortfolio = [],
updateMyPortfolio,
autoAdd,
editItemId
} = props;
const isSingleItemMode = autoAdd || editItemId != null;
const getInitialState = () => {
if (autoAdd) {
const newItem = createEmptyPortfolioItem();
return {
portfolio: [newItem, ...initialPortfolio],
unsavedItemId: newItem.id
};
}
return { portfolio: initialPortfolio, unsavedItemId: null };
};
const initial = getInitialState();
const [portfolio, setPortfolio] = useState(initial.portfolio);
const [unsavedItemId, setUnsavedItemId] = useState<string | null>(
initial.unsavedItemId
);
const [imageValidation, setImageValid] = useState<ProfileValidation>({
state: 'success',
message: ''
@@ -103,6 +128,7 @@ const PortfolioSettings = (props: PortfolioProps) => {
? props.portfolio.map(item => (byId(id)(item) ? itemToSave : item))
: [itemToSave, ...props.portfolio];
updateMyPortfolio({ portfolio: updatedPortfolio });
props.onSave?.();
}
};
@@ -118,6 +144,7 @@ const PortfolioSettings = (props: PortfolioProps) => {
setUnsavedItemId(null);
}
updateMyPortfolio({ portfolio: props.portfolio.filter(notById(id)) });
props.onSave?.();
};
const isFormPristine = (id: string) => {
@@ -344,8 +371,16 @@ const PortfolioSettings = (props: PortfolioProps) => {
);
};
const itemsToRender = autoAdd
? portfolio.filter(item => item.id === unsavedItemId)
: editItemId != null
? portfolio.filter(item => item.id === editItemId)
: portfolio;
return (
<section id='portfolio-settings'>
{!isSingleItemMode && (
<>
<SectionHeader>{t('settings.headings.portfolio')}</SectionHeader>
<FullWidthRow>
<p>{t('settings.share-projects')}</p>
@@ -362,7 +397,9 @@ const PortfolioSettings = (props: PortfolioProps) => {
</Button>
</FullWidthRow>
<Spacer size='l' />
{interleave(portfolio.map(renderPortfolio), () => (
</>
)}
{interleave(itemsToRender.map(renderPortfolio), () => (
<>
<Spacer size='m' />
<hr />
@@ -2,13 +2,13 @@
padding: 1em;
background: var(--primary-background);
border: 1px solid var(--quaternary-background);
margin-bottom: 1.5em;
margin-bottom: 1em;
}
.profile-completeness-header {
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
width: 100%;
background: none;
border: none;
@@ -23,29 +23,25 @@
color: var(--primary-color);
}
.profile-completeness-bar-container {
.profile-completeness-title {
flex: 1;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.profile-completeness-bar-fill {
height: 100%;
background-color: var(--blue-mid);
border-radius: 4px;
transition: width 0.3s ease;
}
.profile-completeness-text {
.profile-completeness-percentage {
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
font-size: 0.875rem;
}
.profile-completeness-chevron {
display: inline-flex;
align-items: center;
transition: transform 0.2s ease;
transform: rotate(180deg);
flex-shrink: 0;
}
.profile-completeness-chevron svg {
@@ -54,12 +50,44 @@
}
.profile-completeness-chevron.expanded {
transform: rotate(180deg);
transform: rotate(0deg);
}
.profile-completeness-expanded {
margin-top: 1rem;
}
.profile-completeness-bar-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.profile-completeness-bar-container {
flex: 1;
height: 8px;
border-radius: 0;
overflow: hidden;
background: var(--quaternary-background);
}
.profile-completeness-bar-fill {
height: 100%;
background-color: var(--blue-mid);
border-radius: 0;
transition: width 0.3s ease;
}
.profile-completeness-progress-text {
font-size: 0.875rem;
white-space: nowrap;
flex-shrink: 0;
}
.profile-completeness-checklist {
list-style: none;
margin: 1rem 0 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
@@ -19,7 +19,8 @@ const baseProps = {
bluesky: '',
website: '',
portfolio: [],
experience: []
experience: [],
isLocked: false
};
describe('hasValue', () => {
@@ -119,9 +120,9 @@ describe('<ProfileCompleteness />', () => {
it('should display all checklist items when expanded', () => {
render(<ProfileCompleteness {...baseProps} />);
// Should show all 7 items
// Should show all 8 items (name, location, picture, about, social, portfolio, experience, privacy)
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(7);
expect(listItems).toHaveLength(8);
});
it('should start expanded when core items (name, picture, about) are incomplete', () => {
@@ -159,7 +160,7 @@ describe('<ProfileCompleteness />', () => {
});
it('should calculate weighted percentage correctly', () => {
// Name (20) + About (20) = 40% complete
// Name (20) + About (20) + Privacy (10, isLocked=false) = 50/110 = 45% complete
const propsWithNameAndAbout = {
...baseProps,
name: 'John Doe',
@@ -167,9 +168,9 @@ describe('<ProfileCompleteness />', () => {
};
render(<ProfileCompleteness {...propsWithNameAndAbout} />);
// Check the progress bar width reflects 40%
// Check the progress bar width reflects 45%
const progressBar = screen.getByTestId('profile-completeness-progress');
expect(progressBar).toHaveStyle({ width: '40%' });
expect(progressBar).toHaveStyle({ width: '45%' });
});
it('should mark social as complete if any social link is provided', () => {
@@ -179,9 +180,9 @@ describe('<ProfileCompleteness />', () => {
};
render(<ProfileCompleteness {...propsWithGithub} />);
// Social (10%) should be complete - check progress bar
// Social (10) + Privacy (10, isLocked=false) = 20/110 = 18%
const progressBar = screen.getByTestId('profile-completeness-progress');
expect(progressBar).toHaveStyle({ width: '10%' });
expect(progressBar).toHaveStyle({ width: '18%' });
});
it('should mark social as complete with linkedin only', () => {
@@ -191,8 +192,9 @@ describe('<ProfileCompleteness />', () => {
};
render(<ProfileCompleteness {...propsWithLinkedin} />);
// Social (10) + Privacy (10, isLocked=false) = 20/110 = 18%
const progressBar = screen.getByTestId('profile-completeness-progress');
expect(progressBar).toHaveStyle({ width: '10%' });
expect(progressBar).toHaveStyle({ width: '18%' });
});
it('should mark social as complete with website only', () => {
@@ -202,8 +204,9 @@ describe('<ProfileCompleteness />', () => {
};
render(<ProfileCompleteness {...propsWithWebsite} />);
// Social (10) + Privacy (10, isLocked=false) = 20/110 = 18%
const progressBar = screen.getByTestId('profile-completeness-progress');
expect(progressBar).toHaveStyle({ width: '10%' });
expect(progressBar).toHaveStyle({ width: '18%' });
});
it('should show correct icons for complete and incomplete items', () => {
@@ -213,8 +216,8 @@ describe('<ProfileCompleteness />', () => {
};
render(<ProfileCompleteness {...propsWithName} />);
// Should have 1 green-pass (name complete) and 6 green-not-completed
expect(screen.getAllByTestId('green-pass')).toHaveLength(1);
// name + privacy (isLocked=false) are complete → 2 green-pass, 6 green-not-completed
expect(screen.getAllByTestId('green-pass')).toHaveLength(2);
expect(screen.getAllByTestId('green-not-completed')).toHaveLength(6);
});
});
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui';
import isURL from 'validator/lib/isURL';
import type {
PortfolioProjectData,
@@ -24,6 +23,7 @@ interface ProfileCompletenessProps {
website: string;
portfolio: PortfolioProjectData[];
experience: ExperienceData[];
isLocked: boolean;
}
interface CompletionItem {
@@ -56,15 +56,11 @@ export const ProfileCompleteness = ({
bluesky,
website,
portfolio,
experience
experience,
isLocked
}: ProfileCompletenessProps): JSX.Element | null => {
const { t } = useTranslation();
// Collapse by default if core profile items (name, picture, about) are complete
const coreItemsComplete =
hasValue(name) && isValidPicture(picture) && hasValue(about);
const [isExpanded, setIsExpanded] = useState(!coreItemsComplete);
const completionItems: CompletionItem[] = [
{
key: 'name',
@@ -112,6 +108,12 @@ export const ProfileCompleteness = ({
translationKey: 'profile.completeness.experience',
isComplete: experience.length > 0,
weight: 10
},
{
key: 'privacy',
translationKey: 'profile.completeness.privacy',
isComplete: !isLocked,
weight: 10
}
];
@@ -124,6 +126,10 @@ export const ProfileCompleteness = ({
.reduce((sum, item) => sum + item.weight, 0);
const percentage = Math.round((completedWeight / totalWeight) * 100);
const coreComplete =
hasValue(name) && isValidPicture(picture) && hasValue(about);
const [isExpanded, setIsExpanded] = useState(!coreComplete);
// Don't render if profile is complete
if (percentage === 100) {
return null;
@@ -140,12 +146,24 @@ export const ProfileCompleteness = ({
aria-expanded={isExpanded}
type='button'
>
<h2 className='profile-completeness-title'>
{t('profile.completeness.title')}
</h2>
{!isExpanded && (
<span className='profile-completeness-percentage'>
{percentage}%
</span>
)}
<span
className={`profile-completeness-chevron ${isExpanded ? 'expanded' : ''}`}
aria-hidden='true'
>
<DropDown />
</span>
</button>
{isExpanded && (
<div className='profile-completeness-expanded'>
<div className='profile-completeness-bar-row'>
<div className='profile-completeness-bar-container'>
<div
className='profile-completeness-bar-fill'
@@ -153,11 +171,10 @@ export const ProfileCompleteness = ({
style={{ width: `${percentage}%` }}
/>
</div>
<span className='profile-completeness-text'>
{t('profile.completeness.heading', { percentage })}
<span className='profile-completeness-progress-text'>
{t('profile.completeness.progress', { percentage })}
</span>
</button>
{isExpanded && (
</div>
<ul className='profile-completeness-checklist'>
{completionItems.map(item => (
<li
@@ -179,9 +196,9 @@ export const ProfileCompleteness = ({
</li>
))}
</ul>
</div>
)}
</div>
<Spacer size='m' />
</FullWidthRow>
);
};
@@ -0,0 +1,51 @@
.profile-privacy {
padding: 1em;
background: var(--primary-background);
border: 1px solid var(--quaternary-background);
margin-bottom: 1.5em;
}
.profile-privacy-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--primary-color);
text-align: left;
}
.profile-privacy-header:hover {
background: none;
color: var(--primary-color);
}
.profile-privacy-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.profile-privacy-chevron {
display: inline-flex;
align-items: center;
transition: transform 0.2s ease;
flex-shrink: 0;
transform: rotate(180deg);
}
.profile-privacy-chevron svg {
width: 15px;
height: 15px;
}
.profile-privacy-chevron.expanded {
transform: rotate(0deg);
}
.profile-privacy-content {
margin-top: 1rem;
}
@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import { ProfilePrivacy } from './profile-privacy';
const defaultProfileUI = {
isLocked: false,
showName: true,
showLocation: true,
showAbout: true,
showPoints: true,
showHeatMap: true,
showCerts: true,
showPortfolio: true,
showExperience: true,
showTimeLine: true,
showDonation: true
};
function makeStore(overrides: Partial<typeof defaultProfileUI> = {}) {
return createStore(() => ({
app: {
user: {
sessionUser: { profileUI: { ...defaultProfileUI, ...overrides } }
}
}
}));
}
// The header toggle button has the exact accessible name 'settings.headings.privacy'.
// The save submit button's accessible name starts with 'buttons.save'.
const TOGGLE_BTN_NAME = 'settings.headings.privacy';
const SAVE_BTN_NAME = /buttons\.save/;
describe('<ProfilePrivacy />', () => {
it('renders the privacy section heading', () => {
render(
<Provider store={makeStore()}>
<ProfilePrivacy />
</Provider>
);
expect(screen.getByText('settings.headings.privacy')).toBeInTheDocument();
});
it('starts collapsed (aria-expanded=false) when isLocked is false', () => {
render(
<Provider store={makeStore({ isLocked: false })}>
<ProfilePrivacy />
</Provider>
);
const button = screen.getByRole('button', { name: TOGGLE_BTN_NAME });
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('starts expanded (aria-expanded=true) when isLocked is true', () => {
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
const button = screen.getByRole('button', { name: TOGGLE_BTN_NAME });
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('toggles expanded state when the header button is clicked', async () => {
const user = userEvent.setup();
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
const button = screen.getByRole('button', { name: TOGGLE_BTN_NAME });
expect(button).toHaveAttribute('aria-expanded', 'true');
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'false');
await user.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('shows the privacy form controls when expanded', () => {
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
// Expanded (isLocked=true), so the privacy group div is visible
expect(
screen.getByRole('group', { name: 'settings.headings.privacy' })
).toBeInTheDocument();
});
it('hides the privacy form controls when collapsed', () => {
render(
<Provider store={makeStore({ isLocked: false })}>
<ProfilePrivacy />
</Provider>
);
// Collapsed (isLocked=false), so the form group is not visible
expect(screen.queryByRole('group')).not.toBeInTheDocument();
});
it('save button is aria-disabled when no changes have been made', () => {
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
// Already expanded (isLocked=true)
const saveButton = screen.getByRole('button', { name: SAVE_BTN_NAME });
expect(saveButton).toHaveAttribute('aria-disabled', 'true');
});
it('save button is not aria-disabled after a privacy toggle is changed', async () => {
const user = userEvent.setup();
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
// Already expanded (isLocked=true)
const saveButton = screen.getByRole('button', { name: SAVE_BTN_NAME });
expect(saveButton).toHaveAttribute('aria-disabled', 'true');
// Click the second radio in the first toggle group (public option for isLocked)
const radios = screen.getAllByRole('radio');
await user.click(radios[1]);
expect(saveButton).not.toHaveAttribute('aria-disabled', 'true');
});
it('save button becomes aria-disabled again after saving', async () => {
const user = userEvent.setup();
render(
<Provider store={makeStore({ isLocked: true })}>
<ProfilePrivacy />
</Provider>
);
const saveButton = screen.getByRole('button', { name: SAVE_BTN_NAME });
// Make a change
const radios = screen.getAllByRole('radio');
await user.click(radios[1]);
expect(saveButton).not.toHaveAttribute('aria-disabled', 'true');
// Submit the form
await user.click(saveButton);
expect(saveButton).toHaveAttribute('aria-disabled', 'true');
});
});
@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import type { Dispatch } from 'redux';
import { createSelector } from 'reselect';
import { Button, Spacer } from '@freecodecamp/ui';
import type { ProfileUI } from '../../../redux/prop-types';
import { userSelector } from '../../../redux/selectors';
import { submitProfileUI } from '../../../redux/settings/actions';
import { FullWidthRow } from '../../helpers';
import ToggleRadioSetting from '../../settings/toggle-radio-setting';
import DropDown from '../../../assets/icons/dropdown';
import './profile-privacy.css';
const mapStateToProps = createSelector(userSelector, user => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
user
}));
const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators({ submitProfileUI }, dispatch);
type ProfilePrivacyProps = {
submitProfileUI: (profileUI: ProfileUI) => void;
user: { profileUI: ProfileUI };
};
function ProfilePrivacyComponent({
submitProfileUI,
user
}: ProfilePrivacyProps): JSX.Element {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(user.profileUI.isLocked);
const [privacyValues, setPrivacyValues] = useState({ ...user.profileUI });
const [madeChanges, setMadeChanges] = useState(false);
function toggleFlag(flag: keyof ProfileUI): () => void {
return () => {
setMadeChanges(true);
setPrivacyValues({ ...privacyValues, [flag]: !privacyValues[flag] });
};
}
function submitNewProfileSettings(e: React.FormEvent) {
e.preventDefault();
if (!madeChanges) return;
submitProfileUI(privacyValues);
setMadeChanges(false);
}
return (
<FullWidthRow>
<div className='profile-privacy'>
<button
className='profile-privacy-header'
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
type='button'
>
<h2 className='profile-privacy-title'>
{t('settings.headings.privacy')}
</h2>
<span
className={`profile-privacy-chevron ${isExpanded ? 'expanded' : ''}`}
aria-hidden='true'
>
<DropDown />
</span>
</button>
{isExpanded && (
<div className='profile-privacy-content'>
<p>{t('settings.privacy')}</p>
<form onSubmit={submitNewProfileSettings}>
<div role='group' aria-label={t('settings.headings.privacy')}>
<ToggleRadioSetting
action={t('settings.labels.my-profile')}
explain={t('settings.disabled')}
flag={privacyValues['isLocked']}
flagName='isLocked'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('isLocked')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-name')}
explain={t('settings.private-name')}
flag={!privacyValues['showName']}
flagName='name'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showName')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-location')}
flag={!privacyValues['showLocation']}
flagName='showLocation'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showLocation')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-about')}
flag={!privacyValues['showAbout']}
flagName='showAbout'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showAbout')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-points')}
flag={!privacyValues['showPoints']}
flagName='showPoints'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showPoints')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-heatmap')}
flag={!privacyValues['showHeatMap']}
flagName='showHeatMap'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showHeatMap')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-certs')}
explain={t('settings.disabled')}
flag={!privacyValues['showCerts']}
flagName='showCerts'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showCerts')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-portfolio')}
flag={!privacyValues['showPortfolio']}
flagName='showPortfolio'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showPortfolio')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-experience')}
flag={!privacyValues['showExperience']}
flagName='showExperience'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showExperience')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-timeline')}
explain={t('settings.disabled')}
flag={!privacyValues['showTimeLine']}
flagName='showTimeLine'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showTimeLine')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-donations')}
flag={!privacyValues['showDonation']}
flagName='showDonation'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showDonation')}
/>
</div>
<Spacer size='s' />
<Button
type='submit'
size='large'
variant='primary'
block={true}
disabled={!madeChanges}
{...(!madeChanges && { tabIndex: -1 })}
>
{t('buttons.save')}{' '}
<span className='sr-only'>
{t('settings.headings.privacy')}
</span>
</Button>
</form>
</div>
)}
</div>
<Spacer size='m' />
</FullWidthRow>
);
}
ProfilePrivacyComponent.displayName = 'ProfilePrivacy';
export const ProfilePrivacy = connect(
mapStateToProps,
mapDispatchToProps
)(ProfilePrivacyComponent);
@@ -17,6 +17,7 @@ import './stats.css';
interface StatsProps {
points: number;
calendar: Record<string, number>;
isPrivate?: boolean;
}
export const calculateStreaks = (calendar: Record<string, number>) => {
@@ -56,7 +57,7 @@ export const calculateStreaks = (calendar: Record<string, number>) => {
return { longestStreak, currentStreak: streakExpired ? 0 : currentStreak };
};
function Stats({ points, calendar }: StatsProps): JSX.Element {
function Stats({ points, calendar, isPrivate }: StatsProps): JSX.Element {
const { t } = useTranslation();
const [currentStreak, setCurrentStreak] = useState(0);
@@ -72,7 +73,14 @@ function Stats({ points, calendar }: StatsProps): JSX.Element {
return (
<FullWidthRow>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.stats')}</h2>
{isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
</div>
<Spacer size='s' />
<dl className='stats'>
<div>
@@ -40,6 +40,7 @@ const ITEMS_PER_PAGE = 15;
interface TimelineProps {
completedMap: CompletedChallenge[];
isPrivate?: boolean;
openModal: (arg: string) => void;
t: TFunction;
username: string;
@@ -60,6 +61,7 @@ interface NameMap {
function TimelineInner({
completedMap,
idToNameMap,
isPrivate,
openModal,
sortedTimeline,
totalPages,
@@ -181,7 +183,14 @@ function TimelineInner({
return (
<FullWidthRow>
<section className='card'>
<div className='profile-section-heading'>
<h2>{t('profile.timeline')}</h2>
{isPrivate && (
<span className='profile-private-badge'>
{t('buttons.private')}
</span>
)}
</div>
<Spacer size='s' />
{completedMap.length === 0 ? (
<p className='text-center'>
+22
View File
@@ -16,3 +16,25 @@
.card-header {
position: relative;
}
.profile-section-heading {
display: flex;
align-items: center;
gap: 0.6rem;
}
.profile-section-heading h2 {
margin: 0;
}
.profile-private-badge {
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.55rem;
border-radius: 0;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--quaternary-color);
border: 1px solid var(--quaternary-color);
white-space: nowrap;
}
+39 -17
View File
@@ -4,9 +4,6 @@ import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { Callout, Container, Modal, Row, Spacer } from '@freecodecamp/ui';
import { FullWidthRow, Link } from '../helpers';
import Portfolio from './components/portfolio';
import Experience from './components/experience';
import UsernameSettings from './components/username';
import About from './components/about';
import Internet from './components/internet';
@@ -20,6 +17,7 @@ import './profile.css';
import { PortfolioProjects } from './components/portfolio-projects';
import { ExperienceDisplay } from './components/experience-display';
import { ProfileCompleteness } from './components/profile-completeness';
import { ProfilePrivacy } from './components/profile-privacy';
interface ProfileProps {
isSessionUser: boolean;
@@ -50,7 +48,7 @@ const UserMessage = ({ t }: Pick<MessageProps, 't'>) => {
};
const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => {
const { portfolio, experience, username } = user;
const { username } = user;
const { t } = useTranslation();
return (
<Modal onClose={() => setIsEditing(false)} open={isEditing} size='large'>
@@ -61,10 +59,6 @@ const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => {
<About user={user} setIsEditing={setIsEditing} />
<Spacer size='m' />
<Internet user={user} setIsEditing={setIsEditing} />
<Spacer size='m' />
<Portfolio portfolio={portfolio} />
<Spacer size='m' />
<Experience experience={experience || []} />
</Modal.Body>
</Modal>
);
@@ -96,6 +90,7 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element {
const {
profileUI: {
isLocked,
showCerts,
showHeatMap,
showPoints,
@@ -137,24 +132,51 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element {
website={user.website}
portfolio={portfolio}
experience={experience || []}
isLocked={isLocked}
/>
)}
{isSessionUser && <ProfilePrivacy />}
<Camper
user={user}
isSessionUser={isSessionUser}
setIsEditing={setIsEditing}
/>
{showPoints ? <Stats points={points} calendar={calendar} /> : null}
{showHeatMap ? <HeatMap calendar={calendar} /> : null}
{showPortfolio ? (
<PortfolioProjects portfolioProjects={portfolio} />
{showPoints || isSessionUser ? (
<Stats
points={points}
calendar={calendar}
isPrivate={isSessionUser && !showPoints}
/>
) : null}
{showExperience ? (
<ExperienceDisplay experience={experience || []} />
{showHeatMap || isSessionUser ? (
<HeatMap
calendar={calendar}
isPrivate={isSessionUser && !showHeatMap}
/>
) : null}
{showCerts ? <Certifications user={user} /> : null}
{showTimeLine ? (
<Timeline completedMap={completedChallenges} username={username} />
{showPortfolio || isSessionUser ? (
<PortfolioProjects
portfolioProjects={portfolio}
isPrivate={isSessionUser && !showPortfolio}
isSessionUser={isSessionUser}
/>
) : null}
{showExperience || isSessionUser ? (
<ExperienceDisplay
experience={experience || []}
isPrivate={isSessionUser && !showExperience}
isSessionUser={isSessionUser}
/>
) : null}
{showCerts || isSessionUser ? (
<Certifications user={user} isPrivate={isSessionUser && !showCerts} />
) : null}
{showTimeLine || isSessionUser ? (
<Timeline
completedMap={completedChallenges}
username={username}
isPrivate={isSessionUser && !showTimeLine}
/>
) : null}
<Spacer size='m' />
</>
@@ -30,6 +30,21 @@ function SettingsSidebarNav({
return (
<SidebarPanel className='settings-sidebar-nav'>
<ul>
<SidebarPanel.Item>
<ScrollLink
to='personal'
href='#personal'
className='sidebar-nav-section-heading'
smooth={true}
offset={scrollOffset}
duration={300}
spy={true}
hashSpy={true}
activeClass='active'
>
{t('settings.headings.personal')}
</ScrollLink>
</SidebarPanel.Item>
<SidebarPanel.Item>
<ScrollLink
to='account'
+9 -17
View File
@@ -17,16 +17,12 @@ test.describe('Add Experience Item', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/developmentuser');
await page.getByRole('button', { name: 'Edit my profile' }).click();
// The 'Add experience' icon button is on the profile page directly.
// Click it to open the experience modal.
await page.getByRole('button', { name: 'Add experience' }).click();
await expect(async () => {
const addExperienceItemButton = page.getByRole('button', {
name: 'Add experience'
});
await addExperienceItemButton.click();
await expect(addExperienceItemButton).toBeDisabled({ timeout: 1 });
}).toPass();
// Wait for the experience form to be visible inside the modal.
await expect(page.getByLabel('Company')).toBeVisible();
});
test('The company has validation', async ({ page }) => {
@@ -101,20 +97,15 @@ test.describe('Add Experience Item', () => {
await page.locator('input[name="experience-location"]').fill('Remote');
await page.getByLabel('Description').fill('Worked on various projects');
await page.getByRole('button', { name: 'Remove Experience' }).click();
await page.getByRole('button', { name: 'Close' }).click();
await page.getByRole('button', { name: 'Remove experience' }).click();
// Modal closes automatically after removal
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your experience/
);
});
test('It should be possible to add an experience item', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Add experience' })
).toBeDisabled();
await page.getByLabel('Company').fill('freeCodeCamp');
await page.getByLabel('Job Title').fill('Software Engineer');
await page.getByLabel('Start Date').fill('01/2020');
@@ -124,7 +115,8 @@ test.describe('Add Experience Item', () => {
await page.getByLabel('Description').fill('Worked on various projects');
await page.getByRole('button', { name: 'Save experience' }).click();
await page.getByRole('button', { name: 'Close' }).click();
// Modal closes automatically after a successful save
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your experience/
);
+12 -46
View File
@@ -16,17 +16,14 @@ test.describe('Add Portfolio Item', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/certifieduser');
await page.getByRole('button', { name: 'Edit my profile' }).click();
// The 'Add portfolio project' icon button is on the profile page directly.
// Click it to open the portfolio modal.
await page.getByRole('button', { name: 'Add portfolio project' }).click();
// Will check if the portfolio button is hydrated correctly with different intervals.
await expect(async () => {
const addPortfolioItemButton = page.getByRole('button', {
name: 'Add a new portfolio Item'
});
await addPortfolioItemButton.click();
await expect(addPortfolioItemButton).toBeDisabled({ timeout: 1 });
}).toPass();
// Wait for the portfolio form to be visible inside the modal.
await expect(
page.getByLabel(translations.settings.labels.title)
).toBeVisible();
});
test('The title has validation', async ({ page }) => {
@@ -115,7 +112,10 @@ test.describe('Add Portfolio Item', () => {
.getByRole('button', { name: 'Remove this portfolio item' })
.click();
await expect(page.getByTestId('portfolio-items')).toBeHidden();
// Modal closes automatically after removal
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your portfolio/
);
});
test('The save button should be disabled when the form is pristine', async ({
@@ -127,10 +127,6 @@ test.describe('Add Portfolio Item', () => {
});
test('It should be possible to add a portfolio item', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Add a new portfolio Item' })
).toBeDisabled();
await page
.getByLabel(translations.settings.labels.title)
.fill('My portfolio');
@@ -154,38 +150,8 @@ test.describe('Add Portfolio Item', () => {
await page
.getByRole('button', { name: 'Save this portfolio item' })
.click();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your portfolio/
);
});
test('The edit modal should stay open after saving a portfolio item', async ({
page
}) => {
await page
.getByLabel(translations.settings.labels.title)
.first()
.fill('My portfolio');
await page
.getByLabel(translations.settings.labels.url)
.first()
.fill('https://my-portfolio.com');
// Wait for form validation to complete
await expect(
page.getByRole('button', { name: 'Save this portfolio item' }).first()
).toBeEnabled();
await page
.getByRole('button', { name: 'Save this portfolio item' })
.first()
.click();
// Modal should still be open and portfolio form should be visible
await expect(page.getByTestId('portfolio-items').first()).toBeVisible();
await page.getByRole('button', { name: 'Close' }).click();
// Modal closes automatically after a successful save
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your portfolio/
);
+8 -4
View File
@@ -166,11 +166,15 @@ test.describe('Profile component', () => {
}
});
test('should not show portfolio when empty', async ({ page }) => {
// @certifieduser doesn't have portfolio information
test('should show portfolio section with add button when empty', async ({
page
}) => {
// @certifieduser doesn't have portfolio information, but session users
// always see the section so they can add projects
await expect(page.getByText(translations.profile.projects)).toBeVisible();
await expect(
page.getByText(translations.profile.projects)
).not.toBeVisible();
page.getByRole('button', { name: translations.aria['add-portfolio'] })
).toBeVisible();
});
test('displays the timeline correctly', async ({ page }) => {