feat: move profile settings to profile page (#56135)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Sem Bauke
2024-10-03 13:34:41 +02:00
committed by GitHub
parent 17bb3aeb73
commit b0146aa865
24 changed files with 563 additions and 287 deletions
@@ -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",
@@ -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({
<FourOhFour />
)
) : (
<Profile isSessionUser={isSessionUser} user={requestedUser} />
<Profile
isSessionUser={isSessionUser}
user={requestedUser}
submitNewAbout={submitNewAbout}
updateMyPortfolio={updateMyPortfolio}
updateMySocials={updateMySocials}
/>
);
}
@@ -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<ThemeProps, 'toggleNightMode'> & {
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 })}
</h1>
<About
about={about}
<MiscSettings
currentTheme={theme}
location={location}
name={name}
picture={picture}
sound={sound}
keyboardShortcuts={keyboardShortcuts}
submitNewAbout={submitNewAbout}
sound={sound}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
toggleNightMode={toggleNightMode}
toggleSoundMode={toggleSoundMode}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
username={username}
/>
<Spacer size='medium' />
<Privacy />
@@ -194,16 +167,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
updateQuincyEmail={updateQuincyEmail}
/>
<Spacer size='medium' />
<Internet
githubProfile={githubProfile}
linkedin={linkedin}
twitter={twitter}
updateSocials={updateSocials}
website={website}
/>
<Spacer size='medium' />
<Portfolio portfolio={portfolio} updatePortfolio={updatePortfolio} />
<Spacer size='medium' />
<Honesty isHonest={isHonest} updateIsHonest={updateIsHonest} />
<Spacer size='medium' />
<Certification
+3 -3
View File
@@ -1,6 +1,6 @@
/*
For some reason this `div` is needed in order to overidde
the CSS rules of `ui-components`'s Alert.
/*
For some reason this `div` is needed in order to overidde
the CSS rules of `ui-components`'s Alert.
*/
div.flash-message {
display: flex;
@@ -175,10 +175,14 @@ exports[`<Profile/> renders correctly 1`] = `
</svg>
</div>
</div>
<h1>
@
string
</h1>
<div
class="profile-edit-container"
>
<h1>
@
string
</h1>
</div>
<div
class="spacer"
@@ -10,34 +10,25 @@ import type { TFunction } from 'i18next';
import { withTranslation } from 'react-i18next';
import isURL from 'validator/lib/isURL';
import { FullWidthRow, Spacer } from '../helpers';
import BlockSaveButton from '../helpers/form/block-save-button';
import type { CamperProps } from '../profile/components/camper';
import SoundSettings from './sound';
import ThemeSettings, { type ThemeProps } from './theme';
import UsernameSettings from './username';
import KeyboardShortcutsSettings from './keyboard-shortcuts';
import SectionHeader from './section-header';
import ScrollbarWidthSettings from './scrollbar-width';
import { FullWidthRow } from '../../helpers';
import BlockSaveButton from '../../helpers/form/block-save-button';
import SectionHeader from '../../settings/section-header';
import type { CamperProps } from './camper';
type AboutProps = ThemeProps &
Omit<
CamperProps,
| 'linkedin'
| 'joinDate'
| 'isDonating'
| 'githubProfile'
| 'twitter'
| 'website'
| 'yearsTopContributor'
> & {
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<AboutProps, 'name' | 'location' | 'picture' | 'about'>;
@@ -81,6 +72,10 @@ class AboutSettings extends Component<AboutProps, AboutState> {
};
}
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<AboutProps, AboutState> {
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<AboutProps, AboutState> {
const {
formValues: { name, location, picture, about }
} = this.state;
const {
currentTheme,
sound,
keyboardShortcuts,
username,
t,
toggleNightMode,
toggleSoundMode,
toggleKeyboardShortcuts
} = this.props;
const { t } = this.props;
return (
<>
<UsernameSettings username={username} />
<Spacer size='medium' />
<SectionHeader>{t('settings.headings.personal-info')}</SectionHeader>
<FullWidthRow>
<form
@@ -282,20 +268,6 @@ class AboutSettings extends Component<AboutProps, AboutState> {
</BlockSaveButton>
</form>
</FullWidthRow>
<Spacer size='medium' />
<FullWidthRow>
<ThemeSettings
currentTheme={currentTheme}
toggleNightMode={toggleNightMode}
/>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
keyboardShortcuts={keyboardShortcuts}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
explain={t('settings.shortcuts-explained')}
/>
<ScrollbarWidthSettings />
</FullWidthRow>
</>
);
}
@@ -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}
/>
</div>
<h1>@{username}</h1>
<div className='profile-edit-container'>
<h1>@{username}</h1>
{isSessionUser && (
<Button
onClick={() => setIsEditing(true)}
size='small'
className='button-fit'
aria-label={t('aria.edit-my-profile')}
>
<FontAwesomeIcon icon={faPen} />
</Button>
)}
</div>
{name && <h2>{name}</h2>}
<Spacer size={'small'} />
{about && <p>{about}</p>}
@@ -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;
}
@@ -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 (
<>
<div className='bio-container'>
@@ -56,6 +60,8 @@ function Camper({
isDonating={isDonating}
yearsTopContributor={yearsTopContributor}
picture={picture}
setIsEditing={setIsEditing}
isSessionUser={isSessionUser}
/>
</div>
{(isDonating || isTopContributor) && (
@@ -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<InternetProps, InternetState> {
};
}
toggleEditing = () => {
this.props.setIsEditing(false);
};
componentDidUpdate() {
const {
githubProfile = '',
@@ -134,8 +139,10 @@ class InternetSettings extends Component<InternetProps, InternetState> {
const { formValues } = this.state;
const { updateSocials } = this.props;
this.toggleEditing();
return updateSocials({ ...formValues });
}
this.toggleEditing();
return null;
};
@@ -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<PortfolioProps, PortfolioState> {
};
}
toggleEditing = () => {
this.props.setIsEditing(false);
};
createOnChangeHandler =
(id: string, key: 'description' | 'image' | 'title' | 'url') =>
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -90,6 +95,7 @@ class PortfolioSettings extends Component<PortfolioProps, PortfolioState> {
this.setState({ unsavedItemId: null });
}
this.props.updatePortfolio({ portfolio });
this.toggleEditing();
};
handleAdd = () => {
@@ -107,6 +113,7 @@ class PortfolioSettings extends Component<PortfolioProps, PortfolioState> {
}),
() => this.updateItem(id)
);
this.toggleEditing();
};
isFormPristine = (id: string) => {
@@ -252,6 +259,7 @@ class PortfolioSettings extends Component<PortfolioProps, PortfolioState> {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>, id: string) => {
e.preventDefault();
if (isButtonDisabled) return null;
this.toggleEditing();
return this.updateItem(id);
};
return (
@@ -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 (
<a
aria-label={t('aria.linkedin', { username: username })}
href={linkedIn}
aria-label={t('aria.linkedin', { username })}
href={href}
rel='noopener noreferrer'
target='_blank'
>
@@ -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 (
<a
aria-label={t('aria.github', { username: username })}
href={ghURL}
aria-label={t('aria.github', { username })}
href={href}
rel='noopener noreferrer'
target='_blank'
>
@@ -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 (
<a
aria-label={t('aria.website', { username: username })}
href={website}
aria-label={t('aria.website', { username })}
href={href}
rel='noopener noreferrer'
target='_blank'
>
@@ -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 (
<a
aria-label={t('aria.twitter', { username: username })}
href={handle}
aria-label={t('aria.twitter', { username })}
href={href}
rel='noopener noreferrer'
target='_blank'
>
@@ -87,10 +92,12 @@ function SocialIcons(props: SocialIconsProps): JSX.Element | null {
return (
<Row>
<Col className='social-icons-row'>
{linkedin ? LinkedInIcon(linkedin, username) : null}
{githubProfile ? GitHubIcon(githubProfile, username) : null}
{website ? WebsiteIcon(website, username) : null}
{twitter ? TwitterIcon(twitter, username) : null}
{linkedin ? <LinkedInIcon href={linkedin} username={username} /> : null}
{githubProfile ? (
<GitHubIcon href={githubProfile} username={username} />
) : null}
{website ? <WebsiteIcon href={website} username={username} /> : null}
{twitter ? <TwitterIcon href={twitter} username={username} /> : null}
</Col>
</Row>
);
@@ -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<UsernameProps, UsernameState> {
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<UsernameProps, UsernameState> {
characterValidation: { valid }
} = this.state;
this.toggleEditing();
return this.setState({ submitClicked: true }, () =>
valid ? submitNewUsername(formValue) : null
);
@@ -0,0 +1,5 @@
.button-fit {
width: 30px;
height: fit-content;
padding: 0 !important;
}
+118 -7
View File
@@ -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<MessageProps, 't'>) => {
);
};
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 (
<Modal onClose={() => setIsEditing(false)} open={isEditing} size='large'>
<Modal.Header>{t('profile.edit-my-profile')}</Modal.Header>
<Modal.Body alignment='left'>
<UsernameSettings username={username} setIsEditing={setIsEditing} />
<Spacer size='medium' />
<About
about={about}
location={location}
name={name}
picture={picture}
username={username}
submitNewAbout={submitNewAbout}
setIsEditing={setIsEditing}
isSessionUser={isSessionUser}
/>
<Spacer size='medium' />
<Internet
githubProfile={githubProfile}
linkedin={linkedin}
twitter={twitter}
updateSocials={updateMySocials}
setIsEditing={setIsEditing}
website={website}
/>
<Spacer size='medium' />
<Portfolio
portfolio={portfolio}
updatePortfolio={updateMyPortfolio}
setIsEditing={setIsEditing}
/>
</Modal.Body>
</Modal>
);
};
const VisitorMessage = ({
t,
username
@@ -53,7 +129,15 @@ const Message = ({ isSessionUser, t, username }: MessageProps) => {
return <VisitorMessage t={t} username={username} />;
};
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 && (
<EditModal
user={user}
isEditing={isEditing}
isSessionUser={isSessionUser}
setIsEditing={setIsEditing}
updateMyPortfolio={updateMyPortfolio}
updateMySocials={updateMySocials}
submitNewAbout={submitNewAbout}
/>
)}
<Camper
about={showAbout ? about : ''}
githubProfile={githubProfile}
@@ -99,13 +194,15 @@ function UserProfile({ user }: { user: ProfileProps['user'] }): JSX.Element {
username={username}
website={website}
yearsTopContributor={yearsTopContributor}
isSessionUser={isSessionUser}
setIsEditing={setIsEditing}
/>
{showPoints ? <Stats points={points} calendar={calendar} /> : null}
{showHeatMap ? <HeatMap calendar={calendar} /> : null}
{showCerts ? <Certifications username={username} /> : null}
{showPortfolio ? (
<PortfolioProjects portfolioProjects={portfolio} />
) : null}
{showCerts ? <Certifications username={username} /> : null}
{showTimeLine ? (
<Timeline completedMap={completedChallenges} username={username} />
) : 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 && (
<Message username={username} isSessionUser={isSessionUser} t={t} />
)}
{showUserProfile && <UserProfile user={user} />}
{showUserProfile && (
<UserProfile
user={user}
isSessionUser={isSessionUser}
updateMyPortfolio={updateMyPortfolio}
updateMySocials={updateMySocials}
submitNewAbout={submitNewAbout}
/>
)}
{!isSessionUser && (
<Row className='text-center'>
<Link to={`/user/${username}/report-user`}>
@@ -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 (
<>
<Spacer size='medium' />
<FullWidthRow>
<ThemeSettings
currentTheme={currentTheme}
toggleNightMode={toggleNightMode}
/>
<SoundSettings sound={sound} toggleSoundMode={toggleSoundMode} />
<KeyboardShortcutsSettings
keyboardShortcuts={keyboardShortcuts}
toggleKeyboardShortcuts={toggleKeyboardShortcuts}
explain={t('settings.shortcuts-explained')?.toString()}
/>
<ScrollbarWidthSettings />
</FullWidthRow>
</>
);
};
export default MiscSettings;
+9 -1
View File
@@ -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(
+54
View File
@@ -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(
''
);
});
+9 -1
View File
@@ -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 }) => {
+17 -17
View File
@@ -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'
);
});
});
});
+10 -2
View File
@@ -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/
);
});
+1 -91
View File
@@ -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.
+80
View File
@@ -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('');
});
+39 -25
View File
@@ -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 ({