mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(client): update profile ui (#66889)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
<Trans i18nKey='settings.profile-note'>
|
||||
<a href={`/${username}`}>your profile</a>
|
||||
</Trans>
|
||||
</ScrollElement>
|
||||
<FullWidthRow>
|
||||
<Callout variant='note' label={t('misc.note')}>
|
||||
<Trans i18nKey='settings.profile-note'>
|
||||
<Link to={`/${username}`} />
|
||||
</Trans>
|
||||
</Callout>
|
||||
</FullWidthRow>
|
||||
<Spacer size='m' />
|
||||
<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'>
|
||||
<h2>{t('profile.badges')}</h2>
|
||||
<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'>
|
||||
<h2 id='fcc-certifications'>{t('profile.fcc-certs')}</h2>
|
||||
<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,42 +23,116 @@ 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'>
|
||||
<h3>{exp.title}</h3>
|
||||
<h4 className='experience-company'>
|
||||
{exp.company}
|
||||
{exp.location && ` • ${exp.location}`}
|
||||
</h4>
|
||||
<p className='experience-date'>
|
||||
{formatDate(exp.startDate)}
|
||||
{' - '}
|
||||
{exp.endDate
|
||||
? formatDate(exp.endDate)
|
||||
: t('profile.experience.present')}
|
||||
</p>
|
||||
{exp.description && (
|
||||
<p className='experience-description'>{exp.description}</p>
|
||||
<div key={exp.id} className='experience-item-wrapper'>
|
||||
<div className='experience-item'>
|
||||
<h3>{exp.title}</h3>
|
||||
<h4 className='experience-company'>
|
||||
{exp.company}
|
||||
{exp.location && ` • ${exp.location}`}
|
||||
</h4>
|
||||
<p className='experience-date'>
|
||||
{formatDate(exp.startDate)}
|
||||
{' - '}
|
||||
{exp.endDate
|
||||
? formatDate(exp.endDate)
|
||||
: t('profile.experience.present')}
|
||||
</p>
|
||||
{exp.description && (
|
||||
<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'>
|
||||
<h2>{t('profile.experience.heading')}</h2>
|
||||
<Spacer size='s' />
|
||||
{interleave(experienceItems, index => (
|
||||
<hr key={`separator-${index}`} />
|
||||
))}
|
||||
<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,25 +471,35 @@ 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'>
|
||||
<SectionHeader>{t('profile.experience.heading')}</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<p>{t('profile.experience.share-experience')}</p>
|
||||
<Spacer size='xs' />
|
||||
<Button
|
||||
block
|
||||
size='large'
|
||||
variant='primary'
|
||||
disabled={newItemId !== null}
|
||||
onClick={handleAdd}
|
||||
type='button'
|
||||
>
|
||||
{t('profile.experience.add')}
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
<Spacer size='l' />
|
||||
{interleave(experience.map(renderExperience), () => (
|
||||
{!isSingleItemMode && (
|
||||
<>
|
||||
<SectionHeader>{t('profile.experience.heading')}</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<p>{t('profile.experience.share-experience')}</p>
|
||||
<Spacer size='xs' />
|
||||
<Button
|
||||
block
|
||||
size='large'
|
||||
variant='primary'
|
||||
disabled={newItemId !== null}
|
||||
onClick={handleAdd}
|
||||
type='button'
|
||||
>
|
||||
{t('profile.experience.add')}
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
<Spacer size='l' />
|
||||
</>
|
||||
)}
|
||||
{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'>
|
||||
<h2>{t('profile.activity')}</h2>
|
||||
<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,61 +1,142 @@
|
||||
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'>
|
||||
<h2>{t('profile.projects')}</h2>
|
||||
<Spacer size='s' />
|
||||
{portfolioProjects.map(
|
||||
({ title, url, image, description, id }, index) => (
|
||||
<React.Fragment key={id}>
|
||||
<a
|
||||
href={url}
|
||||
rel='nofollow noopener noreferrer'
|
||||
target='_blank'
|
||||
className='portfolio-card'
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
alt=''
|
||||
className='portfolio-image'
|
||||
src={image}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.src =
|
||||
'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className='portfolio-card-description'>
|
||||
<div className='portfolio-card-text'>
|
||||
<h3>
|
||||
{title}
|
||||
<span className='sr-only'>
|
||||
, {t('aria.opens-new-window')}
|
||||
</span>
|
||||
</h3>
|
||||
<p>{description}</p>
|
||||
<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'
|
||||
target='_blank'
|
||||
className='portfolio-card'
|
||||
>
|
||||
{image && (
|
||||
<img
|
||||
alt=''
|
||||
className='portfolio-image'
|
||||
src={image}
|
||||
onError={({ currentTarget }) => {
|
||||
currentTarget.src =
|
||||
'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className='portfolio-card-description'>
|
||||
<div className='portfolio-card-text'>
|
||||
<h3>
|
||||
{title}
|
||||
<span className='sr-only'>
|
||||
, {t('aria.opens-new-window')}
|
||||
</span>
|
||||
</h3>
|
||||
<p>{description}</p>
|
||||
</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>
|
||||
</div>
|
||||
</a>
|
||||
{index < portfolioProjects.length - 1 && <hr />}
|
||||
</React.Fragment>
|
||||
)
|
||||
{index < portfolioProjects.length - 1 && <hr />}
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Spacer size='m' />
|
||||
</section>
|
||||
|
||||
@@ -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,25 +371,35 @@ 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'>
|
||||
<SectionHeader>{t('settings.headings.portfolio')}</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<p>{t('settings.share-projects')}</p>
|
||||
<Spacer size='xs' />
|
||||
<Button
|
||||
block
|
||||
size='large'
|
||||
variant='primary'
|
||||
disabled={unsavedItemId !== null}
|
||||
onClick={handleAdd}
|
||||
type='button'
|
||||
>
|
||||
{t('buttons.add-portfolio')}
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
<Spacer size='l' />
|
||||
{interleave(portfolio.map(renderPortfolio), () => (
|
||||
{!isSingleItemMode && (
|
||||
<>
|
||||
<SectionHeader>{t('settings.headings.portfolio')}</SectionHeader>
|
||||
<FullWidthRow>
|
||||
<p>{t('settings.share-projects')}</p>
|
||||
<Spacer size='xs' />
|
||||
<Button
|
||||
block
|
||||
size='large'
|
||||
variant='primary'
|
||||
disabled={unsavedItemId !== null}
|
||||
onClick={handleAdd}
|
||||
type='button'
|
||||
>
|
||||
{t('buttons.add-portfolio')}
|
||||
</Button>
|
||||
</FullWidthRow>
|
||||
<Spacer size='l' />
|
||||
</>
|
||||
)}
|
||||
{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,48 +146,59 @@ 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>
|
||||
<div className='profile-completeness-bar-container'>
|
||||
<div
|
||||
className='profile-completeness-bar-fill'
|
||||
data-testid='profile-completeness-progress'
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='profile-completeness-text'>
|
||||
{t('profile.completeness.heading', { percentage })}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ul className='profile-completeness-checklist'>
|
||||
{completionItems.map(item => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={
|
||||
item.isComplete
|
||||
? 'completeness-item-complete'
|
||||
: 'completeness-item-incomplete'
|
||||
}
|
||||
>
|
||||
<span className='completeness-checkbox' aria-hidden='true'>
|
||||
{item.isComplete ? (
|
||||
<GreenPass hushScreenReaderText />
|
||||
) : (
|
||||
<GreenNotCompleted hushScreenReaderText />
|
||||
)}
|
||||
</span>
|
||||
{t(item.translationKey)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className='profile-completeness-expanded'>
|
||||
<div className='profile-completeness-bar-row'>
|
||||
<div className='profile-completeness-bar-container'>
|
||||
<div
|
||||
className='profile-completeness-bar-fill'
|
||||
data-testid='profile-completeness-progress'
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className='profile-completeness-progress-text'>
|
||||
{t('profile.completeness.progress', { percentage })}
|
||||
</span>
|
||||
</div>
|
||||
<ul className='profile-completeness-checklist'>
|
||||
{completionItems.map(item => (
|
||||
<li
|
||||
key={item.key}
|
||||
className={
|
||||
item.isComplete
|
||||
? 'completeness-item-complete'
|
||||
: 'completeness-item-incomplete'
|
||||
}
|
||||
>
|
||||
<span className='completeness-checkbox' aria-hidden='true'>
|
||||
{item.isComplete ? (
|
||||
<GreenPass hushScreenReaderText />
|
||||
) : (
|
||||
<GreenNotCompleted hushScreenReaderText />
|
||||
)}
|
||||
</span>
|
||||
{t(item.translationKey)}
|
||||
</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'>
|
||||
<h2>{t('profile.stats')}</h2>
|
||||
<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'>
|
||||
<h2>{t('profile.timeline')}</h2>
|
||||
<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'>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user