From 38b80195334d2d599f2bd9b656de34adcbddf56a Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Thu, 24 Oct 2024 19:01:23 +0500 Subject: [PATCH 01/12] feat: reskin of Profile MFE main page --- src/data/reducers.js | 2 +- src/data/sagas.js | 2 +- src/index.scss | 2 +- src/profile-v2/AgeMessage.jsx | 43 + src/profile-v2/Banner.jsx | 5 + src/profile-v2/CertificateCount.jsx | 31 + src/profile-v2/Certificates.jsx | 163 + src/profile-v2/Certificates.messages.jsx | 31 + src/profile-v2/DateJoined.jsx | 31 + src/profile-v2/NotFoundPage.jsx | 16 + src/profile-v2/PageLoading.jsx | 37 + src/profile-v2/ProfilePage.jsx | 204 + src/profile-v2/ProfilePage.messages.jsx | 16 + src/profile-v2/ProfilePage.test.jsx | 334 + src/profile-v2/UsernameDescription.jsx | 20 + .../__mocks__/loadingApp.mockStore.js | 41 + .../__mocks__/savingEditedBio.mockStore.js | 139 + .../__mocks__/viewOtherProfile.mockStore.js | 98 + .../__mocks__/viewOwnProfile.mockStore.js | 138 + .../__snapshots__/ProfilePage.test.jsx.snap | 13307 ++++++++++++++++ src/profile-v2/assets/avatar.svg | 9 + src/profile-v2/assets/dot-pattern-light.png | Bin 0 -> 38914 bytes src/profile-v2/assets/micro-masters.svg | 13 + .../assets/professional-certificate.svg | 1 + .../assets/verified-certificate.svg | 1 + src/profile-v2/data/actions.js | 149 + src/profile-v2/data/actions.test.js | 202 + src/profile-v2/data/constants.js | 28 + src/profile-v2/data/mock_data.js | 7 + src/profile-v2/data/pact-profile.test.js | 80 + src/profile-v2/data/reducers.js | 162 + src/profile-v2/data/sagas.js | 209 + src/profile-v2/data/sagas.test.js | 166 + src/profile-v2/data/selectors.js | 383 + src/profile-v2/data/services.js | 149 + src/profile-v2/forms/ProfileAvatar.jsx | 172 + .../forms/ProfileAvatar.messages.jsx | 16 + src/profile-v2/index.js | 5 + src/profile-v2/index.scss | 226 + src/profile-v2/utils.js | 71 + src/profile-v2/utils.test.js | 103 + src/routes/AppRoutes.jsx | 2 +- 42 files changed, 16810 insertions(+), 4 deletions(-) create mode 100644 src/profile-v2/AgeMessage.jsx create mode 100644 src/profile-v2/Banner.jsx create mode 100644 src/profile-v2/CertificateCount.jsx create mode 100644 src/profile-v2/Certificates.jsx create mode 100644 src/profile-v2/Certificates.messages.jsx create mode 100644 src/profile-v2/DateJoined.jsx create mode 100644 src/profile-v2/NotFoundPage.jsx create mode 100644 src/profile-v2/PageLoading.jsx create mode 100644 src/profile-v2/ProfilePage.jsx create mode 100644 src/profile-v2/ProfilePage.messages.jsx create mode 100644 src/profile-v2/ProfilePage.test.jsx create mode 100644 src/profile-v2/UsernameDescription.jsx create mode 100644 src/profile-v2/__mocks__/loadingApp.mockStore.js create mode 100644 src/profile-v2/__mocks__/savingEditedBio.mockStore.js create mode 100644 src/profile-v2/__mocks__/viewOtherProfile.mockStore.js create mode 100644 src/profile-v2/__mocks__/viewOwnProfile.mockStore.js create mode 100644 src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap create mode 100644 src/profile-v2/assets/avatar.svg create mode 100644 src/profile-v2/assets/dot-pattern-light.png create mode 100644 src/profile-v2/assets/micro-masters.svg create mode 100644 src/profile-v2/assets/professional-certificate.svg create mode 100644 src/profile-v2/assets/verified-certificate.svg create mode 100644 src/profile-v2/data/actions.js create mode 100644 src/profile-v2/data/actions.test.js create mode 100644 src/profile-v2/data/constants.js create mode 100644 src/profile-v2/data/mock_data.js create mode 100644 src/profile-v2/data/pact-profile.test.js create mode 100644 src/profile-v2/data/reducers.js create mode 100644 src/profile-v2/data/sagas.js create mode 100644 src/profile-v2/data/sagas.test.js create mode 100644 src/profile-v2/data/selectors.js create mode 100644 src/profile-v2/data/services.js create mode 100644 src/profile-v2/forms/ProfileAvatar.jsx create mode 100644 src/profile-v2/forms/ProfileAvatar.messages.jsx create mode 100644 src/profile-v2/index.js create mode 100644 src/profile-v2/index.scss create mode 100644 src/profile-v2/utils.js create mode 100644 src/profile-v2/utils.test.js diff --git a/src/data/reducers.js b/src/data/reducers.js index cd17a6394..f2e15d832 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -1,6 +1,6 @@ import { combineReducers } from 'redux'; -import { reducer as profilePage } from '../profile'; +import { reducer as profilePage } from '../profile-v2'; const createRootReducer = () => combineReducers({ profilePage, diff --git a/src/data/sagas.js b/src/data/sagas.js index 6486c6e92..73b3c7384 100644 --- a/src/data/sagas.js +++ b/src/data/sagas.js @@ -1,6 +1,6 @@ import { all } from 'redux-saga/effects'; -import { saga as profileSaga } from '../profile'; +import { saga as profileSaga } from '../profile-v2'; export default function* rootSaga() { yield all([ diff --git a/src/index.scss b/src/index.scss index 8f2b55b9f..d54250a8c 100755 --- a/src/index.scss +++ b/src/index.scss @@ -5,4 +5,4 @@ @import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-footer/dist/footer"; -@import './profile/index'; +@import './profile-v2/index'; diff --git a/src/profile-v2/AgeMessage.jsx b/src/profile-v2/AgeMessage.jsx new file mode 100644 index 000000000..c7f90fba4 --- /dev/null +++ b/src/profile-v2/AgeMessage.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert } from '@openedx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { getConfig } from '@edx/frontend-platform'; + +const AgeMessage = ({ accountSettingsUrl }) => ( + + + + + + + + + +); + +AgeMessage.propTypes = { + accountSettingsUrl: PropTypes.string.isRequired, +}; + +export default AgeMessage; diff --git a/src/profile-v2/Banner.jsx b/src/profile-v2/Banner.jsx new file mode 100644 index 000000000..de8ff9dd9 --- /dev/null +++ b/src/profile-v2/Banner.jsx @@ -0,0 +1,5 @@ +import React from 'react'; + +const Banner = () =>
; + +export default Banner; diff --git a/src/profile-v2/CertificateCount.jsx b/src/profile-v2/CertificateCount.jsx new file mode 100644 index 000000000..7b527bd7d --- /dev/null +++ b/src/profile-v2/CertificateCount.jsx @@ -0,0 +1,31 @@ +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 new file mode 100644 index 000000000..36a34ea63 --- /dev/null +++ b/src/profile-v2/Certificates.jsx @@ -0,0 +1,163 @@ +import React, { useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { + FormattedDate, FormattedMessage, useIntl, +} from '@edx/frontend-platform/i18n'; +import { Hyperlink } from '@openedx/paragon'; +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 { certificatesSelector } from './data/selectors'; + +const Certificates = ({ + certificates, +}) => { + const intl = useIntl(); + + // Memoizing the renderCertificate function to prevent unnecessary re-renders + const renderCertificate = useCallback(({ + certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId, + }) => { + 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'])} + +
+
+
+
+ ); + }, [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, + })), +}; + +Certificates.defaultProps = { + certificates: null, +}; + +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..32d835d90 --- /dev/null +++ b/src/profile-v2/DateJoined.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n'; + +const DateJoined = ({ date }) => { + if (date == null) { + return null; + } + + return ( + + , + }} + /> + + ); +}; + +DateJoined.propTypes = { + date: PropTypes.string, +}; +DateJoined.defaultProps = { + date: null, +}; + +export default DateJoined; diff --git a/src/profile-v2/NotFoundPage.jsx b/src/profile-v2/NotFoundPage.jsx new file mode 100644 index 000000000..0963b153c --- /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..1b1135dcf --- /dev/null +++ b/src/profile-v2/PageLoading.jsx @@ -0,0 +1,37 @@ +import React, { Component } 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()} +
+
+
+ ); + } +} + +PageLoading.propTypes = { + srMessage: PropTypes.string.isRequired, +}; diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx new file mode 100644 index 000000000..6e9d34318 --- /dev/null +++ b/src/profile-v2/ProfilePage.jsx @@ -0,0 +1,204 @@ +import React, { useEffect, useState, useContext } 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 { Alert, Hyperlink } from '@openedx/paragon'; +import { + fetchProfile, + saveProfile, + saveProfilePhoto, + deleteProfilePhoto, + openForm, + closeForm, + updateDraft, +} from './data/actions'; +import ProfileAvatar from './forms/ProfileAvatar'; +import Certificates from './Certificates'; +import DateJoined from './DateJoined'; +import CertificateCount from './CertificateCount'; +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, intl }) => { + const dispatch = useDispatch(); + const context = useContext(AppContext); + const { + requiresParentalConsent, + dateJoined, + yearOfBirth, + courseCertificates, + visibilityCourseCertificates, + name, + profileImage, + savePhotoState, + isLoadingProfile, + photoUploadError, + } = useSelector(profilePageSelector); + + const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null); + + useEffect(() => { + const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL; + if (credentialsBaseUrl) { + setViewMyRecordsUrl(`${credentialsBaseUrl}/records`); + } + + dispatch(fetchProfile(params.username)); + sendTrackingLogEvent('edx.profile.viewed', { + username: params.username, + }); + }, [dispatch, params.username, context.config.CREDENTIALS_BASE_URL]); + + const handleSaveProfilePhoto = (formData) => { + dispatch(saveProfilePhoto(context.authenticatedUser.username, formData)); + }; + + const handleDeleteProfilePhoto = () => { + dispatch(deleteProfilePhoto(context.authenticatedUser.username)); + }; + + const handleClose = (formId) => { + dispatch(closeForm(formId)); + }; + + const handleOpen = (formId) => { + dispatch(openForm(formId)); + }; + + const handleSubmit = (formId) => { + dispatch(saveProfile(formId, context.authenticatedUser.username)); + }; + + const handleChange = (fieldName, value) => { + dispatch(updateDraft(fieldName, value)); + }; + + 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 = () => { + if (photoUploadError === null) { + return null; + } + + return ( +
+
+ + {photoUploadError.userMessage} + +
+
+ ); + }; + + const renderContent = () => { + if (isLoadingProfile) { + return ; + } + + const commonFormProps = { + openHandler: handleOpen, + closeHandler: handleClose, + submitHandler: handleSubmit, + changeHandler: handleChange, + }; + + return ( + <> +
+
+
+
+ +
+

{params.username}

+ {isBlockVisible(name) && ( +

{name}

+ )} +
+ + +
+
+
+ {renderViewMyRecordsButton()} +
+
+
+ {isYOBDisabled() && } +
+
+
+ {renderPhotoUploadErrorMessage()} +
+
+
+
+ {isBlockVisible(courseCertificates.length) && ( + + )} +
+ + ); + }; + + return ( +
+ {/* */} + {renderContent()} +
+ ); +}; + +ProfilePage.propTypes = { + params: PropTypes.shape({ + username: PropTypes.string.isRequired, + }).isRequired, + intl: PropTypes.shape({ + formatMessage: PropTypes.func.isRequired, + }).isRequired, +}; + +export default injectIntl(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..e347ba8c5 --- /dev/null +++ b/src/profile-v2/ProfilePage.test.jsx @@ -0,0 +1,334 @@ +/* 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'; +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'; + +const mockStore = configureMockStore([thunk]); +const storeMocks = { + loadingApp: require('./__mocks__/loadingApp.mockStore'), + viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'), + viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'), + savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'), +}; +const requiredProfilePageProps = { + fetchUserAccount: () => {}, + fetchProfile: () => {}, + saveProfile: () => {}, + saveProfilePhoto: () => {}, + deleteProfilePhoto: () => {}, + openField: () => {}, + closeField: () => {}, + 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, requiresParentalConsent, +}) => ( + + + + + + + +); + +ProfilePageWrapper.defaultProps = { + params: { username: 'staff' }, + requiresParentalConsent: null, +}; + +ProfilePageWrapper.propTypes = { + contextValue: PropTypes.shape({}).isRequired, + store: PropTypes.shape({}).isRequired, + params: PropTypes.shape({}), + requiresParentalConsent: PropTypes.bool, +}; + +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('while saving an edited bio', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('while saving an edited bio with error', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); + storeData.profilePage.errors.bio = { userMessage: 'bio error' }; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('test country edit with error', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); + storeData.profilePage.errors.country = { userMessage: 'country error' }; + storeData.profilePage.currentlyEditingField = 'country'; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('test education edit with error', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); + storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' }; + storeData.profilePage.currentlyEditingField = 'levelOfEducation'; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('test preferreded language edit with error', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); + storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' }; + storeData.profilePage.currentlyEditingField = 'languageProficiencies'; + 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(); + }); + it('test age message alert', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile)); + storeData.userAccount.requiresParentalConsent = true; + storeData.profilePage.account.requiresParentalConsent = true; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true }, + }; + const { container } = render( + , + ); + + expect(container.querySelector('.alert-info')).toHaveClass('show'); + }); + it('test photo error alert', () => { + const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile)); + storeData.profilePage.errors.photo = { userMessage: 'error' }; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true }, + }; + const { container } = render( + , + ); + + expect(container.querySelector('.alert-danger')).toHaveClass('show'); + }); + + it.each([ + ['test user with non-disabled country', 'PK'], + ['test user with disabled country', 'RU'], + ])('test user with %s', (_, accountCountry) => { + const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); + storeData.profilePage.errors.country = {}; + storeData.profilePage.currentlyEditingField = 'country'; + storeData.profilePage.disabledCountries = ['RU']; + storeData.profilePage.account.country = accountCountry; + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + 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/UsernameDescription.jsx b/src/profile-v2/UsernameDescription.jsx new file mode 100644 index 000000000..af03b4eae --- /dev/null +++ b/src/profile-v2/UsernameDescription.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { VisibilityOff } from '@openedx/paragon/icons'; +import { Icon } from '@openedx/paragon'; +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..843a2c931 --- /dev/null +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -0,0 +1,13307 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Renders correctly in various states app loading 1`] = ` +
+
+
+
+
+
+ + Profile loading... + +
+
+
+
+
+`; + +exports[` Renders correctly in various states test country edit with error 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+
+
+ + +
+
+ country error +
+
+
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states test education edit with error 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+
+
+ + +
+
+ education error +
+
+
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states test preferreded language edit with error 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+
+
+ + +
+
+ preferred language error +
+
+
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states test user with test user with disabled country 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states test user with test user with non-disabled country 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states viewing other profile with all fields 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+ + + +
+ Your profile information is only visible to you. Only your username is visible to others on localhost. +
+
+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+ + + +
+ Your profile information is only visible to you. Only your username is visible to others on localhost. +
+
+
+
+
+ +
+
+
+

+ Full Name +

+
+

+ user +

+
+
+
+
+
+

+ Location +

+
+

+

+
+
+
+
+

+ Primary Language Spoken +

+
+

+

+
+
+
+
+

+ Education +

+
+

+ Other education +

+
+
+
+
+
+

+ Social Links +

+
+
    +
+
+
+
+
+
+
+

+ About Me +

+
+

+ bio +

+
+
+
+
+
+

+ My Certificates +

+
+ You don't have any certificates yet. +
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states viewing own profile 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states while saving an edited bio 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states while saving an edited bio with error 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+ +
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+ bio error +
+
+
+
+
+ + + + + + + +
+
+ + +
+
+
+
+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states without credentials service 1`] = ` +
+
+
+
+
+
+
+
+
+
+ +
+ profile avatar +
+
+ +
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+
+
+ +

+ staff +

+

+ Member since + 2017 +

+
+
+
+
+
+
+
+

+ Full Name + +

+

+ + + + Just me + +

+
+

+ Lemon Seltzer +

+ + This is the name that appears in your account and on your certificates. + +
+
+
+
+
+

+ Location + +

+

+ + + + Everyone on localhost + +

+
+

+ Montenegro +

+
+
+
+
+
+

+ Primary Language Spoken + +

+

+ + + + Everyone on localhost + +

+
+

+ Yoruba +

+
+
+
+
+
+

+ Education + +

+

+ + + + Just me + +

+
+

+ Elementary/primary school +

+
+
+
+
+
+

+ Social Links + +

+

+ + + + Everyone on localhost + +

+
+ +
+
+
+
+
+
+
+

+ About Me + +

+

+ + + + Everyone on localhost + +

+
+

+ This is my bio +

+
+
+
+
+
+

+ My Certificates + +

+

+ + + + Everyone on localhost + +

+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+
+

+ From +

+

+ edX +

+
+

+ Completed on + 3/4/2019 +

+ +
+
+
+
+
+
+
+
+
+
+
+`; 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 @@ + + + + avatar + Created with Sketch. + + + + 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 0000000000000000000000000000000000000000..c84a3c52a385bf62a54a7cf948d8cf6c7d3edd31 GIT binary patch literal 38914 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 + + + micro-masters + Created with Sketch. + + + + + + + + \ 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 @@ +cert-bg-logo \ 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 @@ +cert-bg-logo \ 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..38a5b7968 --- /dev/null +++ b/src/profile-v2/data/actions.js @@ -0,0 +1,149 @@ +import { AsyncActionType } from '../utils'; + +export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE'); +export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE'); +export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO'); +export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO'); +export const OPEN_FORM = 'OPEN_FORM'; +export const CLOSE_FORM = 'CLOSE_FORM'; +export const UPDATE_DRAFT = 'UPDATE_DRAFT'; +export const RESET_DRAFTS = 'RESET_DRAFTS'; + +// 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 ACTIONS + +export const saveProfile = (formId, username) => ({ + type: SAVE_PROFILE.BASE, + payload: { + formId, + username, + }, +}); + +export const saveProfileBegin = () => ({ + type: SAVE_PROFILE.BEGIN, +}); + +export const saveProfileSuccess = (account, preferences) => ({ + type: SAVE_PROFILE.SUCCESS, + payload: { + account, + preferences, + }, +}); + +export const saveProfileReset = () => ({ + type: SAVE_PROFILE.RESET, +}); + +export const saveProfileFailure = errors => ({ + type: SAVE_PROFILE.FAILURE, + payload: { errors }, +}); + +// 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, +}); + +// FIELD STATE ACTIONS + +export const openForm = formId => ({ + type: OPEN_FORM, + payload: { + formId, + }, +}); + +export const closeForm = formId => ({ + type: CLOSE_FORM, + payload: { + formId, + }, +}); + +// FORM STATE ACTIONS + +export const updateDraft = (name, value) => ({ + type: UPDATE_DRAFT, + payload: { + name, + value, + }, +}); + +export const resetDrafts = () => ({ + type: RESET_DRAFTS, +}); diff --git a/src/profile-v2/data/actions.test.js b/src/profile-v2/data/actions.test.js new file mode 100644 index 000000000..626888840 --- /dev/null +++ b/src/profile-v2/data/actions.test.js @@ -0,0 +1,202 @@ +import { + openForm, + closeForm, + OPEN_FORM, + CLOSE_FORM, + SAVE_PROFILE, + saveProfileBegin, + saveProfileSuccess, + saveProfileFailure, + saveProfileReset, + saveProfile, + SAVE_PROFILE_PHOTO, + saveProfilePhotoBegin, + saveProfilePhotoSuccess, + saveProfilePhotoFailure, + saveProfilePhotoReset, + saveProfilePhoto, + DELETE_PROFILE_PHOTO, + deleteProfilePhotoBegin, + deleteProfilePhotoSuccess, + deleteProfilePhotoReset, + deleteProfilePhoto, +} from './actions'; + +describe('editable field actions', () => { + it('should create an open action', () => { + const expectedAction = { + type: OPEN_FORM, + payload: { + formId: 'name', + }, + }; + expect(openForm('name')).toEqual(expectedAction); + }); + + it('should create a closed action', () => { + const expectedAction = { + type: CLOSE_FORM, + payload: { + formId: 'name', + }, + }; + expect(closeForm('name')).toEqual(expectedAction); + }); +}); + +describe('SAVE profile actions', () => { + it('should create an action to signal the start of a profile save', () => { + const expectedAction = { + type: SAVE_PROFILE.BASE, + payload: { + formId: 'name', + }, + }; + expect(saveProfile('name')).toEqual(expectedAction); + }); + + it('should create an action to signal user profile save success', () => { + const accountData = { name: 'Full Name' }; + const preferencesData = { visibility: { name: 'private' } }; + const expectedAction = { + type: SAVE_PROFILE.SUCCESS, + payload: { + account: accountData, + preferences: preferencesData, + }, + }; + expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction); + }); + + it('should create an action to signal user profile save beginning', () => { + const expectedAction = { + type: SAVE_PROFILE.BEGIN, + }; + expect(saveProfileBegin()).toEqual(expectedAction); + }); + + it('should create an action to signal user profile save success', () => { + const expectedAction = { + type: SAVE_PROFILE.RESET, + }; + expect(saveProfileReset()).toEqual(expectedAction); + }); + + it('should create an action to signal user account save failure', () => { + const errors = ['Test failure']; + const expectedAction = { + type: SAVE_PROFILE.FAILURE, + payload: { errors }, + }; + expect(saveProfileFailure(errors)).toEqual(expectedAction); + }); +}); + +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 success', () => { + 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 success', () => { + const expectedAction = { + type: DELETE_PROFILE_PHOTO.RESET, + }; + expect(deleteProfilePhotoReset()).toEqual(expectedAction); + }); +}); + +describe('Editable field opening and closing actions', () => { + const formId = 'name'; + + it('should create an action to signal the opening a field', () => { + const expectedAction = { + type: OPEN_FORM, + payload: { + formId, + }, + }; + expect(openForm(formId)).toEqual(expectedAction); + }); + + it('should create an action to signal the closing a field', () => { + const expectedAction = { + type: CLOSE_FORM, + payload: { + formId, + }, + }; + expect(closeForm(formId)).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..88a1bc9c0 --- /dev/null +++ b/src/profile-v2/data/reducers.js @@ -0,0 +1,162 @@ +import { + SAVE_PROFILE, + SAVE_PROFILE_PHOTO, + DELETE_PROFILE_PHOTO, + CLOSE_FORM, + OPEN_FORM, + FETCH_PROFILE, + UPDATE_DRAFT, + RESET_DRAFTS, +} from './actions'; + +export const initialState = { + errors: {}, + saveState: null, + savePhotoState: null, + currentlyEditingField: null, + account: { + socialLinks: [], + }, + preferences: {}, + courseCertificates: [], + drafts: {}, + 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.BEGIN: + return { + ...state, + saveState: 'pending', + errors: {}, + }; + case SAVE_PROFILE.SUCCESS: + return { + ...state, + saveState: 'complete', + errors: {}, + // Account is always replaced completely. + account: action.payload.account !== null ? action.payload.account : state.account, + // Preferences changes get merged in. + preferences: { ...state.preferences, ...action.payload.preferences }, + }; + case SAVE_PROFILE.FAILURE: + return { + ...state, + saveState: 'error', + errors: { ...state.errors, ...action.payload.errors }, + }; + case SAVE_PROFILE.RESET: + return { + ...state, + saveState: null, + errors: {}, + }; + + 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: {}, + }; + + case UPDATE_DRAFT: + return { + ...state, + drafts: { ...state.drafts, [action.payload.name]: action.payload.value }, + }; + + case RESET_DRAFTS: + return { + ...state, + drafts: {}, + }; + case OPEN_FORM: + return { + ...state, + currentlyEditingField: action.payload.formId, + drafts: {}, + }; + case CLOSE_FORM: + // Only close if the field to close is undefined or matches the field that is currently open + if (action.payload.formId === state.currentlyEditingField) { + return { + ...state, + currentlyEditingField: null, + drafts: {}, + }; + } + return state; + default: + return state; + } +}; + +export default profilePage; diff --git a/src/profile-v2/data/sagas.js b/src/profile-v2/data/sagas.js new file mode 100644 index 000000000..cfece88bb --- /dev/null +++ b/src/profile-v2/data/sagas.js @@ -0,0 +1,209 @@ +import { history } from '@edx/frontend-platform'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import pick from 'lodash.pick'; +import { + all, + call, + delay, + put, + select, + takeEvery, +} from 'redux-saga/effects'; +import { + closeForm, + deleteProfilePhotoBegin, + deleteProfilePhotoReset, + deleteProfilePhotoSuccess, + DELETE_PROFILE_PHOTO, + fetchProfileBegin, + fetchProfileReset, + fetchProfileSuccess, + FETCH_PROFILE, + resetDrafts, + saveProfileBegin, + saveProfileFailure, + saveProfilePhotoBegin, + saveProfilePhotoFailure, + saveProfilePhotoReset, + saveProfilePhotoSuccess, + saveProfileReset, + saveProfileSuccess, + SAVE_PROFILE, + SAVE_PROFILE_PHOTO, +} from './actions'; +import { handleSaveProfileSelector, userAccountSelector } from './selectors'; +import * as ProfileApiService from './services'; + +export function* handleFetchProfile(action) { + const { username } = action.payload; + const userAccount = yield select(userAccountSelector); + const isAuthenticatedUserProfile = username === getAuthenticatedUser().username; + // 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* handleSaveProfile(action) { + try { + const { drafts, preferences } = yield select(handleSaveProfileSelector); + + const accountDrafts = pick(drafts, [ + 'bio', + 'courseCertificates', + 'country', + 'levelOfEducation', + 'languageProficiencies', + 'name', + 'socialLinks', + ]); + + const preferencesDrafts = pick(drafts, [ + 'visibilityBio', + 'visibilityCourseCertificates', + 'visibilityCountry', + 'visibilityLevelOfEducation', + 'visibilityLanguageProficiencies', + 'visibilityName', + 'visibilitySocialLinks', + ]); + + if (Object.keys(preferencesDrafts).length > 0) { + preferencesDrafts.accountPrivacy = 'custom'; + } + + yield put(saveProfileBegin()); + let accountResult = null; + // Build the visibility drafts into a structure the API expects. + + if (Object.keys(accountDrafts).length > 0) { + accountResult = yield call( + ProfileApiService.patchProfile, + action.payload.username, + accountDrafts, + ); + } + + let preferencesResult = preferences; // assume it hasn't changed. + if (Object.keys(preferencesDrafts).length > 0) { + yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts); + // TODO: Temporary deoptimization since the patchPreferences call doesn't return anything. + // Remove this second call once we can get a result from the one above. + preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username); + } + + // The account result is returned from the server. + // The preferences draft is valid if the server didn't complain, so + // pass it through directly. + yield put(saveProfileSuccess(accountResult, preferencesResult)); + yield delay(1000); + yield put(closeForm(action.payload.formId)); + yield delay(300); + yield put(saveProfileReset()); + yield put(resetDrafts()); + } catch (e) { + if (e.processedData && e.processedData.fieldErrors) { + yield put(saveProfileFailure(e.processedData.fieldErrors)); + } else { + yield put(saveProfileReset()); + throw e; + } + } +} + +export function* handleSaveProfilePhoto(action) { + const { username, formData } = action.payload; + + try { + yield put(saveProfilePhotoBegin()); + const photoResult = yield call(ProfileApiService.postProfilePhoto, username, formData); + yield put(saveProfilePhotoSuccess(photoResult)); + yield put(saveProfilePhotoReset()); + } catch (e) { + if (e.processedData) { + yield put(saveProfilePhotoFailure(e.processedData)); + } else { + yield put(saveProfilePhotoReset()); + throw e; + } + } +} + +export function* handleDeleteProfilePhoto(action) { + const { username } = action.payload; + + try { + yield put(deleteProfilePhotoBegin()); + const photoResult = yield call(ProfileApiService.deleteProfilePhoto, username); + yield put(deleteProfilePhotoSuccess(photoResult)); + yield put(deleteProfilePhotoReset()); + } catch (e) { + yield put(deleteProfilePhotoReset()); + throw e; + } +} + +export default function* profileSaga() { + yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile); + yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile); + yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto); + yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto); +} diff --git a/src/profile-v2/data/sagas.test.js b/src/profile-v2/data/sagas.test.js new file mode 100644 index 000000000..379c0cfd6 --- /dev/null +++ b/src/profile-v2/data/sagas.test.js @@ -0,0 +1,166 @@ +import { + takeEvery, + put, + call, + delay, + select, + all, +} from 'redux-saga/effects'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import * as profileActions from './actions'; +import { handleSaveProfileSelector, userAccountSelector } from './selectors'; + +jest.mock('./services', () => ({ + getProfile: jest.fn(), + patchProfile: jest.fn(), + postProfilePhoto: jest.fn(), + deleteProfilePhoto: jest.fn(), + getPreferences: jest.fn(), + getAccount: jest.fn(), + getCourseCertificates: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + +// RootSaga and ProfileApiService must be imported AFTER the mock above. +/* eslint-disable import/first */ +import profileSaga, { + handleFetchProfile, + handleSaveProfile, + handleSaveProfilePhoto, + handleDeleteProfilePhoto, +} from './sagas'; +import * as ProfileApiService from './services'; +/* eslint-enable import/first */ + +describe('RootSaga', () => { + describe('profileSaga', () => { + it('should pass actions to the correct sagas', () => { + const gen = profileSaga(); + + expect(gen.next().value) + .toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto)); + + expect(gen.next().value).toBeUndefined(); + }); + }); + + describe('handleFetchProfile', () => { + it('should fetch certificates and preferences for the current user profile', () => { + const userAccount = { + username: 'gonzo', + other: 'data', + }; + getAuthenticatedUser.mockReturnValue(userAccount); + const selectorData = { + userAccount, + }; + + const action = profileActions.fetchProfile('gonzo'); + const gen = handleFetchProfile(action); + + const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }]; + + expect(gen.next().value).toEqual(select(userAccountSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin())); + expect(gen.next().value).toEqual(all([ + call(ProfileApiService.getAccount, 'gonzo'), + call(ProfileApiService.getCourseCertificates, 'gonzo'), + call(ProfileApiService.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('handleSaveProfile', () => { + const selectorData = { + username: 'my username', + drafts: { + name: 'Full Name', + }, + preferences: {}, + }; + + it('should successfully process a saveProfile request if there are no exceptions', () => { + const action = profileActions.saveProfile('ze form id', 'my username'); + const gen = handleSaveProfile(action); + const profile = { + name: 'Full Name', + levelOfEducation: 'b', + }; + expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', { + name: 'Full Name', + })); + // The library would supply the result of the above call + // as the parameter to the NEXT yield. Here: + expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {}))); + expect(gen.next().value).toEqual(delay(1000)); + expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id'))); + expect(gen.next().value).toEqual(delay(300)); + expect(gen.next().value).toEqual(put(profileActions.saveProfileReset())); + expect(gen.next().value).toEqual(put(profileActions.resetDrafts())); + expect(gen.next().value).toBeUndefined(); + }); + + it('should successfully publish a failure action on exception', () => { + const error = new Error('uhoh'); + error.processedData = { + fieldErrors: { + uhoh: 'not good', + }, + }; + const action = profileActions.saveProfile( + 'ze form id', + 'my username', + ); + const gen = handleSaveProfile(action); + + expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + const result = gen.throw(error); + expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' }))); + expect(gen.next().value).toBeUndefined(); + }); + }); +}); diff --git a/src/profile-v2/data/selectors.js b/src/profile-v2/data/selectors.js new file mode 100644 index 000000000..211cdf0d4 --- /dev/null +++ b/src/profile-v2/data/selectors.js @@ -0,0 +1,383 @@ +import { createSelector } from 'reselect'; +import { + getLocale, + getLanguageList, + getCountryList, + getCountryMessages, + getLanguageMessages, +} from '@edx/frontend-platform/i18n'; // eslint-disable-line + +export const formIdSelector = (state, props) => props.formId; +export const userAccountSelector = state => state.userAccount; + +export const profileAccountSelector = state => state.profilePage.account; +export const profileDraftsSelector = state => state.profilePage.drafts; +export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy; +export const profilePreferencesSelector = state => state.profilePage.preferences; +export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates; +export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts; +export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts; +export const saveStateSelector = state => state.profilePage.saveState; +export const savePhotoStateSelector = state => state.profilePage.savePhotoState; +export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile; +export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField; +export const accountErrorsSelector = state => state.profilePage.errors; +export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile; +export const disabledCountriesSelector = state => state.profilePage.disabledCountries; + +export const editableFormModeSelector = createSelector( + profileAccountSelector, + isAuthenticatedUserProfileSelector, + profileCourseCertificatesSelector, + formIdSelector, + currentlyEditingFieldSelector, + (account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => { + // If the prop doesn't exist, that means it hasn't been set (for the current user's profile) + // or is being hidden from us (for other users' profiles) + let propExists = account[formId] != null && account[formId].length > 0; + propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates + // If this isn't the current user's profile + if (!isAuthenticatedUserProfile) { + return 'static'; + } + // the current user has no age set / under 13 ... + if (account.requiresParentalConsent) { + // then there are only two options: static or nothing. + // We use 'null' as a return value because the consumers of + // getMode render nothing at all on a mode of null. + return propExists ? 'static' : null; + } + // Otherwise, if this is the current user's profile... + if (formId === currentlyEditingField) { + return 'editing'; + } + + if (!propExists) { + return 'empty'; + } + + return 'editable'; + }, +); + +export const accountDraftsFieldSelector = createSelector( + formIdSelector, + profileDraftsSelector, + (formId, drafts) => drafts[formId], +); + +export const visibilityDraftsFieldSelector = createSelector( + formIdSelector, + profileVisibilityDraftsSelector, + (formId, visibilityDrafts) => visibilityDrafts[formId], +); + +// Note: Error messages are delivered from the server +// localized according to a user's account settings +export const formErrorSelector = createSelector( + accountErrorsSelector, + formIdSelector, + (errors, formId) => (errors[formId] ? errors[formId].userMessage : null), +); + +export const editableFormSelector = createSelector( + editableFormModeSelector, + formErrorSelector, + saveStateSelector, + (editMode, error, saveState) => ({ + editMode, + error, + saveState, + }), +); + +// Because this selector has no input selectors, it will only be evaluated once. This is fine +// for now because we don't allow users to change the locale after page load. +// Once we DO allow this, we should create an actual action which dispatches the locale into redux, +// then we can modify this to get the locale from state rather than from getLocale() directly. +// Once we do that, this will work as expected and be re-evaluated when the locale changes. +export const localeSelector = () => getLocale(); +export const countryMessagesSelector = createSelector( + localeSelector, + locale => getCountryMessages(locale), +); +export const languageMessagesSelector = createSelector( + localeSelector, + locale => getLanguageMessages(locale), +); + +export const sortedLanguagesSelector = createSelector( + localeSelector, + locale => getLanguageList(locale), +); + +export const sortedCountriesSelector = createSelector( + localeSelector, + locale => getCountryList(locale), +); + +export const preferredLanguageSelector = createSelector( + editableFormSelector, + sortedLanguagesSelector, + languageMessagesSelector, + (editableForm, sortedLanguages, languageMessages) => ({ + ...editableForm, + sortedLanguages, + languageMessages, + }), +); + +export const countrySelector = createSelector( + editableFormSelector, + sortedCountriesSelector, + countryMessagesSelector, + disabledCountriesSelector, + profileAccountSelector, + (editableForm, sortedCountries, countryMessages, disabledCountries, account) => ({ + ...editableForm, + sortedCountries, + countryMessages, + disabledCountries, + committedCountry: account.country, + }), +); + +export const certificatesSelector = createSelector( + editableFormSelector, + profileCourseCertificatesSelector, + (editableForm, certificates) => ({ + ...editableForm, + certificates, + value: certificates, + }), +); + +export const profileImageSelector = createSelector( + profileAccountSelector, + account => (account.profileImage != null + ? { + src: account.profileImage.imageUrlFull, + isDefault: !account.profileImage.hasImage, + } + : {}), +); + +/** + * This is used by a saga to pull out data to process. + */ +export const handleSaveProfileSelector = createSelector( + profileDraftsSelector, + profilePreferencesSelector, + (drafts, preferences) => ({ + drafts, + preferences, + }), +); + +// Reformats the social links in a platform-keyed hash. +const socialLinksByPlatformSelector = createSelector( + profileAccountSelector, + (account) => { + const linksByPlatform = {}; + if (Array.isArray(account.socialLinks)) { + account.socialLinks.forEach((socialLink) => { + linksByPlatform[socialLink.platform] = socialLink; + }); + } + return linksByPlatform; + }, +); + +const draftSocialLinksByPlatformSelector = createSelector( + profileDraftsSelector, + (drafts) => { + const linksByPlatform = {}; + if (Array.isArray(drafts.socialLinks)) { + drafts.socialLinks.forEach((socialLink) => { + linksByPlatform[socialLink.platform] = socialLink; + }); + } + return linksByPlatform; + }, +); + +// Fleshes out our list of existing social links with all the other ones the user can set. +export const formSocialLinksSelector = createSelector( + socialLinksByPlatformSelector, + draftSocialLinksByPlatformSelector, + (linksByPlatform, draftLinksByPlatform) => { + const knownPlatforms = ['twitter', 'facebook', 'linkedin']; + const socialLinks = []; + // For each known platform + knownPlatforms.forEach((platform) => { + // If the link is in our drafts. + if (draftLinksByPlatform[platform] !== undefined) { + // Use the draft one. + socialLinks.push(draftLinksByPlatform[platform]); + } else if (linksByPlatform[platform] !== undefined) { + // Otherwise use the real one. + socialLinks.push(linksByPlatform[platform]); + } else { + // And if it's not in either, use a stub. + socialLinks.push({ + platform, + socialLink: null, + }); + } + }); + return socialLinks; + }, +); + +export const visibilitiesSelector = createSelector( + profilePreferencesSelector, + accountPrivacySelector, + (preferences, accountPrivacy) => { + switch (accountPrivacy) { + case 'custom': + return { + visibilityBio: preferences.visibilityBio || 'all_users', + visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users', + visibilityCountry: preferences.visibilityCountry || 'all_users', + visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users', + visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', + visibilityName: preferences.visibilityName || 'all_users', + visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users', + }; + case 'private': + return { + visibilityBio: 'private', + visibilityCourseCertificates: 'private', + visibilityCountry: 'private', + visibilityLevelOfEducation: 'private', + visibilityLanguageProficiencies: 'private', + visibilityName: 'private', + visibilitySocialLinks: 'private', + }; + case 'all_users': + default: + // All users is intended to fall through to default. + // If there is no value for accountPrivacy in perferences, that means it has not been + // explicitly set yet. The server assumes - today - that this means "all_users", + // so we emulate that here in the client. + return { + visibilityBio: 'all_users', + visibilityCourseCertificates: 'all_users', + visibilityCountry: 'all_users', + visibilityLevelOfEducation: 'all_users', + visibilityLanguageProficiencies: 'all_users', + visibilityName: 'all_users', + visibilitySocialLinks: 'all_users', + }; + } + }, +); + +/** + * If there's no draft present at all (undefined), use the original committed value. + */ +function chooseFormValue(draft, committed) { + return draft !== undefined ? draft : committed; +} + +export const formValuesSelector = createSelector( + profileAccountSelector, + visibilitiesSelector, + profileDraftsSelector, + profileCourseCertificatesSelector, + formSocialLinksSelector, + (account, visibilities, drafts, courseCertificates, socialLinks) => ({ + bio: chooseFormValue(drafts.bio, account.bio), + visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio), + courseCertificates, + visibilityCourseCertificates: chooseFormValue( + drafts.visibilityCourseCertificates, + visibilities.visibilityCourseCertificates, + ), + country: chooseFormValue(drafts.country, account.country), + visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry), + levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation), + visibilityLevelOfEducation: chooseFormValue( + drafts.visibilityLevelOfEducation, + visibilities.visibilityLevelOfEducation, + ), + languageProficiencies: chooseFormValue( + drafts.languageProficiencies, + account.languageProficiencies, + ), + visibilityLanguageProficiencies: chooseFormValue( + drafts.visibilityLanguageProficiencies, + visibilities.visibilityLanguageProficiencies, + ), + name: chooseFormValue(drafts.name, account.name), + visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName), + socialLinks, // Social links is calculated in its own selector, since it's complicated. + visibilitySocialLinks: chooseFormValue( + drafts.visibilitySocialLinks, + visibilities.visibilitySocialLinks, + ), + }), +); + +export const profilePageSelector = createSelector( + profileAccountSelector, + formValuesSelector, + profileImageSelector, + saveStateSelector, + savePhotoStateSelector, + isLoadingProfileSelector, + draftSocialLinksByPlatformSelector, + accountErrorsSelector, + ( + account, + formValues, + profileImage, + saveState, + savePhotoState, + isLoadingProfile, + draftSocialLinksByPlatform, + errors, + ) => ({ + // Account data we need + username: account.username, + profileImage, + requiresParentalConsent: account.requiresParentalConsent, + dateJoined: account.dateJoined, + yearOfBirth: account.yearOfBirth, + + // Bio form data + bio: formValues.bio, + visibilityBio: formValues.visibilityBio, + + // Certificates form data + courseCertificates: formValues.courseCertificates, + visibilityCourseCertificates: formValues.visibilityCourseCertificates, + + // Country form data + country: formValues.country, + visibilityCountry: formValues.visibilityCountry, + + // Education form data + levelOfEducation: formValues.levelOfEducation, + visibilityLevelOfEducation: formValues.visibilityLevelOfEducation, + + // Language proficiency form data + languageProficiencies: formValues.languageProficiencies, + visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies, + + // Name form data + name: formValues.name, + visibilityName: formValues.visibilityName, + + // Social links form data + socialLinks: formValues.socialLinks, + visibilitySocialLinks: formValues.visibilitySocialLinks, + draftSocialLinksByPlatform, + + // Other data we need + saveState, + 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..19ce9c702 --- /dev/null +++ b/src/profile-v2/forms/ProfileAvatar.jsx @@ -0,0 +1,172 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Dropdown } from '@openedx/paragon'; +import { FormattedMessage, injectIntl, intlShape } 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) { + 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) { + return ( + + ); + } + + return ( + + + {intl.formatMessage(messages['profile.profileavatar.change-button'])} + + + + + + + + + + + ); + } + + renderMenu() { + if (!this.props.isEditable) { + return null; + } + + return ( +
+ {this.renderMenuContent()} +
+ ); + } + + renderAvatar() { + const { intl } = this.props; + + return this.props.isDefault ? ( + + ) : ( + {intl.formatMessage(messages['profile.image.alt.attribute'])} + ); + } + + render() { + return ( +
+
+ {this.props.savePhotoState === 'pending' ? this.renderPending() : this.renderMenu() } + {this.renderAvatar()} +
+
+ {/* The name of this input must be 'file' */} + +
+
+ ); + } +} + +export default injectIntl(ProfileAvatar); + +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, + intl: intlShape.isRequired, +}; + +ProfileAvatar.defaultProps = { + src: null, + isDefault: true, + savePhotoState: null, + isEditable: false, +}; 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..5e366cde8 --- /dev/null +++ b/src/profile-v2/index.scss @@ -0,0 +1,226 @@ +.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: 13.5rem; + opacity: .06; + background-size: 90%; + background-repeat: no-repeat; + background-position: right top; + } + + .card-body { + position: relative; + } + } +} + +// Todo: Move the following to edx-paragon + +.btn-rounded { + border-radius: 100px; +} + +.width-75rem { + width: 75rem; +} + +.width-72rem { + width: 72rem !important; +} + +.width-19625rem { + width: 19.625rem; +} + +.height-2625rem { + height: 2.625rem; +} +.rounded-75{ + border-radius: 0.75rem; +} + +.mb-2rem { + margin-bottom: 2rem; +} + +.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; +} + +.pl-25rem { + padding-left: 2.5rem; +} + +.pr-25rem { + 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; +} + +.line-height-1575rem{ + line-height: 1.575rem; +} 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..87128dcd6 100644 --- a/src/routes/AppRoutes.jsx +++ b/src/routes/AppRoutes.jsx @@ -4,7 +4,7 @@ import { PageWrap, } from '@edx/frontend-platform/react'; import { Routes, Route } from 'react-router-dom'; -import { ProfilePage, NotFoundPage } from '../profile'; +import { ProfilePage, NotFoundPage } from '../profile-v2'; const AppRoutes = () => ( From 320852f2a85c1a02d6a309b92ad83547cf69eaa0 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Thu, 24 Oct 2024 19:10:30 +0500 Subject: [PATCH 02/12] feat: reskin of Profile MFE main page --- src/profile-v2/AgeMessage.jsx | 43 ----------------------------------- src/profile-v2/Banner.jsx | 5 ---- 2 files changed, 48 deletions(-) delete mode 100644 src/profile-v2/AgeMessage.jsx delete mode 100644 src/profile-v2/Banner.jsx diff --git a/src/profile-v2/AgeMessage.jsx b/src/profile-v2/AgeMessage.jsx deleted file mode 100644 index c7f90fba4..000000000 --- a/src/profile-v2/AgeMessage.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Alert } from '@openedx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { getConfig } from '@edx/frontend-platform'; - -const AgeMessage = ({ accountSettingsUrl }) => ( - - - - - - - - - -); - -AgeMessage.propTypes = { - accountSettingsUrl: PropTypes.string.isRequired, -}; - -export default AgeMessage; diff --git a/src/profile-v2/Banner.jsx b/src/profile-v2/Banner.jsx deleted file mode 100644 index de8ff9dd9..000000000 --- a/src/profile-v2/Banner.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from 'react'; - -const Banner = () =>
; - -export default Banner; From 7556e19f08c73356d87c30e355fddae5b6c509b7 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Wed, 30 Oct 2024 14:19:34 +0500 Subject: [PATCH 03/12] test: updated tests according to the changes --- src/profile-v2/Certificates.jsx | 1 - src/profile-v2/ProfilePage.jsx | 31 - src/profile-v2/ProfilePage.test.jsx | 156 +- src/profile-v2/UsernameDescription.jsx | 2 - .../__snapshots__/ProfilePage.test.jsx.snap | 13237 +--------------- src/profile-v2/data/actions.js | 66 - src/profile-v2/data/actions.test.js | 108 +- src/profile-v2/data/reducers.js | 63 - src/profile-v2/data/sagas.js | 90 +- src/profile-v2/data/sagas.test.js | 123 +- src/profile-v2/data/selectors.js | 333 +- src/routes/routes.test.jsx | 2 +- 12 files changed, 331 insertions(+), 13881 deletions(-) diff --git a/src/profile-v2/Certificates.jsx b/src/profile-v2/Certificates.jsx index 36a34ea63..1813f7160 100644 --- a/src/profile-v2/Certificates.jsx +++ b/src/profile-v2/Certificates.jsx @@ -22,7 +22,6 @@ const Certificates = ({ }) => { const intl = useIntl(); - // Memoizing the renderCertificate function to prevent unnecessary re-renders const renderCertificate = useCallback(({ certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId, }) => { diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx index 6e9d34318..9ca9083d3 100644 --- a/src/profile-v2/ProfilePage.jsx +++ b/src/profile-v2/ProfilePage.jsx @@ -8,12 +8,8 @@ import { injectIntl } from '@edx/frontend-platform/i18n'; import { Alert, Hyperlink } from '@openedx/paragon'; import { fetchProfile, - saveProfile, saveProfilePhoto, deleteProfilePhoto, - openForm, - closeForm, - updateDraft, } from './data/actions'; import ProfileAvatar from './forms/ProfileAvatar'; import Certificates from './Certificates'; @@ -35,7 +31,6 @@ const ProfilePage = ({ params, intl }) => { dateJoined, yearOfBirth, courseCertificates, - visibilityCourseCertificates, name, profileImage, savePhotoState, @@ -65,22 +60,6 @@ const ProfilePage = ({ params, intl }) => { dispatch(deleteProfilePhoto(context.authenticatedUser.username)); }; - const handleClose = (formId) => { - dispatch(closeForm(formId)); - }; - - const handleOpen = (formId) => { - dispatch(openForm(formId)); - }; - - const handleSubmit = (formId) => { - dispatch(saveProfile(formId, context.authenticatedUser.username)); - }; - - const handleChange = (fieldName, value) => { - dispatch(updateDraft(fieldName, value)); - }; - const isYOBDisabled = () => { const currentYear = new Date().getFullYear(); const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13); @@ -125,13 +104,6 @@ const ProfilePage = ({ params, intl }) => { return ; } - const commonFormProps = { - openHandler: handleOpen, - closeHandler: handleClose, - submitHandler: handleSubmit, - changeHandler: handleChange, - }; - return ( <>
@@ -174,9 +146,7 @@ const ProfilePage = ({ params, intl }) => { {isBlockVisible(courseCertificates.length) && ( )}
@@ -186,7 +156,6 @@ const ProfilePage = ({ params, intl }) => { return (
- {/* */} {renderContent()}
); diff --git a/src/profile-v2/ProfilePage.test.jsx b/src/profile-v2/ProfilePage.test.jsx index e347ba8c5..505b84109 100644 --- a/src/profile-v2/ProfilePage.test.jsx +++ b/src/profile-v2/ProfilePage.test.jsx @@ -18,16 +18,10 @@ const storeMocks = { loadingApp: require('./__mocks__/loadingApp.mockStore'), viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'), viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'), - savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'), }; const requiredProfilePageProps = { fetchUserAccount: () => {}, fetchProfile: () => {}, - saveProfile: () => {}, - saveProfilePhoto: () => {}, - deleteProfilePhoto: () => {}, - openField: () => {}, - closeField: () => {}, params: { username: 'staff' }, }; @@ -66,14 +60,14 @@ beforeEach(() => { }); const ProfilePageWrapper = ({ - contextValue, store, params, requiresParentalConsent, + contextValue, store, params, }) => ( - + @@ -81,14 +75,12 @@ const ProfilePageWrapper = ({ ProfilePageWrapper.defaultProps = { params: { username: 'staff' }, - requiresParentalConsent: null, }; ProfilePageWrapper.propTypes = { contextValue: PropTypes.shape({}).isRequired, store: PropTypes.shape({}).isRequired, params: PropTypes.shape({}), - requiresParentalConsent: PropTypes.bool, }; describe('', () => { @@ -147,92 +139,6 @@ describe('', () => { expect(tree).toMatchSnapshot(); }); - it('while saving an edited bio', () => { - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: getConfig(), - }; - const component = ( - - ); - const { container: tree } = render(component); - expect(tree).toMatchSnapshot(); - }); - - it('while saving an edited bio with error', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); - storeData.profilePage.errors.bio = { userMessage: 'bio error' }; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: getConfig(), - }; - const component = ( - - ); - const { container: tree } = render(component); - expect(tree).toMatchSnapshot(); - }); - - it('test country edit with error', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); - storeData.profilePage.errors.country = { userMessage: 'country error' }; - storeData.profilePage.currentlyEditingField = 'country'; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: getConfig(), - }; - const component = ( - - ); - const { container: tree } = render(component); - expect(tree).toMatchSnapshot(); - }); - - it('test education edit with error', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); - storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' }; - storeData.profilePage.currentlyEditingField = 'levelOfEducation'; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: getConfig(), - }; - const component = ( - - ); - const { container: tree } = render(component); - expect(tree).toMatchSnapshot(); - }); - - it('test preferreded language edit with error', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); - storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' }; - storeData.profilePage.currentlyEditingField = 'languageProficiencies'; - 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 = ''; @@ -250,64 +156,6 @@ describe('', () => { const { container: tree } = render(component); expect(tree).toMatchSnapshot(); }); - it('test age message alert', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile)); - storeData.userAccount.requiresParentalConsent = true; - storeData.profilePage.account.requiresParentalConsent = true; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true }, - }; - const { container } = render( - , - ); - - expect(container.querySelector('.alert-info')).toHaveClass('show'); - }); - it('test photo error alert', () => { - const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile)); - storeData.profilePage.errors.photo = { userMessage: 'error' }; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true }, - }; - const { container } = render( - , - ); - - expect(container.querySelector('.alert-danger')).toHaveClass('show'); - }); - - it.each([ - ['test user with non-disabled country', 'PK'], - ['test user with disabled country', 'RU'], - ])('test user with %s', (_, accountCountry) => { - const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio)); - storeData.profilePage.errors.country = {}; - storeData.profilePage.currentlyEditingField = 'country'; - storeData.profilePage.disabledCountries = ['RU']; - storeData.profilePage.account.country = accountCountry; - const contextValue = { - authenticatedUser: { userId: 123, username: 'staff', administrator: true }, - config: getConfig(), - }; - const component = ( - - ); - const { container: tree } = render(component); - expect(tree).toMatchSnapshot(); - }); }); describe('handles analytics', () => { diff --git a/src/profile-v2/UsernameDescription.jsx b/src/profile-v2/UsernameDescription.jsx index af03b4eae..bbbd70333 100644 --- a/src/profile-v2/UsernameDescription.jsx +++ b/src/profile-v2/UsernameDescription.jsx @@ -1,7 +1,5 @@ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { VisibilityOff } from '@openedx/paragon/icons'; -import { Icon } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; const UsernameDescription = () => ( diff --git a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap index 843a2c931..0f1236898 100644 --- a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -5,9 +5,6 @@ exports[` Renders correctly in various states app loading 1`] = `
-
Renders correctly in various states app loading 1`] = `
`; -exports[` Renders correctly in various states test country edit with error 1`] = ` +exports[` Renders correctly in various states viewing other profile with all fields 1`] = `
-
Renders correctly in various states test country edit w
-
- -
-
Renders correctly in various states test country edit w />
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-
-
-

staff

- Member since - 2017 -

-
-
-
-
-
-
-

- Full Name - -

-

+ Member since - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-
-
- - -
-
- country error -
-
-
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- Primary Language Spoken - -

-

- - + 2017 - Everyone on localhost -

+
-

- Yoruba -

-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

+ View My Records +
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
+ Your learner records information is only visible to you. Only your username is visible to others on localhost. +

+
+
+
+
+
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
+ Your certificates +
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
+ 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 test education edit with error 1`] = ` +exports[` Renders correctly in various states viewing own profile 1`] = `
-
Renders correctly in various states test education edit />
-
-
-
-
-

