feat: merge 2u-main into master (#1231)

* feat: added country disabling feature (#1084)

* feat: added country disabling feature

* fix: lint errors

* test: added test case for disabled countries

* refactor: combined test cases

* feat: reskin of Profile MFE main page (#1114)

* feat: reskin of Profile MFE main page

* feat: reskin of Profile MFE main page

* test: updated tests according to the changes

* fix: added missing name property

* test: updated test snapshot

* test: added tests for reducers

* feat: moved reskin logic behind env variable

* test: updated tests

* refactor: refactored code according to requested changes

* fix: fixed lint errors

* refactor: refactored code according to requested changes

* refactor: refactored code according to requested changes

* feat: fixed reloading issue

* fix: fixed responsiveness issues on mobile view (#1133)

* fix: fixed reponsiveness issues on mobile view

* test: updated tests

* refactor: refactored code as requested

* test: added not found test case

* test: updated test cases

* feat: added restricted country functionality

* fix: fixed test cases

* test: updated snapshot

* test: updated test cases

* feat: made profile editable (#1212)

* feat: readded editable fields to new profile view

* feat: made fullname editable and updated design

* test: updated test cases

* refactor: refactored code based on reviews

* feat: made fullname uneditable and added redirect link (#1215)

* feat: made fullname uneditable and added redirect link

* refactor: refactored code

* refactor: refactored code

* chore: rebase 2u-main with master (#1225)

* fix(deps): update dependency @edx/frontend-platform to v8.3.3 (#1187)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency core-js to v3.41.0 (#1188)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update `@openedx` dependencies to versions that support React 18 (#1189)

* chore(deps): update dependency @openedx/frontend-build to v14.4.1 (#1191)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update react-router monorepo to v6.30.0 (#1192)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* feat: upgrade react to v18 (#1190)

* fix(deps): update dependency @edx/frontend-platform to v8.3.4 (#1195)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.4.0 (#1196)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-header to v6.3.0 (#1197)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.6.0 (#1199)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#1200)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1201)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* feat: import `FooterSlot` from component package instead of slot package (#1198)

* chore(deps): update dependency glob to v11.0.2 (#1202)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.0 (#1203)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#1204)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency core-js to v3.42.0 (#1205)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#1207)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* chore(deps): update commitlint monorepo to v19.8.1 (#1206)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#1209)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.2 (#1213)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update react-router monorepo to v6.30.1 (#1214)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-platform to v8.3.9 (#1216)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* fix(deps): update dependency @edx/frontend-component-footer to v14.9.0 (#1217)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* feat!: add design tokens support (#1220)

BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>

* fix: fixed lint issues

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
Co-authored-by: Hunia Fatima <huniafatima99@gmail.com>
Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>

* fix: fixed font sizing issue after applying elm theme (#1228)

* Revert "fix: fixed font sizing issue after applying elm theme (#1228)" (#1232)

This reverts commit a9159f6613.

---------

Co-authored-by: muhammadadeeltajamul <muhammadadeeltajamul@hotmail.com>
Co-authored-by: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com>
Co-authored-by: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com>
Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
Co-authored-by: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
Co-authored-by: Hunia Fatima <huniafatima99@gmail.com>
Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
Co-authored-by: eemaanamir <eemaan.amir@gmail.com>
This commit is contained in:
Awais Ansari
2025-07-10 10:12:21 +05:00
committed by GitHub
parent fa7267f17c
commit 762e29047f
85 changed files with 11422 additions and 5197 deletions

2
.env
View File

@@ -31,3 +31,5 @@ SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
ENABLE_NEW_PROFILE_VIEW=''
DISABLE_VISIBILITY_EDITING=''

View File

@@ -32,3 +32,5 @@ SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
ENABLE_NEW_PROFILE_VIEW=''
DISABLE_VISIBILITY_EDITING=''

View File

@@ -26,3 +26,5 @@ COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}
ENABLE_NEW_PROFILE_VIEW=''
DISABLE_VISIBILITY_EDITING=''

5755
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,14 @@
import { combineReducers } from 'redux';
import { reducer as profilePage } from '../profile';
import { getConfig } from '@edx/frontend-platform';
import { reducer as profilePageReducer } from '../profile';
import { reducer as newProfilePageReducer } from '../profile-v2';
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW;
const createRootReducer = () => combineReducers({
profilePage,
profilePage: isNewProfileEnabled ? newProfilePageReducer : profilePageReducer,
});
export default createRootReducer;

View File

@@ -1,9 +1,12 @@
import { all } from 'redux-saga/effects';
import { getConfig } from '@edx/frontend-platform';
import { saga as profileSaga } from '../profile';
import { saga as newProfileSaga } from '../profile-v2';
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW;
export default function* rootSaga() {
yield all([
profileSaga(),
isNewProfileEnabled ? newProfileSaga() : profileSaga(),
]);
}

6
src/index-v2.scss Executable file
View File

@@ -0,0 +1,6 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";
@import './profile-v2/index';

View File

@@ -7,13 +7,14 @@ import {
initialize,
mergeConfig,
subscribe,
getConfig,
} from '@edx/frontend-platform';
import {
AppProvider,
ErrorPage,
} from '@edx/frontend-platform/react';
import React, { StrictMode } from 'react';
import React from 'react';
// eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client';
@@ -23,29 +24,33 @@ import { FooterSlot } from '@edx/frontend-component-footer';
import messages from './i18n';
import configureStore from './data/configureStore';
import './index.scss';
import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => {
subscribe(APP_READY, async () => {
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW === 'true';
if (isNewProfileEnabled) {
await import('./index-v2.scss');
} else {
await import('./index.scss');
}
rootNode.render(
<StrictMode>
<AppProvider store={configureStore()}>
<Head />
<Header />
<main id="main">
<AppRoutes />
</main>
<FooterSlot />
</AppProvider>
</StrictMode>,
<AppProvider store={configureStore()}>
<Head />
<Header />
<main id="main">
<AppRoutes isNewProfileEnabled={isNewProfileEnabled} />
</main>
<FooterSlot />
</AppProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
rootNode.render(<ErrorPage message={error.message} />);
rootNode.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
@@ -56,6 +61,7 @@ initialize({
mergeConfig({
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
ENABLE_NEW_PROFILE_VIEW: process.env.ENABLE_NEW_PROFILE_VIEW || null,
}, 'App loadConfig override handler');
},
},

View File

@@ -36,7 +36,11 @@
"dateJoined": "2017-06-07T00:44:23Z",
"email": "staff@example.com",
"isActive": true,
"languageProficiencies": [],
"levelOfEducation": null,
"name": "Lemon Seltzer",
"profileImage": {},
"socialLinks": [],
"username": "staff",
"yearOfBirth": 1901
},

View File

@@ -0,0 +1,146 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import get from 'lodash.get';
import classNames from 'classnames';
import professionalCertificateSVG from './assets/professional-certificate.svg';
import verifiedCertificateSVG from './assets/verified-certificate.svg';
import messages from './Certificates.messages';
import { useIsOnMobileScreen } from './data/hooks';
const CertificateCard = ({
certificateType,
courseDisplayName,
courseOrganization,
modifiedDate,
downloadUrl,
courseId,
uuid,
}) => {
const intl = useIntl();
const certificateIllustration = {
professional: professionalCertificateSVG,
'no-id-professional': professionalCertificateSVG,
verified: verifiedCertificateSVG,
honor: null,
audit: null,
}[certificateType] || null;
const isMobileView = useIsOnMobileScreen();
return (
<div
key={`${modifiedDate}-${courseId}`}
className="col-auto d-flex align-items-center p-0"
>
<div className="col certificate p-4 border-light-400 bg-light-200 w-100 h-100">
<div
className="certificate-type-illustration"
style={{ backgroundImage: `url(${certificateIllustration})` }}
/>
<div className={classNames(
'd-flex flex-column position-relative p-0',
{ 'max-width-304px': isMobileView },
{ 'width-314px': !isMobileView },
)}
>
<div className="w-100 color-black">
<p className={classNames([
'mb-0 font-weight-normal',
isMobileView ? 'x-small' : 'small',
])}
>
{intl.formatMessage(get(
messages,
`profile.certificates.types.${certificateType}`,
messages['profile.certificates.types.unknown'],
))}
</p>
<p className={classNames([
'm-0 color-black',
isMobileView ? 'h5' : 'h4',
])}
>
{courseDisplayName}
</p>
<p className={classNames([
'mb-0',
isMobileView ? 'x-small' : 'small',
])}
>
<FormattedMessage
id="profile.certificate.organization.label"
defaultMessage="From"
/>
</p>
<h5 className="mb-0 color-black">{courseOrganization}</h5>
<p className={classNames([
'mb-0',
isMobileView ? 'x-small' : 'small',
])}
>
<FormattedMessage
id="profile.certificate.completion.date.label"
defaultMessage="Completed on {date}"
values={{
date: <FormattedDate value={new Date(modifiedDate)} />,
}}
/>
</p>
</div>
<div className="pt-3">
<Hyperlink
destination={downloadUrl}
target="_blank"
showLaunchIcon={false}
className={classNames(
'btn btn-primary btn-rounded font-weight-normal px-4 py-10px',
{ 'btn-sm': isMobileView },
)}
>
{intl.formatMessage(messages['profile.certificates.view.certificate'])}
</Hyperlink>
</div>
<p
className={classNames([
'mb-0 pt-3',
isMobileView ? 'x-small' : 'small',
])}
>
<FormattedMessage
id="profile.certificate.uuid"
defaultMessage="Credential ID {certificate_uuid}"
values={{
certificate_uuid: uuid,
}}
/>
</p>
</div>
</div>
</div>
);
};
CertificateCard.propTypes = {
certificateType: PropTypes.string,
courseDisplayName: PropTypes.string,
courseOrganization: PropTypes.string,
modifiedDate: PropTypes.string,
downloadUrl: PropTypes.string,
courseId: PropTypes.string.isRequired,
uuid: PropTypes.string,
};
CertificateCard.defaultProps = {
certificateType: 'unknown',
courseDisplayName: '',
courseOrganization: '',
modifiedDate: '',
downloadUrl: '',
uuid: '',
};
export default CertificateCard;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import classNames from 'classnames';
import CertificateCard from './CertificateCard';
import { certificatesSelector } from './data/selectors';
import { useIsOnTabletScreen } from './data/hooks';
const Certificates = ({ certificates }) => {
const isTabletView = useIsOnTabletScreen();
return (
<div>
<div className="col justify-content-start align-items-start g-5rem p-0">
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
<p className="font-weight-bold text-primary-500 m-0 h2">
<FormattedMessage
id="profile.your.certificates"
defaultMessage="Your certificates"
description="heading for the certificates section"
/>
</p>
</div>
<div className="col justify-content-start align-items-start pt-2 p-0">
<p className="font-weight-normal text-gray-800 m-0 p-0 p">
<FormattedMessage
id="profile.certificates.description"
defaultMessage="Your learner records information is only visible to you. Only your username and profile image are visible to others on {siteName}."
description="description of the certificates section"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</p>
</div>
</div>
{certificates?.length > 0 ? (
<div className="col">
<div className={classNames(
'row align-items-center pt-5 g-3rem',
{ 'justify-content-center': isTabletView },
)}
>
{certificates.map(certificate => (
<CertificateCard
key={certificate.courseId}
certificateType={certificate.certificateType}
courseDisplayName={certificate.courseDisplayName}
courseOrganization={certificate.courseOrganization}
modifiedDate={certificate.modifiedDate}
downloadUrl={certificate.downloadUrl}
courseId={certificate.courseId}
uuid={certificate.uuid}
/>
))}
</div>
</div>
) : (
<div className="pt-5">
<FormattedMessage
id="profile.no.certificates"
defaultMessage="You don't have any certificates yet."
description="displays when user has no course completion certificates"
/>
</div>
)}
</div>
);
};
Certificates.propTypes = {
certificates: PropTypes.arrayOf(PropTypes.shape({
certificateType: PropTypes.string,
courseDisplayName: PropTypes.string,
courseOrganization: PropTypes.string,
modifiedDate: PropTypes.string,
downloadUrl: PropTypes.string,
courseId: PropTypes.string.isRequired,
uuid: PropTypes.string,
})),
};
Certificates.defaultProps = {
certificates: [],
};
export default connect(
certificatesSelector,
{},
)(Certificates);

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.certificates.my.certificates': {
id: 'profile.certificates.my.certificates',
defaultMessage: 'My Certificates',
description: 'A section of a user profile',
},
'profile.certificates.view.certificate': {
id: 'profile.certificates.view.certificate',
defaultMessage: 'View Certificate',
description: 'A call to action to view a certificate',
},
'profile.certificates.types.verified': {
id: 'profile.certificates.types.verified',
defaultMessage: 'Verified Certificate',
description: 'A type of certificate a user may have earned',
},
'profile.certificates.types.professional': {
id: 'profile.certificates.types.professional',
defaultMessage: 'Professional Certificate',
description: 'A type of certificate a user may have earned',
},
'profile.certificates.types.unknown': {
id: 'profile.certificates.types.unknown',
defaultMessage: 'Certificate',
description: 'The string to display when a certificate is of an unknown type',
},
});
export default messages;

View File

@@ -0,0 +1,29 @@
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
const DateJoined = ({ date }) => {
if (!date) { return null; }
return (
<span className="small mb-0 text-gray-800">
<FormattedMessage
id="profile.datejoined.member.since"
defaultMessage="Member since {year}"
description="A label for how long the user has been a member"
values={{
year: <span className="font-weight-bold"> <FormattedDate value={new Date(date)} year="numeric" /> </span>,
}}
/>
</span>
);
};
DateJoined.propTypes = {
date: PropTypes.string,
};
DateJoined.defaultProps = {
date: null,
};
export default memo(DateJoined);

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted max-width-32em">
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
export default NotFoundPage;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
const PageLoading = ({ srMessage }) => (
<div>
<div className="d-flex justify-content-center align-items-center flex-column height-50vh">
<div className="spinner-border text-primary" role="status">
{srMessage && <span className="sr-only">{srMessage}</span>}
</div>
</div>
</div>
);
PageLoading.propTypes = {
srMessage: PropTypes.string.isRequired,
};
export default PageLoading;

View File

@@ -0,0 +1,463 @@
import React, {
useEffect, useState, useContext, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { ensureConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert, Hyperlink, OverlayTrigger, Tooltip,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import classNames from 'classnames';
import {
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openForm,
closeForm,
updateDraft,
} from './data/actions';
import ProfileAvatar from './forms/ProfileAvatar';
import Name from './forms/Name';
import Country from './forms/Country';
import PreferredLanguage from './forms/PreferredLanguage';
import Education from './forms/Education';
import SocialLinks from './forms/SocialLinks';
import Bio from './forms/Bio';
import DateJoined from './DateJoined';
import UserCertificateSummary from './UserCertificateSummary';
import PageLoading from './PageLoading';
import Certificates from './Certificates';
import { profilePageSelector } from './data/selectors';
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage');
const ProfilePage = ({ params }) => {
const dispatch = useDispatch();
const intl = useIntl();
const context = useContext(AppContext);
const {
dateJoined,
courseCertificates,
name,
visibilityName,
profileImage,
savePhotoState,
isLoadingProfile,
photoUploadError,
country,
visibilityCountry,
levelOfEducation,
visibilityLevelOfEducation,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
languageProficiencies,
visibilityLanguageProficiencies,
bio,
visibilityBio,
saveState,
username,
} = useSelector(profilePageSelector);
const navigate = useNavigate();
const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null);
const isMobileView = useIsOnMobileScreen();
const isTabletView = useIsOnTabletScreen();
useEffect(() => {
const { CREDENTIALS_BASE_URL } = context.config;
if (CREDENTIALS_BASE_URL) {
setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`);
}
dispatch(fetchProfile(params.username));
sendTrackingLogEvent('edx.profile.viewed', {
username: params.username,
});
}, [dispatch, params.username, context.config]);
useEffect(() => {
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
}
}, [username, saveState, navigate]);
const authenticatedUserName = context.authenticatedUser.username;
const handleSaveProfilePhoto = useCallback((formData) => {
dispatch(saveProfilePhoto(authenticatedUserName, formData));
}, [dispatch, authenticatedUserName]);
const handleDeleteProfilePhoto = useCallback(() => {
dispatch(deleteProfilePhoto(authenticatedUserName));
}, [dispatch, authenticatedUserName]);
const handleClose = useCallback((formId) => {
dispatch(closeForm(formId));
}, [dispatch]);
const handleOpen = useCallback((formId) => {
dispatch(openForm(formId));
}, [dispatch]);
const handleSubmit = useCallback((formId) => {
dispatch(saveProfile(formId, authenticatedUserName));
}, [dispatch, authenticatedUserName]);
const handleChange = useCallback((fieldName, value) => {
dispatch(updateDraft(fieldName, value));
}, [dispatch]);
const isAuthenticatedUserProfile = () => params.username === authenticatedUserName;
const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile()
|| (!isAuthenticatedUserProfile() && Boolean(blockInfo));
const renderViewMyRecordsButton = () => {
if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) {
return null;
}
return (
<Hyperlink
className={classNames(
'btn btn-brand bg-brand-500 btn-rounded font-weight-normal px-4 py-10px text-nowrap',
{ 'w-100': isMobileView },
)}
target="_blank"
showLaunchIcon={false}
destination={viewMyRecordsUrl}
>
{intl.formatMessage(messages['profile.viewMyRecords'])}
</Hyperlink>
);
};
const renderPhotoUploadErrorMessage = () => (
photoUploadError && (
<div className="row">
<div className="col-md-4 col-lg-3">
<Alert variant="danger" dismissible={false} show>
{photoUploadError.userMessage}
</Alert>
</div>
</div>
)
);
const commonFormProps = {
openHandler: handleOpen,
closeHandler: handleClose,
submitHandler: handleSubmit,
changeHandler: handleChange,
};
return (
<div className="profile-page">
{isLoadingProfile ? (
<PageLoading srMessage={intl.formatMessage(messages['profile.loading'])} />
) : (
<>
<div
className={classNames(
'profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100',
{ 'px-3 py-4': isMobileView },
{ 'px-120px py-5.5': !isMobileView },
)}
>
<div
className={classNames([
'col container-fluid w-100 h-100 bg-white py-0 rounded-75',
{
'px-3': isMobileView,
'px-40px': !isMobileView,
},
])}
>
<div
className={classNames([
'col h-100 w-100 px-0 justify-content-start g-15rem',
{
'py-4': isMobileView,
'py-36px': !isMobileView,
},
])}
>
<div
className={classNames([
'row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem',
isMobileView || isTabletView ? 'flex-column' : 'flex-row',
])}
>
<ProfileAvatar
className="col p-0"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={handleSaveProfilePhoto}
onDelete={handleDeleteProfilePhoto}
savePhotoState={savePhotoState}
isEditable={isAuthenticatedUserProfile()}
/>
<div
className={classNames([
'col h-100 w-100 m-0 p-0',
isMobileView || isTabletView
? 'd-flex flex-column justify-content-center align-items-center'
: 'justify-content-start align-items-start',
])}
>
<p className="row m-0 font-weight-bold text-truncate text-primary-500 h3">
{params.username}
</p>
{isBlockVisible(name) && (
<p className="row pt-2 text-gray-800 font-weight-normal m-0 p">
{name}
</p>
)}
<div className={classNames(
'row pt-2 m-0',
isMobileView
? 'd-flex justify-content-center align-items-center flex-column'
: 'g-1rem',
)}
>
<DateJoined date={dateJoined} />
<UserCertificateSummary count={courseCertificates?.length || 0} />
</div>
</div>
<div className={classNames([
'p-0 ',
isMobileView || isTabletView ? 'col d-flex justify-content-center' : 'col-auto',
])}
>
{renderViewMyRecordsButton()}
</div>
</div>
</div>
<div className="ml-auto">
{renderPhotoUploadErrorMessage()}
</div>
</div>
</div>
<div
className={classNames([
'col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem',
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
])}
>
<div className="w-100 p-0">
<div className="col justify-content-start align-items-start p-0">
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
<p className="font-weight-bold text-primary-500 m-0 h2">
{isMobileView ? (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile"
description="heading for the editable profile section in mobile view"
/>
)
: (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile information"
description="heading for the editable profile section"
/>
)}
</p>
</div>
</div>
<div
className={classNames([
'row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start',
isMobileView ? 'pt-4' : 'pt-5.5',
])}
>
<div
className={classNames([
'col p-0',
isMobileView ? 'col-12' : 'col-6',
])}
>
<div className="m-0">
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.username'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.username.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<h4 className="edit-section-header text-gray-700">
{params.username}
</h4>
</div>
{isBlockVisible(name) && (
<Name
name={name}
accountSettingsUrl={context.config.ACCOUNT_SETTINGS_URL}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
)}
{isBlockVisible(country) && (
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
)}
{isBlockVisible((languageProficiencies || []).length) && (
<PreferredLanguage
languageProficiencies={languageProficiencies || []}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
)}
{isBlockVisible(levelOfEducation) && (
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
)}
</div>
<div
className={classNames([
'col m-0 pr-0',
isMobileView ? 'pl-0 col-12' : 'pl-40px col-6',
])}
>
{isBlockVisible(bio) && (
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
)}
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && (
<SocialLinks
socialLinks={socialLinks || []}
draftSocialLinksByPlatform={draftSocialLinksByPlatform || {}}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
)}
</div>
</div>
</div>
</div>
<div
className={classNames([
'col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem',
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
])}
>
{isBlockVisible((courseCertificates || []).length) && (
<Certificates
certificates={courseCertificates || []}
formId="certificates"
/>
)}
</div>
</>
)}
</div>
);
};
ProfilePage.propTypes = {
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
requiresParentalConsent: PropTypes.bool,
dateJoined: PropTypes.string,
username: PropTypes.string,
bio: PropTypes.string,
visibilityBio: PropTypes.string,
courseCertificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
country: PropTypes.string,
visibilityCountry: PropTypes.string,
levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.string,
languageProficiencies: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
})),
visibilityLanguageProficiencies: PropTypes.string,
name: PropTypes.string,
visibilityName: PropTypes.string,
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
draftSocialLinksByPlatform: PropTypes.objectOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibilitySocialLinks: PropTypes.string,
profileImage: PropTypes.shape({
src: PropTypes.string,
isDefault: PropTypes.bool,
}),
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isLoadingProfile: PropTypes.bool,
photoUploadError: PropTypes.objectOf(PropTypes.string),
};
ProfilePage.defaultProps = {
saveState: null,
username: '',
savePhotoState: null,
photoUploadError: {},
profileImage: {},
name: null,
levelOfEducation: null,
country: null,
socialLinks: [],
draftSocialLinksByPlatform: {},
bio: null,
languageProficiencies: [],
courseCertificates: [],
requiresParentalConsent: null,
dateJoined: null,
visibilityName: null,
visibilityCountry: null,
visibilityLevelOfEducation: null,
visibilitySocialLinks: null,
visibilityLanguageProficiencies: null,
visibilityBio: null,
isLoadingProfile: false,
};
export default withParams(ProfilePage);

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.viewMyRecords': {
id: 'profile.viewMyRecords',
defaultMessage: 'View My Records',
description: 'A link to go view my academic records',
},
'profile.loading': {
id: 'profile.loading',
defaultMessage: 'Profile loading...',
description: 'Message displayed when the profile data is loading.',
},
'profile.username': {
id: 'profile.username',
defaultMessage: 'Username',
description: 'Label for the username field.',
},
'profile.username.tooltip': {
id: 'profile.username.tooltip',
defaultMessage: 'The name that identifies you on edX. You cannot change your username.',
description: 'Tooltip for the username field.',
},
});
export default messages;

View File

@@ -0,0 +1,259 @@
import { getConfig } from '@edx/frontend-platform';
import * as analytics from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
MemoryRouter,
Routes,
Route,
useNavigate,
} from 'react-router-dom';
import messages from '../i18n';
import ProfilePage from './ProfilePage';
import loadingApp from './__mocks__/loadingApp.mockStore';
import viewOwnProfile from './__mocks__/viewOwnProfile.mockStore';
import viewOtherProfile from './__mocks__/viewOtherProfile.mockStore';
import invalidUser from './__mocks__/invalidUser.mockStore';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
const storeMocks = {
loadingApp,
viewOwnProfile,
viewOtherProfile,
invalidUser,
};
const requiredProfilePageProps = {
params: { username: 'staff' },
};
Object.defineProperty(global.document, 'cookie', {
writable: true,
value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`,
});
jest.mock('@edx/frontend-platform/auth', () => ({
configure: () => {},
getAuthenticatedUser: () => null,
fetchAuthenticatedUser: () => null,
getAuthenticatedHttpClient: jest.fn(),
AUTHENTICATED_USER_CHANGED: 'user_changed',
}));
jest.mock('@edx/frontend-platform/analytics', () => ({
configure: () => {},
identifyAnonymousUser: jest.fn(),
identifyAuthenticatedUser: jest.fn(),
sendTrackingLogEvent: jest.fn(),
}));
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset();
useNavigate.mockReset();
});
const ProfilePageWrapper = ({
contextValue, store, params,
}) => (
<AppContext.Provider value={contextValue}>
<IntlProvider locale="en">
<Provider store={store}>
<MemoryRouter initialEntries={[`/profile/${params.username}`]}>
<Routes>
<Route
path="/profile/:username"
element={<ProfilePage {...requiredProfilePageProps} params={params} />}
/>
</Routes>
</MemoryRouter>
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
// eslint-disable-next-line react/default-props-match-prop-types
params: { username: 'staff' },
};
ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired,
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
};
describe('<ProfilePage />', () => {
describe('Renders correctly in various states', () => {
it('app loading', () => {
const contextValue = {
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('viewing own profile', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('viewing other profile with all fields', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore({
...storeMocks.viewOtherProfile,
profilePage: {
...storeMocks.viewOtherProfile.profilePage,
account: {
...storeMocks.viewOtherProfile.profilePage.account,
name: 'Verified User',
country: 'US',
bio: 'About me',
courseCertificates: [{ title: 'Course 1' }],
levelOfEducation: 'bachelors',
languageProficiencies: [{ code: 'en' }],
socialLinks: [{ platform: 'twitter', socialLink: 'https://twitter.com/user' }],
},
preferences: {
...storeMocks.viewOtherProfile.profilePage.preferences,
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users',
},
},
})}
params={{ username: 'verified' }}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('without credentials service', () => {
const config = getConfig();
config.CREDENTIALS_BASE_URL = '';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('successfully redirected to not found page', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const navigate = jest.fn();
useNavigate.mockReturnValue(navigate);
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.invalidUser)}
params={{ username: 'staffTest' }}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
expect(navigate).toHaveBeenCalledWith('/notfound');
});
});
describe('handles analytics', () => {
it('calls sendTrackingLogEvent when mounting', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
params={{ username: 'test-username' }}
/>,
);
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledWith('edx.profile.viewed', {
username: 'test-username',
});
});
});
describe('handles navigation', () => {
it('navigates to notfound on save error with no username', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const navigate = jest.fn();
useNavigate.mockReturnValue(navigate);
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.invalidUser)}
params={{ username: 'staffTest' }}
/>,
);
expect(navigate).toHaveBeenCalledWith('/notfound');
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const UserCertificateSummary = ({ count = 0 }) => {
if (count) {
return (
<span className="small m-0 text-gray-800">
<FormattedMessage
id="profile.certificatecount"
defaultMessage="{certificate_count} certifications"
description="A label for many certificates a user has"
values={{
certificate_count: <span className="font-weight-bold">{count}</span>,
}}
/>
</span>
);
}
return null;
};
UserCertificateSummary.propTypes = {
count: PropTypes.number,
};
export default UserCertificateSummary;

View File

@@ -0,0 +1,42 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: null,
bio: null,
name: null,
country: null,
socialLinks: null,
profileImage: {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null,
learningGoal: null
},
profilePage: {
errors: {},
saveState: 'error',
savePhotoState: null,
currentlyEditingField: null,
account: {
username: '',
socialLinks: []
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {
pathname: '/u/staffTest',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -0,0 +1,42 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: null,
bio: null,
name: null,
country: null,
socialLinks: null,
profileImage: {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null,
learningGoal: null
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
account: {
username: 'staff',
socialLinks: []
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -0,0 +1,139 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
},
profilePage: {
errors: {},
saveState: 'pending',
savePhotoState: null,
currentlyEditingField: 'bio',
isAuthenticatedUserProfile: true,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
email: 'staff@example.com',
username: 'staff',
bio: 'This is my bio',
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
name: 'Lemon Seltzer',
secondaryEmail: null,
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
},
preferences: {
visibilityUserLocation: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityCertificates: 'private',
visibilityLevelOfEducation: 'private',
visibilityCourseCertificates: 'all_users',
prefLang: 'en',
visibilityBio: 'all_users',
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{
username: 'staff',
status: 'downloadable',
courseDisplayName: 'edX Demonstration Course',
grade: '0.89',
courseId: 'course-v1:edX+DemoX+Demo_Course',
courseOrganization: 'edX',
modifiedDate: '2019-03-04T19:31:39.930255Z',
isPassing: true,
downloadUrl: 'http://www.example.com/',
certificateType: 'verified',
createdDate: '2019-03-04T19:31:39.896806Z'
}
],
drafts: {},
isLoadingProfile: false,
disabledCountries: [],
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -0,0 +1,105 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career',
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
isAuthenticatedUserProfile: false,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/static/images/profiles/default_500.png',
imageUrlLarge: 'http://localhost:18000/static/images/profiles/default_120.png',
imageUrlMedium: 'http://localhost:18000/static/images/profiles/default_50.png',
imageUrlSmall: 'http://localhost:18000/static/images/profiles/default_30.png',
hasImage: false
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:19Z',
accomplishmentsShared: false,
email: 'verified@example.com',
username: 'verified',
bio: null,
isActive: true,
yearOfBirth: null,
goals: null,
languageProficiencies: [],
courseCertificates: null,
requiresParentalConsent: true,
name: '',
secondaryEmail: null,
country: null,
socialLinks: [],
timeZone: null,
levelOfEducation: null,
gender: null,
accountPrivacy: 'private'
},
preferences: {
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users'
},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {
pathname: '/u/verified',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -0,0 +1,139 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
isAuthenticatedUserProfile: true,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
email: 'staff@example.com',
username: 'staff',
bio: 'This is my bio',
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
name: 'Lemon Seltzer',
secondaryEmail: null,
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
preferences: {
visibilityUserLocation: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityCertificates: 'private',
visibilityLevelOfEducation: 'private',
visibilityCourseCertificates: 'all_users',
prefLang: 'en',
visibilityBio: 'all_users',
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{
username: 'staff',
status: 'downloadable',
courseDisplayName: 'edX Demonstration Course',
grade: '0.89',
courseId: 'course-v1:edX+DemoX+Demo_Course',
courseOrganization: 'edX',
modifiedDate: '2019-03-04T19:31:39.930255Z',
isPassing: true,
downloadUrl: 'http://www.example.com/',
certificateType: 'verified',
createdDate: '2019-03-04T19:31:39.896806Z'
}
],
drafts: {},
isLoadingProfile: false,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -0,0 +1,2019 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ProfilePage /> Renders correctly in various states app loading 1`] = `
<div>
<div
class="profile-page"
>
<div>
<div
class="d-flex justify-content-center align-items-center flex-column height-50vh"
>
<div
class="spinner-border text-primary"
role="status"
>
<span
class="sr-only"
>
Profile loading...
</span>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states successfully redirected to not found page 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100 px-120px py-5.5"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 rounded-75 px-40px"
>
<div
class="col h-100 w-100 px-0 justify-content-start g-15rem py-36px"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
aria-hidden="true"
class="text-muted"
data-testid="IconMock"
focusable="false"
role="img"
viewbox="0 0 24 24"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staffTest
</p>
<div
class="row pt-2 m-0 g-1rem"
/>
</div>
<div
class="p-0 col-auto"
/>
</div>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div
class="w-100 p-0"
>
<div
class="col justify-content-start align-items-start p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Profile information
</p>
</div>
</div>
<div
class="row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start pt-5.5"
>
<div
class="col p-0 col-6"
>
<div
class="m-0"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Username
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<h4
class="edit-section-header text-gray-700"
>
staffTest
</h4>
</div>
</div>
<div
class="col m-0 pr-0 pl-40px col-6"
/>
</div>
</div>
</div>
<div
class="col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
/>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing other profile with all fields 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100 px-120px py-5.5"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 rounded-75 px-40px"
>
<div
class="col h-100 w-100 px-0 justify-content-start g-15rem py-36px"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
aria-hidden="true"
class="text-muted"
data-testid="IconMock"
focusable="false"
role="img"
viewbox="0 0 24 24"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
verified
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
Verified User
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
</div>
</div>
<div
class="p-0 col-auto"
/>
</div>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div
class="w-100 p-0"
>
<div
class="col justify-content-start align-items-start p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Profile information
</p>
</div>
</div>
<div
class="row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start pt-5.5"
>
<div
class="col p-0 col-6"
>
<div
class="m-0"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Username
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<h4
class="edit-section-header text-gray-700"
>
verified
</h4>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Full name
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Verified User
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Country
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
United States of America
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Primary language spoken
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
English
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Education
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Other education
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
</div>
<div
class="col m-0 pr-0 pl-40px col-6"
>
<div
class="pgn-transition-replace-group position-relative pt-0"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Bio
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
About me
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative p-0"
>
<div
style="padding: .1px 0px;"
>
<div>
<div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
X
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
https://twitter.com/user
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
/>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
/>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing own profile 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100 px-120px py-5.5"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 rounded-75 px-40px"
>
<div
class="col h-100 w-100 px-0 justify-content-start g-15rem py-36px"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
/>
</div>
<div
class="profile-avatar-button"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="btn-icon btn-icon-inverse-primary btn-icon-md btn-icon-inverse-primary-active shadow-sm pgn__dropdown-toggle-iconbutton"
id="dropdown-toggle-with-iconbutton"
type="button"
>
<span
class="btn-icon__icon-container"
>
<span
class="pgn__icon btn-icon__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
fill="currentColor"
/>
<path
d="M9 2 7.17 4H2v16h20V4h-5.17L15 2H9Zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5Z"
fill="currentColor"
/>
</svg>
</span>
</span>
</button>
</div>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staff
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
Lemon Seltzer
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
<span
class="small m-0 text-gray-800"
>
<span
class="font-weight-bold"
>
1
</span>
certifications
</span>
</div>
</div>
<div
class="p-0 col-auto"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-brand bg-brand-500 btn-rounded font-weight-normal px-4 py-10px text-nowrap"
href="http://localhost:18150/records"
rel="noopener noreferrer"
target="_blank"
>
View My Records
</a>
</div>
</div>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div
class="w-100 p-0"
>
<div
class="col justify-content-start align-items-start p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Profile information
</p>
</div>
</div>
<div
class="row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start pt-5.5"
>
<div
class="col p-0 col-6"
>
<div
class="m-0"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Username
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<h4
class="edit-section-header text-gray-700"
>
staff
</h4>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Full name
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Lemon Seltzer
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Country
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Montenegro
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Primary language spoken
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Yoruba
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Education
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Elementary/primary school
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
</div>
<div
class="col m-0 pr-0 pl-40px col-6"
>
<div
class="pgn-transition-replace-group position-relative pt-0"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Bio
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
This is my bio
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative p-0"
>
<div
style="padding: .1px 0px;"
>
<div>
<div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
X
</p>
<div
class="w-100 overflowWrap-breakWord"
>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
https://www.twitter.com/ALOHA
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Facebook
</p>
<div
class="w-100 overflowWrap-breakWord"
>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
https://www.facebook.com/aloha
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
LinkedIn
</p>
<div
class="p-0 m-0"
>
<button
class="p-0 text-left btn btn-link lh-36px"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-1"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add
LinkedIn
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div>
<div
class="col justify-content-start align-items-start g-5rem p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Your certificates
</p>
</div>
<div
class="col justify-content-start align-items-start pt-2 p-0"
>
<p
class="font-weight-normal text-gray-800 m-0 p-0 p"
>
Your learner records information is only visible to you. Only your username and profile image are visible to others on localhost.
</p>
</div>
</div>
<div
class="col"
>
<div
class="row align-items-center pt-5 g-3rem"
>
<div
class="col-auto d-flex align-items-center p-0"
>
<div
class="col certificate p-4 border-light-400 bg-light-200 w-100 h-100"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="d-flex flex-column position-relative p-0 width-314px"
>
<div
class="w-100 color-black"
>
<p
class="mb-0 font-weight-normal small"
>
Verified Certificate
</p>
<p
class="m-0 color-black h4"
>
edX Demonstration Course
</p>
<p
class="mb-0 small"
>
From
</p>
<h5
class="mb-0 color-black"
>
edX
</h5>
<p
class="mb-0 small"
>
Completed on
3/4/2019
</p>
</div>
<div
class="pt-3"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-primary btn-rounded font-weight-normal px-4 py-10px"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
</a>
</div>
<p
class="mb-0 pt-3 small"
>
Credential ID
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states without credentials service 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100 px-120px py-5.5"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 rounded-75 px-40px"
>
<div
class="col h-100 w-100 px-0 justify-content-start g-15rem py-36px"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
/>
</div>
<div
class="profile-avatar-button"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="btn-icon btn-icon-inverse-primary btn-icon-md btn-icon-inverse-primary-active shadow-sm pgn__dropdown-toggle-iconbutton"
id="dropdown-toggle-with-iconbutton"
type="button"
>
<span
class="btn-icon__icon-container"
>
<span
class="pgn__icon btn-icon__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
fill="currentColor"
/>
<path
d="M9 2 7.17 4H2v16h20V4h-5.17L15 2H9Zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5Z"
fill="currentColor"
/>
</svg>
</span>
</span>
</button>
</div>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staff
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
Lemon Seltzer
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
<span
class="small m-0 text-gray-800"
>
<span
class="font-weight-bold"
>
1
</span>
certifications
</span>
</div>
</div>
<div
class="p-0 col-auto"
/>
</div>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div
class="w-100 p-0"
>
<div
class="col justify-content-start align-items-start p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Profile information
</p>
</div>
</div>
<div
class="row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start pt-5.5"
>
<div
class="col p-0 col-6"
>
<div
class="m-0"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Username
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<h4
class="edit-section-header text-gray-700"
>
staff
</h4>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<div
class="row m-0 pb-1.5 align-items-center"
>
<p
class="h5 font-weight-bold m-0"
data-hj-suppress="true"
>
Full name
</p>
<svg
class="m-0 info-icon"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 7h2v2h-2V7Zm0 4h2v6h-2v-6Zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8Z"
fill="currentColor"
/>
</svg>
</div>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Lemon Seltzer
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Country
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Montenegro
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Primary language spoken
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Yoruba
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative pt-40px"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Education
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
Elementary/primary school
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
</div>
<div
class="col m-0 pr-0 pl-40px col-6"
>
<div
class="pgn-transition-replace-group position-relative pt-0"
>
<div
style="padding: .1px 0px;"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Bio
</p>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
This is my bio
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative p-0"
>
<div
style="padding: .1px 0px;"
>
<div>
<div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
X
</p>
<div
class="w-100 overflowWrap-breakWord"
>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
https://www.twitter.com/ALOHA
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
Facebook
</p>
<div
class="w-100 overflowWrap-breakWord"
>
<div
class="row m-0 p-0 d-flex flex-nowrap align-items-center"
>
<div
class="m-0 p-0 col-auto"
>
<h4
class="edit-section-header text-gray-700"
>
https://www.facebook.com/aloha
</h4>
</div>
<div
class="col-auto m-0 p-0 d-flex align-items-center col-auto"
>
<button
class="p-1.5 btn btn-link btn-sm"
type="button"
>
<svg
class="text-gray-700"
fill="none"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m14.06 9.02.92.92L5.92 19H5v-.92l9.06-9.06ZM17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83a.996.996 0 0 0 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29Zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<div
class="row m-0 p-0"
/>
</div>
</div>
<div
class="pt-40px"
>
<p
class="h5 font-weight-bold m-0 pb-1.5"
data-hj-suppress="true"
>
LinkedIn
</p>
<div
class="p-0 m-0"
>
<button
class="p-0 text-left btn btn-link lh-36px"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-1"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add
LinkedIn
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem px-120px py-6"
>
<div>
<div
class="col justify-content-start align-items-start g-5rem p-0"
>
<div
class="col align-self-stretch height-42px justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Your certificates
</p>
</div>
<div
class="col justify-content-start align-items-start pt-2 p-0"
>
<p
class="font-weight-normal text-gray-800 m-0 p-0 p"
>
Your learner records information is only visible to you. Only your username and profile image are visible to others on localhost.
</p>
</div>
</div>
<div
class="col"
>
<div
class="row align-items-center pt-5 g-3rem"
>
<div
class="col-auto d-flex align-items-center p-0"
>
<div
class="col certificate p-4 border-light-400 bg-light-200 w-100 h-100"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="d-flex flex-column position-relative p-0 width-314px"
>
<div
class="w-100 color-black"
>
<p
class="mb-0 font-weight-normal small"
>
Verified Certificate
</p>
<p
class="m-0 color-black h4"
>
edX Demonstration Course
</p>
<p
class="mb-0 small"
>
From
</p>
<h5
class="mb-0 color-black"
>
edX
</h5>
<p
class="mb-0 small"
>
Completed on
3/4/2019
</p>
</div>
<div
class="pt-3"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-primary btn-rounded font-weight-normal px-4 py-10px"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
</a>
</div>
<p
class="mb-0 pt-3 small"
>
Credential ID
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>avatar</title>
<desc>Created with Sketch.</desc>
<g id="avatar" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z" fill="currentColor"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="110px" height="100px" viewBox="0 0 110 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>micro-masters</title>
<desc>Created with Sketch.</desc>
<g id="micro-masters" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Fill-14" fill="#005585" points="1.00481933 99 1 96.14832 108.995272 96 109 98.85168"></polygon>
<polygon id="Fill-16" fill="#005585" points="1.00481933 54 1 51.14832 108.995272 51 109 53.85168"></polygon>
<polygon id="Fill-18" fill="#005585" points="24.9998189 88 4.22751315 66.9102771 4.22751315 87.0451102 1 87.0451102 1 59 24.9998189 83.3666894 49 59 49 87.0451102 45.7724869 87.0451102 45.7724869 66.9102771"></polygon>
<polygon id="Fill-20" fill="#005585" points="83.0003622 88 62.2275375 66.9102771 62.2275375 87.0451102 59 87.0451102 59 59 83.0003622 83.3666894 107 59 107 87.0451102 103.772372 87.0451102 103.772372 66.9102771"></polygon>
<path d="M46.8337141,5.73372796 C40.9959838,9.0009662 32.7599012,14.7887333 26.6032377,23.8303431 C22.1430738,22.980271 18.7577893,22.0941759 16.6724707,21.4890245 C24.4296997,13.0587208 35.0333668,7.38581116 46.8337141,5.73372796 L46.8337141,5.73372796 Z M52.9498983,26.3252576 C44.0609738,26.2888724 36.3049246,25.4607944 30.1183101,24.4518768 C37.8086485,13.8504137 48.4460785,8.26376038 52.9498983,6.25307565 L52.9498983,26.3252576 Z M56.1856092,6.53148511 C60.9824046,8.72165163 70.9689895,14.1856636 78.3079931,24.2371866 C70.4533778,25.5777336 63.0182586,26.1827945 56.1856092,26.3036256 L56.1856092,6.53148511 Z M91.2530147,21.4114572 C88.0536989,22.2655118 84.9019418,22.9909512 81.8101774,23.5971888 C75.749448,14.7672824 67.7047798,9.0930151 61.9038076,5.84261176 C73.3677956,7.61959908 83.6636931,13.2007313 91.2530147,21.4114572 L91.2530147,21.4114572 Z M5.35796556,42 C7.1655563,35.3542865 10.2683027,29.3264862 14.378947,24.1676747 C16.125002,24.7123653 19.7105047,25.7399279 24.7732291,26.7529184 C22.2125965,31.1374149 20.1687556,36.1942214 19.0530339,42 L22.3555447,42 C23.5090228,36.4059248 25.5817257,31.5545765 28.1556093,27.3837747 C34.7214222,28.5242036 43.1689773,29.5005374 52.9498983,29.5419006 L52.9498983,42 L56.1856092,42 L56.1856092,29.5223504 C63.6041191,29.395636 71.7140444,28.7101116 80.2902074,27.16121 C82.9053872,31.3760904 85.0043199,36.3025621 86.1572534,42 L89.460218,42 C88.3437702,36.0858806 86.2630805,30.9538601 83.6533464,26.5180443 C86.9031251,25.8529752 90.216981,25.0530458 93.5773971,24.1181656 C97.7090979,29.2882909 100.82809,35.3331976 102.641853,42 L106,42 C100.00834,18.4385583 78.6589648,2 53.9991832,2 C29.3299624,2 7.9913882,18.4352094 2,42 L5.35796556,42 Z" id="Fill-22" fill="#005585"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,137 @@
import { AsyncActionType } from '../utils';
export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE');
export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO');
export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE,
payload: { username },
});
export const fetchProfileBegin = () => ({
type: FETCH_PROFILE.BEGIN,
});
export const fetchProfileSuccess = (
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
) => ({
type: FETCH_PROFILE.SUCCESS,
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
});
export const fetchProfileReset = () => ({
type: FETCH_PROFILE.RESET,
});
export const saveProfile = (formId, username) => ({
type: SAVE_PROFILE.BASE,
payload: {
formId,
username,
},
});
export const saveProfileBegin = () => ({
type: SAVE_PROFILE.BEGIN,
});
export const saveProfileSuccess = (account, preferences) => ({
type: SAVE_PROFILE.SUCCESS,
payload: {
account,
preferences,
},
});
export const saveProfileReset = () => ({
type: SAVE_PROFILE.RESET,
});
export const saveProfileFailure = errors => ({
type: SAVE_PROFILE.FAILURE,
payload: { errors },
});
export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
username,
formData,
},
});
export const saveProfilePhotoBegin = () => ({
type: SAVE_PROFILE_PHOTO.BEGIN,
});
export const saveProfilePhotoSuccess = profileImage => ({
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage },
});
export const saveProfilePhotoReset = () => ({
type: SAVE_PROFILE_PHOTO.RESET,
});
export const saveProfilePhotoFailure = error => ({
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error },
});
export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
username,
},
});
export const deleteProfilePhotoBegin = () => ({
type: DELETE_PROFILE_PHOTO.BEGIN,
});
export const deleteProfilePhotoSuccess = profileImage => ({
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage },
});
export const deleteProfilePhotoReset = () => ({
type: DELETE_PROFILE_PHOTO.RESET,
});
export const openForm = formId => ({
type: OPEN_FORM,
payload: {
formId,
},
});
export const closeForm = formId => ({
type: CLOSE_FORM,
payload: {
formId,
},
});
export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT,
payload: {
name,
value,
},
});
export const resetDrafts = () => ({
type: RESET_DRAFTS,
});

View File

@@ -0,0 +1,98 @@
import {
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
saveProfilePhotoFailure,
saveProfilePhotoReset,
saveProfilePhoto,
DELETE_PROFILE_PHOTO,
deleteProfilePhotoBegin,
deleteProfilePhotoSuccess,
deleteProfilePhotoReset,
deleteProfilePhoto,
} from './actions';
describe('SAVE profile photo actions', () => {
it('should create an action to signal the start of a profile photo save', () => {
const formData = 'multipart form data';
const expectedAction = {
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
username: 'myusername',
formData,
},
};
expect(saveProfilePhoto('myusername', formData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save beginning', () => {
const expectedAction = {
type: SAVE_PROFILE_PHOTO.BEGIN,
};
expect(saveProfilePhotoBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save success', () => {
const newPhotoData = { hasImage: true };
const expectedAction = {
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: {
profileImage: newPhotoData,
},
};
expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save reset', () => {
const expectedAction = {
type: SAVE_PROFILE_PHOTO.RESET,
};
expect(saveProfilePhotoReset()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save failure', () => {
const error = 'Test failure';
const expectedAction = {
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error },
};
expect(saveProfilePhotoFailure(error)).toEqual(expectedAction);
});
});
describe('DELETE profile photo actions', () => {
it('should create an action to signal the start of a profile photo deletion', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
username: 'myusername',
},
};
expect(deleteProfilePhoto('myusername')).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion beginning', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.BEGIN,
};
expect(deleteProfilePhotoBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion success', () => {
const defaultPhotoData = { hasImage: false };
const expectedAction = {
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: {
profileImage: defaultPhotoData,
},
};
expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion reset', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.RESET,
};
expect(deleteProfilePhotoReset()).toEqual(expectedAction);
});
});

View File

@@ -0,0 +1,33 @@
const EDUCATION_LEVELS = [
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'other',
];
const SOCIAL = {
linkedin: {
title: 'LinkedIn',
},
twitter: {
title: 'Twitter',
},
facebook: {
title: 'Facebook',
},
};
const FIELD_LABELS = {
COUNTRY: 'country',
};
export {
EDUCATION_LEVELS,
SOCIAL,
FIELD_LABELS,
};

View File

@@ -0,0 +1,34 @@
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
export function useIsOnTabletScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.medium.minWidth;
}
export function useIsOnMobileScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.small.minWidth;
}
export function useIsVisibilityEnabled() {
return getConfig().DISABLE_VISIBILITY_EDITING === 'true';
}
export function useHandleChange(changeHandler) {
return (e) => {
const { name, value } = e.target;
changeHandler(name, value);
};
}
export function useHandleSubmit(submitHandler, formId) {
return (e) => {
e.preventDefault();
submitHandler(formId);
};
}
export function useCloseOpenHandler(handler, formId) {
return () => handler(formId);
}

View File

@@ -0,0 +1,7 @@
const mockData = {
learningGoal: 'advance_career',
editMode: 'static',
visibilityLearningGoal: 'private',
};
export default mockData;

View File

@@ -0,0 +1,84 @@
// This test file simply creates a contract that defines
// expectations and correct responses from the Pact stub server.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform';
import { getAccount } from './services';
const expectedUserInfo200 = {
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
dateJoined: '2017-06-07T00:44:23Z',
isActive: true,
yearOfBirth: 1901,
languageProficiencies: [],
levelOfEducation: null,
profileImage: {},
socialLinks: [],
};
const provider = new PactV3({
log: path.resolve(process.cwd(), 'src/pact-logs/pact.log'),
dir: path.resolve(process.cwd(), 'src/pacts'),
consumer: 'frontend-app-profile',
provider: 'edx-platform',
});
describe('getAccount for one username', () => {
beforeAll(async () => {
initializeMockApp();
});
it('returns a HTTP 200 and user information', async () => {
const username200 = 'staff';
await provider.addInteraction({
states: [{ description: "I have a user's basic information" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username200}`,
headers: {},
},
willRespondWith: {
status: 200,
headers: {},
body: MatchersV3.like(expectedUserInfo200),
},
});
return provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
const response = await getAccount(username200);
expect(response).toEqual(expectedUserInfo200);
});
});
it('Account does not exist', async () => {
const username404 = 'staff_not_found';
await provider.addInteraction({
states: [{ description: "Account and user's information does not exist" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username404}`,
},
willRespondWith: {
status: 404,
},
});
await provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
await expect(getAccount(username404).then((response) => response.data)).rejects.toThrow('Request failed with status code 404');
});
});
});

