From 40b5550e96f5868b5edb795ae7a6d84c378e32ce Mon Sep 17 00:00:00 2001 From: Ahmad Abdolsaheb Date: Tue, 21 Apr 2026 13:09:39 +0300 Subject: [PATCH] feat(client): update profile ui (#66889) --- client/i18n/locales/english/translations.json | 10 +- .../client-only-routes/show-settings.test.tsx | 56 ++++- .../src/client-only-routes/show-settings.tsx | 39 +-- .../components/profile/components/about.tsx | 8 +- .../components/profile/components/camper.tsx | 28 ++- .../profile/components/certifications.tsx | 12 +- .../profile/components/experience-display.css | 15 +- .../components/experience-display.test.tsx | 224 +++++++++++++++++ .../profile/components/experience-display.tsx | 127 ++++++++-- .../profile/components/experience.tsx | 75 ++++-- .../profile/components/heat-map.tsx | 22 +- .../profile/components/portfolio-projects.css | 11 + .../components/portfolio-projects.test.tsx | 233 ++++++++++++++++++ .../profile/components/portfolio-projects.tsx | 161 +++++++++--- .../profile/components/portfolio.tsx | 77 ++++-- .../components/profile-completeness.css | 62 +++-- .../components/profile-completeness.test.tsx | 27 +- .../components/profile-completeness.tsx | 95 ++++--- .../profile/components/profile-privacy.css | 51 ++++ .../components/profile-privacy.test.tsx | 154 ++++++++++++ .../profile/components/profile-privacy.tsx | 197 +++++++++++++++ .../components/profile/components/stats.tsx | 12 +- .../profile/components/time-line.tsx | 11 +- client/src/components/profile/profile.css | 22 ++ client/src/components/profile/profile.tsx | 56 +++-- .../settings/settings-sidebar-nav.tsx | 15 ++ e2e/experience.spec.ts | 26 +- e2e/portfolio.spec.ts | 58 +---- e2e/profile.spec.ts | 12 +- 29 files changed, 1605 insertions(+), 291 deletions(-) create mode 100644 client/src/components/profile/components/experience-display.test.tsx create mode 100644 client/src/components/profile/components/portfolio-projects.test.tsx create mode 100644 client/src/components/profile/components/profile-privacy.css create mode 100644 client/src/components/profile/components/profile-privacy.test.tsx create mode 100644 client/src/components/profile/components/profile-privacy.tsx diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index b970e68d682..641dc9348f5 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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.", diff --git a/client/src/client-only-routes/show-settings.test.tsx b/client/src/client-only-routes/show-settings.test.tsx index ddb07f334bb..1a1a4350478 100644 --- a/client/src/client-only-routes/show-settings.test.tsx +++ b/client/src/client-only-routes/show-settings.test.tsx @@ -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('', () => { 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( + + + + ); + + // 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( + + + + ); + + // 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(); + }); }); diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index 8da55580e5c..b49b2d74c25 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -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 })} + + your profile + - - - - - - - - + + + {}} + sectionTitle={t('settings.headings.personal')} + /> + + - + - + - + - + - + - + {userToken && ( <> - + )} diff --git a/client/src/components/profile/components/about.tsx b/client/src/components/profile/components/about.tsx index 77a4e96793f..f6d588c3f47 100644 --- a/client/src/components/profile/components/about.tsx +++ b/client/src/components/profile/components/about.tsx @@ -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 ( <> - {t('settings.headings.personal-info')} + + {sectionTitle ?? t('settings.headings.personal-info')} +
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 ( <>
@@ -37,12 +50,19 @@ function Camper({ isSessionUser={isSessionUser} />
- {((isDonating && showDonation) || isTopContributor) && ( + {showBadgesSection && (
-

{t('profile.badges')}

+
+

{t('profile.badges')}

+ {badgesSectionIsPrivate && ( + + {t('buttons.private')} + + )} +
- {isDonating && ( + {isDonating && (showDonation || isSessionUser) && (
diff --git a/client/src/components/profile/components/certifications.tsx b/client/src/components/profile/components/certifications.tsx index 8fce62fb974..d28948915ec 100644 --- a/client/src/components/profile/components/certifications.tsx +++ b/client/src/components/profile/components/certifications.tsx @@ -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 (
-

{t('profile.fcc-certs')}

+
+

{t('profile.fcc-certs')}

+ {isPrivate && ( + + {t('buttons.private')} + + )} +

{hasModernCert && currentCerts ? (
    diff --git a/client/src/components/profile/components/experience-display.css b/client/src/components/profile/components/experience-display.css index f6065a192b5..be75fc1d7af 100644 --- a/client/src/components/profile/components/experience-display.css +++ b/client/src/components/profile/components/experience-display.css @@ -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 { diff --git a/client/src/components/profile/components/experience-display.test.tsx b/client/src/components/profile/components/experience-display.test.tsx new file mode 100644 index 00000000000..e0ec24ced24 --- /dev/null +++ b/client/src/components/profile/components/experience-display.test.tsx @@ -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>('@freecodecamp/ui'); + const MockModal = Object.assign( + ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
    {children}
    : null, + { + Header: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + Body: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ) + } + ); + 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
    Experience Form
    ; + } +})); + +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('', () => { + beforeEach(() => { + capturedOnSave = undefined; + }); + + it('returns null for non-session users with no experience', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the experience section for session users even with no experience', () => { + render(); + expect(screen.getByText('profile.experience.heading')).toBeInTheDocument(); + }); + + it('renders experience items for visitors', () => { + render( + + ); + expect(screen.getByText('Software Engineer')).toBeInTheDocument(); + expect(screen.getByText(/Tech Corp/)).toBeInTheDocument(); + }); + + it('renders multiple experience items', () => { + render( + + ); + expect(screen.getByText('Software Engineer')).toBeInTheDocument(); + expect(screen.getByText('Senior Engineer')).toBeInTheDocument(); + }); + + it('shows "present" for experiences without an end date', () => { + render( + + ); + expect( + screen.getByText(/profile\.experience\.present/) + ).toBeInTheDocument(); + }); + + it('shows add button only for session users', () => { + render(); + expect(screen.getByLabelText('aria.add-experience')).toBeInTheDocument(); + }); + + it('does not show add button for non-session users', () => { + render( + + ); + expect( + screen.queryByLabelText('aria.add-experience') + ).not.toBeInTheDocument(); + }); + + it('shows edit button for each experience item for session users', () => { + render( + + ); + const editButtons = screen.getAllByLabelText('aria.edit-experience'); + expect(editButtons).toHaveLength(sampleExperience.length); + }); + + it('does not show edit buttons for non-session users', () => { + render( + + ); + expect( + screen.queryByLabelText('aria.edit-experience') + ).not.toBeInTheDocument(); + }); + + it('does not render the experience form by default (modal closed)', () => { + render(); + expect(screen.queryByTestId('experience-form')).not.toBeInTheDocument(); + }); + + it('opens the modal when the add button is clicked', async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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( + + ); + + 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( + + ); + + 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(); + + 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( + + ); + expect(screen.getByText('buttons.private')).toBeInTheDocument(); + }); + + it('does not show private badge when experience is not private', () => { + render( + + ); + expect(screen.queryByText('buttons.private')).not.toBeInTheDocument(); + }); + + it('formats start and end dates for display', () => { + render( + + ); + // startDate '01/2020' → 'Jan 2020', endDate '12/2022' → 'Dec 2022' + expect(screen.getByText(/Jan 2020/)).toBeInTheDocument(); + expect(screen.getByText(/Dec 2022/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/profile/components/experience-display.tsx b/client/src/components/profile/components/experience-display.tsx index 5be4a2a4081..a6b6dcdddf9 100644 --- a/client/src/components/profile/components/experience-display.tsx +++ b/client/src/components/profile/components/experience-display.tsx @@ -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(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 => ( -
    -

    {exp.title}

    -

    - {exp.company} - {exp.location && ` • ${exp.location}`} -

    -

    - {formatDate(exp.startDate)} - {' - '} - {exp.endDate - ? formatDate(exp.endDate) - : t('profile.experience.present')} -

    - {exp.description && ( -

    {exp.description}

    +
    +
    +

    {exp.title}

    +

    + {exp.company} + {exp.location && ` • ${exp.location}`} +

    +

    + {formatDate(exp.startDate)} + {' - '} + {exp.endDate + ? formatDate(exp.endDate) + : t('profile.experience.present')} +

    + {exp.description && ( +

    {exp.description}

    + )} +
    + {isSessionUser && ( + )}
    )); return ( + setIsModalOpen(false)} + open={isModalOpen} + size='large' + > + + {autoAdd ? t('aria.add-experience') : t('aria.edit-experience')} + + + setIsModalOpen(false)} + /> + +
    -

    {t('profile.experience.heading')}

    - - {interleave(experienceItems, index => ( -
    - ))} +
    +

    {t('profile.experience.heading')}

    + {isPrivate && ( + + {t('buttons.private')} + + )} + {isSessionUser && ( + + )} +
    + {experience.length > 0 && ( + <> + + {interleave(experienceItems, index => ( +
    + ))} + + )}
    diff --git a/client/src/components/profile/components/experience.tsx b/client/src/components/profile/components/experience.tsx index c19105b06d3..0c374de9380 100644 --- a/client/src/components/profile/components/experience.tsx +++ b/client/src/components/profile/components/experience.tsx @@ -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(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(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 (
    - {t('profile.experience.heading')} - -

    {t('profile.experience.share-experience')}

    - - -
    - - {interleave(experience.map(renderExperience), () => ( + {!isSingleItemMode && ( + <> + {t('profile.experience.heading')} + +

    {t('profile.experience.share-experience')}

    + + +
    + + + )} + {interleave(itemsToRender.map(renderExperience), () => ( <>
    diff --git a/client/src/components/profile/components/heat-map.tsx b/client/src/components/profile/components/heat-map.tsx index 7fec170c2e9..cbf766590f2 100644 --- a/client/src/components/profile/components/heat-map.tsx +++ b/client/src/components/profile/components/heat-map.tsx @@ -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 { return (
    -

    {t('profile.activity')}

    +
    +

    {t('profile.activity')}

    + {this.props.isPrivate && ( + + {t('buttons.private')} + + )} +
    { 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 ; + return ( + + ); }; HeatMap.displayName = 'HeatMap'; diff --git a/client/src/components/profile/components/portfolio-projects.css b/client/src/components/profile/components/portfolio-projects.css index 3a414af37e5..664e6fb0de7 100644 --- a/client/src/components/profile/components/portfolio-projects.css +++ b/client/src/components/profile/components/portfolio-projects.css @@ -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; diff --git a/client/src/components/profile/components/portfolio-projects.test.tsx b/client/src/components/profile/components/portfolio-projects.test.tsx new file mode 100644 index 00000000000..e5e0d36a061 --- /dev/null +++ b/client/src/components/profile/components/portfolio-projects.test.tsx @@ -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>('@freecodecamp/ui'); + const MockModal = Object.assign( + ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
    {children}
    : null, + { + Header: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + Body: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ) + } + ); + 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
    Portfolio Form
    ; + } +})); + +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('', () => { + beforeEach(() => { + capturedOnSave = undefined; + }); + + it('returns null for non-session users with no portfolio projects', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the section for session users even with no portfolio projects', () => { + render(); + expect(screen.getByText('profile.projects')).toBeInTheDocument(); + }); + + it('renders portfolio project titles for visitors', () => { + render( + + ); + expect(screen.getByText('My Website')).toBeInTheDocument(); + expect(screen.getByText('Open Source Tool')).toBeInTheDocument(); + }); + + it('renders portfolio project descriptions', () => { + render( + + ); + expect(screen.getByText('A personal website')).toBeInTheDocument(); + }); + + it('shows add button only for session users', () => { + render(); + expect(screen.getByLabelText('aria.add-portfolio')).toBeInTheDocument(); + }); + + it('does not show add button for non-session users', () => { + render( + + ); + expect( + screen.queryByLabelText('aria.add-portfolio') + ).not.toBeInTheDocument(); + }); + + it('shows edit button for each portfolio item for session users', () => { + render( + + ); + const editButtons = screen.getAllByLabelText('aria.edit-portfolio'); + expect(editButtons).toHaveLength(samplePortfolio.length); + }); + + it('does not show edit buttons for non-session users', () => { + render( + + ); + expect( + screen.queryByLabelText('aria.edit-portfolio') + ).not.toBeInTheDocument(); + }); + + it('does not render the portfolio form by default (modal closed)', () => { + render(); + expect(screen.queryByTestId('portfolio-form')).not.toBeInTheDocument(); + }); + + it('opens the modal when the add button is clicked', async () => { + const user = userEvent.setup(); + render(); + + 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(); + + 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( + + ); + + 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( + + ); + + 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(); + + 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( + + ); + expect(screen.getByText('buttons.private')).toBeInTheDocument(); + }); + + it('does not show private badge when portfolio is not private', () => { + render( + + ); + expect(screen.queryByText('buttons.private')).not.toBeInTheDocument(); + }); + + it('renders project links with correct href', () => { + render( + + ); + const link = screen.getByRole('link', { name: /My Website/ }); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); +}); diff --git a/client/src/components/profile/components/portfolio-projects.tsx b/client/src/components/profile/components/portfolio-projects.tsx index 5e77f44fb54..77a63318bd0 100644 --- a/client/src/components/profile/components/portfolio-projects.tsx +++ b/client/src/components/profile/components/portfolio-projects.tsx @@ -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(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 ( + setIsModalOpen(false)} + open={isModalOpen} + size='large' + > + + {autoAdd ? t('aria.add-portfolio') : t('aria.edit-portfolio')} + + + setIsModalOpen(false)} + /> + +
    -

    {t('profile.projects')}

    - - {portfolioProjects.map( - ({ title, url, image, description, id }, index) => ( - - - {image && ( - { - currentTarget.src = - 'https://cdn.freecodecamp.org/platform/universal/fcc_meta_1920X1080-indigo.png'; - }} - /> - )} -
    diff --git a/client/src/components/profile/components/portfolio.tsx b/client/src/components/profile/components/portfolio.tsx index c756b52639b..89a29f41093 100644 --- a/client/src/components/profile/components/portfolio.tsx +++ b/client/src/components/profile/components/portfolio.tsx @@ -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(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( + initial.unsavedItemId + ); const [imageValidation, setImageValid] = useState({ 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 (
    - {t('settings.headings.portfolio')} - -

    {t('settings.share-projects')}

    - - -
    - - {interleave(portfolio.map(renderPortfolio), () => ( + {!isSingleItemMode && ( + <> + {t('settings.headings.portfolio')} + +

    {t('settings.share-projects')}

    + + +
    + + + )} + {interleave(itemsToRender.map(renderPortfolio), () => ( <>
    diff --git a/client/src/components/profile/components/profile-completeness.css b/client/src/components/profile/components/profile-completeness.css index c1dfb0ac58b..f8363a0af3c 100644 --- a/client/src/components/profile/components/profile-completeness.css +++ b/client/src/components/profile/components/profile-completeness.css @@ -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; diff --git a/client/src/components/profile/components/profile-completeness.test.tsx b/client/src/components/profile/components/profile-completeness.test.tsx index 352a1002ad9..184b31c1e4b 100644 --- a/client/src/components/profile/components/profile-completeness.test.tsx +++ b/client/src/components/profile/components/profile-completeness.test.tsx @@ -19,7 +19,8 @@ const baseProps = { bluesky: '', website: '', portfolio: [], - experience: [] + experience: [], + isLocked: false }; describe('hasValue', () => { @@ -119,9 +120,9 @@ describe('', () => { it('should display all checklist items when expanded', () => { render(); - // 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('', () => { }); 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('', () => { }; render(); - // 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('', () => { }; render(); - // 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('', () => { }; render(); + // 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('', () => { }; render(); + // 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('', () => { }; render(); - // 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); }); }); diff --git a/client/src/components/profile/components/profile-completeness.tsx b/client/src/components/profile/components/profile-completeness.tsx index e61681c08a1..d3f72c72623 100644 --- a/client/src/components/profile/components/profile-completeness.tsx +++ b/client/src/components/profile/components/profile-completeness.tsx @@ -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' > +

    + {t('profile.completeness.title')} +

    + {!isExpanded && ( + + {percentage}% + + )} -
    -
    -
    - - {t('profile.completeness.heading', { percentage })} - {isExpanded && ( -
      - {completionItems.map(item => ( -
    • - - {t(item.translationKey)} -
    • - ))} -
    +
    +
    +
    +
    +
    + + {t('profile.completeness.progress', { percentage })} + +
    +
      + {completionItems.map(item => ( +
    • + + {t(item.translationKey)} +
    • + ))} +
    +
    )}
    - ); }; diff --git a/client/src/components/profile/components/profile-privacy.css b/client/src/components/profile/components/profile-privacy.css new file mode 100644 index 00000000000..80062cc910c --- /dev/null +++ b/client/src/components/profile/components/profile-privacy.css @@ -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; +} diff --git a/client/src/components/profile/components/profile-privacy.test.tsx b/client/src/components/profile/components/profile-privacy.test.tsx new file mode 100644 index 00000000000..d1583b0c283 --- /dev/null +++ b/client/src/components/profile/components/profile-privacy.test.tsx @@ -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 = {}) { + 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('', () => { + it('renders the privacy section heading', () => { + render( + + + + ); + expect(screen.getByText('settings.headings.privacy')).toBeInTheDocument(); + }); + + it('starts collapsed (aria-expanded=false) when isLocked is false', () => { + render( + + + + ); + 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( + + + + ); + 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( + + + + ); + 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( + + + + ); + // 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( + + + + ); + // 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( + + + + ); + // 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( + + + + ); + // 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( + + + + ); + 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'); + }); +}); diff --git a/client/src/components/profile/components/profile-privacy.tsx b/client/src/components/profile/components/profile-privacy.tsx new file mode 100644 index 00000000000..af9c510f415 --- /dev/null +++ b/client/src/components/profile/components/profile-privacy.tsx @@ -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 ( + +
    + + {isExpanded && ( +
    +

    {t('settings.privacy')}

    + +
    + + + + + + + + + + + +
    + + + +
    + )} +
    + +
    + ); +} + +ProfilePrivacyComponent.displayName = 'ProfilePrivacy'; + +export const ProfilePrivacy = connect( + mapStateToProps, + mapDispatchToProps +)(ProfilePrivacyComponent); diff --git a/client/src/components/profile/components/stats.tsx b/client/src/components/profile/components/stats.tsx index 342cc739623..292be7f588c 100644 --- a/client/src/components/profile/components/stats.tsx +++ b/client/src/components/profile/components/stats.tsx @@ -17,6 +17,7 @@ import './stats.css'; interface StatsProps { points: number; calendar: Record; + isPrivate?: boolean; } export const calculateStreaks = (calendar: Record) => { @@ -56,7 +57,7 @@ export const calculateStreaks = (calendar: Record) => { 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 (
    -

    {t('profile.stats')}

    +
    +

    {t('profile.stats')}

    + {isPrivate && ( + + {t('buttons.private')} + + )} +
    diff --git a/client/src/components/profile/components/time-line.tsx b/client/src/components/profile/components/time-line.tsx index 652818ab41e..b27f61a8094 100644 --- a/client/src/components/profile/components/time-line.tsx +++ b/client/src/components/profile/components/time-line.tsx @@ -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 (
    -

    {t('profile.timeline')}

    +
    +

    {t('profile.timeline')}

    + {isPrivate && ( + + {t('buttons.private')} + + )} +
    {completedMap.length === 0 ? (

    diff --git a/client/src/components/profile/profile.css b/client/src/components/profile/profile.css index afcc72457fb..4a21b145abc 100644 --- a/client/src/components/profile/profile.css +++ b/client/src/components/profile/profile.css @@ -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; +} diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx index df14a2b321e..0bcc3f99465 100644 --- a/client/src/components/profile/profile.tsx +++ b/client/src/components/profile/profile.tsx @@ -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) => { }; const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => { - const { portfolio, experience, username } = user; + const { username } = user; const { t } = useTranslation(); return ( setIsEditing(false)} open={isEditing} size='large'> @@ -61,10 +59,6 @@ const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => { - - - - ); @@ -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 && } - {showPoints ? : null} - {showHeatMap ? : null} - {showPortfolio ? ( - + {showPoints || isSessionUser ? ( + ) : null} - {showExperience ? ( - + {showHeatMap || isSessionUser ? ( + ) : null} - {showCerts ? : null} - {showTimeLine ? ( - + {showPortfolio || isSessionUser ? ( + + ) : null} + {showExperience || isSessionUser ? ( + + ) : null} + {showCerts || isSessionUser ? ( + + ) : null} + {showTimeLine || isSessionUser ? ( + ) : null} diff --git a/client/src/components/settings/settings-sidebar-nav.tsx b/client/src/components/settings/settings-sidebar-nav.tsx index 96371f4303c..b3b70768f4c 100644 --- a/client/src/components/settings/settings-sidebar-nav.tsx +++ b/client/src/components/settings/settings-sidebar-nav.tsx @@ -30,6 +30,21 @@ function SettingsSidebarNav({ return (

      + + + {t('settings.headings.personal')} + + { 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/ ); diff --git a/e2e/portfolio.spec.ts b/e2e/portfolio.spec.ts index 107ae027b88..d0ae385ad09 100644 --- a/e2e/portfolio.spec.ts +++ b/e2e/portfolio.spec.ts @@ -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/ ); diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index e8c277d378c..d473899d13b 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -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 }) => {