diff --git a/src/data/reducers.js b/src/data/reducers.js index c5b3b97f1..98b95d263 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -1,12 +1,14 @@ import { combineReducers } from 'redux'; -import { reducer as profilePage } from '../profile'; -import { reducer as NewProfilePageReducer } from '../profile-v2'; +import { getConfig } from '@edx/frontend-platform'; -const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; +import { reducer as profilePageReducer } from '../profile'; +import { reducer as newProfilePageReducer } from '../profile-v2'; + +const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW; const createRootReducer = () => combineReducers({ - profilePage: isNewProfileEnabled ? NewProfilePageReducer : profilePage, + profilePage: isNewProfileEnabled ? newProfilePageReducer : profilePageReducer, }); export default createRootReducer; diff --git a/src/data/sagas.js b/src/data/sagas.js index 1c26efd1a..fab5ecad8 100644 --- a/src/data/sagas.js +++ b/src/data/sagas.js @@ -1,11 +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'; +import { saga as newProfileSaga } from '../profile-v2'; -const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; +const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW; export default function* rootSaga() { yield all([ - isNewProfileEnabled ? NewProfileSaga() : profileSaga(), + isNewProfileEnabled ? newProfileSaga() : profileSaga(), ]); } diff --git a/src/index.jsx b/src/index.jsx index eacc1c9c7..af9ce1581 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -58,6 +58,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/CertificateCount.jsx b/src/profile-v2/CertificateCount.jsx deleted file mode 100644 index 7b527bd7d..000000000 --- a/src/profile-v2/CertificateCount.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; - -const CertificateCount = ({ count }) => { - if (count === 0) { - return null; - } - - return ( - - {count} , - }} - /> - - ); -}; - -CertificateCount.propTypes = { - count: PropTypes.number, -}; -CertificateCount.defaultProps = { - count: 0, -}; - -export default CertificateCount; diff --git a/src/profile-v2/Certificates.jsx b/src/profile-v2/Certificates.jsx index eabf6421d..3a6cab3b4 100644 --- a/src/profile-v2/Certificates.jsx +++ b/src/profile-v2/Certificates.jsx @@ -1,168 +1,71 @@ -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { - FormattedDate, FormattedMessage, useIntl, -} from '@edx/frontend-platform/i18n'; -import { Hyperlink } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { connect } from 'react-redux'; -import get from 'lodash.get'; - import { getConfig } from '@edx/frontend-platform'; -import messages from './Certificates.messages'; - -// Assets -import professionalCertificateSVG from './assets/professional-certificate.svg'; -import verifiedCertificateSVG from './assets/verified-certificate.svg'; -// Selectors +import CertificateCard from './CertificateCard'; import { certificatesSelector } from './data/selectors'; -const Certificates = ({ - certificates, -}) => { - const intl = useIntl(); - - const renderCertificate = useCallback(({ - certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId, uuid, - }) => { - const certificateIllustration = (() => { - switch (certificateType) { - case 'professional': - case 'no-id-professional': - return professionalCertificateSVG; - case 'verified': - return verifiedCertificateSVG; - case 'honor': - case 'audit': - default: - return null; - } - })(); - - return ( -
-
-
( +
+
+
+

+ +

+
+
+

+ -

-
-

- {intl.formatMessage(get( - messages, - `profile.certificates.types.${certificateType}`, - messages['profile.certificates.types.unknown'], - ))} -

-
{courseDisplayName}
-

- -

-

{courseOrganization}

-

- , - }} - /> -

-
-
- - {intl.formatMessage(messages['profile.certificates.view.certificate'])} - -
-

- -

-
+

+
+
+ {certificates && certificates.length > 0 ? ( +
+
+ {certificates.map(certificate => ( + + ))}
- ); - }, [intl]); - - // Memoizing the renderCertificates to avoid recalculations - const renderCertificates = useMemo(() => { - if (!certificates || certificates.length === 0) { - return ( + ) : ( +
- ); - } - - return ( -
-
- {certificates.map(certificate => renderCertificate(certificate))} -
- ); - }, [certificates, renderCertificate]); - - // Main Render - return ( -
-
-
-

- -

-
-
-

- -