View File

@@ -0,0 +1,181 @@
import {
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
CLOSE_FORM,
OPEN_FORM,
FETCH_PROFILE,
UPDATE_DRAFT,
RESET_DRAFTS,
} from './actions';
export const initialState = {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
account: {
socialLinks: [],
languageProficiencies: [],
name: '',
bio: '',
country: '',
levelOfEducation: '',
profileImage: {},
yearOfBirth: '',
},
preferences: {
visibilityName: '',
visibilityBio: '',
visibilityCountry: '',
visibilityLevelOfEducation: '',
visibilitySocialLinks: '',
visibilityLanguageProficiencies: '',
},
courseCertificates: [],
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: false,
disabledCountries: ['RU'],
countriesCodesList: [],
};
const profilePage = (state = initialState, action = {}) => {
switch (action.type) {
case FETCH_PROFILE.BEGIN:
return {
...state,
// TODO: uncomment this line after ARCH-438 Image Post API returns the url
// is complete. Right now we refetch the whole profile causing us to show a full reload
// instead of a partial one.
// isLoadingProfile: true,
};
case FETCH_PROFILE.SUCCESS:
return {
...state,
account: {
...state.account,
...action.account,
socialLinks: action.account.socialLinks || [],
languageProficiencies: action.account.languageProficiencies || [],
},
preferences: action.preferences,
courseCertificates: action.courseCertificates || [],
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList || [],
};
case SAVE_PROFILE.BEGIN:
return {
...state,
saveState: 'pending',
errors: {},
};
case SAVE_PROFILE.SUCCESS:
return {
...state,
saveState: 'complete',
errors: {},
account: action.payload.account !== null ? {
...state.account,
...action.payload.account,
socialLinks: action.payload.account.socialLinks || [],
languageProficiencies: action.payload.account.languageProficiencies || [],
} : state.account,
preferences: { ...state.preferences, ...action.payload.preferences },
};
case SAVE_PROFILE.FAILURE:
return {
...state,
saveState: 'error',
isLoadingProfile: false,
errors: { ...state.errors, ...action.payload.errors },
};
case SAVE_PROFILE.RESET:
return {
...state,
saveState: null,
isLoadingProfile: false,
errors: {},
};
case SAVE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
errors: {},
};
case SAVE_PROFILE_PHOTO.SUCCESS:
return {
...state,
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
case SAVE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
errors: { ...state.errors, photo: action.payload.error },
};
case SAVE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
errors: {},
};
case DELETE_PROFILE_PHOTO.SUCCESS:
return {
...state,
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
case DELETE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
errors: { ...state.errors, ...action.payload.errors },
};
case DELETE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
errors: {},
};
case UPDATE_DRAFT:
return {
...state,
drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
};
case RESET_DRAFTS:
return {
...state,
drafts: {},
};
case OPEN_FORM:
return {
...state,
currentlyEditingField: action.payload.formId,
drafts: {},
};
case CLOSE_FORM:
if (action.payload.formId === state.currentlyEditingField) {
return {
...state,
currentlyEditingField: null,
drafts: {},
};
}
return state;
default:
return state;
}
};
export default profilePage;

