diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 3d8d22e3c59..e5e8ce0ba52 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -332,7 +332,8 @@ "total-points": "Total Points:", "points": "{{count}} point on {{date}}", "points_plural": "{{count}} points on {{date}}", - "page-number": "{{pageNumber}} of {{totalPages}}" + "page-number": "{{pageNumber}} of {{totalPages}}", + "edit-my-profile": "Edit My Profile" }, "footer": { "tax-exempt-status": "freeCodeCamp is a donor-supported tax-exempt 501(c)(3) charitable organization (United States Federal Tax Identification Number: 82-0779546).", @@ -772,7 +773,8 @@ "code-example": "{{codeName}} code example", "opens-new-window": "Opens in new window", "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" + "similar-questions-checkbox": "I have searched for similar questions that have already been answered on the forum", + "edit-my-profile": "Edit my profile" }, "flash": { "honest-first": "To claim a certification, you must first agree to our academic honesty policy", diff --git a/client/src/client-only-routes/show-profile-or-four-oh-four.tsx b/client/src/client-only-routes/show-profile-or-four-oh-four.tsx index 304314db273..59c01ace546 100644 --- a/client/src/client-only-routes/show-profile-or-four-oh-four.tsx +++ b/client/src/client-only-routes/show-profile-or-four-oh-four.tsx @@ -7,15 +7,25 @@ import FourOhFour from '../components/FourOhFour'; import Loader from '../components/helpers/loader'; import Profile from '../components/profile/profile'; import { fetchProfileForUser } from '../redux/actions'; + +import { + submitNewAbout, + updateMyPortfolio, + updateMySocials +} from '../redux/settings/actions'; import { usernameSelector, userByNameSelector, userProfileFetchStateSelector } from '../redux/selectors'; import { User } from '../redux/prop-types'; +import { Socials } from '../components/profile/components/internet'; interface ShowProfileOrFourOhFourProps { fetchProfileForUser: (username: string) => void; + updateMyPortfolio: () => void; + submitNewAbout: () => void; + updateMySocials: (formValues: Socials) => void; fetchState: { pending: boolean; complete: boolean; @@ -53,14 +63,23 @@ const makeMapStateToProps = const mapDispatchToProps: { fetchProfileForUser: ShowProfileOrFourOhFourProps['fetchProfileForUser']; + submitNewAbout: () => void; + updateMyPortfolio: () => void; + updateMySocials: (formValues: Socials) => void; } = { - fetchProfileForUser + fetchProfileForUser, + submitNewAbout, + updateMyPortfolio, + updateMySocials }; function ShowProfileOrFourOhFour({ requestedUser, maybeUser, fetchProfileForUser, + submitNewAbout, + updateMyPortfolio, + updateMySocials, isSessionUser, showLoading }: ShowProfileOrFourOhFourProps) { @@ -85,7 +104,13 @@ function ShowProfileOrFourOhFour({ ) ) : ( - + ); } diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index 32c0bf97696..e24773cc6a7 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -11,12 +11,10 @@ import envData from '../../config/env.json'; import { createFlashMessage } from '../components/Flash/redux'; import { Loader, Spacer } from '../components/helpers'; import Certification from '../components/settings/certification'; -import About from '../components/settings/about'; +import MiscSettings from '../components/settings/misc-settings'; import DangerZone from '../components/settings/danger-zone'; import Email from '../components/settings/email'; import Honesty from '../components/settings/honesty'; -import Internet, { Socials } from '../components/settings/internet'; -import Portfolio from '../components/settings/portfolio'; import Privacy from '../components/settings/privacy'; import { type ThemeProps, Themes } from '../components/settings/theme'; import UserToken from '../components/settings/user-token'; @@ -31,9 +29,7 @@ import { User } from '../redux/prop-types'; import { submitNewAbout, updateMyHonesty, - updateMyPortfolio, updateMyQuincyEmail, - updateMySocials, updateMySound, updateMyTheme, updateMyKeyboardShortcuts, @@ -47,12 +43,9 @@ type ShowSettingsProps = Pick & { isSignedIn: boolean; navigate: (location: string) => void; showLoading: boolean; - submitNewAbout: () => void; toggleSoundMode: (sound: boolean) => void; toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; - updateSocials: (formValues: Socials) => void; updateIsHonest: () => void; - updatePortfolio: () => void; updateQuincyEmail: (isSendQuincyEmail: boolean) => void; user: User; verifyCert: typeof verifyCert; @@ -81,9 +74,7 @@ const mapDispatchToProps = { toggleSoundMode: (sound: boolean) => updateMySound({ sound }), toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => updateMyKeyboardShortcuts({ keyboardShortcuts }), - updateSocials: (formValues: Socials) => updateMySocials(formValues), updateIsHonest: updateMyHonesty, - updatePortfolio: updateMyPortfolio, updateQuincyEmail: (sendQuincyEmail: boolean) => updateMyQuincyEmail({ sendQuincyEmail }), verifyCert @@ -94,7 +85,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { const { createFlashMessage, isSignedIn, - submitNewAbout, toggleNightMode, toggleSoundMode, toggleKeyboardShortcuts, @@ -124,23 +114,12 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { isHonest, sendQuincyEmail, username, - about, - picture, theme, - keyboardShortcuts, - location, - name, - githubProfile, - linkedin, - twitter, - website, - portfolio + keyboardShortcuts }, navigate, showLoading, updateQuincyEmail, - updateSocials, - updatePortfolio, updateIsHonest, verifyCert, userToken @@ -170,19 +149,13 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { > {t('settings.for', { username: username })} - @@ -194,16 +167,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { updateQuincyEmail={updateQuincyEmail} /> - - - - renders correctly 1`] = ` -

- @ - string -

+
+

+ @ + string +

+
& { - sound: boolean; - keyboardShortcuts: boolean; - submitNewAbout: (formValues: FormValues) => void; - t: TFunction; - toggleSoundMode: (sound: boolean) => void; - toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; - }; +type AboutProps = Omit< + CamperProps, + | 'linkedin' + | 'joinDate' + | 'isDonating' + | 'githubProfile' + | 'twitter' + | 'website' + | 'yearsTopContributor' +> & { + t: TFunction; + submitNewAbout: (formValues: FormValues) => void; + setIsEditing: (isEditing: boolean) => void; +}; type FormValues = Pick; @@ -81,6 +72,10 @@ class AboutSettings extends Component { }; } + toggleEditing = () => { + this.props.setIsEditing(false); + }; + componentDidUpdate() { const { name, location, picture, about } = this.props; const { formValues, formClicked } = this.state; @@ -119,10 +114,12 @@ class AboutSettings extends Component { const { formValues } = this.state; const { submitNewAbout } = this.props; if (this.state.isPictureUrlValid === true && !this.isFormPristine()) { + this.toggleEditing(); return this.setState({ formClicked: true }, () => submitNewAbout(formValues) ); } else { + this.toggleEditing(); return false; } }; @@ -198,20 +195,9 @@ class AboutSettings extends Component { const { formValues: { name, location, picture, about } } = this.state; - const { - currentTheme, - sound, - keyboardShortcuts, - username, - t, - toggleNightMode, - toggleSoundMode, - toggleKeyboardShortcuts - } = this.props; + const { t } = this.props; return ( <> - - {t('settings.headings.personal-info')}
{
- - - - - - - ); } diff --git a/client/src/components/profile/components/bio.tsx b/client/src/components/profile/components/bio.tsx index aee5b1df023..3ae910199b8 100644 --- a/client/src/components/profile/components/bio.tsx +++ b/client/src/components/profile/components/bio.tsx @@ -1,12 +1,16 @@ import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCalendar, faLocationDot } from '@fortawesome/free-solid-svg-icons'; +import { + faCalendar, + faLocationDot, + faPen +} from '@fortawesome/free-solid-svg-icons'; import { useTranslation } from 'react-i18next'; +import { Button } from '@freecodecamp/ui'; import { AvatarRenderer, FullWidthRow, Spacer } from '../../helpers'; import { parseDate } from './utils'; import SocialIcons from './social-icons'; import { type CamperProps } from './camper'; - const Bio = ({ joinDate, location, @@ -19,7 +23,9 @@ const Bio = ({ website, isDonating, yearsTopContributor, - picture + picture, + setIsEditing, + isSessionUser }: CamperProps) => { const { t } = useTranslation(); @@ -35,7 +41,19 @@ const Bio = ({ picture={picture} />
-

@{username}

+
+

@{username}

+ {isSessionUser && ( + + )} +
{name &&

{name}

} {about &&

{about}

} diff --git a/client/src/components/profile/components/camper.css b/client/src/components/profile/components/camper.css index be57642821f..a17f00ab453 100644 --- a/client/src/components/profile/components/camper.css +++ b/client/src/components/profile/components/camper.css @@ -40,6 +40,17 @@ color: var(--quaternary-color); } +.profile-edit-container { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 1em; +} + +.profile-edit-container h1 { + margin: 0; +} + .profile-meta-container div { margin-inline-end: 12px; } diff --git a/client/src/components/profile/components/camper.tsx b/client/src/components/profile/components/camper.tsx index c861b4843fc..cf09ef7fca7 100644 --- a/client/src/components/profile/components/camper.tsx +++ b/client/src/components/profile/components/camper.tsx @@ -21,7 +21,10 @@ export type CamperProps = Pick< | 'picture' | 'name' | 'joinDate' ->; +> & { + setIsEditing: (value: boolean) => void; + isSessionUser: boolean; +}; function Camper({ name, @@ -35,11 +38,12 @@ function Camper({ joinDate, linkedin, twitter, - website + website, + isSessionUser, + setIsEditing }: CamperProps): JSX.Element { const { t } = useTranslation(); const isTopContributor = yearsTopContributor.filter(Boolean).length > 0; - return ( <>
@@ -56,6 +60,8 @@ function Camper({ isDonating={isDonating} yearsTopContributor={yearsTopContributor} picture={picture} + setIsEditing={setIsEditing} + isSessionUser={isSessionUser} />
{(isDonating || isTopContributor) && ( diff --git a/client/src/components/settings/internet.tsx b/client/src/components/profile/components/internet.tsx similarity index 95% rename from client/src/components/settings/internet.tsx rename to client/src/components/profile/components/internet.tsx index 52da7446991..a0fb36c11db 100644 --- a/client/src/components/settings/internet.tsx +++ b/client/src/components/profile/components/internet.tsx @@ -12,11 +12,11 @@ import { type FormGroupProps } from '@freecodecamp/ui'; -import { maybeUrlRE } from '../../utils'; +import { maybeUrlRE } from '../../../utils'; -import { FullWidthRow } from '../helpers'; -import BlockSaveButton from '../helpers/form/block-save-button'; -import SectionHeader from './section-header'; +import { FullWidthRow } from '../../helpers'; +import BlockSaveButton from '../../helpers/form/block-save-button'; +import SectionHeader from '../../settings/section-header'; export interface Socials { githubProfile: string; @@ -28,6 +28,7 @@ export interface Socials { interface InternetProps extends Socials { t: TFunction; updateSocials: (formValues: Socials) => void; + setIsEditing: (isEditing: boolean) => void; } type InternetState = { @@ -61,6 +62,10 @@ class InternetSettings extends Component { }; } + toggleEditing = () => { + this.props.setIsEditing(false); + }; + componentDidUpdate() { const { githubProfile = '', @@ -134,8 +139,10 @@ class InternetSettings extends Component { const { formValues } = this.state; const { updateSocials } = this.props; + this.toggleEditing(); return updateSocials({ ...formValues }); } + this.toggleEditing(); return null; }; diff --git a/client/src/components/settings/portfolio.tsx b/client/src/components/profile/components/portfolio.tsx similarity index 95% rename from client/src/components/settings/portfolio.tsx rename to client/src/components/profile/components/portfolio.tsx index aa1fe1a8d86..78781318866 100644 --- a/client/src/components/settings/portfolio.tsx +++ b/client/src/components/profile/components/portfolio.tsx @@ -12,13 +12,13 @@ import { } from '@freecodecamp/ui'; import { withTranslation } from 'react-i18next'; import isURL from 'validator/lib/isURL'; -import { PortfolioProjectData } from '../../redux/prop-types'; +import { PortfolioProjectData } from '../../../redux/prop-types'; -import { hasProtocolRE } from '../../utils'; +import { hasProtocolRE } from '../../../utils'; -import { FullWidthRow, Spacer } from '../helpers'; -import BlockSaveButton from '../helpers/form/block-save-button'; -import SectionHeader from './section-header'; +import { FullWidthRow, Spacer } from '../../helpers'; +import BlockSaveButton from '../../helpers/form/block-save-button'; +import SectionHeader from '../../settings/section-header'; type PortfolioProps = { picture?: string; @@ -26,6 +26,7 @@ type PortfolioProps = { t: TFunction; updatePortfolio: (obj: { portfolio: PortfolioProjectData[] }) => void; username?: string; + setIsEditing: (isEditing: boolean) => void; }; type PortfolioState = { @@ -65,6 +66,10 @@ class PortfolioSettings extends Component { }; } + toggleEditing = () => { + this.props.setIsEditing(false); + }; + createOnChangeHandler = (id: string, key: 'description' | 'image' | 'title' | 'url') => (e: React.ChangeEvent) => { @@ -90,6 +95,7 @@ class PortfolioSettings extends Component { this.setState({ unsavedItemId: null }); } this.props.updatePortfolio({ portfolio }); + this.toggleEditing(); }; handleAdd = () => { @@ -107,6 +113,7 @@ class PortfolioSettings extends Component { }), () => this.updateItem(id) ); + this.toggleEditing(); }; isFormPristine = (id: string) => { @@ -252,6 +259,7 @@ class PortfolioSettings extends Component { const handleSubmit = (e: React.FormEvent, id: string) => { e.preventDefault(); if (isButtonDisabled) return null; + this.toggleEditing(); return this.updateItem(id); }; return ( diff --git a/client/src/components/profile/components/social-icons.tsx b/client/src/components/profile/components/social-icons.tsx index d55780c6888..b1285b01a9f 100644 --- a/client/src/components/profile/components/social-icons.tsx +++ b/client/src/components/profile/components/social-icons.tsx @@ -21,12 +21,17 @@ interface SocialIconsProps { website: string; } -function LinkedInIcon(linkedIn: string, username: string): JSX.Element { +interface IconProps { + href: string; + username: string; +} + +function LinkedInIcon({ href, username }: IconProps): JSX.Element { const { t } = useTranslation(); return ( @@ -35,12 +40,12 @@ function LinkedInIcon(linkedIn: string, username: string): JSX.Element { ); } -function GitHubIcon(ghURL: string, username: string): JSX.Element { +function GitHubIcon({ href, username }: IconProps): JSX.Element { const { t } = useTranslation(); return ( @@ -49,12 +54,12 @@ function GitHubIcon(ghURL: string, username: string): JSX.Element { ); } -function WebsiteIcon(website: string, username: string): JSX.Element { +function WebsiteIcon({ href, username }: IconProps): JSX.Element { const { t } = useTranslation(); return ( @@ -63,12 +68,12 @@ function WebsiteIcon(website: string, username: string): JSX.Element { ); } -function TwitterIcon(handle: string, username: string): JSX.Element { +function TwitterIcon({ href, username }: IconProps): JSX.Element { const { t } = useTranslation(); return ( @@ -87,10 +92,12 @@ function SocialIcons(props: SocialIconsProps): JSX.Element | null { return ( - {linkedin ? LinkedInIcon(linkedin, username) : null} - {githubProfile ? GitHubIcon(githubProfile, username) : null} - {website ? WebsiteIcon(website, username) : null} - {twitter ? TwitterIcon(twitter, username) : null} + {linkedin ? : null} + {githubProfile ? ( + + ) : null} + {website ? : null} + {twitter ? : null} ); diff --git a/client/src/components/settings/username.tsx b/client/src/components/profile/components/username.tsx similarity index 93% rename from client/src/components/settings/username.tsx rename to client/src/components/profile/components/username.tsx index 8f742765840..b8e374a19b6 100644 --- a/client/src/components/settings/username.tsx +++ b/client/src/components/profile/components/username.tsx @@ -8,14 +8,14 @@ import { bindActionCreators } from 'redux'; import type { Dispatch } from 'redux'; import { createSelector } from 'reselect'; -import { isValidUsername } from '../../../../shared/utils/validate'; -import { usernameValidationSelector } from '../../redux/settings/selectors'; +import { isValidUsername } from '../../../../../shared/utils/validate'; +import { usernameValidationSelector } from '../../../redux/settings/selectors'; import { validateUsername, submitNewUsername -} from '../../redux/settings/actions'; -import BlockSaveButton from '../helpers/form/block-save-button'; -import FullWidthRow from '../helpers/full-width-row'; +} from '../../../redux/settings/actions'; +import BlockSaveButton from '../../helpers/form/block-save-button'; +import FullWidthRow from '../../helpers/full-width-row'; type UsernameProps = { isValidUsername: boolean; @@ -24,6 +24,7 @@ type UsernameProps = { username: string; validateUsername: (name: string) => void; validating: boolean; + setIsEditing: (isEditing: boolean) => void; }; type UsernameState = { @@ -101,6 +102,10 @@ class UsernameSettings extends Component { return null; } + toggleEditing = () => { + this.props.setIsEditing(false); + }; + handleSubmit(e: React.FormEvent) { e.preventDefault(); const { submitNewUsername } = this.props; @@ -109,6 +114,8 @@ class UsernameSettings extends Component { characterValidation: { valid } } = this.state; + this.toggleEditing(); + return this.setState({ submitClicked: true }, () => valid ? submitNewUsername(formValue) : null ); diff --git a/client/src/components/profile/profile.css b/client/src/components/profile/profile.css new file mode 100644 index 00000000000..62b3b15bce0 --- /dev/null +++ b/client/src/components/profile/profile.css @@ -0,0 +1,5 @@ +.button-fit { + width: 30px; + height: fit-content; + padding: 0 !important; +} diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx index 663038baead..65632f9521e 100644 --- a/client/src/components/profile/profile.tsx +++ b/client/src/components/profile/profile.tsx @@ -1,21 +1,39 @@ -import React from 'react'; +import React, { useState } from 'react'; import Helmet from 'react-helmet'; import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; - -import { Alert, Container, Row } from '@freecodecamp/ui'; +import { Alert, Container, Modal, Row } from '@freecodecamp/ui'; import { FullWidthRow, Link, Spacer } from '../helpers'; +import Portfolio from './components/portfolio'; + +import UsernameSettings from './components/username'; +import About from './components/about'; +import Internet, { Socials } from './components/internet'; import { User } from './../../redux/prop-types'; import Timeline from './components/time-line'; import Camper from './components/camper'; import Certifications from './components/certifications'; import Stats from './components/stats'; import HeatMap from './components/heat-map'; +import './profile.css'; import { PortfolioProjects } from './components/portfolio-projects'; interface ProfileProps { isSessionUser: boolean; user: User; + updateMyPortfolio: () => void; + updateMySocials: (formValues: Socials) => void; + submitNewAbout: () => void; +} + +interface EditModalProps { + user: User; + isEditing: boolean; + isSessionUser: boolean; + setIsEditing: (isEditing: boolean) => void; + updateMySocials: (formValues: Socials) => void; + updateMyPortfolio: () => void; + submitNewAbout: () => void; } interface MessageProps { isSessionUser: boolean; @@ -32,6 +50,64 @@ const UserMessage = ({ t }: Pick) => { ); }; +const EditModal = ({ + user, + isEditing, + isSessionUser, + setIsEditing, + updateMyPortfolio, + updateMySocials, + submitNewAbout +}: EditModalProps) => { + const { + portfolio, + username, + about, + location, + name, + picture, + githubProfile, + linkedin, + twitter, + website + } = user; + const { t } = useTranslation(); + return ( + setIsEditing(false)} open={isEditing} size='large'> + {t('profile.edit-my-profile')} + + + + + + + + + + + ); +}; + const VisitorMessage = ({ t, username @@ -53,7 +129,15 @@ const Message = ({ isSessionUser, t, username }: MessageProps) => { return ; }; -function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element { +function UserProfile({ + user, + isSessionUser, + updateMyPortfolio, + updateMySocials, + submitNewAbout +}: ProfileProps): JSX.Element { + const [isEditing, setIsEditing] = useState(false); + const { profileUI: { showAbout, @@ -86,6 +170,17 @@ function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element { return ( <> + {isSessionUser && ( + + )} {showPoints ? : null} {showHeatMap ? : null} - {showCerts ? : null} {showPortfolio ? ( ) : null} + {showCerts ? : null} {showTimeLine ? ( ) : null} @@ -114,7 +211,13 @@ function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element { ); } -function Profile({ user, isSessionUser }: ProfileProps): JSX.Element { +function Profile({ + user, + isSessionUser, + updateMyPortfolio, + updateMySocials, + submitNewAbout +}: ProfileProps): JSX.Element { const { t } = useTranslation(); const { profileUI: { isLocked }, @@ -134,7 +237,15 @@ function Profile({ user, isSessionUser }: ProfileProps): JSX.Element { {isLocked && ( )} - {showUserProfile && } + {showUserProfile && ( + + )} {!isSessionUser && ( diff --git a/client/src/components/settings/misc-settings.tsx b/client/src/components/settings/misc-settings.tsx new file mode 100644 index 00000000000..0468238a76d --- /dev/null +++ b/client/src/components/settings/misc-settings.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Spacer, FullWidthRow } from '../helpers'; +import ThemeSettings, { ThemeProps } from '../../components/settings/theme'; +import SoundSettings from '../../components/settings/sound'; +import KeyboardShortcutsSettings from '../../components/settings/keyboard-shortcuts'; +import ScrollbarWidthSettings from '../../components/settings/scrollbar-width'; + +type MiscSettingsProps = ThemeProps & { + currentTheme: string; + keyboardShortcuts: boolean; + sound: boolean; + toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; + toggleNightMode: () => void; + toggleSoundMode: (sound: boolean) => void; +}; + +const MiscSettings = ({ + currentTheme, + keyboardShortcuts, + sound, + toggleKeyboardShortcuts, + toggleNightMode, + toggleSoundMode +}: MiscSettingsProps) => { + const { t } = useTranslation(); + + return ( + <> + + + + + + + + + ); +}; + +export default MiscSettings; diff --git a/e2e/cert-username-case-navigation.spec.ts b/e2e/cert-username-case-navigation.spec.ts index 4a9c6ef4a8b..9da76f01537 100644 --- a/e2e/cert-username-case-navigation.spec.ts +++ b/e2e/cert-username-case-navigation.spec.ts @@ -23,7 +23,15 @@ test.describe('Public profile certifications', () => { test('Should show claimed certifications if the username includes uppercase characters', async ({ page }) => { - await page.goto('/settings'); + await page.goto('/certifieduser'); + + if (!process.env.CI) { + await page + .getByRole('button', { name: 'Preview custom 404 page' }) + .click(); + } + await page.getByRole('button', { name: 'Edit my profile' }).click(); + await page.getByLabel('Username').fill('CertifiedBoozer'); await page.getByRole('button', { name: 'Save' }).nth(0).click(); await expect(page.getByTestId('flash-message')).toContainText( diff --git a/e2e/edit-profile-modal.spec.ts b/e2e/edit-profile-modal.spec.ts new file mode 100644 index 00000000000..8a41f2b122a --- /dev/null +++ b/e2e/edit-profile-modal.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test'; +import translations from '../client/i18n/locales/english/translations.json'; + +test.use({ storageState: 'playwright/.auth/certified-user.json' }); + +test.beforeEach(async ({ page }) => { + await page.goto('/certifieduser'); + + if (!process.env.CI) { + await page.getByRole('button', { name: 'Preview custom 404 page' }).click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); +}); + +test('edit profile modal should render correctly', async ({ page }) => { + // Internet Presence + await expect( + page.getByRole('heading', { + name: translations.settings.headings.internet + }) + ).toBeVisible(); + await expect(page.getByTestId('internet-presence')).toBeVisible(); + await expect( + page.getByRole('button', { + name: translations.settings.headings.internet + }) + ).toBeVisible(); + + // Personal Information + await expect( + page.getByRole('heading', { + name: translations.settings.headings['personal-info'] + }) + ).toBeVisible(); + await expect(page.getByTestId('camper-identity')).toBeVisible(); + const savePersonalInfoButton = page.getByRole('button', { + name: translations.settings.headings['personal-info'] + }); + await expect(savePersonalInfoButton).toBeVisible(); + await expect(savePersonalInfoButton).toBeDisabled(); + await expect( + page.getByLabel(translations.settings.labels.name, { exact: true }) + ).toHaveValue('Full Stack User'); + await expect( + page.getByLabel(translations.settings.labels.location) + ).toHaveValue(''); + await expect( + page.getByLabel(translations.settings.labels.picture) + ).toHaveValue(''); + await expect(page.getByLabel(translations.settings.labels.about)).toHaveValue( + '' + ); +}); diff --git a/e2e/image-picture-check.spec.ts b/e2e/image-picture-check.spec.ts index be0830c2a0f..c4d3671093d 100644 --- a/e2e/image-picture-check.spec.ts +++ b/e2e/image-picture-check.spec.ts @@ -4,7 +4,15 @@ test.describe('Picture input field', () => { test.use({ storageState: 'playwright/.auth/certified-user.json' }); test.beforeEach(async ({ page }) => { - await page.goto('/settings'); + await page.goto('/certifieduser'); + + if (!process.env.CI) { + await page + .getByRole('button', { name: 'Preview custom 404 page' }) + .click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); }); test('Should be possible to type', async ({ page }) => { diff --git a/e2e/internet-presence-settings.spec.ts b/e2e/internet-presence-settings.spec.ts index 68a94ef9e2d..dd2bbf0deef 100644 --- a/e2e/internet-presence-settings.spec.ts +++ b/e2e/internet-presence-settings.spec.ts @@ -1,3 +1,4 @@ +import { execSync } from 'child_process'; import { test, expect } from '@playwright/test'; import translations from '../client/i18n/locales/english/translations.json'; @@ -14,10 +15,20 @@ const settingsPageElement = { test.use({ storageState: 'playwright/.auth/certified-user.json' }); test.beforeEach(async ({ page }) => { - await page.goto('/settings'); + // Reset input values + execSync('node ./tools/scripts/seed/seed-demo-user --certified-user'); + + await page.goto('/certifieduser'); + + if (!process.env.CI) { + await page.getByRole('button', { name: 'Preview custom 404 page' }).click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); }); test.describe('Your Internet Presence', () => { + test.skip(({ browserName }) => browserName === 'webkit', 'flaky on Safari'); test('should display the section with save button being disabled', async ({ page }) => { @@ -70,7 +81,8 @@ test.describe('Your Internet Presence', () => { }); test(`should update ${social.name} URL`, async ({ page }) => { - const socialInput = page.getByLabel(social.label); + const socialInput = page.getByRole('textbox', { name: social.label }); + await expect(socialInput).toBeVisible(); await socialInput.fill(social.url); const socialCheckmark = page.getByTestId(social.checkTestId); await expect(socialCheckmark).toBeVisible(); @@ -81,21 +93,9 @@ test.describe('Your Internet Presence', () => { await expect(saveButton).toBeVisible(); await saveButton.click(); - await expect( - page.getByTestId(settingsPageElement.flashMessageAlert) - ).toContainText('We have updated your social links'); - - // clear value before next test - await socialInput.clear(); - await Promise.all([ - page.waitForResponse( - response => - response.url().includes('update-my-socials') && - response.status() === 200 - ), - saveButton.click() - ]); - await expect(socialCheckmark).toBeHidden(); + await expect(page.getByRole('alert').first()).toContainText( + 'We have updated your social links' + ); }); }); }); diff --git a/e2e/portfolio.spec.ts b/e2e/portfolio.spec.ts index 51629def38b..493721371e5 100644 --- a/e2e/portfolio.spec.ts +++ b/e2e/portfolio.spec.ts @@ -14,7 +14,15 @@ test.afterAll(() => { test.describe('Add Portfolio Item', () => { test.beforeEach(async ({ page }) => { - await page.goto('/settings'); + await page.goto('/developmentuser'); + + if (!process.env.CI) { + await page + .getByRole('button', { name: 'Preview custom 404 page' }) + .click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); }); test('The title has validation', async ({ page }) => { @@ -134,7 +142,7 @@ test.describe('Add Portfolio Item', () => { await page .getByRole('button', { name: 'Save this portfolio item' }) .click(); - await expect(page.getByTestId('flash-message')).toContainText( + await expect(page.getByRole('alert').first()).toContainText( /We have updated your portfolio/ ); }); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index e7c78f8d8ed..fa25e0616d2 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -6,9 +6,7 @@ import { alertToBeVisible } from './utils/alerts'; const settingsTestIds = { settingsHeading: 'settings-heading', - internetPresence: 'internet-presence', - portfolioItems: 'portfolio-items', - camperIdentity: 'camper-identity' + portfolioItems: 'portfolio-items' }; const settingsObject = { @@ -173,47 +171,6 @@ test.describe('Settings - Certified User', () => { }); await expect(downloadButton).toBeVisible(); - // Internet Presence - await expect( - page.getByRole('heading', { - name: translations.settings.headings.internet - }) - ).toBeVisible(); - await expect( - page.getByTestId(settingsTestIds.internetPresence) - ).toBeVisible(); - await expect( - page.getByRole('button', { - name: translations.settings.headings.internet - }) - ).toBeVisible(); - - // Personal Information - await expect( - page.getByRole('heading', { - name: translations.settings.headings['personal-info'] - }) - ).toBeVisible(); - await expect( - page.getByTestId(settingsTestIds.camperIdentity) - ).toBeVisible(); - const savePersonalInfoButton = page.getByRole('button', { - name: translations.settings.headings['personal-info'] - }); - await expect(savePersonalInfoButton).toBeVisible(); - await expect(savePersonalInfoButton).toBeDisabled(); - await expect( - page.getByLabel(translations.settings.labels.name, { exact: true }) - ).toHaveValue('Full Stack User'); - await expect( - page.getByLabel(translations.settings.labels.location) - ).toHaveValue(''); - await expect( - page.getByLabel(translations.settings.labels.picture) - ).toHaveValue(''); - await expect( - page.getByLabel(translations.settings.labels.about) - ).toHaveValue(''); await expect( page .getByRole('group', { @@ -299,53 +256,6 @@ test.describe('Settings - Certified User', () => { }) ).toBeVisible(); }); - - test('Should allow empty string in any field in about settings', async ({ - page - }) => { - const saveButton = page.getByRole('button', { - name: translations.settings.headings['personal-info'] - }); - - const nameInput = page.getByLabel(translations.settings.labels.name, { - exact: true - }); - const locationInput = page.getByLabel( - translations.settings.labels.location - ); - const pictureInput = page.getByLabel(translations.settings.labels.picture); - const aboutInput = page.getByLabel(translations.settings.labels.about); - const updatedAlert = page.getByText(translations.flash['updated-about-me']); - - await nameInput.fill('Quincy Larson'); - await locationInput.fill('USA'); - await pictureInput.fill( - 'https://cdn.freecodecamp.org/platform/english/images/quincy-larson-signature.svg' - ); - await aboutInput.fill('Teacher at freeCodeCamp'); - - await expect(saveButton).not.toBeDisabled(); - await saveButton.click(); - await expect(updatedAlert).toBeVisible(); - // clear the alert to make sure it's gone before we save again. - await updatedAlert.getByRole('button').click(); - - await nameInput.fill(''); - await locationInput.fill(''); - await pictureInput.fill(''); - await aboutInput.fill(''); - - await expect(saveButton).not.toBeDisabled(); - await saveButton.click(); - await expect(updatedAlert).toBeVisible(); - - await page.reload(); - - await expect(nameInput).toHaveValue(''); - await expect(locationInput).toHaveValue(''); - await expect(pictureInput).toHaveValue(''); - await expect(aboutInput).toHaveValue(''); - }); }); // In order to claim the Full Stack cert, the user needs to complete 6 certs. diff --git a/e2e/update-about-me.spec.ts b/e2e/update-about-me.spec.ts new file mode 100644 index 00000000000..de50e0d6fcf --- /dev/null +++ b/e2e/update-about-me.spec.ts @@ -0,0 +1,80 @@ +import { execSync } from 'child_process'; +import { test, expect } from '@playwright/test'; +import translations from '../client/i18n/locales/english/translations.json'; + +test.use({ storageState: 'playwright/.auth/certified-user.json' }); + +test.beforeEach(async ({ page }) => { + execSync( + 'node ./tools/scripts/seed/seed-demo-user --certified-user --set-false isFullStackCert' + ); + await page.goto('/settings'); +}); +test.beforeEach(async ({ page }) => { + await page.goto('/certifieduser'); + + if (!process.env.CI) { + await page.getByRole('button', { name: 'Preview custom 404 page' }).click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); +}); + +test.afterAll(() => { + execSync('node ./tools/scripts/seed/seed-demo-user --certified-user'); +}); + +test('Should allow empty string in any field in about settings', async ({ + page +}) => { + test.setTimeout(20000); + + const saveButton = page.getByRole('button', { + name: translations.settings.headings['personal-info'] + }); + + const nameInput = page.getByLabel(translations.settings.labels.name, { + exact: true + }); + const locationInput = page.getByLabel(translations.settings.labels.location); + const pictureInput = page.getByLabel(translations.settings.labels.picture); + const aboutInput = page.getByLabel(translations.settings.labels.about); + const updatedAlert = page + .getByRole('alert') + .filter({ hasText: translations.flash['updated-about-me'] }) + .first(); + + await nameInput.fill('Quincy Larson'); + await locationInput.fill('USA'); + await pictureInput.fill( + 'https://cdn.freecodecamp.org/platform/english/images/quincy-larson-signature.svg' + ); + await aboutInput.fill('Teacher at freeCodeCamp'); + + await expect(saveButton).not.toBeDisabled(); + await saveButton.click(); + await expect(updatedAlert).toBeVisible(); + // clear the alert to make sure it's gone before we save again. + await updatedAlert.getByRole('button').click(); + await page.getByRole('button', { name: 'Edit my profile' }).click(); + await nameInput.fill(''); + await locationInput.fill(''); + await pictureInput.fill(''); + await aboutInput.fill(''); + + await expect(saveButton).not.toBeDisabled(); + await saveButton.click(); + await expect(updatedAlert).toBeVisible(); + + await page.reload(); + + if (!process.env.CI) { + await page.getByRole('button', { name: 'Preview custom 404 page' }).click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); + await expect(nameInput).toHaveValue(''); + await expect(locationInput).toHaveValue(''); + await expect(pictureInput).toHaveValue(''); + await expect(aboutInput).toHaveValue(''); +}); diff --git a/e2e/username-change.spec.ts b/e2e/username-change.spec.ts index 17b7d4b11be..f26b06d40c7 100644 --- a/e2e/username-change.spec.ts +++ b/e2e/username-change.spec.ts @@ -16,9 +16,19 @@ const settingsObject = { errorCode: '404' }; +let currentUsername = 'certifieduser'; + test.describe('Username Settings Validation', () => { test.beforeEach(async ({ page }) => { - await page.goto('/settings'); + await page.goto(`/${currentUsername}`); + + if (!process.env.CI) { + await page + .getByRole('button', { name: 'Preview custom 404 page' }) + .click(); + } + + await page.getByRole('button', { name: 'Edit my profile' }).click(); }); test('Should display Username Input and Save Button', async ({ page }) => { @@ -87,17 +97,18 @@ test.describe('Username Settings Validation', () => { const saveButton = page.getByRole('button', { name: translations.settings.labels.username }); + const flashText = translations.flash['username-updated'].replace( + settingsObject.usernamePlaceholder, + settingsObject.usernameAvailable + ); + await inputLabel.fill(settingsObject.usernameAvailable); await expect(saveButton).not.toBeDisabled(); await saveButton.click(); await expect( - page.getByText( - translations.flash['username-updated'].replace( - settingsObject.usernamePlaceholder, - settingsObject.usernameAvailable - ) - ) + page.getByRole('alert').filter({ hasText: flashText }).first() ).toBeVisible(); + currentUsername = settingsObject.usernameAvailable; }); test('should update username in lowercase and reflect in the UI', async ({ @@ -107,17 +118,18 @@ test.describe('Username Settings Validation', () => { const saveButton = page.getByRole('button', { name: translations.settings.labels.username }); + const flashText = translations.flash['username-updated'].replace( + settingsObject.usernamePlaceholder, + settingsObject.usernameUpdateToLowerCase + ); + await inputLabel.fill(settingsObject.usernameUpdateToLowerCase); await expect(saveButton).not.toBeDisabled(); await saveButton.click(); await expect( - page.getByText( - translations.flash['username-updated'].replace( - settingsObject.usernamePlaceholder, - settingsObject.usernameUpdateToLowerCase - ) - ) + page.getByRole('alert').filter({ hasText: flashText }).first() ).toBeVisible(); + currentUsername = settingsObject.usernameUpdateToLowerCase; }); test('should update username in uppercase and reflect in the UI', async ({ @@ -127,23 +139,29 @@ test.describe('Username Settings Validation', () => { const saveButton = page.getByRole('button', { name: translations.settings.labels.username }); + const flashText = translations.flash['username-updated'].replace( + settingsObject.usernamePlaceholder, + settingsObject.usernameUpdateToUpperCase + ); + await inputLabel.fill(settingsObject.usernameUpdateToUpperCase); await expect(saveButton).not.toBeDisabled(); await saveButton.click(); await expect( - page.getByText( - translations.flash['username-updated'].replace( - settingsObject.usernamePlaceholder, - settingsObject.usernameUpdateToUpperCase - ) - ) + page.getByRole('alert').filter({ hasText: flashText }).first() ).toBeVisible(); + currentUsername = settingsObject.usernameUpdateToUpperCase; }); test('should update username by pressing enter', async ({ page }) => { const inputLabel = page.getByLabel(translations.settings.labels.username); await inputLabel.fill(settingsObject.testUser); + const flashText = translations.flash['username-updated'].replace( + settingsObject.usernamePlaceholder, + settingsObject.testUser + ); + await expect( page.getByText(translations.settings.username.available) ).toBeVisible(); @@ -151,13 +169,9 @@ test.describe('Username Settings Validation', () => { await inputLabel.press('Enter'); await expect( - page.getByText( - translations.flash['username-updated'].replace( - settingsObject.usernamePlaceholder, - settingsObject.testUser - ) - ) + page.getByRole('alert').filter({ hasText: flashText }).first() ).toBeVisible(); + currentUsername = settingsObject.testUser; }); test('should not be able to update username to the same username', async ({