-
-
- {renderCertificates} -
- ); -}; + )} +
+); Certificates.propTypes = { - - // From Selector certificates: PropTypes.arrayOf(PropTypes.shape({ - title: PropTypes.string, + 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: null, + certificates: [], }; export default connect( diff --git a/src/profile-v2/DateJoined.jsx b/src/profile-v2/DateJoined.jsx index 32d835d90..5b02d4bbe 100644 --- a/src/profile-v2/DateJoined.jsx +++ b/src/profile-v2/DateJoined.jsx @@ -1,11 +1,9 @@ -import React from 'react'; +import React, { memo } from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n'; const DateJoined = ({ date }) => { - if (date == null) { - return null; - } + if (!date) { return null; } return ( @@ -28,4 +26,4 @@ DateJoined.defaultProps = { date: null, }; -export default DateJoined; +export default memo(DateJoined); diff --git a/src/profile-v2/PageLoading.jsx b/src/profile-v2/PageLoading.jsx index 1b1135dcf..b921db502 100644 --- a/src/profile-v2/PageLoading.jsx +++ b/src/profile-v2/PageLoading.jsx @@ -1,37 +1,23 @@ -import React, { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -export default class PageLoading extends Component { - renderSrMessage() { - if (!this.props.srMessage) { - return null; - } - - return ( - - {this.props.srMessage} - - ); - } - - render() { - return ( -
-
-
- {this.renderSrMessage()} -
-
+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 index 4033c1266..cb2d1323b 100644 --- a/src/profile-v2/ProfilePage.jsx +++ b/src/profile-v2/ProfilePage.jsx @@ -1,10 +1,12 @@ -import React, { useEffect, useState, useContext } from 'react'; +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 { injectIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Alert, Hyperlink } from '@openedx/paragon'; import { fetchProfile, @@ -14,7 +16,7 @@ import { import ProfileAvatar from './forms/ProfileAvatar'; import Certificates from './Certificates'; import DateJoined from './DateJoined'; -import CertificateCount from './CertificateCount'; +import UserCertificateSummary from './UserCertificateSummary'; import UsernameDescription from './UsernameDescription'; import PageLoading from './PageLoading'; import { profilePageSelector } from './data/selectors'; @@ -23,8 +25,9 @@ import withParams from '../utils/hoc'; ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage'); -const ProfilePage = ({ params, intl }) => { +const ProfilePage = ({ params }) => { const dispatch = useDispatch(); + const intl = useIntl(); const context = useContext(AppContext); const { requiresParentalConsent, @@ -41,24 +44,24 @@ const ProfilePage = ({ params, intl }) => { const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null); useEffect(() => { - const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL; - if (credentialsBaseUrl) { - setViewMyRecordsUrl(`${credentialsBaseUrl}/records`); + 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.CREDENTIALS_BASE_URL]); + }, [dispatch, params.username, context.config]); - const handleSaveProfilePhoto = (formData) => { + const handleSaveProfilePhoto = useCallback((formData) => { dispatch(saveProfilePhoto(context.authenticatedUser.username, formData)); - }; + }, [dispatch, context.authenticatedUser.username]); - const handleDeleteProfilePhoto = () => { + const handleDeleteProfilePhoto = useCallback(() => { dispatch(deleteProfilePhoto(context.authenticatedUser.username)); - }; + }, [dispatch, context.authenticatedUser.username]); const isYOBDisabled = () => { const currentYear = new Date().getFullYear(); @@ -83,21 +86,17 @@ const ProfilePage = ({ params, intl }) => { ); }; - const renderPhotoUploadErrorMessage = () => { - if (photoUploadError === null) { - return null; - } - - return ( -
-
- - {photoUploadError.userMessage} - -
+ const renderPhotoUploadErrorMessage = () => ( + photoUploadError && ( +
+
+ + {photoUploadError.userMessage} +
- ); - }; +
+ ) + ); const renderContent = () => { if (isLoadingProfile) { @@ -107,7 +106,7 @@ const ProfilePage = ({ params, intl }) => { return ( <>
-
+
{ )}
- +
@@ -165,9 +164,6 @@ ProfilePage.propTypes = { params: PropTypes.shape({ username: PropTypes.string.isRequired, }).isRequired, - intl: PropTypes.shape({ - formatMessage: PropTypes.func.isRequired, - }).isRequired, }; -export default injectIntl(withParams(ProfilePage)); +export default withParams(ProfilePage); diff --git a/src/profile-v2/ProfilePage.test.jsx b/src/profile-v2/ProfilePage.test.jsx index 505b84109..7c0e2d46c 100644 --- a/src/profile-v2/ProfilePage.test.jsx +++ b/src/profile-v2/ProfilePage.test.jsx @@ -1,4 +1,3 @@ -/* eslint-disable global-require */ import { getConfig } from '@edx/frontend-platform'; import * as analytics from '@edx/frontend-platform/analytics'; import { AppContext } from '@edx/frontend-platform/react'; @@ -12,12 +11,16 @@ 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: require('./__mocks__/loadingApp.mockStore'), - viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'), - viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'), + loadingApp, + viewOwnProfile, + viewOtherProfile, }; const requiredProfilePageProps = { fetchUserAccount: () => {}, 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/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap index c289482b4..29a9d33d9 100644 --- a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -35,7 +35,7 @@ exports[` Renders correctly in various states viewing other profi class="profile-page-bg-banner bg-primary d-md-block align-items-center px-75rem py-4rem h-100 w-100" >
Renders correctly in various states viewing other profi + + + + 0 + + + certifications +
Renders correctly in various states viewing other profi

- You don't have any certificates yet. +
+ You don't have any certificates yet. +
@@ -169,7 +185,7 @@ exports[` Renders correctly in various states viewing own profile class="profile-page-bg-banner bg-primary d-md-block align-items-center px-75rem py-4rem h-100 w-100" >
Renders correctly in various states viewing own profile style="background-image: url(icon/mock/path);" />
Renders correctly in various states viewing own profile > Verified Certificate

-
edX Demonstration Course -
+

From

-

edX -

+

@@ -396,7 +412,7 @@ exports[` Renders correctly in various states without credentials class="profile-page-bg-banner bg-primary d-md-block align-items-center px-75rem py-4rem h-100 w-100" >

Renders correctly in various states without credentials style="background-image: url(icon/mock/path);" />
Renders correctly in various states without credentials > Verified Certificate

-
edX Demonstration Course -
+

From

-

edX -

+

diff --git a/src/profile-v2/data/selectors.js b/src/profile-v2/data/selectors.js index 4457f8dd8..f5349a3dc 100644 --- a/src/profile-v2/data/selectors.js +++ b/src/profile-v2/data/selectors.js @@ -1,7 +1,6 @@ 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; diff --git a/src/profile-v2/forms/ProfileAvatar.jsx b/src/profile-v2/forms/ProfileAvatar.jsx index 19ce9c702..8c064ed68 100644 --- a/src/profile-v2/forms/ProfileAvatar.jsx +++ b/src/profile-v2/forms/ProfileAvatar.jsx @@ -1,66 +1,60 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { Button, Dropdown } from '@openedx/paragon'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ReactComponent as DefaultAvatar } from '../assets/avatar.svg'; - import messages from './ProfileAvatar.messages'; -class ProfileAvatar extends React.Component { - constructor(props) { - super(props); - - this.fileInput = React.createRef(); - this.form = React.createRef(); - - this.onClickUpload = this.onClickUpload.bind(this); - this.onClickDelete = this.onClickDelete.bind(this); - this.onChangeInput = this.onChangeInput.bind(this); - this.onSubmit = this.onSubmit.bind(this); - } - - onClickUpload() { - this.fileInput.current.click(); - } - - onClickDelete() { - this.props.onDelete(); - } - - onChangeInput() { - this.onSubmit(); - } - - onSubmit(e) { +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(); } - this.props.onSave(new FormData(this.form.current)); - this.form.current.reset(); - } - - renderPending() { - return ( -

-
-
- ); - } - - renderMenuContent() { - const { intl } = this.props; - - if (this.props.isDefault) { + onSave(new FormData(form.current)); + form.current.reset(); + }; + + const onChangeInput = () => { + onSubmit(); + }; + + const renderPending = () => ( +
+
+
+ ); + + const renderMenuContent = () => { + if (isDefault) { return (