diff --git a/.env b/.env
index 1feaf21df..f96e39bef 100644
--- a/.env
+++ b/.env
@@ -29,3 +29,4 @@ APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER_PROFILE=''
+ENABLE_NEW_PROFILE_VIEW=''
diff --git a/.env.development b/.env.development
index 5694a28d8..23a16e1c1 100644
--- a/.env.development
+++ b/.env.development
@@ -30,3 +30,4 @@ APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER_PROFILE=''
+ENABLE_NEW_PROFILE_VIEW=''
diff --git a/.env.test b/.env.test
index 716cae188..cf5a90fc7 100644
--- a/.env.test
+++ b/.env.test
@@ -25,3 +25,4 @@ LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
+ENABLE_NEW_PROFILE_VIEW=''
diff --git a/src/data/reducers.js b/src/data/reducers.js
index cd17a6394..98b95d263 100755
--- a/src/data/reducers.js
+++ b/src/data/reducers.js
@@ -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;
diff --git a/src/data/sagas.js b/src/data/sagas.js
index 6486c6e92..fab5ecad8 100644
--- a/src/data/sagas.js
+++ b/src/data/sagas.js
@@ -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(),
]);
}
diff --git a/src/index-v2.scss b/src/index-v2.scss
new file mode 100755
index 000000000..d54250a8c
--- /dev/null
+++ b/src/index-v2.scss
@@ -0,0 +1,8 @@
+@import "~@edx/brand/paragon/fonts";
+@import "~@edx/brand/paragon/variables";
+@import "~@openedx/paragon/scss/core/core";
+@import "~@edx/brand/paragon/overrides";
+@import "~@edx/frontend-component-header/dist/index";
+@import "~@edx/frontend-component-footer/dist/footer";
+
+@import './profile-v2/index';
diff --git a/src/index.jsx b/src/index.jsx
index d3aba8fff..c754a1e77 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -7,6 +7,7 @@ import {
initialize,
mergeConfig,
subscribe,
+ getConfig,
} from '@edx/frontend-platform';
import {
AppProvider,
@@ -22,18 +23,23 @@ import FooterSlot from '@openedx/frontend-slot-footer';
import messages from './i18n';
import configureStore from './data/configureStore';
-import './index.scss';
import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
-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');
+ }
ReactDOM.render(
-
+
,
@@ -53,6 +59,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');
},
},
diff --git a/src/profile-v2/CertificateCard.jsx b/src/profile-v2/CertificateCard.jsx
new file mode 100644
index 000000000..bbdb55f6b
--- /dev/null
+++ b/src/profile-v2/CertificateCard.jsx
@@ -0,0 +1,111 @@
+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 professionalCertificateSVG from './assets/professional-certificate.svg';
+import verifiedCertificateSVG from './assets/verified-certificate.svg';
+import messages from './Certificates.messages';
+
+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;
+
+ return (
+
+
+
+
+
+
+ {intl.formatMessage(get(
+ messages,
+ `profile.certificates.types.${certificateType}`,
+ messages['profile.certificates.types.unknown'],
+ ))}
+
+
{courseDisplayName}
+
+
+
+
{courseOrganization}
+
+ ,
+ }}
+ />
+
+
+
+
+ {intl.formatMessage(messages['profile.certificates.view.certificate'])}
+
+
+
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/src/profile-v2/Certificates.jsx b/src/profile-v2/Certificates.jsx
new file mode 100644
index 000000000..cd8a227ee
--- /dev/null
+++ b/src/profile-v2/Certificates.jsx
@@ -0,0 +1,74 @@
+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 CertificateCard from './CertificateCard';
+import { certificatesSelector } from './data/selectors';
+
+const Certificates = ({ certificates }) => (
+
+
+ {certificates?.length > 0 ? (
+
+
+ {certificates.map(certificate => (
+
+ ))}
+
+
+ ) : (
+
+
+
+ )}
+
+);
+
+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);
diff --git a/src/profile-v2/Certificates.messages.jsx b/src/profile-v2/Certificates.messages.jsx
new file mode 100644
index 000000000..17d12b847
--- /dev/null
+++ b/src/profile-v2/Certificates.messages.jsx
@@ -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;
diff --git a/src/profile-v2/DateJoined.jsx b/src/profile-v2/DateJoined.jsx
new file mode 100644
index 000000000..5b02d4bbe
--- /dev/null
+++ b/src/profile-v2/DateJoined.jsx
@@ -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 (
+
+ ,
+ }}
+ />
+
+ );
+};
+
+DateJoined.propTypes = {
+ date: PropTypes.string,
+};
+DateJoined.defaultProps = {
+ date: null,
+};
+
+export default memo(DateJoined);
diff --git a/src/profile-v2/NotFoundPage.jsx b/src/profile-v2/NotFoundPage.jsx
new file mode 100644
index 000000000..b33f0db11
--- /dev/null
+++ b/src/profile-v2/NotFoundPage.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+const NotFoundPage = () => (
+
+);
+
+export default NotFoundPage;
diff --git a/src/profile-v2/PageLoading.jsx b/src/profile-v2/PageLoading.jsx
new file mode 100644
index 000000000..a730b39f3
--- /dev/null
+++ b/src/profile-v2/PageLoading.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const PageLoading = ({ srMessage }) => (
+
+
+
+ {srMessage && {srMessage}}
+
+
+
+);
+
+PageLoading.propTypes = {
+ srMessage: PropTypes.string.isRequired,
+};
+
+export default PageLoading;
diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx
new file mode 100644
index 000000000..22bcf7179
--- /dev/null
+++ b/src/profile-v2/ProfilePage.jsx
@@ -0,0 +1,168 @@
+import React, {
+ useEffect, useState, useContext, useCallback,
+} from 'react';
+import PropTypes from 'prop-types';
+import { useDispatch, useSelector } from 'react-redux';
+import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
+import { ensureConfig, getConfig } from '@edx/frontend-platform';
+import { AppContext } from '@edx/frontend-platform/react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Alert, Hyperlink } from '@openedx/paragon';
+import {
+ fetchProfile,
+ saveProfilePhoto,
+ deleteProfilePhoto,
+} from './data/actions';
+import ProfileAvatar from './forms/ProfileAvatar';
+import Certificates from './Certificates';
+import DateJoined from './DateJoined';
+import UserCertificateSummary from './UserCertificateSummary';
+import UsernameDescription from './UsernameDescription';
+import PageLoading from './PageLoading';
+import { profilePageSelector } from './data/selectors';
+import messages from './ProfilePage.messages';
+import withParams from '../utils/hoc';
+
+ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
+
+const ProfilePage = ({ params }) => {
+ const dispatch = useDispatch();
+ const intl = useIntl();
+ const context = useContext(AppContext);
+ const {
+ requiresParentalConsent,
+ dateJoined,
+ yearOfBirth,
+ courseCertificates,
+ name,
+ profileImage,
+ savePhotoState,
+ isLoadingProfile,
+ photoUploadError,
+ } = useSelector(profilePageSelector);
+
+ const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null);
+
+ 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]);
+
+ const handleSaveProfilePhoto = useCallback((formData) => {
+ dispatch(saveProfilePhoto(context.authenticatedUser.username, formData));
+ }, [dispatch, context.authenticatedUser.username]);
+
+ const handleDeleteProfilePhoto = useCallback(() => {
+ dispatch(deleteProfilePhoto(context.authenticatedUser.username));
+ }, [dispatch, context.authenticatedUser.username]);
+
+ const isYOBDisabled = () => {
+ const currentYear = new Date().getFullYear();
+ const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
+ return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
+ };
+
+ const isAuthenticatedUserProfile = () => params.username === context.authenticatedUser.username;
+
+ const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile()
+ || (!isAuthenticatedUserProfile() && Boolean(blockInfo));
+
+ const renderViewMyRecordsButton = () => {
+ if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) {
+ return null;
+ }
+
+ return (
+
+ {intl.formatMessage(messages['profile.viewMyRecords'])}
+
+ );
+ };
+
+ const renderPhotoUploadErrorMessage = () => (
+ photoUploadError && (
+
+
+
+ {photoUploadError.userMessage}
+
+
+
+ )
+ );
+
+ return (
+
+ {isLoadingProfile ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
{params.username}
+ {isBlockVisible(name) && (
+
{name}
+ )}
+
+
+
+
+
+
+ {renderViewMyRecordsButton()}
+
+
+
+ {isYOBDisabled() && }
+
+
+
+ {renderPhotoUploadErrorMessage()}
+
+
+
+
+ {isBlockVisible(courseCertificates.length) && (
+
+ )}
+
+ >
+ )}
+
+ );
+};
+
+ProfilePage.propTypes = {
+ params: PropTypes.shape({
+ username: PropTypes.string.isRequired,
+ }).isRequired,
+};
+
+export default withParams(ProfilePage);
diff --git a/src/profile-v2/ProfilePage.messages.jsx b/src/profile-v2/ProfilePage.messages.jsx
new file mode 100644
index 000000000..4dfeef607
--- /dev/null
+++ b/src/profile-v2/ProfilePage.messages.jsx
@@ -0,0 +1,16 @@
+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.',
+ },
+});
+
+export default messages;
diff --git a/src/profile-v2/ProfilePage.test.jsx b/src/profile-v2/ProfilePage.test.jsx
new file mode 100644
index 000000000..7c0e2d46c
--- /dev/null
+++ b/src/profile-v2/ProfilePage.test.jsx
@@ -0,0 +1,185 @@
+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 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';
+
+const mockStore = configureMockStore([thunk]);
+
+const storeMocks = {
+ loadingApp,
+ viewOwnProfile,
+ viewOtherProfile,
+};
+const requiredProfilePageProps = {
+ fetchUserAccount: () => {},
+ fetchProfile: () => {},
+ params: { username: 'staff' },
+};
+
+// Mock language cookie
+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();
+});
+
+const ProfilePageWrapper = ({
+ contextValue, store, params,
+}) => (
+
+
+
+
+
+
+
+);
+
+ProfilePageWrapper.defaultProps = {
+ params: { username: 'staff' },
+};
+
+ProfilePageWrapper.propTypes = {
+ contextValue: PropTypes.shape({}).isRequired,
+ store: PropTypes.shape({}).isRequired,
+ params: PropTypes.shape({}),
+};
+
+describe('', () => {
+ describe('Renders correctly in various states', () => {
+ it('app loading', () => {
+ const contextValue = {
+ authenticatedUser: { userId: null, username: null, administrator: false },
+ config: getConfig(),
+ };
+ const component = ;
+ 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 = ;
+ 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 = (
+
+ );
+ 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 = (
+
+ );
+ const { container: tree } = render(component);
+ expect(tree).toMatchSnapshot();
+ });
+ });
+
+ describe('handles analytics', () => {
+ it('calls sendTrackingLogEvent when mounting', () => {
+ const contextValue = {
+ authenticatedUser: { userId: 123, username: 'staff', administrator: true },
+ config: getConfig(),
+ };
+ render(
+ ,
+ );
+
+ expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
+ expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');
+ expect(analytics.sendTrackingLogEvent.mock.calls[0][1]).toEqual({
+ username: 'test-username',
+ });
+ });
+ });
+});
diff --git a/src/profile-v2/UserCertificateSummary.jsx b/src/profile-v2/UserCertificateSummary.jsx
new file mode 100644
index 000000000..f07f1a3ea
--- /dev/null
+++ b/src/profile-v2/UserCertificateSummary.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+
+const UserCertificateSummary = ({ count = 0 }) => (
+
+ {count} ,
+ }}
+ />
+
+);
+
+UserCertificateSummary.propTypes = {
+ count: PropTypes.number,
+};
+
+export default UserCertificateSummary;
diff --git a/src/profile-v2/UsernameDescription.jsx b/src/profile-v2/UsernameDescription.jsx
new file mode 100644
index 000000000..bbbd70333
--- /dev/null
+++ b/src/profile-v2/UsernameDescription.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { FormattedMessage } from '@edx/frontend-platform/i18n';
+import { getConfig } from '@edx/frontend-platform';
+
+const UsernameDescription = () => (
+
+
+
+);
+
+export default UsernameDescription;
diff --git a/src/profile-v2/__mocks__/loadingApp.mockStore.js b/src/profile-v2/__mocks__/loadingApp.mockStore.js
new file mode 100644
index 000000000..dfef507a8
--- /dev/null
+++ b/src/profile-v2/__mocks__/loadingApp.mockStore.js
@@ -0,0 +1,41 @@
+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,
+ },
+ router: {
+ location: {
+ pathname: '/u/staff',
+ search: '',
+ hash: ''
+ },
+ action: 'POP'
+ }
+};
diff --git a/src/profile-v2/__mocks__/savingEditedBio.mockStore.js b/src/profile-v2/__mocks__/savingEditedBio.mockStore.js
new file mode 100644
index 000000000..a104762d6
--- /dev/null
+++ b/src/profile-v2/__mocks__/savingEditedBio.mockStore.js
@@ -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'
+ }
+};
diff --git a/src/profile-v2/__mocks__/viewOtherProfile.mockStore.js b/src/profile-v2/__mocks__/viewOtherProfile.mockStore.js
new file mode 100644
index 000000000..e894d483e
--- /dev/null
+++ b/src/profile-v2/__mocks__/viewOtherProfile.mockStore.js
@@ -0,0 +1,98 @@
+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: {},
+ courseCertificates: [],
+ drafts: {},
+ isLoadingProfile: false,
+ learningGoal: 'advance_career',
+ },
+ router: {
+ location: {
+ pathname: '/u/verified',
+ search: '',
+ hash: ''
+ },
+ action: 'POP'
+ }
+};
diff --git a/src/profile-v2/__mocks__/viewOwnProfile.mockStore.js b/src/profile-v2/__mocks__/viewOwnProfile.mockStore.js
new file mode 100644
index 000000000..9e9419eb0
--- /dev/null
+++ b/src/profile-v2/__mocks__/viewOwnProfile.mockStore.js
@@ -0,0 +1,138 @@
+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
+ },
+ router: {
+ location: {
+ pathname: '/u/staff',
+ search: '',
+ hash: ''
+ },
+ action: 'POP'
+ }
+};
diff --git a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap
new file mode 100644
index 000000000..d0434b33a
--- /dev/null
+++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap
@@ -0,0 +1,619 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Renders correctly in various states app loading 1`] = `
+
+
+
+
+
+
+ Profile loading...
+
+
+
+
+
+
+`;
+
+exports[` Renders correctly in various states viewing other profile with all fields 1`] = `
+
+
+
+
+
+
+
+
+
+ staff
+
+
+ user
+
+
+
+ Member since
+
+
+ 2017
+
+
+
+
+
+
+ 0
+
+
+ certifications
+
+
+
+
+
+
+
+ Your learner records information is only visible to you. Only your username is visible to others on localhost.
+
+
+
+
+
+
+
+
+
+
+
+ Your certificates
+
+
+
+
+ Your learner records information is only visible to you. Only your username is visible to others on localhost.
+
+
+
+
+ You don't have any certificates yet.
+
+
+
+
+
+`;
+
+exports[` Renders correctly in various states viewing own profile 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ staff
+
+
+ Lemon Seltzer
+
+
+
+ Member since
+
+
+ 2017
+
+
+
+
+
+
+ 1
+
+
+ certifications
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your certificates
+
+
+
+
+ Your learner records information is only visible to you. Only your username is visible to others on localhost.
+
+
+
+
+
+
+
+
+
+
+
+ Verified Certificate
+
+
+ edX Demonstration Course
+
+
+ From
+
+
+ edX
+
+
+ Completed on
+ 3/4/2019
+
+
+
+
+ Credential ID
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[` Renders correctly in various states without credentials service 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ staff
+
+
+ Lemon Seltzer
+
+
+
+ Member since
+
+
+ 2017
+
+
+
+
+
+
+ 1
+
+
+ certifications
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your certificates
+
+
+
+
+ Your learner records information is only visible to you. Only your username is visible to others on localhost.
+
+
+
+
+
+
+
+
+
+
+
+ Verified Certificate
+
+
+ edX Demonstration Course
+
+
+ From
+
+
+ edX
+
+
+ Completed on
+ 3/4/2019
+
+
+
+
+ Credential ID
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/profile-v2/assets/avatar.svg b/src/profile-v2/assets/avatar.svg
new file mode 100644
index 000000000..d7fe4bce6
--- /dev/null
+++ b/src/profile-v2/assets/avatar.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/src/profile-v2/assets/dot-pattern-light.png b/src/profile-v2/assets/dot-pattern-light.png
new file mode 100644
index 000000000..c84a3c52a
Binary files /dev/null and b/src/profile-v2/assets/dot-pattern-light.png differ
diff --git a/src/profile-v2/assets/micro-masters.svg b/src/profile-v2/assets/micro-masters.svg
new file mode 100644
index 000000000..bca900de9
--- /dev/null
+++ b/src/profile-v2/assets/micro-masters.svg
@@ -0,0 +1,13 @@
+
+
\ No newline at end of file
diff --git a/src/profile-v2/assets/professional-certificate.svg b/src/profile-v2/assets/professional-certificate.svg
new file mode 100644
index 000000000..2940d10b0
--- /dev/null
+++ b/src/profile-v2/assets/professional-certificate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/profile-v2/assets/verified-certificate.svg b/src/profile-v2/assets/verified-certificate.svg
new file mode 100644
index 000000000..2940d10b0
--- /dev/null
+++ b/src/profile-v2/assets/verified-certificate.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/profile-v2/data/actions.js b/src/profile-v2/data/actions.js
new file mode 100644
index 000000000..ddf8c34cf
--- /dev/null
+++ b/src/profile-v2/data/actions.js
@@ -0,0 +1,83 @@
+import { AsyncActionType } from '../utils';
+
+export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
+export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO');
+export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
+
+// FETCH PROFILE ACTIONS
+
+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,
+});
+
+// SAVE PROFILE PHOTO ACTIONS
+
+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 },
+});
+
+// DELETE PROFILE PHOTO ACTIONS
+
+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,
+});
diff --git a/src/profile-v2/data/actions.test.js b/src/profile-v2/data/actions.test.js
new file mode 100644
index 000000000..275d695ca
--- /dev/null
+++ b/src/profile-v2/data/actions.test.js
@@ -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);
+ });
+});
diff --git a/src/profile-v2/data/constants.js b/src/profile-v2/data/constants.js
new file mode 100644
index 000000000..1db167380
--- /dev/null
+++ b/src/profile-v2/data/constants.js
@@ -0,0 +1,28 @@
+const EDUCATION_LEVELS = [
+ 'p',
+ 'm',
+ 'b',
+ 'a',
+ 'hs',
+ 'jhs',
+ 'el',
+ 'none',
+ 'other',
+];
+
+const SOCIAL = {
+ linkedin: {
+ title: 'LinkedIn',
+ },
+ twitter: {
+ title: 'Twitter',
+ },
+ facebook: {
+ title: 'Facebook',
+ },
+};
+
+export {
+ EDUCATION_LEVELS,
+ SOCIAL,
+};
diff --git a/src/profile-v2/data/mock_data.js b/src/profile-v2/data/mock_data.js
new file mode 100644
index 000000000..c43ed984f
--- /dev/null
+++ b/src/profile-v2/data/mock_data.js
@@ -0,0 +1,7 @@
+const mockData = {
+ learningGoal: 'advance_career',
+ editMode: 'static',
+ visibilityLearningGoal: 'private',
+};
+
+export default mockData;
diff --git a/src/profile-v2/data/pact-profile.test.js b/src/profile-v2/data/pact-profile.test.js
new file mode 100644
index 000000000..3addaa4c8
--- /dev/null
+++ b/src/profile-v2/data/pact-profile.test.js
@@ -0,0 +1,80 @@
+// 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,
+};
+
+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');
+ });
+ });
+});
diff --git a/src/profile-v2/data/reducers.js b/src/profile-v2/data/reducers.js
new file mode 100644
index 000000000..684fa71cb
--- /dev/null
+++ b/src/profile-v2/data/reducers.js
@@ -0,0 +1,99 @@
+import {
+ SAVE_PROFILE_PHOTO,
+ DELETE_PROFILE_PHOTO,
+ FETCH_PROFILE,
+} from './actions';
+
+export const initialState = {
+ errors: {},
+ savePhotoState: null,
+ account: {
+ socialLinks: [],
+ },
+ preferences: {},
+ courseCertificates: [],
+ isLoadingProfile: true,
+ isAuthenticatedUserProfile: false,
+ disabledCountries: ['RU'],
+};
+
+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: action.account,
+ preferences: action.preferences,
+ courseCertificates: action.courseCertificates,
+ isLoadingProfile: false,
+ isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
+ };
+
+ case SAVE_PROFILE_PHOTO.BEGIN:
+ return {
+ ...state,
+ savePhotoState: 'pending',
+ errors: {},
+ };
+ case SAVE_PROFILE_PHOTO.SUCCESS:
+ return {
+ ...state,
+ // Merge in new profile image data
+ 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,
+ // Merge in new profile image data (should be empty or default image)
+ 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: {},
+ };
+
+ default:
+ return state;
+ }
+};
+
+export default profilePage;
diff --git a/src/profile-v2/data/reducers.test.js b/src/profile-v2/data/reducers.test.js
new file mode 100644
index 000000000..49d5a1060
--- /dev/null
+++ b/src/profile-v2/data/reducers.test.js
@@ -0,0 +1,140 @@
+import profilePage, { initialState } from './reducers';
+import {
+ SAVE_PROFILE_PHOTO,
+ DELETE_PROFILE_PHOTO,
+ FETCH_PROFILE,
+} 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,
+ // Uncomment isLoadingProfile: true if this functionality is required.
+ };
+ expect(profilePage(initialState, action)).toEqual(expectedState);
+ });
+
+ it('should handle FETCH_PROFILE.SUCCESS', () => {
+ const action = {
+ type: FETCH_PROFILE.SUCCESS,
+ account: { name: 'John Doe' },
+ preferences: { theme: 'dark' },
+ courseCertificates: ['cert1', 'cert2'],
+ isAuthenticatedUserProfile: true,
+ };
+ const expectedState = {
+ ...initialState,
+ account: action.account,
+ preferences: action.preferences,
+ courseCertificates: action.courseCertificates,
+ isLoadingProfile: false,
+ isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
+ };
+ 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: '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: '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: { ...initialState.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);
+ });
+ });
+});
diff --git a/src/profile-v2/data/sagas.js b/src/profile-v2/data/sagas.js
new file mode 100644
index 000000000..5e869dd48
--- /dev/null
+++ b/src/profile-v2/data/sagas.js
@@ -0,0 +1,127 @@
+import { history } from '@edx/frontend-platform';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import {
+ all,
+ call,
+ put,
+ select,
+ takeEvery,
+} from 'redux-saga/effects';
+import {
+ deleteProfilePhotoBegin,
+ deleteProfilePhotoReset,
+ deleteProfilePhotoSuccess,
+ DELETE_PROFILE_PHOTO,
+ fetchProfileBegin,
+ fetchProfileReset,
+ fetchProfileSuccess,
+ FETCH_PROFILE,
+ saveProfilePhotoBegin,
+ saveProfilePhotoReset,
+ saveProfilePhotoSuccess,
+ SAVE_PROFILE_PHOTO,
+} from './actions';
+import { 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;
+ // Default our data assuming the account is the current user's account.
+ let preferences = {};
+ let account = userAccount;
+ let courseCertificates = null;
+
+ try {
+ yield put(fetchProfileBegin());
+
+ // Depending on which profile we're loading, we need to make different calls.
+ const calls = [
+ call(ProfileApiService.getAccount, username),
+ call(ProfileApiService.getCourseCertificates, username),
+ ];
+
+ if (isAuthenticatedUserProfile) {
+ // If the profile is for the current user, get their preferences.
+ // We don't need them for other users.
+ calls.push(call(ProfileApiService.getPreferences, username));
+ }
+
+ // Make all the calls in parallel.
+ const result = yield all(calls);
+
+ if (isAuthenticatedUserProfile) {
+ [account, courseCertificates, preferences] = result;
+ } else {
+ [account, courseCertificates] = result;
+ }
+
+ // Set initial visibility values for account
+ // Set account_privacy as custom is necessary so that when viewing another user's profile,
+ // their full name is displayed and change visibility forms are worked correctly
+ 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,
+ ));
+
+ yield put(fetchProfileReset());
+ } catch (e) {
+ if (e.response.status === 404) {
+ history.push('/notfound');
+ } else {
+ 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) {
+ // Just reset on error, since editing functionality is deprecated
+ 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) {
+ // Just reset on error, since editing functionality is deprecated
+ yield put(deleteProfilePhotoReset());
+ }
+}
+
+export default function* profileSaga() {
+ yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
+ yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);
+ yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto);
+}
diff --git a/src/profile-v2/data/sagas.test.js b/src/profile-v2/data/sagas.test.js
new file mode 100644
index 000000000..9482d8907
--- /dev/null
+++ b/src/profile-v2/data/sagas.test.js
@@ -0,0 +1,159 @@
+import {
+ takeEvery,
+ put,
+ call,
+ select,
+ all,
+} from 'redux-saga/effects';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+
+import * as profileActions from './actions';
+import { userAccountSelector } from './selectors';
+
+import profileSaga, {
+ handleFetchProfile,
+ handleSaveProfilePhoto,
+ handleDeleteProfilePhoto,
+} from './sagas';
+import * as ProfileApiService from './services';
+import {
+ deleteProfilePhotoBegin,
+ deleteProfilePhotoReset,
+ saveProfilePhotoBegin,
+ saveProfilePhotoReset,
+} from './actions';
+
+jest.mock('./services', () => ({
+ getAccount: jest.fn(),
+ getCourseCertificates: jest.fn(),
+ getPreferences: jest.fn(),
+ postProfilePhoto: jest.fn(),
+ deleteProfilePhoto: jest.fn(),
+}));
+
+jest.mock('@edx/frontend-platform/auth', () => ({
+ getAuthenticatedUser: jest.fn(),
+}));
+
+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_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.getPreferences, 'gonzo'),
+ ]));
+ expect(gen.next(result).value)
+ .toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], 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',
+ };
+ getAuthenticatedUser.mockReturnValue(userAccount);
+ const selectorData = {
+ userAccount,
+ };
+
+ const action = profileActions.fetchProfile('booyah');
+ const gen = handleFetchProfile(action);
+
+ const result = [{}, [1, 2, 3]];
+
+ 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'),
+ ]));
+ expect(gen.next(result).value)
+ .toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
+ expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
+ expect(gen.next().value).toBeUndefined();
+ });
+ });
+
+ describe('handleSaveProfilePhoto', () => {
+ it('should publish a reset action on error', () => {
+ const action = profileActions.saveProfilePhoto('my username', {});
+ const gen = handleSaveProfilePhoto(action);
+ const error = new Error('Error occurred');
+
+ expect(gen.next().value).toEqual(put(saveProfilePhotoBegin()));
+ expect(gen.throw(error).value).toEqual(put(saveProfilePhotoReset()));
+ expect(gen.next().value).toBeUndefined();
+ });
+ });
+
+ describe('handleDeleteProfilePhoto', () => {
+ it('should publish a reset action on error', () => {
+ const action = profileActions.deleteProfilePhoto('my username');
+ const gen = handleDeleteProfilePhoto(action);
+ const error = new Error('Error occurred');
+
+ expect(gen.next().value).toEqual(put(deleteProfilePhotoBegin()));
+ expect(gen.throw(error).value).toEqual(put(deleteProfilePhotoReset()));
+ expect(gen.next().value).toBeUndefined();
+ });
+ });
+
+ describe('handleDeleteProfilePhoto', () => {
+ it('should successfully process a deleteProfilePhoto request if there are no exceptions', () => {
+ const action = profileActions.deleteProfilePhoto('my username');
+ const gen = handleDeleteProfilePhoto(action);
+ const photoResult = {};
+
+ expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
+ expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'my username'));
+ expect(gen.next(photoResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(photoResult)));
+ expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset()));
+ expect(gen.next().value).toBeUndefined();
+ });
+
+ it('should publish a failure action on exception', () => {
+ const error = new Error('Error occurred');
+ const action = profileActions.deleteProfilePhoto('my username');
+ const gen = handleDeleteProfilePhoto(action);
+
+ expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
+ const result = gen.throw(error);
+ expect(result.value).toEqual(put(profileActions.deleteProfilePhotoReset()));
+ expect(gen.next().value).toBeUndefined();
+ });
+ });
+});
diff --git a/src/profile-v2/data/selectors.js b/src/profile-v2/data/selectors.js
new file mode 100644
index 000000000..f5349a3dc
--- /dev/null
+++ b/src/profile-v2/data/selectors.js
@@ -0,0 +1,58 @@
+import { createSelector } from 'reselect';
+
+export const userAccountSelector = state => state.userAccount;
+export const profileAccountSelector = state => state.profilePage.account;
+export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
+export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
+export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
+export const accountErrorsSelector = state => state.profilePage.errors;
+
+export const certificatesSelector = createSelector(
+ profileCourseCertificatesSelector,
+ (certificates) => ({
+ certificates,
+ value: certificates,
+ }),
+);
+
+export const profileImageSelector = createSelector(
+ profileAccountSelector,
+ account => (account.profileImage != null
+ ? {
+ src: account.profileImage.imageUrlFull,
+ isDefault: !account.profileImage.hasImage,
+ }
+ : {}),
+);
+
+export const profilePageSelector = createSelector(
+ profileAccountSelector,
+ profileCourseCertificatesSelector,
+ profileImageSelector,
+ savePhotoStateSelector,
+ isLoadingProfileSelector,
+ accountErrorsSelector,
+ (
+ account,
+ courseCertificates,
+ profileImage,
+ savePhotoState,
+ isLoadingProfile,
+ errors,
+ ) => ({
+ // Account data we need
+ username: account.username,
+ profileImage,
+ requiresParentalConsent: account.requiresParentalConsent,
+ dateJoined: account.dateJoined,
+ yearOfBirth: account.yearOfBirth,
+ name: account.name,
+
+ courseCertificates,
+
+ // Other data we need
+ savePhotoState,
+ isLoadingProfile,
+ photoUploadError: errors.photo || null,
+ }),
+);
diff --git a/src/profile-v2/data/services.js b/src/profile-v2/data/services.js
new file mode 100644
index 000000000..45bf68777
--- /dev/null
+++ b/src/profile-v2/data/services.js
@@ -0,0 +1,149 @@
+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';
+
+ensureConfig(['LMS_BASE_URL'], 'Profile API service');
+
+function processAccountData(data) {
+ return camelCaseObject(data);
+}
+
+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;
+ }
+}
+
+// GET ACCOUNT
+export async function getAccount(username) {
+ const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
+
+ // Process response data
+ return processAccountData(data);
+}
+
+// PATCH PROFILE
+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);
+ });
+
+ // Process response data
+ return processAccountData(data);
+}
+
+// POST PROFILE PHOTO
+
+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;
+}
+
+// DELETE PROFILE PHOTO
+
+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;
+}
+
+// GET PREFERENCES
+export async function getPreferences(username) {
+ const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
+
+ return camelCaseObject(data);
+}
+
+// PATCH PREFERENCES
+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.
+}
+
+// GET COURSE CERTIFICATES
+
+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 [];
+ }
+}
diff --git a/src/profile-v2/forms/ProfileAvatar.jsx b/src/profile-v2/forms/ProfileAvatar.jsx
new file mode 100644
index 000000000..6be26d2e6
--- /dev/null
+++ b/src/profile-v2/forms/ProfileAvatar.jsx
@@ -0,0 +1,159 @@
+import React, { useRef } from 'react';
+import PropTypes from 'prop-types';
+import { Button, Dropdown } from '@openedx/paragon';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+
+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 = () => (
+
+ );
+
+ const renderMenuContent = () => {
+ if (isDefault) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {intl.formatMessage(messages['profile.profileavatar.change-button'])}
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ const renderMenu = () => {
+ if (!isEditable) {
+ return null;
+ }
+ return (
+
+ {renderMenuContent()}
+
+ );
+ };
+
+ const renderAvatar = () => (
+ isDefault ? (
+
+ ) : (
+
+ )
+ );
+
+ return (
+
+
+ {savePhotoState === 'pending' ? renderPending() : renderMenu()}
+ {renderAvatar()}
+
+
+
+ );
+};
+
+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;
diff --git a/src/profile-v2/forms/ProfileAvatar.messages.jsx b/src/profile-v2/forms/ProfileAvatar.messages.jsx
new file mode 100644
index 000000000..121da2b50
--- /dev/null
+++ b/src/profile-v2/forms/ProfileAvatar.messages.jsx
@@ -0,0 +1,16 @@
+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',
+ },
+});
+
+export default messages;
diff --git a/src/profile-v2/index.js b/src/profile-v2/index.js
new file mode 100644
index 000000000..4cb72e831
--- /dev/null
+++ b/src/profile-v2/index.js
@@ -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';
diff --git a/src/profile-v2/index.scss b/src/profile-v2/index.scss
new file mode 100644
index 000000000..32762c523
--- /dev/null
+++ b/src/profile-v2/index.scss
@@ -0,0 +1,228 @@
+.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: $gray-500;
+}
+
+.profile-page {
+ .edit-section-header {
+ @extend .h6;
+ display: block;
+ font-weight: normal;
+ letter-spacing: 0;
+ margin: 0;
+ }
+
+ label.edit-section-header {
+ margin-bottom: $spacer * .5;
+ }
+
+ .profile-avatar-wrap {
+ @include media-breakpoint-up(md) {
+ max-width: 12rem;
+ margin-right: 0;
+ height: auto;
+ }
+ }
+
+ .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%;
+
+ @include media-breakpoint-up(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;
+ @include media-breakpoint-up(md) {
+ margin-bottom: 1.2rem;
+ }
+ }
+
+ .dropdown {
+ @include media-breakpoint-up(md) {
+ margin-bottom: 1.2rem;
+ }
+
+ .btn {
+ color: $white;
+ background: transparent;
+ border-color: transparent;
+ margin: 0;
+ }
+ }
+ }
+
+ .profile-avatar {
+ width: 5rem;
+ height: 5rem;
+ position: relative;
+
+ @include media-breakpoint-up(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;
+
+ @include media-breakpoint-up(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;
+ }
+ }
+}
+
+// Todo: Move the following to edx-paragon
+
+.btn-rounded {
+ border-radius: 100px;
+}
+
+.max-width-32em {
+ max-width: 32em;
+}
+
+.width-75rem {
+ width: 75rem;
+}
+
+.width-72rem {
+ width: 72rem !important;
+}
+
+.width-19625rem {
+ width: 19.625rem;
+}
+
+.height-2625rem {
+ height: 2.625rem;
+}
+
+.height-50vh {
+ height: 50vh;
+}
+
+.rounded-75 {
+ border-radius: 0.75rem;
+}
+
+.pt-4rem {
+ padding-top: 4rem;
+}
+
+.py-4rem {
+ padding-top: 4rem;
+ padding-bottom: 4rem;
+}
+
+.py-0625rem {
+ padding-top: 0.625rem;
+ padding-bottom: 0.625rem;
+}
+
+.px-75rem {
+ padding-left: 7.5rem;
+ padding-right: 7.5rem;
+}
+
+.px-25rem {
+ padding-left: 2.5rem;
+ padding-right: 2.5rem;
+}
+
+.g-15rem {
+ gap: 1.5rem;
+}
+
+.g-5rem {
+ gap: 0.5rem;
+}
+
+.g-1rem {
+ gap: 1rem;
+}
+
+.g-3rem {
+ gap: 3rem;
+}
+
+.color-black {
+ color: #000;
+}
+
+.background-black-65 {
+ background-color: rgba(0,0,0,.65)
+}
+
+.object-fit-cover {
+ object-fit: cover;
+}
diff --git a/src/profile-v2/utils.js b/src/profile-v2/utils.js
new file mode 100644
index 000000000..29981a600
--- /dev/null
+++ b/src/profile-v2/utils.js
@@ -0,0 +1,71 @@
+import camelCase from 'lodash.camelcase';
+import snakeCase from 'lodash.snakecase';
+
+export function modifyObjectKeys(object, modify) {
+ // If the passed in object is not an object, return it.
+ if (
+ object === undefined
+ || object === null
+ || (typeof object !== 'object' && !Array.isArray(object))
+ ) {
+ return object;
+ }
+
+ if (Array.isArray(object)) {
+ return object.map(value => modifyObjectKeys(value, modify));
+ }
+
+ // Otherwise, process all its keys.
+ 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`;
+ }
+}
diff --git a/src/profile-v2/utils.test.js b/src/profile-v2/utils.test.js
new file mode 100644
index 000000000..c015e0eb7
--- /dev/null
+++ b/src/profile-v2/utils.test.js
@@ -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');
+ });
+});
diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx
index 1d46786b5..84c84402e 100644
--- a/src/routes/AppRoutes.jsx
+++ b/src/routes/AppRoutes.jsx
@@ -1,17 +1,32 @@
import React from 'react';
+import PropTypes from 'prop-types';
import {
AuthenticatedPageRoute,
PageWrap,
} from '@edx/frontend-platform/react';
import { Routes, Route } 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;
+
+ return (
+
+ } />
+ } />
+ } />
+
+ );
+};
+
+AppRoutes.propTypes = {
+ isNewProfileEnabled: PropTypes.bool,
+};
+
+AppRoutes.defaultProps = {
+ isNewProfileEnabled: null,
+};
export default AppRoutes;
diff --git a/src/routes/routes.test.jsx b/src/routes/routes.test.jsx
index 9bd40dfea..3b2b622cb 100644
--- a/src/routes/routes.test.jsx
+++ b/src/routes/routes.test.jsx
@@ -17,6 +17,11 @@ jest.mock('../profile', () => ({
NotFoundPage: () => (Not found page
),
}));
+jest.mock('../profile-v2', () => ({
+ ProfilePage: () => (Profile page
),
+ NotFoundPage: () => (Not found page
),
+}));
+
const RoutesWithProvider = (context, path) => (