View File

@@ -0,0 +1,309 @@
import profilePage, { initialState } from './reducers';
import {
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
CLOSE_FORM,
OPEN_FORM,
FETCH_PROFILE,
UPDATE_DRAFT,
RESET_DRAFTS,
} from './actions';
describe('profilePage reducer', () => {
it('should return the initial state by default', () => {
expect(profilePage(undefined, {})).toEqual(initialState);
});
describe('FETCH_PROFILE actions', () => {
it('should handle FETCH_PROFILE.BEGIN', () => {
const action = { type: FETCH_PROFILE.BEGIN };
const expectedState = {
...initialState,
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle FETCH_PROFILE.SUCCESS', () => {
const action = {
type: FETCH_PROFILE.SUCCESS,
account: {
name: 'John Doe',
bio: 'Software Engineer',
country: 'US',
levelOfEducation: 'bachelors',
socialLinks: [{ platform: 'twitter', link: 'twitter.com/johndoe' }],
languageProficiencies: [{ code: 'en', name: 'English' }],
profileImage: { url: 'profile.jpg' },
yearOfBirth: 1990,
},
preferences: {
visibilityName: 'public',
visibilityBio: 'public',
visibilityCountry: 'public',
visibilityLevelOfEducation: 'public',
visibilitySocialLinks: 'public',
visibilityLanguageProficiencies: 'public',
},
courseCertificates: ['cert1', 'cert2'],
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA'],
};
const expectedState = {
...initialState,
account: {
...initialState.account,
...action.account,
socialLinks: action.account.socialLinks,
languageProficiencies: action.account.languageProficiencies,
},
preferences: action.preferences,
courseCertificates: action.courseCertificates,
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList,
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('SAVE_PROFILE actions', () => {
it('should handle SAVE_PROFILE.BEGIN', () => {
const action = { type: SAVE_PROFILE.BEGIN };
const expectedState = {
...initialState,
saveState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.SUCCESS', () => {
const action = {
type: SAVE_PROFILE.SUCCESS,
payload: {
account: {
name: 'Jane Doe',
bio: 'Updated bio',
socialLinks: [{ platform: 'linkedin', link: 'linkedin.com/janedoe' }],
languageProficiencies: [{ code: 'es', name: 'Spanish' }],
},
preferences: {
visibilityName: 'private',
visibilityBio: 'private',
},
},
};
const expectedState = {
...initialState,
saveState: 'complete',
errors: {},
account: {
...initialState.account,
...action.payload.account,
socialLinks: action.payload.account.socialLinks,
languageProficiencies: action.payload.account.languageProficiencies,
},
preferences: {
...initialState.preferences,
...action.payload.preferences,
},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.FAILURE', () => {
const action = {
type: SAVE_PROFILE.FAILURE,
payload: { errors: { save: 'Failed to save profile' } },
};
const expectedState = {
...initialState,
saveState: 'error',
isLoadingProfile: false,
errors: { save: action.payload.errors.save },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.RESET', () => {
const action = { type: SAVE_PROFILE.RESET };
const expectedState = {
...initialState,
saveState: null,
isLoadingProfile: false,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('SAVE_PROFILE_PHOTO actions', () => {
it('should handle SAVE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: SAVE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: { url: 'new-image-url.jpg' } },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error: 'Photo upload failed' },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { photo: action.payload.error },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.RESET', () => {
const action = { type: SAVE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('DELETE_PROFILE_PHOTO actions', () => {
it('should handle DELETE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: DELETE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: { url: 'default-image-url.jpg' } },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: DELETE_PROFILE_PHOTO.FAILURE,
payload: { errors: { delete: 'Failed to delete photo' } },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { delete: action.payload.errors.delete },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.RESET', () => {
const action = { type: DELETE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('Draft and Form actions', () => {
it('should handle UPDATE_DRAFT', () => {
const action = {
type: UPDATE_DRAFT,
payload: { name: 'bio', value: 'New bio draft' },
};
const expectedState = {
...initialState,
drafts: { bio: 'New bio draft' },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle RESET_DRAFTS', () => {
const initialStateWithDrafts = {
...initialState,
drafts: { bio: 'New bio draft', name: 'New name' },
};
const action = { type: RESET_DRAFTS };
const expectedState = {
...initialStateWithDrafts,
drafts: {},
};
expect(profilePage(initialStateWithDrafts, action)).toEqual(expectedState);
});
it('should handle OPEN_FORM', () => {
const action = {
type: OPEN_FORM,
payload: { formId: 'bioForm' },
};
const expectedState = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle CLOSE_FORM when formId matches currentlyEditingField', () => {
const initialStateWithForm = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: { bio: 'New bio draft' },
};
const action = {
type: CLOSE_FORM,
payload: { formId: 'bioForm' },
};
const expectedState = {
...initialStateWithForm,
currentlyEditingField: null,
drafts: {},
};
expect(profilePage(initialStateWithForm, action)).toEqual(expectedState);
});
it('should not handle CLOSE_FORM when formId does not match currentlyEditingField', () => {
const initialStateWithForm = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: { bio: 'New bio draft' },
};
const action = {
type: CLOSE_FORM,
payload: { formId: 'nameForm' },
};
expect(profilePage(initialStateWithForm, action)).toEqual(initialStateWithForm);
});
});
});

View File

@@ -0,0 +1,191 @@
import { history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick';
import {
all,
call,
delay,
put,
select,
takeEvery,
} from 'redux-saga/effects';
import {
closeForm,
deleteProfilePhotoBegin,
deleteProfilePhotoReset,
deleteProfilePhotoSuccess,
DELETE_PROFILE_PHOTO,
fetchProfileBegin,
fetchProfileReset,
fetchProfileSuccess,
FETCH_PROFILE,
resetDrafts,
saveProfileBegin,
saveProfileFailure,
saveProfileReset,
saveProfileSuccess,
SAVE_PROFILE,
saveProfilePhotoBegin,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
SAVE_PROFILE_PHOTO,
} from './actions';
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
import * as ProfileApiService from './services';
export function* handleFetchProfile(action) {
const { username } = action.payload;
const userAccount = yield select(userAccountSelector);
const isAuthenticatedUserProfile = username === getAuthenticatedUser().username;
let preferences = {};
let account = userAccount;
let courseCertificates = null;
let countriesCodesList = [];
try {
yield put(fetchProfileBegin());
const calls = [
call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username),
call(ProfileApiService.getCountryList),
];
if (isAuthenticatedUserProfile) {
calls.push(call(ProfileApiService.getPreferences, username));
}
const result = yield all(calls);
if (isAuthenticatedUserProfile) {
[account, courseCertificates, countriesCodesList, preferences] = result;
} else {
[account, courseCertificates, countriesCodesList] = result;
}
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
yield call(ProfileApiService.patchPreferences, action.payload.username, {
account_privacy: 'custom',
'visibility.name': 'all_users',
'visibility.bio': 'all_users',
'visibility.course_certificates': 'all_users',
'visibility.country': 'all_users',
'visibility.date_joined': 'all_users',
'visibility.level_of_education': 'all_users',
'visibility.language_proficiencies': 'all_users',
'visibility.social_links': 'all_users',
'visibility.time_zone': 'all_users',
});
}
yield put(fetchProfileSuccess(
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
));
yield put(fetchProfileReset());
} catch (e) {
if (e.response.status === 404) {
history.push('/notfound');
} else {
throw e;
}
}
}
export function* handleSaveProfile(action) {
try {
const { drafts, preferences } = yield select(handleSaveProfileSelector);
const accountDrafts = pick(drafts, [
'bio',
'country',
'levelOfEducation',
'languageProficiencies',
'name',
'socialLinks',
]);
const preferencesDrafts = pick(drafts, [
'visibilityBio',
'visibilityCountry',
'visibilityLevelOfEducation',
'visibilityLanguageProficiencies',
'visibilityName',
'visibilitySocialLinks',
]);
if (Object.keys(preferencesDrafts).length > 0) {
preferencesDrafts.accountPrivacy = 'custom';
}
yield put(saveProfileBegin());
let accountResult = null;
if (Object.keys(accountDrafts).length > 0) {
accountResult = yield call(
ProfileApiService.patchProfile,
action.payload.username,
accountDrafts,
);
}
let preferencesResult = preferences;
if (Object.keys(preferencesDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts);
// TODO: Temporary deoptimization since the patchPreferences call doesn't return anything.
preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username);
}
yield put(saveProfileSuccess(accountResult, preferencesResult));
yield delay(1000);
yield put(closeForm(action.payload.formId));
yield delay(300);
yield put(saveProfileReset());
yield put(resetDrafts());
} catch (e) {
if (e.processedData && e.processedData.fieldErrors) {
yield put(saveProfileFailure(e.processedData.fieldErrors));
} else {
yield put(saveProfileReset());
throw e;
}
}
}
export function* handleSaveProfilePhoto(action) {
const { username, formData } = action.payload;
try {
yield put(saveProfilePhotoBegin());
const photoResult = yield call(ProfileApiService.postProfilePhoto, username, formData);
yield put(saveProfilePhotoSuccess(photoResult));
yield put(saveProfilePhotoReset());
} catch (e) {
yield put(saveProfilePhotoReset());
}
}
export function* handleDeleteProfilePhoto(action) {
const { username } = action.payload;
try {
yield put(deleteProfilePhotoBegin());
const photoResult = yield call(ProfileApiService.deleteProfilePhoto, username);
yield put(deleteProfilePhotoSuccess(photoResult));
yield put(deleteProfilePhotoReset());
} catch (e) {
yield put(deleteProfilePhotoReset());
}
}
export default function* profileSaga() {
yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile);
yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);
yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto);
}

View File

@@ -0,0 +1,167 @@
import {
takeEvery,
put,
call,
delay,
select,
all,
} from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import * as profileActions from './actions';
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
jest.mock('./services', () => ({
getProfile: jest.fn(),
patchProfile: jest.fn(),
postProfilePhoto: jest.fn(),
deleteProfilePhoto: jest.fn(),
getPreferences: jest.fn(),
getAccount: jest.fn(),
getCourseCertificates: jest.fn(),
getCountryList: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
/* eslint-disable import/first */
import profileSaga, {
handleFetchProfile,
handleSaveProfile,
handleSaveProfilePhoto,
handleDeleteProfilePhoto,
} from './sagas';
import * as ProfileApiService from './services';
/* eslint-enable import/first */
describe('RootSaga', () => {
describe('profileSaga', () => {
it('should pass actions to the correct sagas', () => {
const gen = profileSaga();
expect(gen.next().value)
.toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleFetchProfile', () => {
it('should fetch certificates and preferences for the current user profile', () => {
const userAccount = {
username: 'gonzo',
other: 'data',
};
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
};
const action = profileActions.fetchProfile('gonzo');
const gen = handleFetchProfile(action);
const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'gonzo'),
call(ProfileApiService.getCourseCertificates, 'gonzo'),
call(ProfileApiService.getCountryList),
call(ProfileApiService.getPreferences, 'gonzo'),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, [])));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
it('should fetch certificates and profile for some other user profile', () => {
const userAccount = {
username: 'gonzo',
other: 'data',
};
const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }];
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
};
const action = profileActions.fetchProfile('booyah');
const gen = handleFetchProfile(action);
const result = [{}, [1, 2, 3], countriesCodesList];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'booyah'),
call(ProfileApiService.getCourseCertificates, 'booyah'),
call(ProfileApiService.getCountryList),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleSaveProfile', () => {
const selectorData = {
username: 'my username',
drafts: {
name: 'Full Name',
},
preferences: {},
};
it('should successfully process a saveProfile request if there are no exceptions', () => {
const action = profileActions.saveProfile('ze form id', 'my username');
const gen = handleSaveProfile(action);
const profile = {
name: 'Full Name',
levelOfEducation: 'b',
};
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', {
name: 'Full Name',
}));
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {})));
expect(gen.next().value).toEqual(delay(1000));
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.saveProfileReset()));
expect(gen.next().value).toEqual(put(profileActions.resetDrafts()));
expect(gen.next().value).toBeUndefined();
});
it('should successfully publish a failure action on exception', () => {
const error = new Error('uhoh');
error.processedData = {
fieldErrors: {
uhoh: 'not good',
},
};
const action = profileActions.saveProfile(
'ze form id',
'my username',
);
const gen = handleSaveProfile(action);
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
const result = gen.throw(error);
expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' })));
expect(gen.next().value).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,338 @@
import { createSelector } from 'reselect';
import {
getLocale,
getLanguageList,
getCountryList,
getCountryMessages,
getLanguageMessages,
} from '@edx/frontend-platform/i18n';
export const formIdSelector = (state, props) => props.formId;
export const userAccountSelector = state => state.userAccount;
export const profileAccountSelector = state => state.profilePage.account;
export const profileDraftsSelector = state => state.profilePage.drafts;
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
export const profilePreferencesSelector = state => state.profilePage.preferences;
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
export const saveStateSelector = state => state.profilePage.saveState;
export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile;
export const countriesCodesListSelector = state => state.profilePage.countriesCodesList;
export const editableFormModeSelector = createSelector(
profileAccountSelector,
isAuthenticatedUserProfileSelector,
profileCourseCertificatesSelector,
formIdSelector,
currentlyEditingFieldSelector,
(account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => {
let propExists = account[formId] != null && account[formId].length > 0;
propExists = formId === 'certificates' ? certificates.length > 0 : propExists;
if (!isAuthenticatedUserProfile) {
return 'static';
}
if (formId === currentlyEditingField) {
return 'editing';
}
if (!propExists) {
return 'empty';
}
return 'editable';
},
);
export const accountDraftsFieldSelector = createSelector(
formIdSelector,
profileDraftsSelector,
(formId, drafts) => drafts[formId],
);
export const visibilityDraftsFieldSelector = createSelector(
formIdSelector,
profileDraftsSelector,
(formId, drafts) => drafts[`visibility${formId.charAt(0).toUpperCase() + formId.slice(1)}`],
);
export const formErrorSelector = createSelector(
accountErrorsSelector,
formIdSelector,
(errors, formId) => (errors[formId] ? errors[formId].userMessage : null),
);
export const editableFormSelector = createSelector(
editableFormModeSelector,
formErrorSelector,
saveStateSelector,
(editMode, error, saveState) => ({
editMode,
error,
saveState,
}),
);
export const localeSelector = () => getLocale();
export const countryMessagesSelector = createSelector(
localeSelector,
locale => getCountryMessages(locale),
);
export const languageMessagesSelector = createSelector(
localeSelector,
locale => getLanguageMessages(locale),
);
export const sortedLanguagesSelector = createSelector(
localeSelector,
locale => getLanguageList(locale),
);
export const sortedCountriesSelector = createSelector(
localeSelector,
countriesCodesListSelector,
profileAccountSelector,
(locale, countriesCodesList, profileAccount) => {
const countryList = getCountryList(locale);
const userCountry = profileAccount.country;
return countryList.filter(({ code }) => code === userCountry || countriesCodesList.find(x => x === code));
},
);
export const preferredLanguageSelector = createSelector(
editableFormSelector,
sortedLanguagesSelector,
languageMessagesSelector,
(editableForm, sortedLanguages, languageMessages) => ({
...editableForm,
sortedLanguages,
languageMessages,
}),
);
export const countrySelector = createSelector(
editableFormSelector,
sortedCountriesSelector,
countryMessagesSelector,
countriesCodesListSelector,
profileAccountSelector,
(editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({
...editableForm,
translatedCountries,
countryMessages,
countriesCodesList,
committedCountry: account.country,
}),
);
export const certificatesSelector = createSelector(
editableFormSelector,
profileCourseCertificatesSelector,
(editableForm, certificates) => ({
...editableForm,
certificates,
value: certificates,
}),
);
export const profileImageSelector = createSelector(
profileAccountSelector,
account => (account.profileImage != null
? {
src: account.profileImage.imageUrlFull,
isDefault: !account.profileImage.hasImage,
}
: {}),
);
export const handleSaveProfileSelector = createSelector(
profileDraftsSelector,
profilePreferencesSelector,
(drafts, preferences) => ({
drafts,
preferences,
}),
);
const socialLinksByPlatformSelector = createSelector(
profileAccountSelector,
(account) => {
const linksByPlatform = {};
if (Array.isArray(account.socialLinks)) {
account.socialLinks.forEach((socialLink) => {
linksByPlatform[socialLink.platform] = socialLink;
});
}
return linksByPlatform;
},
);
const draftSocialLinksByPlatformSelector = createSelector(
profileDraftsSelector,
(drafts) => {
const linksByPlatform = {};
if (Array.isArray(drafts.socialLinks)) {
drafts.socialLinks.forEach((socialLink) => {
linksByPlatform[socialLink.platform] = socialLink;
});
}
return linksByPlatform;
},
);
export const formSocialLinksSelector = createSelector(
socialLinksByPlatformSelector,
draftSocialLinksByPlatformSelector,
(linksByPlatform, draftLinksByPlatform) => {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const socialLinks = [];
knownPlatforms.forEach((platform) => {
if (draftLinksByPlatform[platform] !== undefined) {
socialLinks.push(draftLinksByPlatform[platform]);
} else if (linksByPlatform[platform] !== undefined) {
socialLinks.push(linksByPlatform[platform]);
} else {
socialLinks.push({
platform,
socialLink: null,
});
}
});
return socialLinks;
},
);
export const visibilitiesSelector = createSelector(
profilePreferencesSelector,
accountPrivacySelector,
(preferences, accountPrivacy) => {
switch (accountPrivacy) {
case 'custom':
return {
visibilityBio: preferences.visibilityBio || 'all_users',
visibilityCountry: preferences.visibilityCountry || 'all_users',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
visibilityName: preferences.visibilityName || 'all_users',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users',
};
case 'private':
return {
visibilityBio: 'private',
visibilityCountry: 'private',
visibilityLevelOfEducation: 'private',
visibilityLanguageProficiencies: 'private',
visibilityName: 'private',
visibilitySocialLinks: 'private',
};
case 'all_users':
default:
return {
visibilityBio: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilityName: 'all_users',
visibilitySocialLinks: 'all_users',
};
}
},
);
function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
export const formValuesSelector = createSelector(
profileAccountSelector,
visibilitiesSelector,
profileDraftsSelector,
profileCourseCertificatesSelector,
formSocialLinksSelector,
(account, visibilities, drafts, courseCertificates, socialLinks) => ({
bio: chooseFormValue(drafts.bio, account.bio),
visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio),
courseCertificates,
country: chooseFormValue(drafts.country, account.country),
visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry),
levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation),
visibilityLevelOfEducation: chooseFormValue(
drafts.visibilityLevelOfEducation,
visibilities.visibilityLevelOfEducation,
),
languageProficiencies: chooseFormValue(
drafts.languageProficiencies,
account.languageProficiencies,
),
visibilityLanguageProficiencies: chooseFormValue(
drafts.visibilityLanguageProficiencies,
visibilities.visibilityLanguageProficiencies,
),
name: chooseFormValue(drafts.name, account.name),
visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName),
socialLinks,
visibilitySocialLinks: chooseFormValue(
drafts.visibilitySocialLinks,
visibilities.visibilitySocialLinks,
),
}),
);
export const profilePageSelector = createSelector(
profileAccountSelector,
formValuesSelector,
profileImageSelector,
saveStateSelector,
savePhotoStateSelector,
isLoadingProfileSelector,
draftSocialLinksByPlatformSelector,
accountErrorsSelector,
isAuthenticatedUserProfileSelector,
(
account,
formValues,
profileImage,
saveState,
savePhotoState,
isLoadingProfile,
draftSocialLinksByPlatform,
errors,
isAuthenticatedUserProfile,
) => ({
username: account.username,
profileImage,
requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined,
yearOfBirth: account.yearOfBirth,
bio: formValues.bio,
visibilityBio: formValues.visibilityBio,
courseCertificates: formValues.courseCertificates,
country: formValues.country,
visibilityCountry: formValues.visibilityCountry,
levelOfEducation: formValues.levelOfEducation,
visibilityLevelOfEducation: formValues.visibilityLevelOfEducation,
languageProficiencies: formValues.languageProficiencies,
visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies,
name: formValues.name,
visibilityName: formValues.visibilityName,
socialLinks: formValues.socialLinks,
visibilitySocialLinks: formValues.visibilitySocialLinks,
draftSocialLinksByPlatform,
saveState,
savePhotoState,
isLoadingProfile,
photoUploadError: errors.photo || null,
isAuthenticatedUserProfile,
}),
);

View File

@@ -0,0 +1,168 @@
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
import { FIELD_LABELS } from './constants';
ensureConfig(['LMS_BASE_URL'], 'Profile API service');
function processAccountData(data) {
const processedData = camelCaseObject(data);
return {
...processedData,
socialLinks: Array.isArray(processedData.socialLinks) ? processedData.socialLinks : [],
languageProficiencies: Array.isArray(processedData.languageProficiencies)
? processedData.languageProficiencies : [],
name: processedData.name || null,
bio: processedData.bio || null,
country: processedData.country || null,
levelOfEducation: processedData.levelOfEducation || null,
profileImage: processedData.profileImage || {},
yearOfBirth: processedData.yearOfBirth || null,
};
}
function processAndThrowError(error, errorDataProcessor) {
const processedError = Object.create(error);
if (error.response && error.response.data && typeof error.response.data === 'object') {
processedError.processedData = errorDataProcessor(error.response.data);
throw processedError;
} else {
throw error;
}
}
export async function getAccount(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
return processAccountData(data);
}
export async function patchProfile(username, params) {
const processedParams = snakeCaseObject(params);
const { data } = await getHttpClient()
.patch(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`, processedParams, {
headers: {
'Content-Type': 'application/merge-patch+json',
},
})
.catch((error) => {
processAndThrowError(error, processAccountData);
});
return processAccountData(data);
}
export async function postProfilePhoto(username, formData) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().post(
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
).catch((error) => {
processAndThrowError(error, camelCaseObject);
});
// TODO: Someday in the future the POST photo endpoint
// will return the new values. At that time we should
// use the commented line below instead of the separate
// getAccount request that follows.
// return camelCaseObject(data);
const updatedData = await getAccount(username);
return updatedData.profileImage;
}
export async function deleteProfilePhoto(username) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`);
// TODO: Someday in the future the POST photo endpoint
// will return the new values. At that time we should
// use the commented line below instead of the separate
// getAccount request that follows.
// return camelCaseObject(data);
const updatedData = await getAccount(username);
return updatedData.profileImage;
}
export async function getPreferences(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return camelCaseObject(data);
}
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
visibility_bio: 'visibility.bio',
visibility_course_certificates: 'visibility.course_certificates',
visibility_country: 'visibility.country',
visibility_date_joined: 'visibility.date_joined',
visibility_level_of_education: 'visibility.level_of_education',
visibility_language_proficiencies: 'visibility.language_proficiencies',
visibility_name: 'visibility.name',
visibility_social_links: 'visibility.social_links',
visibility_time_zone: 'visibility.time_zone',
});
await getHttpClient().patch(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`, processedParams, {
headers: { 'Content-Type': 'application/merge-patch+json' },
});
return params; // TODO: Once the server returns the updated preferences object, return that.
}
function transformCertificateData(data) {
const transformedData = [];
data.forEach((cert) => {
// download_url may be full url or absolute path.
// note: using the URL() api breaks in ie 11
const urlIsPath = typeof cert.download_url === 'string'
&& cert.download_url.search(/http[s]?:\/\//) !== 0;
const downloadUrl = urlIsPath
? `${getConfig().LMS_BASE_URL}${cert.download_url}`
: cert.download_url;
transformedData.push({
...camelCaseObject(cert),
certificateType: cert.certificate_type,
downloadUrl,
});
});
return transformedData;
}
export async function getCourseCertificates(username) {
const url = `${getConfig().LMS_BASE_URL}/api/certificates/v0/certificates/${username}/`;
try {
const { data } = await getHttpClient().get(url);
return transformCertificateData(data);
} catch (e) {
logError(e);
return [];
}
}
function extractCountryList(data) {
return data?.fields
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
?.options?.map(({ value }) => (value)) || [];
}
export async function getCountryList() {
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
try {
const { data } = await getHttpClient().get(url);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -0,0 +1,151 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import classNames from 'classnames';
import messages from './Bio.messages';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsOnMobileScreen,
useIsVisibilityEnabled,
} from '../data/hooks';
const Bio = ({
formId,
bio,
visibilityBio,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isMobileView = useIsOnMobileScreen();
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
return (
<SwitchContent
className={classNames([
isMobileView ? 'pt-40px' : 'pt-0',
])}
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<textarea
className="form-control py-10px"
id={formId}
name={formId}
value={bio}
onChange={handleChange}
/>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityBio"
saveState={saveState}
visibility={visibilityBio}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EditableItemHeader
content={bio}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityBio !== null && isVisibilityEnabled}
visibility={visibilityBio}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage
id="profile.bio.empty"
defaultMessage="Add a short bio"
description="instructions when the user hasn't written an About Me"
/>
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EditableItemHeader content={bio} />
</>
),
}}
/>
);
};
Bio.propTypes = {
formId: PropTypes.string.isRequired,
bio: PropTypes.string,
visibilityBio: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Bio.defaultProps = {
editMode: 'static',
saveState: null,
bio: null,
visibilityBio: 'private',
error: null,
};
export default connect(
editableFormSelector,
{},
)(Bio);

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.bio.about.me': {
id: 'profile.bio.about.me',
defaultMessage: 'Bio',
description: 'A section of a user profile',
},
});
export default messages;

View File

@@ -0,0 +1,163 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import messages from './Country.messages';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { countrySelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Country = ({
formId,
country,
visibilityCountry,
editMode,
saveState,
error,
translatedCountries,
countriesCodesList,
countryMessages,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
const isDisabledCountry = (countryCode) => countriesCodesList.length > 0
&& !countriesCodesList.find(code => code === countryCode);
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<select
data-hj-suppress
className="form-control py-10px"
type="select"
id={formId}
name={formId}
value={country}
onChange={handleChange}
>
<option value=""> </option>
{translatedCountries.map(({ code, name }) => (
<option key={code} value={code} disabled={isDisabledCountry(code)}>
{name}
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityCountry"
saveState={saveState}
visibility={visibilityCountry}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EditableItemHeader
content={countryMessages[country]}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityCountry !== null && isVisibilityEnabled}
visibility={visibilityCountry}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.country.empty'])}
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EditableItemHeader content={countryMessages[country]} />
</>
),
}}
/>
);
};
Country.propTypes = {
formId: PropTypes.string.isRequired,
country: PropTypes.string,
visibilityCountry: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
translatedCountries: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
countriesCodesList: PropTypes.arrayOf(PropTypes.string).isRequired,
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Country.defaultProps = {
editMode: 'static',
saveState: null,
country: null,
visibilityCountry: 'private',
error: null,
};
export default connect(
countrySelector,
{},
)(Country);

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.country.label': {
id: 'profile.country.label',
defaultMessage: 'Country',
description: 'The label for a country in a user profile.',
},
'profile.country.empty': {
id: 'profile.country.empty',
defaultMessage: 'Add country',
description: 'The affordance to add country location to a users profile.',
},
});
export default messages;

View File

@@ -0,0 +1,171 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
import { Form } from '@openedx/paragon';
import messages from './Education.messages';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { EDUCATION_LEVELS } from '../data/constants';
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Education = ({
formId,
levelOfEducation,
visibilityLevelOfEducation,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<select
data-hj-suppress
className="form-control py-10px"
id={formId}
name={formId}
value={levelOfEducation}
onChange={handleChange}
>
<option value=""> </option>
{EDUCATION_LEVELS.map(level => (
<option key={level} value={level}>
{intl.formatMessage(get(
messages,
`profile.education.levels.${level}`,
messages['profile.education.levels.o'],
))}
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLevelOfEducation"
saveState={saveState}
visibility={visibilityLevelOfEducation}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EditableItemHeader
content={intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityLevelOfEducation !== null && isVisibilityEnabled}
visibility={visibilityLevelOfEducation}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage
id="profile.education.empty"
defaultMessage="Add level of education"
description="instructions when the user doesn't have their level of education set"
/>
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EditableItemHeader
content={intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
/>
</>
),
}}
/>
);
};
Education.propTypes = {
formId: PropTypes.string.isRequired,
levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Education.defaultProps = {
editMode: 'static',
saveState: null,
levelOfEducation: null,
visibilityLevelOfEducation: 'private',
error: null,
};
export default connect(
editableFormSelector,
{},
)(Education);

View File

@@ -0,0 +1,56 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.education.education': {
id: 'profile.education.education',
defaultMessage: 'Education',
description: 'A section of a user profile',
},
'profile.education.levels.p': {
id: 'profile.education.levels.p',
defaultMessage: 'Doctorate',
description: 'Selected by the user if their highest level of education is a doctorate degree.',
},
'profile.education.levels.m': {
id: 'profile.education.levels.m',
defaultMessage: "Master's or professional degree",
description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.",
},
'profile.education.levels.b': {
id: 'profile.education.levels.b',
defaultMessage: "Bachelor's Degree",
description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.",
},
'profile.education.levels.a': {
id: 'profile.education.levels.a',
defaultMessage: "Associate's degree",
description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.",
},
'profile.education.levels.hs': {
id: 'profile.education.levels.hs',
defaultMessage: 'Secondary/high school',
description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.',
},
'profile.education.levels.jhs': {
id: 'profile.education.levels.jhs',
defaultMessage: 'Junior secondary/junior high/middle school',
description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.',
},
'profile.education.levels.el': {
id: 'profile.education.levels.el',
defaultMessage: 'Elementary/primary school',
description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.',
},
'profile.education.levels.none': {
id: 'profile.education.levels.none',
defaultMessage: 'No formal education',
description: 'Selected by the user to describe their education.',
},
'profile.education.levels.o': {
id: 'profile.education.levels.o',
defaultMessage: 'Other education',
description: 'Selected by the user if they have a type of education not described by the other choices.',
},
});
export default messages;

View File

@@ -0,0 +1,192 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { InfoOutline } from '@openedx/paragon/icons';
import { Hyperlink, OverlayTrigger, Tooltip } from '@openedx/paragon';
import messages from './Name.messages';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Name = ({
formId,
name,
visibilityName,
editMode,
saveState,
changeHandler,
submitHandler,
closeHandler,
openHandler,
accountSettingsUrl,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<div className="form-group">
<div className="row m-0 pb-2.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader content={name} />
<h4 className="font-weight-normal">
<Hyperlink destination={accountSettingsUrl} target="_blank">
{intl.formatMessage(messages['profile.name.redirect'])}
</Hyperlink>
</h4>
</div>
<FormControls
visibilityId="visibilityName"
saveState={saveState}
visibility={visibilityName}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
</form>
</div>
),
editable: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader
content={name}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityName !== null && isVisibilityEnabled}
visibility={visibilityName}
/>
</>
),
empty: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.name.empty'])}
</EmptyContent>
</>
),
static: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader content={name} />
</>
),
}}
/>
);
};
Name.propTypes = {
formId: PropTypes.string.isRequired,
name: PropTypes.string,
visibilityName: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
accountSettingsUrl: PropTypes.string.isRequired,
};
Name.defaultProps = {
editMode: 'static',
saveState: null,
name: null,
visibilityName: 'private',
};
export default connect(
editableFormSelector,
{},
)(Name);

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.name.full.name': {
id: 'profile.name.full.name',
defaultMessage: 'Full name',
description: 'A section of a user profile',
},
'profile.name.empty': {
id: 'profile.name.empty',
defaultMessage: 'Add full name',
description: 'The affordance to add a name to a users profile.',
},
'profile.name.tooltip': {
id: 'profile.name.tooltip',
defaultMessage: 'The name that is used for ID verification and that appears on your certificates',
description: 'Tooltip for the full name field.',
},
'profile.name.redirect': {
id: 'profile.name.redirect',
defaultMessage: 'Edit full name from the Accounts page',
description: 'Redirect message for editing the name from the Accounts page.',
},
});
export default messages;

View File

@@ -0,0 +1,166 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import messages from './PreferredLanguage.messages';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { preferredLanguageSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const PreferredLanguage = ({
formId,
languageProficiencies,
visibilityLanguageProficiencies,
editMode,
saveState,
error,
sortedLanguages,
languageMessages,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = ({ target: { name, value } }) => {
let newValue = value;
if (name === formId) {
newValue = value ? [{ code: value }] : [];
}
changeHandler(name, newValue);
};
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<select
data-hj-suppress
id={formId}
name={formId}
className="form-control py-10px"
value={value}
onChange={handleChange}
>
<option value=""> </option>
{sortedLanguages.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLanguageProficiencies"
saveState={saveState}
visibility={visibilityLanguageProficiencies}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EditableItemHeader
content={languageMessages[value]}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityLanguageProficiencies !== null && isVisibilityEnabled}
visibility={visibilityLanguageProficiencies}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.preferredlanguage.empty'])}
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EditableItemHeader content={languageMessages[value]} />
</>
),
}}
/>
);
};
PreferredLanguage.propTypes = {
formId: PropTypes.string.isRequired,
languageProficiencies: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })),
PropTypes.oneOf(['']),
]),
visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
sortedLanguages: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
languageMessages: PropTypes.objectOf(PropTypes.string).isRequired,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
PreferredLanguage.defaultProps = {
editMode: 'static',
saveState: null,
languageProficiencies: [],
visibilityLanguageProficiencies: 'private',
error: null,
};
export default connect(
preferredLanguageSelector,
{},
)(PreferredLanguage);

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.preferredlanguage.empty': {
id: 'profile.preferredlanguage.empty',
defaultMessage: 'Add language',
description: 'Instructions when the user doesnt have a preferred language set.',
},
'profile.preferredlanguage.label': {
id: 'profile.preferredlanguage.label',
defaultMessage: 'Primary language spoken',
description: 'The label for a users primary spoken language.',
},
});
export default messages;

View File

@@ -0,0 +1,170 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import {
Dropdown,
IconButton,
Icon,
Tooltip,
OverlayTrigger,
} from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { PhotoCamera } from '@openedx/paragon/icons';
import { ReactComponent as DefaultAvatar } from '../assets/avatar.svg';
import messages from './ProfileAvatar.messages';
const ProfileAvatar = ({
src,
isDefault,
onSave,
onDelete,
savePhotoState,
isEditable,
}) => {
const intl = useIntl();
const fileInput = useRef(null);
const form = useRef(null);
const onClickUpload = () => {
fileInput.current.click();
};
const onClickDelete = () => {
onDelete();
};
const onSubmit = (e) => {
if (e) {
e.preventDefault();
}
onSave(new FormData(form.current));
form.current.reset();
};
const onChangeInput = () => {
onSubmit();
};
const renderPending = () => (
<div
className="position-absolute w-100 h-100 d-flex justify-content-center align-items-center rounded-circle bg-black bg-opacity-65"
>
<div className="spinner-border text-primary" role="status" />
</div>
);
const renderEditButton = () => {
if (!isEditable) {
return null;
}
return (
<div className="profile-avatar-button">
<Dropdown>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
{!isDefault ? (
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.profileavatar.tooltip.edit'])}
</p>
) : (
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.profileavatar.tooltip.upload'])}
</p>
)}
</Tooltip>
)}
>
<Dropdown.Toggle
invertColors
isActive
id="dropdown-toggle-with-iconbutton"
as={IconButton}
src={PhotoCamera}
iconAs={Icon}
variant="primary"
className="shadow-sm"
/>
</OverlayTrigger>
<Dropdown.Menu className="min-width-179px p-0 m-0">
<Dropdown.Item type="button" onClick={onClickUpload}>
<FormattedMessage
id="profile.profileavatar.upload-button"
defaultMessage="Upload photo"
description="Upload photo button"
/>
</Dropdown.Item>
{!isDefault && (
<Dropdown.Item type="button" onClick={onClickDelete}>
<FormattedMessage
id="profile.profileavatar.remove.button"
defaultMessage="Remove photo"
description="Remove photo button"
/>
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</div>
);
};
const renderAvatar = () => (
isDefault ? (
<DefaultAvatar className="text-muted" role="img" aria-hidden focusable="false" viewBox="0 0 24 24" />
) : (
<img
data-hj-suppress
className="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
alt={intl.formatMessage(messages['profile.image.alt.attribute'])}
src={src}
/>
)
);
return (
<div className="profile-avatar-wrap position-relative">
<div className="profile-avatar rounded-circle bg-light">
{savePhotoState === 'pending' && renderPending()}
{renderAvatar()}
</div>
{renderEditButton()}
<form
ref={form}
onSubmit={onSubmit}
encType="multipart/form-data"
>
<input
className="d-none form-control-file"
ref={fileInput}
type="file"
name="file"
id="photo-file"
onChange={onChangeInput}
accept=".jpg, .jpeg, .png"
/>
</form>
</div>
);
};
ProfileAvatar.propTypes = {
src: PropTypes.string,
isDefault: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isEditable: PropTypes.bool,
};
ProfileAvatar.defaultProps = {
src: null,
isDefault: true,
savePhotoState: null,
isEditable: false,
};
export default ProfileAvatar;

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.image.alt.attribute': {
id: 'profile.image.alt.attribute',
defaultMessage: 'profile avatar',
description: 'Alt attribute for a profile photo',
},
'profile.profileavatar.change-button': {
id: 'profile.profileavatar.change-button',
defaultMessage: 'Change',
description: 'Change photo button',
},
'profile.profileavatar.tooltip.edit': {
id: 'profile.profileavatar.tooltip.edit',
defaultMessage: 'Edit photo',
description: 'Tooltip for edit photo button',
},
'profile.profileavatar.tooltip.upload': {
id: 'profile.profileavatar.tooltip.upload',
defaultMessage: 'Upload photo',
description: 'Tooltip for upload photo button',
},
});
export default messages;

View File

@@ -0,0 +1,258 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { connect } from 'react-redux';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { editableFormSelector } from '../data/selectors';
import { useIsVisibilityEnabled } from '../data/hooks';
const platformDisplayInfo = {
facebook: {
icon: faFacebook,
name: 'Facebook',
},
twitter: {
icon: faTwitter,
name: 'X',
},
linkedin: {
icon: faLinkedin,
name: 'LinkedIn',
},
};
const SocialLinks = ({
formId,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const [activePlatform, setActivePlatform] = useState(null);
const mergeWithDrafts = (newSocialLink) => {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const updated = [];
knownPlatforms.forEach((platform) => {
if (newSocialLink.platform === platform) {
updated.push(newSocialLink);
} else if (draftSocialLinksByPlatform[platform] !== undefined) {
updated.push(draftSocialLinksByPlatform[platform]);
}
});
return updated;
};
const handleChange = (e) => {
const { name, value } = e.target;
if (name !== 'visibilitySocialLinks') {
changeHandler(
'socialLinks',
mergeWithDrafts({
platform: name,
socialLink: value,
}),
);
} else {
changeHandler(name, value);
}
};
const handleSubmit = (e) => {
e.preventDefault();
submitHandler(formId);
setActivePlatform(null);
};
const handleClose = () => {
closeHandler(formId);
setActivePlatform(null);
};
const handleOpen = (platform) => {
openHandler(formId);
setActivePlatform(platform);
};
const renderPlatformContent = (platform, socialLink, isEditing) => {
if (isEditing) {
return (
<form onSubmit={handleSubmit}>
<div className="form-group m-0">
{error !== null && (
<div id="social-error-feedback">
<Alert variant="danger" dismissible={false} show>
{error}
</Alert>
</div>
)}
<div className="pb-3">
<input
className={classNames('form-control py-10px', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={socialLink || ''}
onChange={handleChange}
aria-describedby="social-error-feedback"
/>
</div>
<FormControls
visibilityId="visibilitySocialLinks"
saveState={saveState}
visibility={visibilitySocialLinks}
cancelHandler={handleClose}
changeHandler={handleChange}
submitHandler={handleSubmit}
/>
</div>
</form>
);
}
if (socialLink) {
return (
<div className="w-100 overflowWrap-breakWord">
<EditableItemHeader
content={socialLink}
showEditButton
onClickEdit={() => handleOpen(platform)}
showVisibility={visibilitySocialLinks !== null && isVisibilityEnabled}
visibility={visibilitySocialLinks}
/>
</div>
);
}
return (
<EmptyContent onClick={() => handleOpen(platform)}>
Add {platformDisplayInfo[platform].name}
</EmptyContent>
);
};
return (
<SwitchContent
className="p-0"
expression={editMode}
cases={{
empty: (
<div>
<div>
{socialLinks.map(({ platform }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
<EmptyContent onClick={() => handleOpen(platform)}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network} profile"
values={{
network: platformDisplayInfo[platform].name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</div>
))}
</div>
</div>
),
static: (
<div>
<div>
{socialLinks
.filter(({ socialLink }) => Boolean(socialLink))
.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
<EditableItemHeader
content={socialLink}
contentPrefix={`${platformDisplayInfo[platform].name}: `}
/>
</div>
))}
</div>
</div>
),
editable: (
<div>
<div>
{socialLinks.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
</div>
))}
</div>
</div>
),
editing: (
<div>
<div>
{socialLinks.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{platformDisplayInfo[platform].name}
</p>
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
</div>
))}
</div>
</div>
),
}}
/>
);
};
SocialLinks.propTypes = {
formId: PropTypes.string.isRequired,
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})).isRequired,
draftSocialLinksByPlatform: PropTypes.objectOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibilitySocialLinks: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
SocialLinks.defaultProps = {
editMode: 'static',
saveState: null,
draftSocialLinksByPlatform: {},
visibilitySocialLinks: 'private',
error: null,
};
export default connect(
editableFormSelector,
{},
)(SocialLinks);

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.sociallinks.social.links': {
id: 'profile.sociallinks.social.links',
defaultMessage: 'Social Links',
description: 'A section of a user profile',
},
});
export default messages;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { EditOutline } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon';
import messages from './EditButton.messages';
const EditButton = ({
onClick, className, style, intl,
}) => (
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.editbutton.edit'])}
</p>
</Tooltip>
)}
>
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<EditOutline className="text-gray-700" />
</Button>
</OverlayTrigger>
);
export default injectIntl(EditButton);
EditButton.propTypes = {
onClick: PropTypes.func.isRequired,
className: PropTypes.string,
style: PropTypes.object, // eslint-disable-line
intl: intlShape.isRequired,
};
EditButton.defaultProps = {
className: null,
style: null,
};

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.editbutton.edit': {
id: 'profile.editbutton.edit',
defaultMessage: 'Edit',
description: 'A button label',
},
});
export default messages;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import EditButton from './EditButton';
import { Visibility } from './Visibility';
import { useIsOnMobileScreen } from '../../data/hooks';
const EditableItemHeader = ({
content,
showVisibility,
visibility,
showEditButton,
onClickEdit,
headingId,
}) => {
const isMobileView = useIsOnMobileScreen();
return (
<>
<div className="row m-0 p-0 d-flex flex-nowrap align-items-center">
<div
className={classNames([
'm-0 p-0 col-auto',
isMobileView ? 'w-90' : '',
])}
>
<h4
className="edit-section-header text-gray-700"
id={headingId}
>
{content}
</h4>
</div>
<div
className={classNames([
'col-auto m-0 p-0 d-flex align-items-center',
isMobileView ? 'col-1' : 'col-auto',
])}
>
{showEditButton ? <EditButton className="p-1.5" onClick={onClickEdit} /> : null}
</div>
</div>
<div className="row m-0 p-0">
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div>
</>
);
};
export default EditableItemHeader;
EditableItemHeader.propTypes = {
onClickEdit: PropTypes.func,
showVisibility: PropTypes.bool,
showEditButton: PropTypes.bool,
content: PropTypes.node,
visibility: PropTypes.oneOf(['private', 'all_users']),
headingId: PropTypes.string,
};
EditableItemHeader.defaultProps = {
onClickEdit: () => {
},
showVisibility: false,
showEditButton: false,
content: '',
visibility: 'private',
headingId: null,
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
const EmptyContent = ({ children, onClick, showPlusIcon }) => (
<div className="p-0 m-0">
{onClick ? (
<button
type="button"
className="p-0 text-left btn btn-link lh-36px"
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') { onClick(); } }}
tabIndex={0}
>
{showPlusIcon ? <FontAwesomeIcon size="xs" className="mr-1" icon={faPlus} /> : null}
{children}
</button>
) : children}
</div>
);
export default EmptyContent;
EmptyContent.propTypes = {
onClick: PropTypes.func,
children: PropTypes.node,
showPlusIcon: PropTypes.bool,
};
EmptyContent.defaultProps = {
onClick: null,
children: null,
showPlusIcon: true,
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, StatefulButton } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './FormControls.messages';
import { VisibilitySelect } from './Visibility';
import { useIsVisibilityEnabled } from '../../data/hooks';
const FormControls = ({
cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
}) => {
const buttonState = saveState === 'error' ? null : saveState;
const isVisibilityEnabled = useIsVisibilityEnabled();
return (
<div className="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center">
{isVisibilityEnabled && (
<div className="form-group d-flex flex-wrap">
<label className="col-form-label" htmlFor={visibilityId}>
{intl.formatMessage(messages['profile.formcontrols.who.can.see'])}
</label>
<VisibilitySelect
id={visibilityId}
className="d-flex align-items-center"
type="select"
name={visibilityId}
value={visibility}
onChange={changeHandler}
/>
</div>
)}
<div className="row form-group flex-shrink-0 flex-grow-1 m-0 p-0">
<div className="pr-2 pl-0 m-0">
<Button variant="outline-primary" onClick={cancelHandler}>
{intl.formatMessage(messages['profile.formcontrols.button.cancel'])}
</Button>
</div>
<div className="p-0 m-0">
<StatefulButton
type="submit"
state={buttonState}
labels={{
default: intl.formatMessage(messages['profile.formcontrols.button.save']),
pending: intl.formatMessage(messages['profile.formcontrols.button.saving']),
complete: intl.formatMessage(messages['profile.formcontrols.button.saved']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (buttonState === 'pending') {
e.preventDefault();
}
}}
disabledStates={[]}
/>
</div>
</div>
</div>
);
};
export default injectIntl(FormControls);
FormControls.propTypes = {
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
visibility: PropTypes.oneOf(['private', 'all_users']),
visibilityId: PropTypes.string.isRequired,
cancelHandler: PropTypes.func.isRequired,
changeHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
FormControls.defaultProps = {
visibility: 'private',
saveState: null,
};

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.formcontrols.who.can.see': {
id: 'profile.formcontrols.who.can.see',
defaultMessage: 'Who can see this:',
description: 'What users can see this area?',
},
'profile.formcontrols.button.cancel': {
id: 'profile.formcontrols.button.cancel',
defaultMessage: 'Cancel',
description: 'A button label',
},
'profile.formcontrols.button.save': {
id: 'profile.formcontrols.button.save',
defaultMessage: 'Save',
description: 'A button label',
},
'profile.formcontrols.button.saving': {
id: 'profile.formcontrols.button.saving',
defaultMessage: 'Saving',
description: 'A button label',
},
'profile.formcontrols.button.saved': {
id: 'profile.formcontrols.button.saved',
defaultMessage: 'Saved',
description: 'A button label',
},
});
export default messages;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TransitionReplace } from '@openedx/paragon';
const onChildExit = (htmlNode) => {
if (htmlNode.contains(document.activeElement)) {
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
if (!enteringChild) {
return;
}
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusableElements.length) {
focusableElements[0].focus();
}
}
};
const SwitchContent = ({ expression, cases, className }) => {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
return getContent(cases[caseKey]);
}
return React.cloneElement(cases[caseKey], { key: caseKey });
}
if (cases.default) {
if (typeof cases.default === 'string') {
return getContent(cases.default);
}
React.cloneElement(cases.default, { key: 'default' });
}
return null;
};
return (
<TransitionReplace
className={className}
onChildExit={onChildExit}
>
{getContent(expression)}
</TransitionReplace>
);
};
SwitchContent.propTypes = {
expression: PropTypes.string,
cases: PropTypes.objectOf(PropTypes.node).isRequired,
className: PropTypes.string,
};
SwitchContent.defaultProps = {
expression: null,
className: null,
};
export default SwitchContent;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEyeSlash, faEye } from '@fortawesome/free-regular-svg-icons';
import messages from './Visibility.messages';
const Visibility = ({ to, intl }) => {
const icon = to === 'private' ? faEyeSlash : faEye;
const label = to === 'private'
? intl.formatMessage(messages['profile.visibility.who.just.me'])
: intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME });
return (
<span className="ml-auto small text-muted">
<FontAwesomeIcon icon={icon} /> {label}
</span>
);
};
Visibility.propTypes = {
to: PropTypes.oneOf(['private', 'all_users']),
intl: intlShape.isRequired,
};
Visibility.defaultProps = {
to: 'private',
};
const VisibilitySelect = ({ intl, className, ...props }) => {
const { value } = props;
const icon = value === 'private' ? faEyeSlash : faEye;
return (
<span className={className}>
<span className="d-inline-block ml-1 mr-2 width-24px">
<FontAwesomeIcon icon={icon} />
</span>
<select className="d-inline-block form-control" {...props}>
<option key="private" value="private">
{intl.formatMessage(messages['profile.visibility.who.just.me'])}
</option>
<option key="all_users" value="all_users">
{intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME })}
</option>
</select>
</span>
);
};
VisibilitySelect.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
name: PropTypes.string,
value: PropTypes.oneOf(['private', 'all_users']),
onChange: PropTypes.func,
intl: intlShape.isRequired,
};
VisibilitySelect.defaultProps = {
id: null,
className: null,
name: 'visibility',
value: null,
onChange: null,
};
const intlVisibility = injectIntl(Visibility);
const intlVisibilitySelect = injectIntl(VisibilitySelect);
export {
intlVisibility as Visibility,
intlVisibilitySelect as VisibilitySelect,
};

View File

@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.visibility.who.just.me': {
id: 'profile.visibility.who.just.me',
defaultMessage: 'Just me',
description: 'What users can see this area?',
},
'profile.visibility.who.everyone': {
id: 'profile.visibility.who.everyone',
defaultMessage: 'Everyone on {siteName}',
description: 'What users can see this area?',
},
});
export default messages;

5
src/profile-v2/index.js Normal file
View File

@@ -0,0 +1,5 @@
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { default as ProfilePage } from './ProfilePage';
export { default as NotFoundPage } from './NotFoundPage';
export { default as messages } from './ProfilePage.messages';

265
src/profile-v2/index.scss Normal file
View File

@@ -0,0 +1,265 @@
.word-break-all {
word-break: break-all !important;
}
// TODO: Update edx-bootstrap theme to incorporate these edits.
.btn, a.btn {
text-decoration: none;
&:hover {
text-decoration: none;
}
}
.btn-link {
text-decoration: underline;
&:hover {
text-decoration: underline;
}
}
.profile-page-bg-banner {
height: 298px;
width: 100%;
background-image: url('./assets/dot-pattern-light.png');
background-repeat: repeat-x;
background-size: auto 85%;
}
.icon-visibility-off {
height: 1rem;
color: var(--pgn-color-gray-500);
}
.profile-page {
.edit-section-header {
font-size: var(--pgn-typography-font-size-h4-base);
display: block;
font-weight: 400;
letter-spacing: 0;
margin: 0;
line-height: 2.25rem;
}
label.edit-section-header {
margin-bottom: calc(var(--pgn-spacing-spacer-base) * .5);
}
.profile-avatar-wrap {
@media (--pgn-size-breakpoint-min-width-md) {
max-width: 12rem;
margin-right: 0;
height: auto;
}
}
.profile-avatar-button {
position: absolute;
left: 76px;
top: 76px;
}
.profile-avatar-menu-container {
background: rgba(0,0,0,.65);
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
@media (--pgn-size-breakpoint-min-width-md) {
background: linear-gradient(to top, rgba(0,0,0,.65) 4rem, rgba(0,0,0,0) 4rem);
align-items: flex-end;
}
.btn {
text-decoration: none;
@media (--pgn-size-breakpoint-min-width-md) {
margin-bottom: 1.2rem;
}
}
.dropdown {
@media (--pgn-size-breakpoint-min-width-md) {
margin-bottom: 1.2rem;
}
.btn {
color:var(--pgn-color-white);;
background: transparent;
border-color: transparent;
margin: 0;
}
}
}
.profile-avatar {
width: 7.5rem;
height: 7.5rem;
position: relative;
@media (--pgn-size-breakpoint-min-width-md) {
width: 7.5rem;
height: 7.5rem;
}
.profile-avatar-edit-button {
border: none;
position: absolute;
height: 100%;
left: 0;
width: 100%;
bottom: 0;
display: flex;
justify-content: center;
padding-top: .1rem;
font-weight: 600;
background: rgba(0,0,0,.5);
border-radius:0;
transition: opacity 200ms ease;
@media (--pgn-size-breakpoint-min-width-md) {
height: 4rem;
}
&:focus, &:hover, &:active, &.active {
opacity: 1;
}
}
}
.certificate {
background-color: #F3F1ED;
border-radius: 0.75rem;
overflow: hidden;
border: 1px #E7E4DB solid;
.certificate-type-illustration {
position: absolute;
top: 1rem;
right: 1rem;
bottom: 0;
width: 15.15rem;
opacity: .06;
background-size: 90%;
background-repeat: no-repeat;
background-position: right top;
}
}
}
.info-icon {
width: 1.5rem;
height: 1.5rem;
padding-left: 0.125rem;
}
.max-width-32em {
max-width: 32em;
}
.height-50vh {
height: 50vh;
}
// Todo: Move the following to edx-paragon
.btn-rounded {
border-radius: 100px;
}
.min-width-179px {
min-width: 179px;
}
.max-width-304px{
max-width: 304px;
}
.width-314px {
width: 314px;
}
.w-90{
max-width: 90%;
}
.width-24px{
width: 24px;
}
.height-42px {
height: 42px;
}
.rounded-75 {
border-radius: 0.75rem;
}
.pt-40px{
padding-top: 40px;
}
.pl-40px {
padding-left: 40px;
}
.py-10px{
padding-top: 10px;
padding-bottom: 10px;
}
.py-36px {
padding-top: 36px;
padding-bottom: 36px;
}
.px-120px {
padding-left: 120px;
padding-right: 120px;
}
.px-40px {
padding-left: 40px;
padding-right: 40px;
}
.g-15rem {
gap: 1.5rem;
}
.g-5rem {
gap: 0.5rem;
}
.g-1rem {
gap: 1rem;
}
.g-3rem {
gap: 3rem;
}
.color-black {
color: #000;
}
.bg-color-grey-FBFAF9 {
background-color: #FBFAF9;
}
.background-black-65 {
background-color: rgba(0,0,0,.65)
}
.object-fit-cover {
object-fit: cover;
}
.lh-36px {
line-height: 36px;
}
.overflowWrap-breakWord {
overflow-wrap: break-word;
}

69
src/profile-v2/utils.js Normal file
View File

@@ -0,0 +1,69 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
if (
object === undefined
|| object === null
|| (typeof object !== 'object' && !Array.isArray(object))
) {
return object;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
}
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
return modifyObjectKeys(object, transformer);
}
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*
* TODO: Put somewhere common to it can be used by other MFEs.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}

View File

@@ -0,0 +1,103 @@
import {
AsyncActionType,
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
} from './utils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
function meowKeys(key) {
return `${key}Meow`;
}
const result = modifyObjectKeys(
{
one: undefined,
two: null,
three: '',
four: 0,
five: NaN,
six: [1, 2, { seven: 'woof' }],
eight: { nine: { ten: 'bark' }, eleven: true },
},
meowKeys,
);
expect(result).toEqual({
oneMeow: undefined,
twoMeow: null,
threeMeow: '',
fourMeow: 0,
fiveMeow: NaN,
sixMeow: [1, 2, { sevenMeow: 'woof' }],
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
});
});
});
describe('camelCaseObject', () => {
it('should make everything camelCase', () => {
const result = camelCaseObject({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
dotDotDot: 123,
});
});
});
describe('snakeCaseObject', () => {
it('should make everything snake_case', () => {
const result = snakeCaseObject({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
dot_dot_dot: 123,
});
});
});
describe('convertKeyNames', () => {
it('should replace the specified keynames', () => {
const result = convertKeyNames(
{
one: { two: { three: 'four' } },
five: 'six',
},
{
two: 'blue',
five: 'alive',
seven: 'heaven',
},
);
expect(result).toEqual({
one: { blue: { three: 'four' } },
alive: 'six',
});
});
});
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});

View File

@@ -319,6 +319,29 @@ describe('<ProfilePage />', () => {
expect(container.querySelector('.alert-danger')).toHaveClass('show');
});
it.each([
['test user with non-disabled country', 'PK'],
['test user with disabled country', 'RU'],
])('test user with %s', (_, accountCountry) => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.country = {};
storeData.profilePage.currentlyEditingField = 'country';
storeData.profilePage.disabledCountries = ['RU'];
storeData.profilePage.account.country = accountCountry;
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
});
describe('handles analytics', () => {

View File

@@ -29,6 +29,7 @@ module.exports = {
drafts: {},
isLoadingProfile: false,
isAuthenticatedUserProfile: true,
countriesCodesList: [],
},
router: {
location: {
@@ -38,4 +39,4 @@ module.exports = {
},
action: 'POP'
}
};
};

View File

@@ -125,7 +125,8 @@ module.exports = {
}
],
drafts: {},
isLoadingProfile: false
isLoadingProfile: false,
countriesCodesList: [],
},
router: {
location: {

View File

@@ -86,6 +86,7 @@ module.exports = {
drafts: {},
isLoadingProfile: false,
learningGoal: 'advance_career',
countriesCodesList: [],
},
router: {
location: {

View File

@@ -124,6 +124,7 @@ module.exports = {
createdDate: '2019-03-04T19:31:39.896806Z'
}
],
countriesCodesList:[{code:"AX"},{code:"AL"}],
drafts: {},
isLoadingProfile: false
},

View File

@@ -977,1256 +977,11 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
>
 
</option>
<option
value="AF"
>
Afghanistan
</option>
<option
value="AL"
>
Albania
</option>
<option
value="DZ"
>
Algeria
</option>
<option
value="AS"
>
American Samoa
</option>
<option
value="AD"
>
Andorra
</option>
<option
value="AO"
>
Angola
</option>
<option
value="AI"
>
Anguilla
</option>
<option
value="AQ"
>
Antarctica
</option>
<option
value="AG"
>
Antigua and Barbuda
</option>
<option
value="AR"
>
Argentina
</option>
<option
value="AM"
>
Armenia
</option>
<option
value="AW"
>
Aruba
</option>
<option
value="AU"
>
Australia
</option>
<option
value="AT"
>
Austria
</option>
<option
value="AZ"
>
Azerbaijan
</option>
<option
value="BS"
>
Bahamas
</option>
<option
value="BH"
>
Bahrain
</option>
<option
value="BD"
>
Bangladesh
</option>
<option
value="BB"
>
Barbados
</option>
<option
value="BY"
>
Belarus
</option>
<option
value="BE"
>
Belgium
</option>
<option
value="BZ"
>
Belize
</option>
<option
value="BJ"
>
Benin
</option>
<option
value="BM"
>
Bermuda
</option>
<option
value="BT"
>
Bhutan
</option>
<option
value="BO"
>
Bolivia
</option>
<option
value="BA"
>
Bosnia and Herzegovina
</option>
<option
value="BW"
>
Botswana
</option>
<option
value="BV"
>
Bouvet Island
</option>
<option
value="BR"
>
Brazil
</option>
<option
value="IO"
>
British Indian Ocean Territory
</option>
<option
value="BN"
>
Brunei Darussalam
</option>
<option
value="BG"
>
Bulgaria
</option>
<option
value="BF"
>
Burkina Faso
</option>
<option
value="BI"
>
Burundi
</option>
<option
value="KH"
>
Cambodia
</option>
<option
value="CM"
>
Cameroon
</option>
<option
value="CA"
>
Canada
</option>
<option
value="CV"
>
Cape Verde
</option>
<option
value="KY"
>
Cayman Islands
</option>
<option
value="CF"
>
Central African Republic
</option>
<option
value="TD"
>
Chad
</option>
<option
value="CL"
>
Chile
</option>
<option
value="CN"
>
China
</option>
<option
value="CX"
>
Christmas Island
</option>
<option
value="CC"
>
Cocos (Keeling) Islands
</option>
<option
value="CO"
>
Colombia
</option>
<option
value="KM"
>
Comoros
</option>
<option
value="CG"
>
Congo
</option>
<option
value="CD"
>
Congo, the Democratic Republic of the
</option>
<option
value="CK"
>
Cook Islands
</option>
<option
value="CR"
>
Costa Rica
</option>
<option
value="CI"
>
Cote D'Ivoire
</option>
<option
value="HR"
>
Croatia
</option>
<option
value="CU"
>
Cuba
</option>
<option
value="CY"
>
Cyprus
</option>
<option
value="CZ"
>
Czech Republic
</option>
<option
value="DK"
>
Denmark
</option>
<option
value="DJ"
>
Djibouti
</option>
<option
value="DM"
>
Dominica
</option>
<option
value="DO"
>
Dominican Republic
</option>
<option
value="EC"
>
Ecuador
</option>
<option
value="EG"
>
Egypt
</option>
<option
value="SV"
>
El Salvador
</option>
<option
value="GQ"
>
Equatorial Guinea
</option>
<option
value="ER"
>
Eritrea
</option>
<option
value="EE"
>
Estonia
</option>
<option
value="ET"
>
Ethiopia
</option>
<option
value="FK"
>
Falkland Islands (Malvinas)
</option>
<option
value="FO"
>
Faroe Islands
</option>
<option
value="FJ"
>
Fiji
</option>
<option
value="FI"
>
Finland
</option>
<option
value="FR"
>
France
</option>
<option
value="GF"
>
French Guiana
</option>
<option
value="PF"
>
French Polynesia
</option>
<option
value="TF"
>
French Southern Territories
</option>
<option
value="GA"
>
Gabon
</option>
<option
value="GM"
>
Gambia
</option>
<option
value="GE"
>
Georgia
</option>
<option
value="DE"
>
Germany
</option>
<option
value="GH"
>
Ghana
</option>
<option
value="GI"
>
Gibraltar
</option>
<option
value="GR"
>
Greece
</option>
<option
value="GL"
>
Greenland
</option>
<option
value="GD"
>
Grenada
</option>
<option
value="GP"
>
Guadeloupe
</option>
<option
value="GU"
>
Guam
</option>
<option
value="GT"
>
Guatemala
</option>
<option
value="GN"
>
Guinea
</option>
<option
value="GW"
>
Guinea-Bissau
</option>
<option
value="GY"
>
Guyana
</option>
<option
value="HT"
>
Haiti
</option>
<option
value="HM"
>
Heard Island and Mcdonald Islands
</option>
<option
value="VA"
>
Holy See (Vatican City State)
</option>
<option
value="HN"
>
Honduras
</option>
<option
value="HK"
>
Hong Kong
</option>
<option
value="HU"
>
Hungary
</option>
<option
value="IS"
>
Iceland
</option>
<option
value="IN"
>
India
</option>
<option
value="ID"
>
Indonesia
</option>
<option
value="IR"
>
Iran, Islamic Republic of
</option>
<option
value="IQ"
>
Iraq
</option>
<option
value="IE"
>
Ireland
</option>
<option
value="IL"
>
Israel
</option>
<option
value="IT"
>
Italy
</option>
<option
value="JM"
>
Jamaica
</option>
<option
value="JP"
>
Japan
</option>
<option
value="JO"
>
Jordan
</option>
<option
value="KZ"
>
Kazakhstan
</option>
<option
value="KE"
>
Kenya
</option>
<option
value="KI"
>
Kiribati
</option>
<option
value="KP"
>
North Korea
</option>
<option
value="KR"
>
South Korea
</option>
<option
value="KW"
>
Kuwait
</option>
<option
value="KG"
>
Kyrgyzstan
</option>
<option
value="LA"
>
Lao People's Democratic Republic
</option>
<option
value="LV"
>
Latvia
</option>
<option
value="LB"
>
Lebanon
</option>
<option
value="LS"
>
Lesotho
</option>
<option
value="LR"
>
Liberia
</option>
<option
value="LY"
>
Libya
</option>
<option
value="LI"
>
Liechtenstein
</option>
<option
value="LT"
>
Lithuania
</option>
<option
value="LU"
>
Luxembourg
</option>
<option
value="MO"
>
Macao
</option>
<option
value="MG"
>
Madagascar
</option>
<option
value="MW"
>
Malawi
</option>
<option
value="MY"
>
Malaysia
</option>
<option
value="MV"
>
Maldives
</option>
<option
value="ML"
>
Mali
</option>
<option
value="MT"
>
Malta
</option>
<option
value="MH"
>
Marshall Islands
</option>
<option
value="MQ"
>
Martinique
</option>
<option
value="MR"
>
Mauritania
</option>
<option
value="MU"
>
Mauritius
</option>
<option
value="YT"
>
Mayotte
</option>
<option
value="MX"
>
Mexico
</option>
<option
value="FM"
>
Micronesia, Federated States of
</option>
<option
value="MD"
>
Moldova, Republic of
</option>
<option
value="MC"
>
Monaco
</option>
<option
value="MN"
>
Mongolia
</option>
<option
value="MS"
>
Montserrat
</option>
<option
value="MA"
>
Morocco
</option>
<option
value="MZ"
>
Mozambique
</option>
<option
value="MM"
>
Myanmar
</option>
<option
value="NA"
>
Namibia
</option>
<option
value="NR"
>
Nauru
</option>
<option
value="NP"
>
Nepal
</option>
<option
value="NL"
>
Netherlands
</option>
<option
value="NC"
>
New Caledonia
</option>
<option
value="NZ"
>
New Zealand
</option>
<option
value="NI"
>
Nicaragua
</option>
<option
value="NE"
>
Niger
</option>
<option
value="NG"
>
Nigeria
</option>
<option
value="NU"
>
Niue
</option>
<option
value="NF"
>
Norfolk Island
</option>
<option
value="MK"
>
North Macedonia, Republic of
</option>
<option
value="MP"
>
Northern Mariana Islands
</option>
<option
value="NO"
>
Norway
</option>
<option
value="OM"
>
Oman
</option>
<option
value="PK"
>
Pakistan
</option>
<option
value="PW"
>
Palau
</option>
<option
value="PS"
>
Palestinian Territory, Occupied
</option>
<option
value="PA"
>
Panama
</option>
<option
value="PG"
>
Papua New Guinea
</option>
<option
value="PY"
>
Paraguay
</option>
<option
value="PE"
>
Peru
</option>
<option
value="PH"
>
Philippines
</option>
<option
value="PN"
>
Pitcairn
</option>
<option
value="PL"
>
Poland
</option>
<option
value="PT"
>
Portugal
</option>
<option
value="PR"
>
Puerto Rico
</option>
<option
value="QA"
>
Qatar
</option>
<option
value="RE"
>
Reunion
</option>
<option
value="RO"
>
Romania
</option>
<option
value="RU"
>
Russian Federation
</option>
<option
value="RW"
>
Rwanda
</option>
<option
value="SH"
>
Saint Helena
</option>
<option
value="KN"
>
Saint Kitts and Nevis
</option>
<option
value="LC"
>
Saint Lucia
</option>
<option
value="PM"
>
Saint Pierre and Miquelon
</option>
<option
value="VC"
>
Saint Vincent and the Grenadines
</option>
<option
value="WS"
>
Samoa
</option>
<option
value="SM"
>
San Marino
</option>
<option
value="ST"
>
Sao Tome and Principe
</option>
<option
value="SA"
>
Saudi Arabia
</option>
<option
value="SN"
>
Senegal
</option>
<option
value="SC"
>
Seychelles
</option>
<option
value="SL"
>
Sierra Leone
</option>
<option
value="SG"
>
Singapore
</option>
<option
value="SK"
>
Slovakia
</option>
<option
value="SI"
>
Slovenia
</option>
<option
value="SB"
>
Solomon Islands
</option>
<option
value="SO"
>
Somalia
</option>
<option
value="ZA"
>
South Africa
</option>
<option
value="GS"
>
South Georgia and the South Sandwich Islands
</option>
<option
value="ES"
>
Spain
</option>
<option
value="LK"
>
Sri Lanka
</option>
<option
value="SD"
>
Sudan
</option>
<option
value="SR"
>
Suriname
</option>
<option
value="SJ"
>
Svalbard and Jan Mayen
</option>
<option
value="SZ"
>
Swaziland
</option>
<option
value="SE"
>
Sweden
</option>
<option
value="CH"
>
Switzerland
</option>
<option
value="SY"
>
Syrian Arab Republic
</option>
<option
value="TW"
>
Taiwan
</option>
<option
value="TJ"
>
Tajikistan
</option>
<option
value="TZ"
>
Tanzania, United Republic of
</option>
<option
value="TH"
>
Thailand
</option>
<option
value="TL"
>
Timor-Leste
</option>
<option
value="TG"
>
Togo
</option>
<option
value="TK"
>
Tokelau
</option>
<option
value="TO"
>
Tonga
</option>
<option
value="TT"
>
Trinidad and Tobago
</option>
<option
value="TN"
>
Tunisia
</option>
<option
value="TR"
>
Turkey
</option>
<option
value="TM"
>
Turkmenistan
</option>
<option
value="TC"
>
Turks and Caicos Islands
</option>
<option
value="TV"
>
Tuvalu
</option>
<option
value="UG"
>
Uganda
</option>
<option
value="UA"
>
Ukraine
</option>
<option
value="AE"
>
United Arab Emirates
</option>
<option
value="GB"
>
United Kingdom
</option>
<option
value="US"
>
United States of America
</option>
<option
value="UM"
>
United States Minor Outlying Islands
</option>
<option
value="UY"
>
Uruguay
</option>
<option
value="UZ"
>
Uzbekistan
</option>
<option
value="VU"
>
Vanuatu
</option>
<option
value="VE"
>
Venezuela
</option>
<option
value="VN"
>
Viet Nam
</option>
<option
value="VG"
>
Virgin Islands, British
</option>
<option
value="VI"
>
Virgin Islands, U.S.
</option>
<option
value="WF"
>
Wallis and Futuna
</option>
<option
value="EH"
>
Western Sahara
</option>
<option
value="YE"
>
Yemen
</option>
<option
value="ZM"
>
Zambia
</option>
<option
value="ZW"
>
Zimbabwe
</option>
<option
value="AX"
>
Åland Islands
</option>
<option
value="BQ"
>
Bonaire, Sint Eustatius and Saba
</option>
<option
value="CW"
>
Curaçao
</option>
<option
value="GG"
>
Guernsey
</option>
<option
value="IM"
>
Isle of Man
</option>
<option
value="JE"
>
Jersey
</option>
<option
value="ME"
>
Montenegro
</option>
<option
value="BL"
>
Saint Barthélemy
</option>
<option
value="MF"
>
Saint Martin (French part)
</option>
<option
value="RS"
>
Serbia
</option>
<option
value="SX"
>
Sint Maarten (Dutch part)
</option>
<option
value="SS"
>
South Sudan
</option>
<option
value="XK"
>
Kosovo
</option>
</select>
<div
class="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
@@ -5742,6 +4497,1750 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states test user with test user with disabled country 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block p-relative"
/>
<div
class="container-fluid"
>
<div
class="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0"
>
<div
class="col-auto col-md-4 col-lg-3"
>
<div
class="d-flex align-items-center d-md-block"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
class="profile-avatar-menu-container"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle btn btn-primary"
type="button"
>
Change
</button>
</div>
</div>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
style="object-fit: cover;"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
</div>
</div>
<div
class="col"
>
<div
class="d-md-none"
>
<span
data-hj-suppress="true"
>
<h1
class="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
<p
class="mb-0"
>
Member since
2017
</p>
<hr
class="d-none d-md-block"
/>
</span>
</div>
<div
class="d-none d-md-block float-right"
/>
</div>
</div>
<div
class="row"
>
<div
class="col-md-4 col-lg-4"
>
<div
class="d-none d-md-block mb-4"
>
<span
data-hj-suppress="true"
>
<h1
class="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
<p
class="mb-0"
>
Member since
2017
</p>
<hr
class="d-none d-md-block"
/>
</span>
</div>
<div
class="d-md-none mb-4"
/>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Full Name
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Lemon Seltzer
</p>
<small
class="form-text text-muted"
>
This is the name that appears in your account and on your certificates.
</small>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
aria-labelledby="country-label"
role="dialog"
>
<form>
<div
class="pgn__form-group"
>
<label
class="edit-section-header"
for="country"
>
Location
</label>
<select
class="form-control"
data-hj-suppress="true"
id="country"
name="country"
type="select"
>
<option
value=""
>
 
</option>
<option
value="RU"
>
Russian Federation
</option>
</select>
</div>
<div
class="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"
>
<div
class="form-group d-flex flex-wrap"
>
<label
class="col-form-label"
for="visibilityCountry"
>
Who can see this:
</label>
<span
class="d-flex align-items-center"
>
<span
class="d-inline-block ml-1 mr-2"
style="width: 1.5rem;"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
</span>
<select
class="d-inline-block form-control"
id="visibilityCountry"
name="visibilityCountry"
type="select"
>
<option
value="private"
>
Just me
</option>
<option
value="all_users"
>
Everyone on localhost
</option>
</select>
</span>
</div>
<div
class="form-group flex-shrink-0 flex-grow-1"
>
<button
aria-disabled="false"
aria-live="assertive"
class="pgn__stateful-btn pgn__stateful-btn-state-pending btn btn-primary"
type="submit"
>
<span
class="d-flex align-items-center justify-content-center"
>
<span
class="pgn__stateful-btn-icon"
>
<span
class="pgn__icon icon-spin"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
</span>
</span>
<span>
Saving
</span>
</span>
</button>
<button
class="btn btn-link"
type="button"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Primary Language Spoken
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Yoruba
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Education
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Elementary/primary school
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add
LinkedIn
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
<div
class="pt-md-3 col-md-8 col-lg-7 offset-lg-1"
>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
About Me
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<p
class="lead"
data-hj-suppress="true"
>
This is my bio
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-4"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
My Certificates
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<div
class="row align-items-stretch"
>
<div
class="col-12 col-sm-6 d-flex align-items-stretch"
>
<div
class="card mb-4 certificate flex-grow-1"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="card-body d-flex flex-column"
>
<div
class="card-title"
>
<p
class="small mb-0"
>
Verified Certificate
</p>
<h4
class="certificate-title"
>
edX Demonstration Course
</h4>
</div>
<p
class="small mb-0"
>
From
</p>
<p
class="h6 mb-4"
>
edX
</p>
<div
class="flex-grow-1"
/>
<p
class="small mb-2"
>
Completed on
3/4/2019
</p>
<div>
<a
class="pgn__hyperlink default-link standalone-link btn btn-outline-primary"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
<span
class="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span
class="pgn__icon"
data-testid="hyperlink-icon"
style="height: 1em; width: 1em;"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
class="sr-only"
>
in a new tab
</span>
</span>
</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states test user with test user with non-disabled country 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block p-relative"
/>
<div
class="container-fluid"
>
<div
class="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0"
>
<div
class="col-auto col-md-4 col-lg-3"
>
<div
class="d-flex align-items-center d-md-block"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
class="profile-avatar-menu-container"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle btn btn-primary"
type="button"
>
Change
</button>
</div>
</div>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
style="object-fit: cover;"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
</div>
</div>
<div
class="col"
>
<div
class="d-md-none"
>
<span
data-hj-suppress="true"
>
<h1
class="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
<p
class="mb-0"
>
Member since
2017
</p>
<hr
class="d-none d-md-block"
/>
</span>
</div>
<div
class="d-none d-md-block float-right"
/>
</div>
</div>
<div
class="row"
>
<div
class="col-md-4 col-lg-4"
>
<div
class="d-none d-md-block mb-4"
>
<span
data-hj-suppress="true"
>
<h1
class="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
<p
class="mb-0"
>
Member since
2017
</p>
<hr
class="d-none d-md-block"
/>
</span>
</div>
<div
class="d-md-none mb-4"
/>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Full Name
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Lemon Seltzer
</p>
<small
class="form-text text-muted"
>
This is the name that appears in your account and on your certificates.
</small>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
aria-labelledby="country-label"
role="dialog"
>
<form>
<div
class="pgn__form-group"
>
<label
class="edit-section-header"
for="country"
>
Location
</label>
<select
class="form-control"
data-hj-suppress="true"
id="country"
name="country"
type="select"
>
<option
value=""
>
 
</option>
<option
value="PK"
>
Pakistan
</option>
</select>
</div>
<div
class="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"
>
<div
class="form-group d-flex flex-wrap"
>
<label
class="col-form-label"
for="visibilityCountry"
>
Who can see this:
</label>
<span
class="d-flex align-items-center"
>
<span
class="d-inline-block ml-1 mr-2"
style="width: 1.5rem;"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
</span>
<select
class="d-inline-block form-control"
id="visibilityCountry"
name="visibilityCountry"
type="select"
>
<option
value="private"
>
Just me
</option>
<option
value="all_users"
>
Everyone on localhost
</option>
</select>
</span>
</div>
<div
class="form-group flex-shrink-0 flex-grow-1"
>
<button
aria-disabled="false"
aria-live="assertive"
class="pgn__stateful-btn pgn__stateful-btn-state-pending btn btn-primary"
type="submit"
>
<span
class="d-flex align-items-center justify-content-center"
>
<span
class="pgn__stateful-btn-icon"
>
<span
class="pgn__icon icon-spin"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
</span>
</span>
<span>
Saving
</span>
</span>
</button>
<button
class="btn btn-link"
type="button"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Primary Language Spoken
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Yoruba
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Education
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<p
class="h5"
data-hj-suppress="true"
>
Elementary/primary school
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add
LinkedIn
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
<div
class="pt-md-3 col-md-8 col-lg-7 offset-lg-1"
>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
About Me
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<p
class="lead"
data-hj-suppress="true"
>
This is my bio
</p>
</div>
</div>
<div
class="pgn-transition-replace-group position-relative mb-4"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
My Certificates
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye "
data-icon="eye"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z"
fill="currentColor"
/>
</svg>
Everyone on localhost
</span>
</p>
</div>
<div
class="row align-items-stretch"
>
<div
class="col-12 col-sm-6 d-flex align-items-stretch"
>
<div
class="card mb-4 certificate flex-grow-1"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="card-body d-flex flex-column"
>
<div
class="card-title"
>
<p
class="small mb-0"
>
Verified Certificate
</p>
<h4
class="certificate-title"
>
edX Demonstration Course
</h4>
</div>
<p
class="small mb-0"
>
From
</p>
<p
class="h6 mb-4"
>
edX
</p>
<div
class="flex-grow-1"
/>
<p
class="small mb-2"
>
Completed on
3/4/2019
</p>
<div>
<a
class="pgn__hyperlink default-link standalone-link btn btn-outline-primary"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
<span
class="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span
class="pgn__icon"
data-testid="hyperlink-icon"
style="height: 1em; width: 1em;"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
class="sr-only"
>
in a new tab
</span>
</span>
</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing other profile with all fields 1`] = `
<div>
<div

View File

@@ -25,12 +25,14 @@ export const fetchProfileSuccess = (
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
) => ({
type: FETCH_PROFILE.SUCCESS,
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
});
export const fetchProfileReset = () => ({

View File

@@ -22,7 +22,12 @@ const SOCIAL = {
},
};
const FIELD_LABELS = {
COUNTRY: 'country',
};
export {
EDUCATION_LEVELS,
SOCIAL,
FIELD_LABELS,
};

View File

@@ -22,6 +22,7 @@ export const initialState = {
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: false,
countriesCodesList: [],
};
const profilePage = (state = initialState, action = {}) => {
@@ -42,6 +43,7 @@ const profilePage = (state = initialState, action = {}) => {
courseCertificates: action.courseCertificates,
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList,
};
case SAVE_PROFILE.BEGIN:
return {

View File

@@ -41,6 +41,7 @@ export function* handleFetchProfile(action) {
let preferences = {};
let account = userAccount;
let courseCertificates = null;
let countriesCodesList = [];
try {
yield put(fetchProfileBegin());
@@ -49,6 +50,7 @@ export function* handleFetchProfile(action) {
const calls = [
call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username),
call(ProfileApiService.getCountryList),
];
if (isAuthenticatedUserProfile) {
@@ -61,9 +63,9 @@ export function* handleFetchProfile(action) {
const result = yield all(calls);
if (isAuthenticatedUserProfile) {
[account, courseCertificates, preferences] = result;
[account, courseCertificates, countriesCodesList, preferences] = result;
} else {
[account, courseCertificates] = result;
[account, courseCertificates, countriesCodesList] = result;
}
// Set initial visibility values for account
@@ -89,6 +91,7 @@ export function* handleFetchProfile(action) {
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
));
yield put(fetchProfileReset());

View File

@@ -19,6 +19,7 @@ jest.mock('./services', () => ({
getPreferences: jest.fn(),
getAccount: jest.fn(),
getCourseCertificates: jest.fn(),
getCountryList: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -68,17 +69,19 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('gonzo');
const gen = handleFetchProfile(action);
const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }];
const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'gonzo'),
call(ProfileApiService.getCourseCertificates, 'gonzo'),
call(ProfileApiService.getCountryList),
call(ProfileApiService.getPreferences, 'gonzo'),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], result[1], true)));
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, [])));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
@@ -88,6 +91,7 @@ describe('RootSaga', () => {
username: 'gonzo',
other: 'data',
};
const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }];
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
@@ -96,16 +100,17 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('booyah');
const gen = handleFetchProfile(action);
const result = [{}, [1, 2, 3]];
const result = [{}, [1, 2, 3], countriesCodesList];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'booyah'),
call(ProfileApiService.getCourseCertificates, 'booyah'),
call(ProfileApiService.getCountryList),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});

View File

@@ -23,6 +23,7 @@ export const isLoadingProfileSelector = state => state.profilePage.isLoadingProf
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile;
export const countriesCodesListSelector = state => state.profilePage.countriesCodesList;
export const editableFormModeSelector = createSelector(
profileAccountSelector,
@@ -112,7 +113,14 @@ export const sortedLanguagesSelector = createSelector(
export const sortedCountriesSelector = createSelector(
localeSelector,
locale => getCountryList(locale),
countriesCodesListSelector,
profileAccountSelector,
(locale, countriesCodesList, profileAccount) => {
const countryList = getCountryList(locale);
const userCountry = profileAccount.country;
return countryList.filter(({ code }) => code === userCountry || countriesCodesList.find(x => x === code));
},
);
export const preferredLanguageSelector = createSelector(
@@ -130,10 +138,14 @@ export const countrySelector = createSelector(
editableFormSelector,
sortedCountriesSelector,
countryMessagesSelector,
(editableForm, sortedCountries, countryMessages) => ({
countriesCodesListSelector,
profileAccountSelector,
(editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({
...editableForm,
sortedCountries,
translatedCountries,
countryMessages,
countriesCodesList,
committedCountry: account.country,
}),
);

View File

@@ -2,6 +2,7 @@ import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
import { FIELD_LABELS } from './constants';
ensureConfig(['LMS_BASE_URL'], 'Profile API service');
@@ -147,3 +148,21 @@ export async function getCourseCertificates(username) {
return [];
}
}
function extractCountryList(data) {
return data?.fields
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
?.options?.map(({ value }) => (value)) || [];
}
export async function getCountryList() {
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
try {
const { data } = await getHttpClient().get(url);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -23,6 +23,7 @@ class Country extends React.Component {
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.isDisabledCountry = this.isDisabledCountry.bind(this);
}
handleChange(e) {
@@ -46,6 +47,12 @@ class Country extends React.Component {
this.props.openHandler(this.props.formId);
}
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(code => code === country);
};
render() {
const {
formId,
@@ -55,7 +62,7 @@ class Country extends React.Component {
saveState,
error,
intl,
sortedCountries,
translatedCountries,
countryMessages,
} = this.props;
@@ -84,8 +91,8 @@ class Country extends React.Component {
onChange={this.handleChange}
>
<option value="">&nbsp;</option>
{sortedCountries.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
{translatedCountries.map(({ code, name }) => (
<option key={code} value={code} disabled={this.isDisabledCountry(code)}>{name}</option>
))}
</select>
{error !== null && (
@@ -153,10 +160,11 @@ Country.propTypes = {
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
sortedCountries: PropTypes.arrayOf(PropTypes.shape({
translatedCountries: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
countriesCodesList: PropTypes.arrayOf(PropTypes.string).isRequired,
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
// Actions

View File

@@ -1,21 +1,30 @@
import React from 'react';
import {
AuthenticatedPageRoute,
PageWrap,
} from '@edx/frontend-platform/react';
import PropTypes from 'prop-types';
import { AuthenticatedPageRoute, PageWrap } from '@edx/frontend-platform/react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import { ProfilePage, NotFoundPage } from '../profile';
import { ProfilePage as NewProfilePage, NotFoundPage as NewNotFoundPage } from '../profile-v2';
const AppRoutes = () => {
const AppRoutes = ({ isNewProfileEnabled }) => {
const SelectedProfilePage = isNewProfileEnabled ? NewProfilePage : ProfilePage;
const SelectedNotFoundPage = isNewProfileEnabled ? NewNotFoundPage : NotFoundPage;
const navigate = useNavigate();
return (
<Routes>
<Route path="/u/:username" element={<AuthenticatedPageRoute><ProfilePage navigate={navigate} /></AuthenticatedPageRoute>} />
<Route path="/notfound" element={<PageWrap><NotFoundPage /></PageWrap>} />
<Route path="*" element={<PageWrap><NotFoundPage /></PageWrap>} />
<Route path="/u/:username" element={<AuthenticatedPageRoute><SelectedProfilePage navigate={navigate} /></AuthenticatedPageRoute>} />
<Route path="/notfound" element={<PageWrap><SelectedNotFoundPage /></PageWrap>} />
<Route path="*" element={<PageWrap><SelectedNotFoundPage /></PageWrap>} />
</Routes>
);
};
AppRoutes.propTypes = {
isNewProfileEnabled: PropTypes.bool,
};
AppRoutes.defaultProps = {
isNewProfileEnabled: null,
};
export default AppRoutes;

View File

@@ -17,6 +17,11 @@ jest.mock('../profile', () => ({
NotFoundPage: () => (<div>Not found page</div>),
}));
jest.mock('../profile-v2', () => ({
ProfilePage: () => (<div>Profile page</div>),
NotFoundPage: () => (<div>Not found page</div>),
}));
const RoutesWithProvider = (context, path) => (
<AppContext.Provider value={context}>
<Router initialEntries={[`${path}`]}>