staff

- Member since - 2017 -

-
-
-
- -
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-

- Full Name - -

-

- - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - + 1 - Everyone on localhost -

+ certifications +
-

- Montenegro -

-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

+ View My Records +
+
+
+
+
+
+
+
+
-
-
-
-
- - -
-
- education error -
-
-
-
-
- - - - - - - -
-
- - -
-
-
-
-
+ Your certificates +
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
+ Your learner records information is only visible to you. Only your username is visible to others on localhost. +

-

+
- About Me - -

-

- -

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- -

- - - - Everyone on localhost - -

-
-
-
+ edX +

+

+ Completed on + 3/4/2019 +

+
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
+ View Certificate +
@@ -3233,25 +378,22 @@ exports[` Renders correctly in various states test education edit
`; -exports[` Renders correctly in various states test preferreded language edit with error 1`] = ` +exports[` Renders correctly in various states without credentials service 1`] = `
-
Renders correctly in various states test preferreded la />
-
-
- +
+
+
-
+
+
+
- -

- staff -

-

- Member since - 2017 -

-
-
+ Your certificates +
- - View My Records - - - - - in a new tab - - - - + Your learner records information is only visible to you. Only your username is visible to others on localhost. +

+
+
-

+
- Full Name - -

-

- +

+ edX Demonstration Course +
+

+ From +

+

+ edX +

+

+ Completed on + 3/4/2019 +

+
+
- - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - - - Everyone on localhost - -

-
-

- Montenegro -

-
-
-
-
-
-
-
- - -
-
- preferred language error -
-
-
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states test user with test user with disabled country 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-
-
- - -
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states test user with test user with non-disabled country 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-
-
- - -
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states viewing other profile with all fields 1`] = ` -
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
- - - -
- Your profile information is only visible to you. Only your username is visible to others on localhost. -
-
-
-
-
- -
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
- - - -
- Your profile information is only visible to you. Only your username is visible to others on localhost. -
-
-
-
-
- -
-
-
-

- Full Name -

-
-

- user -

-
-
-
-
-
-

- Location -

-
-

-

-
-
-
-
-

- Primary Language Spoken -

-
-

-

-
-
-
-
-

- Education -

-
-

- Other education -

-
-
-
-
-
-

- Social Links -

-
-
    -
-
-
-
-
-
-
-

- About Me -

-
-

- bio -

-
-
-
-
-
-

- My Certificates -

-
- You don't have any certificates yet. -
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states viewing own profile 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - - - Everyone on localhost - -

-
-

- Montenegro -

-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states while saving an edited bio 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - - - Everyone on localhost - -

-
-

- Montenegro -

-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-
-
- - -
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states while saving an edited bio with error 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
- -
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - - - Everyone on localhost - -

-
-

- Montenegro -

-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-
-
- - -
-
- bio error -
-
-
-
-
- - - - - - - -
-
- - -
-
-
-
-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
-
-
-
-
-
-
-
-
-
-
-`; - -exports[` Renders correctly in various states without credentials service 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
- profile avatar -
-
- -
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-
-
- -

- staff -

-

- Member since - 2017 -

-
-
-
-
-
-
-
-

- Full Name - -

-

- - - - Just me - -

-
-

- Lemon Seltzer -

- - This is the name that appears in your account and on your certificates. - -
-
-
-
-
-

- Location - -

-

- - - - Everyone on localhost - -

-
-

- Montenegro -

-
-
-
-
-
-

- Primary Language Spoken - -

-

- - - - Everyone on localhost - -

-
-

- Yoruba -

-
-
-
-
-
-

- Education - -

-

- - - - Just me - -

-
-

- Elementary/primary school -

-
-
-
-
-
-

- Social Links - -

-

- - - - Everyone on localhost - -

-
- -
-
-
-
-
-
-
-

- About Me - -

-

- - - - Everyone on localhost - -

-
-

- This is my bio -

-
-
-
-
-
-

- My Certificates - -

-

- - - - Everyone on localhost - -

-
-
-
-
-
-
-
-

- Verified Certificate -

-

- edX Demonstration Course -

-
-

- From -

-

- edX -

-
-

- Completed on - 3/4/2019 -

- -
+ View Certificate +
diff --git a/src/profile-v2/data/actions.js b/src/profile-v2/data/actions.js index 38a5b7968..ddf8c34cf 100644 --- a/src/profile-v2/data/actions.js +++ b/src/profile-v2/data/actions.js @@ -1,13 +1,8 @@ import { AsyncActionType } from '../utils'; export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE'); -export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE'); export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO'); export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO'); -export const OPEN_FORM = 'OPEN_FORM'; -export const CLOSE_FORM = 'CLOSE_FORM'; -export const UPDATE_DRAFT = 'UPDATE_DRAFT'; -export const RESET_DRAFTS = 'RESET_DRAFTS'; // FETCH PROFILE ACTIONS @@ -37,37 +32,6 @@ export const fetchProfileReset = () => ({ type: FETCH_PROFILE.RESET, }); -// SAVE PROFILE ACTIONS - -export const saveProfile = (formId, username) => ({ - type: SAVE_PROFILE.BASE, - payload: { - formId, - username, - }, -}); - -export const saveProfileBegin = () => ({ - type: SAVE_PROFILE.BEGIN, -}); - -export const saveProfileSuccess = (account, preferences) => ({ - type: SAVE_PROFILE.SUCCESS, - payload: { - account, - preferences, - }, -}); - -export const saveProfileReset = () => ({ - type: SAVE_PROFILE.RESET, -}); - -export const saveProfileFailure = errors => ({ - type: SAVE_PROFILE.FAILURE, - payload: { errors }, -}); - // SAVE PROFILE PHOTO ACTIONS export const saveProfilePhoto = (username, formData) => ({ @@ -117,33 +81,3 @@ export const deleteProfilePhotoSuccess = profileImage => ({ export const deleteProfilePhotoReset = () => ({ type: DELETE_PROFILE_PHOTO.RESET, }); - -// FIELD STATE ACTIONS - -export const openForm = formId => ({ - type: OPEN_FORM, - payload: { - formId, - }, -}); - -export const closeForm = formId => ({ - type: CLOSE_FORM, - payload: { - formId, - }, -}); - -// FORM STATE ACTIONS - -export const updateDraft = (name, value) => ({ - type: UPDATE_DRAFT, - payload: { - name, - value, - }, -}); - -export const resetDrafts = () => ({ - type: RESET_DRAFTS, -}); diff --git a/src/profile-v2/data/actions.test.js b/src/profile-v2/data/actions.test.js index 626888840..275d695ca 100644 --- a/src/profile-v2/data/actions.test.js +++ b/src/profile-v2/data/actions.test.js @@ -1,14 +1,4 @@ import { - openForm, - closeForm, - OPEN_FORM, - CLOSE_FORM, - SAVE_PROFILE, - saveProfileBegin, - saveProfileSuccess, - saveProfileFailure, - saveProfileReset, - saveProfile, SAVE_PROFILE_PHOTO, saveProfilePhotoBegin, saveProfilePhotoSuccess, @@ -22,76 +12,6 @@ import { deleteProfilePhoto, } from './actions'; -describe('editable field actions', () => { - it('should create an open action', () => { - const expectedAction = { - type: OPEN_FORM, - payload: { - formId: 'name', - }, - }; - expect(openForm('name')).toEqual(expectedAction); - }); - - it('should create a closed action', () => { - const expectedAction = { - type: CLOSE_FORM, - payload: { - formId: 'name', - }, - }; - expect(closeForm('name')).toEqual(expectedAction); - }); -}); - -describe('SAVE profile actions', () => { - it('should create an action to signal the start of a profile save', () => { - const expectedAction = { - type: SAVE_PROFILE.BASE, - payload: { - formId: 'name', - }, - }; - expect(saveProfile('name')).toEqual(expectedAction); - }); - - it('should create an action to signal user profile save success', () => { - const accountData = { name: 'Full Name' }; - const preferencesData = { visibility: { name: 'private' } }; - const expectedAction = { - type: SAVE_PROFILE.SUCCESS, - payload: { - account: accountData, - preferences: preferencesData, - }, - }; - expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction); - }); - - it('should create an action to signal user profile save beginning', () => { - const expectedAction = { - type: SAVE_PROFILE.BEGIN, - }; - expect(saveProfileBegin()).toEqual(expectedAction); - }); - - it('should create an action to signal user profile save success', () => { - const expectedAction = { - type: SAVE_PROFILE.RESET, - }; - expect(saveProfileReset()).toEqual(expectedAction); - }); - - it('should create an action to signal user account save failure', () => { - const errors = ['Test failure']; - const expectedAction = { - type: SAVE_PROFILE.FAILURE, - payload: { errors }, - }; - expect(saveProfileFailure(errors)).toEqual(expectedAction); - }); -}); - describe('SAVE profile photo actions', () => { it('should create an action to signal the start of a profile photo save', () => { const formData = 'multipart form data'; @@ -123,7 +43,7 @@ describe('SAVE profile photo actions', () => { expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction); }); - it('should create an action to signal user profile photo save success', () => { + it('should create an action to signal user profile photo save reset', () => { const expectedAction = { type: SAVE_PROFILE_PHOTO.RESET, }; @@ -169,34 +89,10 @@ describe('DELETE profile photo actions', () => { expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction); }); - it('should create an action to signal user profile photo deletion success', () => { + it('should create an action to signal user profile photo deletion reset', () => { const expectedAction = { type: DELETE_PROFILE_PHOTO.RESET, }; expect(deleteProfilePhotoReset()).toEqual(expectedAction); }); }); - -describe('Editable field opening and closing actions', () => { - const formId = 'name'; - - it('should create an action to signal the opening a field', () => { - const expectedAction = { - type: OPEN_FORM, - payload: { - formId, - }, - }; - expect(openForm(formId)).toEqual(expectedAction); - }); - - it('should create an action to signal the closing a field', () => { - const expectedAction = { - type: CLOSE_FORM, - payload: { - formId, - }, - }; - expect(closeForm(formId)).toEqual(expectedAction); - }); -}); diff --git a/src/profile-v2/data/reducers.js b/src/profile-v2/data/reducers.js index 88a1bc9c0..684fa71cb 100644 --- a/src/profile-v2/data/reducers.js +++ b/src/profile-v2/data/reducers.js @@ -1,25 +1,17 @@ import { - SAVE_PROFILE, SAVE_PROFILE_PHOTO, DELETE_PROFILE_PHOTO, - CLOSE_FORM, - OPEN_FORM, FETCH_PROFILE, - UPDATE_DRAFT, - RESET_DRAFTS, } from './actions'; export const initialState = { errors: {}, - saveState: null, savePhotoState: null, - currentlyEditingField: null, account: { socialLinks: [], }, preferences: {}, courseCertificates: [], - drafts: {}, isLoadingProfile: true, isAuthenticatedUserProfile: false, disabledCountries: ['RU'], @@ -44,34 +36,6 @@ const profilePage = (state = initialState, action = {}) => { isLoadingProfile: false, isAuthenticatedUserProfile: action.isAuthenticatedUserProfile, }; - case SAVE_PROFILE.BEGIN: - return { - ...state, - saveState: 'pending', - errors: {}, - }; - case SAVE_PROFILE.SUCCESS: - return { - ...state, - saveState: 'complete', - errors: {}, - // Account is always replaced completely. - account: action.payload.account !== null ? action.payload.account : state.account, - // Preferences changes get merged in. - preferences: { ...state.preferences, ...action.payload.preferences }, - }; - case SAVE_PROFILE.FAILURE: - return { - ...state, - saveState: 'error', - errors: { ...state.errors, ...action.payload.errors }, - }; - case SAVE_PROFILE.RESET: - return { - ...state, - saveState: null, - errors: {}, - }; case SAVE_PROFILE_PHOTO.BEGIN: return { @@ -127,33 +91,6 @@ const profilePage = (state = initialState, action = {}) => { errors: {}, }; - case UPDATE_DRAFT: - return { - ...state, - drafts: { ...state.drafts, [action.payload.name]: action.payload.value }, - }; - - case RESET_DRAFTS: - return { - ...state, - drafts: {}, - }; - case OPEN_FORM: - return { - ...state, - currentlyEditingField: action.payload.formId, - drafts: {}, - }; - case CLOSE_FORM: - // Only close if the field to close is undefined or matches the field that is currently open - if (action.payload.formId === state.currentlyEditingField) { - return { - ...state, - currentlyEditingField: null, - drafts: {}, - }; - } - return state; default: return state; } diff --git a/src/profile-v2/data/sagas.js b/src/profile-v2/data/sagas.js index cfece88bb..5e869dd48 100644 --- a/src/profile-v2/data/sagas.js +++ b/src/profile-v2/data/sagas.js @@ -1,16 +1,13 @@ import { history } from '@edx/frontend-platform'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import pick from 'lodash.pick'; import { all, call, - delay, put, select, takeEvery, } from 'redux-saga/effects'; import { - closeForm, deleteProfilePhotoBegin, deleteProfilePhotoReset, deleteProfilePhotoSuccess, @@ -19,19 +16,12 @@ import { fetchProfileReset, fetchProfileSuccess, FETCH_PROFILE, - resetDrafts, - saveProfileBegin, - saveProfileFailure, saveProfilePhotoBegin, - saveProfilePhotoFailure, saveProfilePhotoReset, saveProfilePhotoSuccess, - saveProfileReset, - saveProfileSuccess, - SAVE_PROFILE, SAVE_PROFILE_PHOTO, } from './actions'; -import { handleSaveProfileSelector, userAccountSelector } from './selectors'; +import { userAccountSelector } from './selectors'; import * as ProfileApiService from './services'; export function* handleFetchProfile(action) { @@ -102,73 +92,6 @@ export function* handleFetchProfile(action) { } } -export function* handleSaveProfile(action) { - try { - const { drafts, preferences } = yield select(handleSaveProfileSelector); - - const accountDrafts = pick(drafts, [ - 'bio', - 'courseCertificates', - 'country', - 'levelOfEducation', - 'languageProficiencies', - 'name', - 'socialLinks', - ]); - - const preferencesDrafts = pick(drafts, [ - 'visibilityBio', - 'visibilityCourseCertificates', - 'visibilityCountry', - 'visibilityLevelOfEducation', - 'visibilityLanguageProficiencies', - 'visibilityName', - 'visibilitySocialLinks', - ]); - - if (Object.keys(preferencesDrafts).length > 0) { - preferencesDrafts.accountPrivacy = 'custom'; - } - - yield put(saveProfileBegin()); - let accountResult = null; - // Build the visibility drafts into a structure the API expects. - - if (Object.keys(accountDrafts).length > 0) { - accountResult = yield call( - ProfileApiService.patchProfile, - action.payload.username, - accountDrafts, - ); - } - - let preferencesResult = preferences; // assume it hasn't changed. - if (Object.keys(preferencesDrafts).length > 0) { - yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts); - // TODO: Temporary deoptimization since the patchPreferences call doesn't return anything. - // Remove this second call once we can get a result from the one above. - preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username); - } - - // The account result is returned from the server. - // The preferences draft is valid if the server didn't complain, so - // pass it through directly. - yield put(saveProfileSuccess(accountResult, preferencesResult)); - yield delay(1000); - yield put(closeForm(action.payload.formId)); - yield delay(300); - yield put(saveProfileReset()); - yield put(resetDrafts()); - } catch (e) { - if (e.processedData && e.processedData.fieldErrors) { - yield put(saveProfileFailure(e.processedData.fieldErrors)); - } else { - yield put(saveProfileReset()); - throw e; - } - } -} - export function* handleSaveProfilePhoto(action) { const { username, formData } = action.payload; @@ -178,12 +101,8 @@ export function* handleSaveProfilePhoto(action) { yield put(saveProfilePhotoSuccess(photoResult)); yield put(saveProfilePhotoReset()); } catch (e) { - if (e.processedData) { - yield put(saveProfilePhotoFailure(e.processedData)); - } else { - yield put(saveProfilePhotoReset()); - throw e; - } + // Just reset on error, since editing functionality is deprecated + yield put(saveProfilePhotoReset()); } } @@ -196,14 +115,13 @@ export function* handleDeleteProfilePhoto(action) { yield put(deleteProfilePhotoSuccess(photoResult)); yield put(deleteProfilePhotoReset()); } catch (e) { + // Just reset on error, since editing functionality is deprecated yield put(deleteProfilePhotoReset()); - throw e; } } export default function* profileSaga() { yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile); - yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile); yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto); yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto); } diff --git a/src/profile-v2/data/sagas.test.js b/src/profile-v2/data/sagas.test.js index 379c0cfd6..9482d8907 100644 --- a/src/profile-v2/data/sagas.test.js +++ b/src/profile-v2/data/sagas.test.js @@ -2,40 +2,39 @@ import { takeEvery, put, call, - delay, select, all, } from 'redux-saga/effects'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import * as profileActions from './actions'; -import { handleSaveProfileSelector, userAccountSelector } from './selectors'; +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', () => ({ - getProfile: jest.fn(), - patchProfile: jest.fn(), - postProfilePhoto: jest.fn(), - deleteProfilePhoto: jest.fn(), - getPreferences: jest.fn(), getAccount: jest.fn(), getCourseCertificates: jest.fn(), + getPreferences: jest.fn(), + postProfilePhoto: jest.fn(), + deleteProfilePhoto: jest.fn(), })); jest.mock('@edx/frontend-platform/auth', () => ({ getAuthenticatedUser: jest.fn(), })); -// RootSaga and ProfileApiService must be imported AFTER the mock above. -/* eslint-disable import/first */ -import profileSaga, { - handleFetchProfile, - handleSaveProfile, - handleSaveProfilePhoto, - handleDeleteProfilePhoto, -} from './sagas'; -import * as ProfileApiService from './services'; -/* eslint-enable import/first */ - describe('RootSaga', () => { describe('profileSaga', () => { it('should pass actions to the correct sagas', () => { @@ -43,8 +42,6 @@ describe('RootSaga', () => { expect(gen.next().value) .toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile)); - expect(gen.next().value) - .toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile)); expect(gen.next().value) .toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto)); expect(gen.next().value) @@ -111,55 +108,51 @@ describe('RootSaga', () => { }); }); - describe('handleSaveProfile', () => { - const selectorData = { - username: 'my username', - drafts: { - name: 'Full Name', - }, - preferences: {}, - }; - - it('should successfully process a saveProfile request if there are no exceptions', () => { - const action = profileActions.saveProfile('ze form id', 'my username'); - const gen = handleSaveProfile(action); - const profile = { - name: 'Full Name', - levelOfEducation: 'b', - }; - expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); - expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); - expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', { - name: 'Full Name', - })); - // The library would supply the result of the above call - // as the parameter to the NEXT yield. Here: - expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {}))); - expect(gen.next().value).toEqual(delay(1000)); - expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id'))); - expect(gen.next().value).toEqual(delay(300)); - expect(gen.next().value).toEqual(put(profileActions.saveProfileReset())); - expect(gen.next().value).toEqual(put(profileActions.resetDrafts())); + 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(); }); + }); - it('should successfully publish a failure action on exception', () => { - const error = new Error('uhoh'); - error.processedData = { - fieldErrors: { - uhoh: 'not good', - }, - }; - const action = profileActions.saveProfile( - 'ze form id', - 'my username', - ); - const gen = handleSaveProfile(action); - - expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); - expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + 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.saveProfileFailure({ uhoh: 'not good' }))); + 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 index 211cdf0d4..5241c5acd 100644 --- a/src/profile-v2/data/selectors.js +++ b/src/profile-v2/data/selectors.js @@ -1,152 +1,16 @@ import { createSelector } from 'reselect'; -import { - getLocale, - getLanguageList, - getCountryList, - getCountryMessages, - getLanguageMessages, -} from '@edx/frontend-platform/i18n'; // eslint-disable-line -export const formIdSelector = (state, props) => props.formId; export const userAccountSelector = state => state.userAccount; export const profileAccountSelector = state => state.profilePage.account; -export const profileDraftsSelector = state => state.profilePage.drafts; -export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy; -export const profilePreferencesSelector = state => state.profilePage.preferences; export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates; -export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts; -export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts; -export const saveStateSelector = state => state.profilePage.saveState; export const savePhotoStateSelector = state => state.profilePage.savePhotoState; export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile; -export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField; export const accountErrorsSelector = state => state.profilePage.errors; -export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile; -export const disabledCountriesSelector = state => state.profilePage.disabledCountries; - -export const editableFormModeSelector = createSelector( - profileAccountSelector, - isAuthenticatedUserProfileSelector, - profileCourseCertificatesSelector, - formIdSelector, - currentlyEditingFieldSelector, - (account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => { - // If the prop doesn't exist, that means it hasn't been set (for the current user's profile) - // or is being hidden from us (for other users' profiles) - let propExists = account[formId] != null && account[formId].length > 0; - propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates - // If this isn't the current user's profile - if (!isAuthenticatedUserProfile) { - return 'static'; - } - // the current user has no age set / under 13 ... - if (account.requiresParentalConsent) { - // then there are only two options: static or nothing. - // We use 'null' as a return value because the consumers of - // getMode render nothing at all on a mode of null. - return propExists ? 'static' : null; - } - // Otherwise, if this is the current user's profile... - if (formId === currentlyEditingField) { - return 'editing'; - } - - if (!propExists) { - return 'empty'; - } - - return 'editable'; - }, -); - -export const accountDraftsFieldSelector = createSelector( - formIdSelector, - profileDraftsSelector, - (formId, drafts) => drafts[formId], -); - -export const visibilityDraftsFieldSelector = createSelector( - formIdSelector, - profileVisibilityDraftsSelector, - (formId, visibilityDrafts) => visibilityDrafts[formId], -); - -// Note: Error messages are delivered from the server -// localized according to a user's account settings -export const formErrorSelector = createSelector( - accountErrorsSelector, - formIdSelector, - (errors, formId) => (errors[formId] ? errors[formId].userMessage : null), -); - -export const editableFormSelector = createSelector( - editableFormModeSelector, - formErrorSelector, - saveStateSelector, - (editMode, error, saveState) => ({ - editMode, - error, - saveState, - }), -); - -// Because this selector has no input selectors, it will only be evaluated once. This is fine -// for now because we don't allow users to change the locale after page load. -// Once we DO allow this, we should create an actual action which dispatches the locale into redux, -// then we can modify this to get the locale from state rather than from getLocale() directly. -// Once we do that, this will work as expected and be re-evaluated when the locale changes. -export const localeSelector = () => getLocale(); -export const countryMessagesSelector = createSelector( - localeSelector, - locale => getCountryMessages(locale), -); -export const languageMessagesSelector = createSelector( - localeSelector, - locale => getLanguageMessages(locale), -); - -export const sortedLanguagesSelector = createSelector( - localeSelector, - locale => getLanguageList(locale), -); - -export const sortedCountriesSelector = createSelector( - localeSelector, - locale => getCountryList(locale), -); - -export const preferredLanguageSelector = createSelector( - editableFormSelector, - sortedLanguagesSelector, - languageMessagesSelector, - (editableForm, sortedLanguages, languageMessages) => ({ - ...editableForm, - sortedLanguages, - languageMessages, - }), -); - -export const countrySelector = createSelector( - editableFormSelector, - sortedCountriesSelector, - countryMessagesSelector, - disabledCountriesSelector, - profileAccountSelector, - (editableForm, sortedCountries, countryMessages, disabledCountries, account) => ({ - ...editableForm, - sortedCountries, - countryMessages, - disabledCountries, - committedCountry: account.country, - }), -); export const certificatesSelector = createSelector( - editableFormSelector, profileCourseCertificatesSelector, - (editableForm, certificates) => ({ - ...editableForm, + (certificates) => ({ certificates, value: certificates, }), @@ -162,180 +26,19 @@ export const profileImageSelector = createSelector( : {}), ); -/** - * This is used by a saga to pull out data to process. - */ -export const handleSaveProfileSelector = createSelector( - profileDraftsSelector, - profilePreferencesSelector, - (drafts, preferences) => ({ - drafts, - preferences, - }), -); - -// Reformats the social links in a platform-keyed hash. -const socialLinksByPlatformSelector = createSelector( - profileAccountSelector, - (account) => { - const linksByPlatform = {}; - if (Array.isArray(account.socialLinks)) { - account.socialLinks.forEach((socialLink) => { - linksByPlatform[socialLink.platform] = socialLink; - }); - } - return linksByPlatform; - }, -); - -const draftSocialLinksByPlatformSelector = createSelector( - profileDraftsSelector, - (drafts) => { - const linksByPlatform = {}; - if (Array.isArray(drafts.socialLinks)) { - drafts.socialLinks.forEach((socialLink) => { - linksByPlatform[socialLink.platform] = socialLink; - }); - } - return linksByPlatform; - }, -); - -// Fleshes out our list of existing social links with all the other ones the user can set. -export const formSocialLinksSelector = createSelector( - socialLinksByPlatformSelector, - draftSocialLinksByPlatformSelector, - (linksByPlatform, draftLinksByPlatform) => { - const knownPlatforms = ['twitter', 'facebook', 'linkedin']; - const socialLinks = []; - // For each known platform - knownPlatforms.forEach((platform) => { - // If the link is in our drafts. - if (draftLinksByPlatform[platform] !== undefined) { - // Use the draft one. - socialLinks.push(draftLinksByPlatform[platform]); - } else if (linksByPlatform[platform] !== undefined) { - // Otherwise use the real one. - socialLinks.push(linksByPlatform[platform]); - } else { - // And if it's not in either, use a stub. - socialLinks.push({ - platform, - socialLink: null, - }); - } - }); - return socialLinks; - }, -); - -export const visibilitiesSelector = createSelector( - profilePreferencesSelector, - accountPrivacySelector, - (preferences, accountPrivacy) => { - switch (accountPrivacy) { - case 'custom': - return { - visibilityBio: preferences.visibilityBio || 'all_users', - visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users', - visibilityCountry: preferences.visibilityCountry || 'all_users', - visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users', - visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', - visibilityName: preferences.visibilityName || 'all_users', - visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users', - }; - case 'private': - return { - visibilityBio: 'private', - visibilityCourseCertificates: 'private', - visibilityCountry: 'private', - visibilityLevelOfEducation: 'private', - visibilityLanguageProficiencies: 'private', - visibilityName: 'private', - visibilitySocialLinks: 'private', - }; - case 'all_users': - default: - // All users is intended to fall through to default. - // If there is no value for accountPrivacy in perferences, that means it has not been - // explicitly set yet. The server assumes - today - that this means "all_users", - // so we emulate that here in the client. - return { - visibilityBio: 'all_users', - visibilityCourseCertificates: 'all_users', - visibilityCountry: 'all_users', - visibilityLevelOfEducation: 'all_users', - visibilityLanguageProficiencies: 'all_users', - visibilityName: 'all_users', - visibilitySocialLinks: 'all_users', - }; - } - }, -); - -/** - * If there's no draft present at all (undefined), use the original committed value. - */ -function chooseFormValue(draft, committed) { - return draft !== undefined ? draft : committed; -} - -export const formValuesSelector = createSelector( - profileAccountSelector, - visibilitiesSelector, - profileDraftsSelector, - profileCourseCertificatesSelector, - formSocialLinksSelector, - (account, visibilities, drafts, courseCertificates, socialLinks) => ({ - bio: chooseFormValue(drafts.bio, account.bio), - visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio), - courseCertificates, - visibilityCourseCertificates: chooseFormValue( - drafts.visibilityCourseCertificates, - visibilities.visibilityCourseCertificates, - ), - country: chooseFormValue(drafts.country, account.country), - visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry), - levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation), - visibilityLevelOfEducation: chooseFormValue( - drafts.visibilityLevelOfEducation, - visibilities.visibilityLevelOfEducation, - ), - languageProficiencies: chooseFormValue( - drafts.languageProficiencies, - account.languageProficiencies, - ), - visibilityLanguageProficiencies: chooseFormValue( - drafts.visibilityLanguageProficiencies, - visibilities.visibilityLanguageProficiencies, - ), - name: chooseFormValue(drafts.name, account.name), - visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName), - socialLinks, // Social links is calculated in its own selector, since it's complicated. - visibilitySocialLinks: chooseFormValue( - drafts.visibilitySocialLinks, - visibilities.visibilitySocialLinks, - ), - }), -); - export const profilePageSelector = createSelector( profileAccountSelector, - formValuesSelector, + profileCourseCertificatesSelector, profileImageSelector, - saveStateSelector, savePhotoStateSelector, isLoadingProfileSelector, - draftSocialLinksByPlatformSelector, accountErrorsSelector, ( account, - formValues, + courseCertificates, profileImage, - saveState, savePhotoState, isLoadingProfile, - draftSocialLinksByPlatform, errors, ) => ({ // Account data we need @@ -345,37 +48,9 @@ export const profilePageSelector = createSelector( dateJoined: account.dateJoined, yearOfBirth: account.yearOfBirth, - // Bio form data - bio: formValues.bio, - visibilityBio: formValues.visibilityBio, - - // Certificates form data - courseCertificates: formValues.courseCertificates, - visibilityCourseCertificates: formValues.visibilityCourseCertificates, - - // Country form data - country: formValues.country, - visibilityCountry: formValues.visibilityCountry, - - // Education form data - levelOfEducation: formValues.levelOfEducation, - visibilityLevelOfEducation: formValues.visibilityLevelOfEducation, - - // Language proficiency form data - languageProficiencies: formValues.languageProficiencies, - visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies, - - // Name form data - name: formValues.name, - visibilityName: formValues.visibilityName, - - // Social links form data - socialLinks: formValues.socialLinks, - visibilitySocialLinks: formValues.visibilitySocialLinks, - draftSocialLinksByPlatform, + courseCertificates, // Other data we need - saveState, savePhotoState, isLoadingProfile, photoUploadError: errors.photo || null, diff --git a/src/routes/routes.test.jsx b/src/routes/routes.test.jsx index 9bd40dfea..5c2bf5acd 100644 --- a/src/routes/routes.test.jsx +++ b/src/routes/routes.test.jsx @@ -12,7 +12,7 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getLoginRedirectUrl: jest.fn(), })); -jest.mock('../profile', () => ({ +jest.mock('../profile-v2', () => ({ ProfilePage: () => (
Profile page
), NotFoundPage: () => (
Not found page
), })); From e9f26d062aefecd57b6b39828175ca082b2c8945 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Wed, 30 Oct 2024 14:51:16 +0500 Subject: [PATCH 04/12] fix: added missing name property --- src/profile-v2/data/selectors.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/profile-v2/data/selectors.js b/src/profile-v2/data/selectors.js index 5241c5acd..4457f8dd8 100644 --- a/src/profile-v2/data/selectors.js +++ b/src/profile-v2/data/selectors.js @@ -47,6 +47,7 @@ export const profilePageSelector = createSelector( requiresParentalConsent: account.requiresParentalConsent, dateJoined: account.dateJoined, yearOfBirth: account.yearOfBirth, + name: account.name, courseCertificates, From 06707df7624427a11a95e4e06031ded15dd4b0f8 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Wed, 30 Oct 2024 14:53:47 +0500 Subject: [PATCH 05/12] test: updated test snapshot --- .../__snapshots__/ProfilePage.test.jsx.snap | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap index 0f1236898..2796a6200 100644 --- a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -79,7 +79,9 @@ exports[` Renders correctly in various states viewing other profi

+ > + user +

@@ -228,7 +230,9 @@ exports[` Renders correctly in various states viewing own profile

+ > + Lemon Seltzer +

@@ -448,7 +452,9 @@ exports[` Renders correctly in various states without credentials

+ > + Lemon Seltzer +

From ce0d0902b3fb8b6b3bdde1a91fbb47bcf21b888c Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Sun, 3 Nov 2024 23:05:01 +0500 Subject: [PATCH 06/12] test: added tests for reducers --- src/profile-v2/Certificates.jsx | 11 +- src/profile-v2/ProfilePage.jsx | 10 +- .../__snapshots__/ProfilePage.test.jsx.snap | 32 ++-- src/profile-v2/data/reducers.test.js | 140 ++++++++++++++++++ src/profile-v2/index.scss | 2 +- 5 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 src/profile-v2/data/reducers.test.js diff --git a/src/profile-v2/Certificates.jsx b/src/profile-v2/Certificates.jsx index 1813f7160..eabf6421d 100644 --- a/src/profile-v2/Certificates.jsx +++ b/src/profile-v2/Certificates.jsx @@ -23,7 +23,7 @@ const Certificates = ({ const intl = useIntl(); const renderCertificate = useCallback(({ - certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId, + certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId, uuid, }) => { const certificateIllustration = (() => { switch (certificateType) { @@ -86,6 +86,15 @@ const Certificates = ({ {intl.formatMessage(messages['profile.certificates.view.certificate'])}
+

+ +

diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx index 9ca9083d3..4033c1266 100644 --- a/src/profile-v2/ProfilePage.jsx +++ b/src/profile-v2/ProfilePage.jsx @@ -77,7 +77,7 @@ const ProfilePage = ({ params, intl }) => { } return ( - + {intl.formatMessage(messages['profile.viewMyRecords'])} ); @@ -107,11 +107,11 @@ const ProfilePage = ({ params, intl }) => { return ( <>
-
+
-
+
{
-
+
{renderViewMyRecordsButton()}
diff --git a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap index 2796a6200..c289482b4 100644 --- a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -35,13 +35,13 @@ 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
@@ -391,13 +396,13 @@ 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
Renders correctly in various states without credentials View Certificate
+

+ Credential ID +

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/index.scss b/src/profile-v2/index.scss index 5e366cde8..40ff565c7 100644 --- a/src/profile-v2/index.scss +++ b/src/profile-v2/index.scss @@ -132,7 +132,7 @@ top: 1rem; right: 1rem; bottom: 0; - width: 13.5rem; + width: 15.15rem; opacity: .06; background-size: 90%; background-repeat: no-repeat; From f851641e288327d61e5d6dafb4603edbcf36558e Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Mon, 4 Nov 2024 14:16:38 +0500 Subject: [PATCH 07/12] feat: moved reskin logic behind env variable --- .env | 1 + .env.development | 1 + .env.test | 1 + src/data/reducers.js | 12 ++++++++---- src/data/sagas.js | 6 ++++-- src/index-v2.scss | 8 ++++++++ src/index.jsx | 7 ++++++- src/index.scss | 2 +- src/routes/AppRoutes.jsx | 24 ++++++++++++++++++++---- src/routes/routes.test.jsx | 17 +++++++++++++---- 10 files changed, 63 insertions(+), 16 deletions(-) create mode 100755 src/index-v2.scss 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 f2e15d832..c2ff27178 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -1,9 +1,13 @@ import { combineReducers } from 'redux'; -import { reducer as profilePage } from '../profile-v2'; +import { reducer as profilePage } from '../profile'; +import { reducer as NewProfilePageReducer } from '../profile-v2'; -const createRootReducer = () => combineReducers({ - profilePage, -}); +const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; + +const createRootReducer = () => + combineReducers({ + profilePage: isNewProfileEnabled ? NewProfilePageReducer : profilePage, + }); export default createRootReducer; diff --git a/src/data/sagas.js b/src/data/sagas.js index 73b3c7384..1c26efd1a 100644 --- a/src/data/sagas.js +++ b/src/data/sagas.js @@ -1,9 +1,11 @@ import { all } from 'redux-saga/effects'; +import { saga as profileSaga } from '../profile'; +import { saga as NewProfileSaga } from '../profile-v2'; -import { saga as profileSaga } from '../profile-v2'; +const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; 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..eacc1c9c7 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -22,11 +22,16 @@ 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'; +if (process.env.ENABLE_NEW_PROFILE_VIEW === 'true') { + import('./index-v2.scss'); +} else { + import('./index.scss'); +} + subscribe(APP_READY, () => { ReactDOM.render( diff --git a/src/index.scss b/src/index.scss index d54250a8c..8f2b55b9f 100755 --- a/src/index.scss +++ b/src/index.scss @@ -5,4 +5,4 @@ @import "~@edx/frontend-component-header/dist/index"; @import "~@edx/frontend-component-footer/dist/footer"; -@import './profile-v2/index'; +@import './profile/index'; diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx index 87128dcd6..fb0877cb3 100644 --- a/src/routes/AppRoutes.jsx +++ b/src/routes/AppRoutes.jsx @@ -4,13 +4,29 @@ import { PageWrap, } from '@edx/frontend-platform/react'; import { Routes, Route } from 'react-router-dom'; -import { ProfilePage, NotFoundPage } from '../profile-v2'; +import { ProfilePage, NotFoundPage } from '../profile'; +import { ProfilePage as NewProfilePage, NotFoundPage as NewNotFoundPage } from '../profile-v2'; + +const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; const AppRoutes = () => ( - } /> - } /> - } /> + + {isNewProfileEnabled ? : } + + } + /> + {isNewProfileEnabled ? : }} + /> + {isNewProfileEnabled ? : }} + /> ); diff --git a/src/routes/routes.test.jsx b/src/routes/routes.test.jsx index 5c2bf5acd..3d4a1ce78 100644 --- a/src/routes/routes.test.jsx +++ b/src/routes/routes.test.jsx @@ -12,10 +12,19 @@ jest.mock('@edx/frontend-platform/auth', () => ({ getLoginRedirectUrl: jest.fn(), })); -jest.mock('../profile-v2', () => ({ - ProfilePage: () => (
Profile page
), - NotFoundPage: () => (
Not found page
), -})); +const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; + +if (isNewProfileEnabled) { + jest.mock('../profile-v2', () => ({ + ProfilePage: () => (
New Profile page
), + NotFoundPage: () => (
New Not found page
), + })); +} else { + jest.mock('../profile', () => ({ + ProfilePage: () => (
Profile page
), + NotFoundPage: () => (
Not found page
), + })); +} const RoutesWithProvider = (context, path) => ( From 07e0ec8f6df734d840441e27cac9891b00fa27aa Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Mon, 4 Nov 2024 15:16:36 +0500 Subject: [PATCH 08/12] test: updated tests --- src/data/reducers.js | 7 +++---- src/routes/AppRoutes.jsx | 8 +++++--- src/routes/routes.test.jsx | 20 ++++++++------------ 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/data/reducers.js b/src/data/reducers.js index c2ff27178..c5b3b97f1 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -5,9 +5,8 @@ import { reducer as NewProfilePageReducer } from '../profile-v2'; const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; -const createRootReducer = () => - combineReducers({ - profilePage: isNewProfileEnabled ? NewProfilePageReducer : profilePage, - }); +const createRootReducer = () => combineReducers({ + profilePage: isNewProfileEnabled ? NewProfilePageReducer : profilePage, +}); export default createRootReducer; diff --git a/src/routes/AppRoutes.jsx b/src/routes/AppRoutes.jsx index fb0877cb3..42658b8a4 100644 --- a/src/routes/AppRoutes.jsx +++ b/src/routes/AppRoutes.jsx @@ -14,9 +14,11 @@ const AppRoutes = () => ( - {isNewProfileEnabled ? : } - + ( + + {isNewProfileEnabled ? : } + + ) } /> ({ getLoginRedirectUrl: jest.fn(), })); -const isNewProfileEnabled = process.env.ENABLE_NEW_PROFILE_VIEW === 'true'; +jest.mock('../profile', () => ({ + ProfilePage: () => (
Profile page
), + NotFoundPage: () => (
Not found page
), +})); -if (isNewProfileEnabled) { - jest.mock('../profile-v2', () => ({ - ProfilePage: () => (
New Profile page
), - NotFoundPage: () => (
New Not found page
), - })); -} else { - jest.mock('../profile', () => ({ - ProfilePage: () => (
Profile page
), - NotFoundPage: () => (
Not found page
), - })); -} +jest.mock('../profile-v2', () => ({ + ProfilePage: () => (
Profile page
), + NotFoundPage: () => (
Not found page
), +})); const RoutesWithProvider = (context, path) => ( From cbc9538edeb0842f8848ad54217de98132b37aa3 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Mon, 11 Nov 2024 14:37:20 +0500 Subject: [PATCH 09/12] refactor: refactored code according to requested changes --- src/data/reducers.js | 10 +- src/data/sagas.js | 7 +- src/index.jsx | 1 + src/profile-v2/CertificateCard.jsx | 111 ++++++++++ src/profile-v2/CertificateCount.jsx | 31 --- src/profile-v2/Certificates.jsx | 189 +++++------------- src/profile-v2/DateJoined.jsx | 8 +- src/profile-v2/PageLoading.jsx | 46 ++--- src/profile-v2/ProfilePage.jsx | 60 +++--- src/profile-v2/ProfilePage.test.jsx | 11 +- src/profile-v2/UserCertificateSummary.jsx | 22 ++ .../__snapshots__/ProfilePage.test.jsx.snap | 52 +++-- src/profile-v2/data/selectors.js | 1 - src/profile-v2/forms/ProfileAvatar.jsx | 176 ++++++++-------- src/profile-v2/index.scss | 21 +- src/routes/AppRoutes.jsx | 25 +-- 16 files changed, 370 insertions(+), 401 deletions(-) create mode 100644 src/profile-v2/CertificateCard.jsx delete mode 100644 src/profile-v2/CertificateCount.jsx create mode 100644 src/profile-v2/UserCertificateSummary.jsx 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 (
- {certificates && certificates.length > 0 ? ( + {certificates?.length > 0 ? (
{certificates.map(certificate => ( diff --git a/src/profile-v2/NotFoundPage.jsx b/src/profile-v2/NotFoundPage.jsx index 0963b153c..b33f0db11 100644 --- a/src/profile-v2/NotFoundPage.jsx +++ b/src/profile-v2/NotFoundPage.jsx @@ -3,7 +3,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; const NotFoundPage = () => (
-

+

(

-
+
{srMessage && {srMessage}}
diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx index cb2d1323b..22bcf7179 100644 --- a/src/profile-v2/ProfilePage.jsx +++ b/src/profile-v2/ProfilePage.jsx @@ -80,7 +80,12 @@ const ProfilePage = ({ params }) => { } return ( - + {intl.formatMessage(messages['profile.viewMyRecords'])} ); @@ -98,64 +103,58 @@ const ProfilePage = ({ params }) => { ) ); - const renderContent = () => { - if (isLoadingProfile) { - return ; - } - - return ( - <> -
-
-
-
- -
-

{params.username}

- {isBlockVisible(name) && ( -

{name}

- )} -
- - + return ( +
+ {isLoadingProfile ? ( + + ) : ( + <> +
+
+
+
+ +
+

{params.username}

+ {isBlockVisible(name) && ( +

{name}

+ )} +
+ + +
+
+
+ {renderViewMyRecordsButton()}
-
- {renderViewMyRecordsButton()} +
+ {isYOBDisabled() && }
-
- {isYOBDisabled() && } +
+ {renderPhotoUploadErrorMessage()}
-
- {renderPhotoUploadErrorMessage()} -
-
-
- {isBlockVisible(courseCertificates.length) && ( - - )} -
- - ); - }; - - return ( -
- {renderContent()} +
+ {isBlockVisible(courseCertificates.length) && ( + + )} +
+ + )}
); }; diff --git a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap index 29a9d33d9..d0434b33a 100644 --- a/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -7,8 +7,7 @@ exports[` Renders correctly in various states app loading 1`] = ` >
Renders correctly in various states viewing own profile
profile avatar
Renders correctly in various states without credentials
profile avatar
(
@@ -107,8 +107,7 @@ const ProfileAvatar = ({ ) : ( {intl.formatMessage(messages['profile.image.alt.attribute'])} diff --git a/src/profile-v2/index.scss b/src/profile-v2/index.scss index 9f634eb03..32762c523 100644 --- a/src/profile-v2/index.scss +++ b/src/profile-v2/index.scss @@ -144,7 +144,11 @@ // Todo: Move the following to edx-paragon .btn-rounded { - border-radius: 100px; + border-radius: 100px; +} + +.max-width-32em { + max-width: 32em; } .width-75rem { @@ -162,12 +166,17 @@ .height-2625rem { height: 2.625rem; } -.rounded-75{ - border-radius: 0.75rem; + +.height-50vh { + height: 50vh; } -.pt-4rem{ - padding-top: 4rem; +.rounded-75 { + border-radius: 0.75rem; +} + +.pt-4rem { + padding-top: 4rem; } .py-4rem { @@ -186,26 +195,34 @@ } .px-25rem { -padding-left: 2.5rem; -padding-right: 2.5rem; + padding-left: 2.5rem; + padding-right: 2.5rem; +} + +.g-15rem { + gap: 1.5rem; +} + +.g-5rem { + gap: 0.5rem; } -.g-15rem{ - gap: 1.5rem; +.g-1rem { + gap: 1rem; } -.g-5rem{ - gap: 0.5rem; +.g-3rem { + gap: 3rem; } -.g-1rem{ - gap: 1rem; +.color-black { + color: #000; } -.g-3rem{ - gap: 3rem; +.background-black-65 { + background-color: rgba(0,0,0,.65) } -.color-black{ - color: #000; +.object-fit-cover { + object-fit: cover; }