diff --git a/.env b/.env index 1feaf21df..75f3acfb8 100644 --- a/.env +++ b/.env @@ -29,3 +29,5 @@ APP_ID='' MFE_CONFIG_API_URL='' SEARCH_CATALOG_URL='' ENABLE_SKILLS_BUILDER_PROFILE='' +ENABLE_NEW_PROFILE_VIEW='' +DISABLE_VISIBILITY_EDITING='' diff --git a/.env.development b/.env.development index 5694a28d8..53836b5ae 100644 --- a/.env.development +++ b/.env.development @@ -30,3 +30,5 @@ APP_ID='' MFE_CONFIG_API_URL='' SEARCH_CATALOG_URL='http://localhost:18000/courses' ENABLE_SKILLS_BUILDER_PROFILE='' +ENABLE_NEW_PROFILE_VIEW='' +DISABLE_VISIBILITY_EDITING='' diff --git a/.env.test b/.env.test index 716cae188..7e279966e 100644 --- a/.env.test +++ b/.env.test @@ -25,3 +25,5 @@ LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990' COLLECT_YEAR_OF_BIRTH=true APP_ID='' MFE_CONFIG_API_URL='' +ENABLE_NEW_PROFILE_VIEW='' +DISABLE_VISIBILITY_EDITING='' diff --git a/package-lock.json b/package-lock.json index cb85cd3e3..821a1f7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2354,7 +2354,6 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-8.3.1.tgz", "integrity": "sha512-wDCCFtbWdxk8N/ExIGd/etyidF9YewaRdyGix2nSTujdfKZU/+2cObRxGkardGHREQDGrvqCPW5tmcSNedAIIg==", - "license": "AGPL-3.0", "dependencies": { "@cospired/i18n-iso-languages": "4.2.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -2395,7 +2394,6 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", - "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -2407,7 +2405,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2427,7 +2424,6 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -15562,9 +15558,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.18", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.18.tgz", - "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==" + "version": "2.2.19", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.19.tgz", + "integrity": "sha512-94bcyI3RsqiZufXjkr3ltkI86iEl+I7uiHVDtcq9wJUTwYQJ5odHDeSzkkrRzi80jJ8MaeZgqKjH1bAWAFw9bA==" }, "node_modules/object-assign": { "version": "4.1.1", diff --git a/src/data/reducers.js b/src/data/reducers.js index cd17a6394..98b95d263 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -1,9 +1,14 @@ import { combineReducers } from 'redux'; -import { reducer as profilePage } from '../profile'; +import { getConfig } from '@edx/frontend-platform'; + +import { reducer as profilePageReducer } from '../profile'; +import { reducer as newProfilePageReducer } from '../profile-v2'; + +const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW; const createRootReducer = () => combineReducers({ - profilePage, + profilePage: isNewProfileEnabled ? newProfilePageReducer : profilePageReducer, }); export default createRootReducer; diff --git a/src/data/sagas.js b/src/data/sagas.js index 6486c6e92..fab5ecad8 100644 --- a/src/data/sagas.js +++ b/src/data/sagas.js @@ -1,9 +1,12 @@ import { all } from 'redux-saga/effects'; - +import { getConfig } from '@edx/frontend-platform'; import { saga as profileSaga } from '../profile'; +import { saga as newProfileSaga } from '../profile-v2'; + +const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW; export default function* rootSaga() { yield all([ - profileSaga(), + isNewProfileEnabled ? newProfileSaga() : profileSaga(), ]); } diff --git a/src/index-v2.scss b/src/index-v2.scss new file mode 100755 index 000000000..d54250a8c --- /dev/null +++ b/src/index-v2.scss @@ -0,0 +1,8 @@ +@import "~@edx/brand/paragon/fonts"; +@import "~@edx/brand/paragon/variables"; +@import "~@openedx/paragon/scss/core/core"; +@import "~@edx/brand/paragon/overrides"; +@import "~@edx/frontend-component-header/dist/index"; +@import "~@edx/frontend-component-footer/dist/footer"; + +@import './profile-v2/index'; diff --git a/src/index.jsx b/src/index.jsx index d3aba8fff..c754a1e77 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -7,6 +7,7 @@ import { initialize, mergeConfig, subscribe, + getConfig, } from '@edx/frontend-platform'; import { AppProvider, @@ -22,18 +23,23 @@ import FooterSlot from '@openedx/frontend-slot-footer'; import messages from './i18n'; import configureStore from './data/configureStore'; -import './index.scss'; import Head from './head/Head'; import AppRoutes from './routes/AppRoutes'; -subscribe(APP_READY, () => { +subscribe(APP_READY, async () => { + const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW === 'true'; + if (isNewProfileEnabled) { + await import('./index-v2.scss'); + } else { + await import('./index.scss'); + } ReactDOM.render(
- +
, @@ -53,6 +59,7 @@ initialize({ mergeConfig({ COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH, ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE, + ENABLE_NEW_PROFILE_VIEW: process.env.ENABLE_NEW_PROFILE_VIEW || null, }, 'App loadConfig override handler'); }, }, diff --git a/src/pacts/frontend-app-profile-edx-platform.json b/src/pacts/frontend-app-profile-edx-platform.json index 45605ab18..12d37176a 100644 --- a/src/pacts/frontend-app-profile-edx-platform.json +++ b/src/pacts/frontend-app-profile-edx-platform.json @@ -36,7 +36,11 @@ "dateJoined": "2017-06-07T00:44:23Z", "email": "staff@example.com", "isActive": true, + "languageProficiencies": [], + "levelOfEducation": null, "name": "Lemon Seltzer", + "profileImage": {}, + "socialLinks": [], "username": "staff", "yearOfBirth": 1901 }, diff --git a/src/profile-v2/CertificateCard.jsx b/src/profile-v2/CertificateCard.jsx new file mode 100644 index 000000000..bf8f2f6ec --- /dev/null +++ b/src/profile-v2/CertificateCard.jsx @@ -0,0 +1,146 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Hyperlink } from '@openedx/paragon'; +import get from 'lodash.get'; + +import classNames from 'classnames'; +import professionalCertificateSVG from './assets/professional-certificate.svg'; +import verifiedCertificateSVG from './assets/verified-certificate.svg'; +import messages from './Certificates.messages'; +import { useIsOnMobileScreen } from './data/hooks'; + +const CertificateCard = ({ + certificateType, + courseDisplayName, + courseOrganization, + modifiedDate, + downloadUrl, + courseId, + uuid, +}) => { + const intl = useIntl(); + + const certificateIllustration = { + professional: professionalCertificateSVG, + 'no-id-professional': professionalCertificateSVG, + verified: verifiedCertificateSVG, + honor: null, + audit: null, + }[certificateType] || null; + + const isMobileView = useIsOnMobileScreen(); + + return ( +
+
+
+
+
+

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

+

+ {courseDisplayName} +

+

+ +

+
{courseOrganization}
+

+ , + }} + /> +

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

+ +

+
+
+
+ ); +}; + +CertificateCard.propTypes = { + certificateType: PropTypes.string, + courseDisplayName: PropTypes.string, + courseOrganization: PropTypes.string, + modifiedDate: PropTypes.string, + downloadUrl: PropTypes.string, + courseId: PropTypes.string.isRequired, + uuid: PropTypes.string, +}; + +CertificateCard.defaultProps = { + certificateType: 'unknown', + courseDisplayName: '', + courseOrganization: '', + modifiedDate: '', + downloadUrl: '', + uuid: '', +}; + +export default CertificateCard; diff --git a/src/profile-v2/Certificates.jsx b/src/profile-v2/Certificates.jsx new file mode 100644 index 000000000..af82a744f --- /dev/null +++ b/src/profile-v2/Certificates.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { connect } from 'react-redux'; +import { getConfig } from '@edx/frontend-platform'; + +import classNames from 'classnames'; +import CertificateCard from './CertificateCard'; +import { certificatesSelector } from './data/selectors'; +import { useIsOnTabletScreen } from './data/hooks'; + +const Certificates = ({ certificates }) => { + const isTabletView = useIsOnTabletScreen(); + return ( +
+
+
+

+ +

+
+
+

+ +

+
+
+ {certificates?.length > 0 ? ( +
+
+ {certificates.map(certificate => ( + + ))} +
+
+ ) : ( +
+ +
+ )} +
+ ); +}; + +Certificates.propTypes = { + certificates: PropTypes.arrayOf(PropTypes.shape({ + certificateType: PropTypes.string, + courseDisplayName: PropTypes.string, + courseOrganization: PropTypes.string, + modifiedDate: PropTypes.string, + downloadUrl: PropTypes.string, + courseId: PropTypes.string.isRequired, + uuid: PropTypes.string, + })), +}; + +Certificates.defaultProps = { + certificates: [], +}; + +export default connect( + certificatesSelector, + {}, +)(Certificates); diff --git a/src/profile-v2/Certificates.messages.jsx b/src/profile-v2/Certificates.messages.jsx new file mode 100644 index 000000000..17d12b847 --- /dev/null +++ b/src/profile-v2/Certificates.messages.jsx @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'profile.certificates.my.certificates': { + id: 'profile.certificates.my.certificates', + defaultMessage: 'My Certificates', + description: 'A section of a user profile', + }, + 'profile.certificates.view.certificate': { + id: 'profile.certificates.view.certificate', + defaultMessage: 'View Certificate', + description: 'A call to action to view a certificate', + }, + 'profile.certificates.types.verified': { + id: 'profile.certificates.types.verified', + defaultMessage: 'Verified Certificate', + description: 'A type of certificate a user may have earned', + }, + 'profile.certificates.types.professional': { + id: 'profile.certificates.types.professional', + defaultMessage: 'Professional Certificate', + description: 'A type of certificate a user may have earned', + }, + 'profile.certificates.types.unknown': { + id: 'profile.certificates.types.unknown', + defaultMessage: 'Certificate', + description: 'The string to display when a certificate is of an unknown type', + }, +}); + +export default messages; diff --git a/src/profile-v2/DateJoined.jsx b/src/profile-v2/DateJoined.jsx new file mode 100644 index 000000000..5b02d4bbe --- /dev/null +++ b/src/profile-v2/DateJoined.jsx @@ -0,0 +1,29 @@ +import React, { memo } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n'; + +const DateJoined = ({ date }) => { + if (!date) { return null; } + + return ( + + , + }} + /> + + ); +}; + +DateJoined.propTypes = { + date: PropTypes.string, +}; +DateJoined.defaultProps = { + date: null, +}; + +export default memo(DateJoined); diff --git a/src/profile-v2/NotFoundPage.jsx b/src/profile-v2/NotFoundPage.jsx new file mode 100644 index 000000000..b33f0db11 --- /dev/null +++ b/src/profile-v2/NotFoundPage.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const NotFoundPage = () => ( +
+

+ +

+
+); + +export default NotFoundPage; diff --git a/src/profile-v2/NotFoundPage.test.jsx b/src/profile-v2/NotFoundPage.test.jsx new file mode 100644 index 000000000..f14021daa --- /dev/null +++ b/src/profile-v2/NotFoundPage.test.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import NotFoundPage from './NotFoundPage'; + +describe('NotFoundPage Snapshot Tests', () => { + it('renders correctly', () => { + const { asFragment } = render( + + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders with custom props', () => { + const { asFragment } = render( + + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/profile-v2/PageLoading.jsx b/src/profile-v2/PageLoading.jsx new file mode 100644 index 000000000..a730b39f3 --- /dev/null +++ b/src/profile-v2/PageLoading.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const PageLoading = ({ srMessage }) => ( +
+
+
+ {srMessage && {srMessage}} +
+
+
+); + +PageLoading.propTypes = { + srMessage: PropTypes.string.isRequired, +}; + +export default PageLoading; diff --git a/src/profile-v2/ProfilePage.jsx b/src/profile-v2/ProfilePage.jsx new file mode 100644 index 000000000..2411755c4 --- /dev/null +++ b/src/profile-v2/ProfilePage.jsx @@ -0,0 +1,463 @@ +import React, { + useEffect, useState, useContext, useCallback, +} from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; +import { ensureConfig } from '@edx/frontend-platform'; +import { AppContext } from '@edx/frontend-platform/react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, Hyperlink, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; +import { InfoOutline } from '@openedx/paragon/icons'; +import classNames from 'classnames'; + +import { + fetchProfile, + saveProfile, + saveProfilePhoto, + deleteProfilePhoto, + openForm, + closeForm, + updateDraft, +} from './data/actions'; + +import ProfileAvatar from './forms/ProfileAvatar'; +import Name from './forms/Name'; +import Country from './forms/Country'; +import PreferredLanguage from './forms/PreferredLanguage'; +import Education from './forms/Education'; +import SocialLinks from './forms/SocialLinks'; +import Bio from './forms/Bio'; +import DateJoined from './DateJoined'; +import UserCertificateSummary from './UserCertificateSummary'; +import PageLoading from './PageLoading'; +import Certificates from './Certificates'; + +import { profilePageSelector } from './data/selectors'; +import messages from './ProfilePage.messages'; +import withParams from '../utils/hoc'; +import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks'; + +ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage'); + +const ProfilePage = ({ params }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const context = useContext(AppContext); + const { + dateJoined, + courseCertificates, + name, + visibilityName, + profileImage, + savePhotoState, + isLoadingProfile, + photoUploadError, + country, + visibilityCountry, + levelOfEducation, + visibilityLevelOfEducation, + socialLinks, + draftSocialLinksByPlatform, + visibilitySocialLinks, + languageProficiencies, + visibilityLanguageProficiencies, + bio, + visibilityBio, + saveState, + username, + } = useSelector(profilePageSelector); + + const navigate = useNavigate(); + const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null); + const isMobileView = useIsOnMobileScreen(); + const isTabletView = useIsOnTabletScreen(); + + useEffect(() => { + const { CREDENTIALS_BASE_URL } = context.config; + if (CREDENTIALS_BASE_URL) { + setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`); + } + + dispatch(fetchProfile(params.username)); + sendTrackingLogEvent('edx.profile.viewed', { + username: params.username, + }); + }, [dispatch, params.username, context.config]); + + useEffect(() => { + if (!username && saveState === 'error' && navigate) { + navigate('/notfound'); + } + }, [username, saveState, navigate]); + + const authenticatedUserName = context.authenticatedUser.username; + + const handleSaveProfilePhoto = useCallback((formData) => { + dispatch(saveProfilePhoto(authenticatedUserName, formData)); + }, [dispatch, authenticatedUserName]); + + const handleDeleteProfilePhoto = useCallback(() => { + dispatch(deleteProfilePhoto(authenticatedUserName)); + }, [dispatch, authenticatedUserName]); + + const handleClose = useCallback((formId) => { + dispatch(closeForm(formId)); + }, [dispatch]); + + const handleOpen = useCallback((formId) => { + dispatch(openForm(formId)); + }, [dispatch]); + + const handleSubmit = useCallback((formId) => { + dispatch(saveProfile(formId, authenticatedUserName)); + }, [dispatch, authenticatedUserName]); + + const handleChange = useCallback((fieldName, value) => { + dispatch(updateDraft(fieldName, value)); + }, [dispatch]); + + const isAuthenticatedUserProfile = () => params.username === authenticatedUserName; + + const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile() + || (!isAuthenticatedUserProfile() && Boolean(blockInfo)); + + const renderViewMyRecordsButton = () => { + if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) { + return null; + } + + return ( + + {intl.formatMessage(messages['profile.viewMyRecords'])} + + ); + }; + + const renderPhotoUploadErrorMessage = () => ( + photoUploadError && ( +
+
+ + {photoUploadError.userMessage} + +
+
+ ) + ); + + const commonFormProps = { + openHandler: handleOpen, + closeHandler: handleClose, + submitHandler: handleSubmit, + changeHandler: handleChange, + }; + + return ( +
+ {isLoadingProfile ? ( + + ) : ( + <> +
+
+
+
+ +
+

+ {params.username} +

+ {isBlockVisible(name) && ( +

+ {name} +

+ )} +
+ + +
+
+
+ {renderViewMyRecordsButton()} +
+
+
+
+ {renderPhotoUploadErrorMessage()} +
+
+
+
+
+
+
+

+ {isMobileView ? ( + + ) + : ( + + )} +

+
+
+
+
+
+
+

+ {intl.formatMessage(messages['profile.username'])} +

+ +

+ {intl.formatMessage(messages['profile.username.tooltip'])} +

+ + )} + > + +
+
+

+ {params.username} +

+
+ {isBlockVisible(name) && ( + + )} + {isBlockVisible(country) && ( + + )} + {isBlockVisible((languageProficiencies || []).length) && ( + + )} + {isBlockVisible(levelOfEducation) && ( + + )} +
+
+ {isBlockVisible(bio) && ( + + )} + {isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && ( + + )} +
+
+
+
+
+ {isBlockVisible((courseCertificates || []).length) && ( + + )} +
+ + )} +
+ ); +}; + +ProfilePage.propTypes = { + params: PropTypes.shape({ + username: PropTypes.string.isRequired, + }).isRequired, + requiresParentalConsent: PropTypes.bool, + dateJoined: PropTypes.string, + username: PropTypes.string, + bio: PropTypes.string, + visibilityBio: PropTypes.string, + courseCertificates: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string, + })), + country: PropTypes.string, + visibilityCountry: PropTypes.string, + levelOfEducation: PropTypes.string, + visibilityLevelOfEducation: PropTypes.string, + languageProficiencies: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + })), + visibilityLanguageProficiencies: PropTypes.string, + name: PropTypes.string, + visibilityName: PropTypes.string, + socialLinks: PropTypes.arrayOf(PropTypes.shape({ + platform: PropTypes.string, + socialLink: PropTypes.string, + })), + draftSocialLinksByPlatform: PropTypes.objectOf(PropTypes.shape({ + platform: PropTypes.string, + socialLink: PropTypes.string, + })), + visibilitySocialLinks: PropTypes.string, + profileImage: PropTypes.shape({ + src: PropTypes.string, + isDefault: PropTypes.bool, + }), + saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']), + savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']), + isLoadingProfile: PropTypes.bool, + photoUploadError: PropTypes.objectOf(PropTypes.string), +}; + +ProfilePage.defaultProps = { + saveState: null, + username: '', + savePhotoState: null, + photoUploadError: {}, + profileImage: {}, + name: null, + levelOfEducation: null, + country: null, + socialLinks: [], + draftSocialLinksByPlatform: {}, + bio: null, + languageProficiencies: [], + courseCertificates: [], + requiresParentalConsent: null, + dateJoined: null, + visibilityName: null, + visibilityCountry: null, + visibilityLevelOfEducation: null, + visibilitySocialLinks: null, + visibilityLanguageProficiencies: null, + visibilityBio: null, + isLoadingProfile: false, +}; + +export default withParams(ProfilePage); diff --git a/src/profile-v2/ProfilePage.messages.jsx b/src/profile-v2/ProfilePage.messages.jsx new file mode 100644 index 000000000..c7cec79d6 --- /dev/null +++ b/src/profile-v2/ProfilePage.messages.jsx @@ -0,0 +1,26 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'profile.viewMyRecords': { + id: 'profile.viewMyRecords', + defaultMessage: 'View My Records', + description: 'A link to go view my academic records', + }, + 'profile.loading': { + id: 'profile.loading', + defaultMessage: 'Profile loading...', + description: 'Message displayed when the profile data is loading.', + }, + 'profile.username': { + id: 'profile.username', + defaultMessage: 'Username', + description: 'Label for the username field.', + }, + 'profile.username.tooltip': { + id: 'profile.username.tooltip', + defaultMessage: 'The name that identifies you on edX. You cannot change your username.', + description: 'Tooltip for the username field.', + }, +}); + +export default messages; diff --git a/src/profile-v2/ProfilePage.test.jsx b/src/profile-v2/ProfilePage.test.jsx new file mode 100644 index 000000000..43741109d --- /dev/null +++ b/src/profile-v2/ProfilePage.test.jsx @@ -0,0 +1,516 @@ +import { getConfig } from '@edx/frontend-platform'; +import * as analytics from '@edx/frontend-platform/analytics'; +import { AppContext } from '@edx/frontend-platform/react'; +import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n'; +import { render } from '@testing-library/react'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { Provider } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { + MemoryRouter, + Routes, + Route, + useNavigate, +} from 'react-router-dom'; + +import messages from '../i18n'; +import ProfilePage from './ProfilePage'; +import loadingApp from './__mocks__/loadingApp.mockStore'; +import viewOwnProfile from './__mocks__/viewOwnProfile.mockStore'; +import viewOtherProfile from './__mocks__/viewOtherProfile.mockStore'; +import invalidUser from './__mocks__/invalidUser.mockStore'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), +})); + +const mockStore = configureMockStore([thunk]); + +const storeMocks = { + loadingApp, + viewOwnProfile, + viewOtherProfile, + invalidUser, +}; + +const requiredProfilePageProps = { + params: { username: 'staff' }, +}; + +Object.defineProperty(global.document, 'cookie', { + writable: true, + value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`, +}); + +jest.mock('@edx/frontend-platform/auth', () => ({ + configure: () => {}, + getAuthenticatedUser: () => null, + fetchAuthenticatedUser: () => null, + getAuthenticatedHttpClient: jest.fn(), + AUTHENTICATED_USER_CHANGED: 'user_changed', +})); + +jest.mock('@edx/frontend-platform/analytics', () => ({ + configure: () => {}, + identifyAnonymousUser: jest.fn(), + identifyAuthenticatedUser: jest.fn(), + sendTrackingLogEvent: jest.fn(), +})); + +configureI18n({ + loggingService: { logError: jest.fn() }, + config: { + ENVIRONMENT: 'production', + LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum', + }, + messages, +}); + +beforeEach(() => { + analytics.sendTrackingLogEvent.mockReset(); + useNavigate.mockReset(); +}); + +const ProfilePageWrapper = ({ + contextValue, store, params, +}) => ( + + + + + + } + /> + + + + + +); + +ProfilePageWrapper.defaultProps = { + // eslint-disable-next-line react/default-props-match-prop-types + params: { username: 'staff' }, +}; + +ProfilePageWrapper.propTypes = { + contextValue: PropTypes.shape({}).isRequired, + store: PropTypes.shape({}).isRequired, + params: PropTypes.shape({ + username: PropTypes.string.isRequired, + }).isRequired, +}; + +describe('', () => { + describe('Renders correctly in various states', () => { + it('app loading', () => { + const contextValue = { + authenticatedUser: { userId: null, username: null, administrator: false }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('viewing own profile', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('viewing other profile with all fields', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('without credentials service', () => { + const config = getConfig(); + config.CREDENTIALS_BASE_URL = ''; + + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + + it('successfully redirected to not found page', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + expect(navigate).toHaveBeenCalledWith('/notfound'); + }); + }); + + describe('handles analytics', () => { + it('calls sendTrackingLogEvent when mounting', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + render( + , + ); + + expect(analytics.sendTrackingLogEvent).toHaveBeenCalledTimes(1); + expect(analytics.sendTrackingLogEvent).toHaveBeenCalledWith('edx.profile.viewed', { + username: 'test-username', + }); + }); + }); + + describe('handles navigation', () => { + it('navigates to notfound on save error with no username', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + render( + , + ); + + expect(navigate).toHaveBeenCalledWith('/notfound'); + }); + }); + + describe('form fields', () => { + it('renders all form fields for own profile', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const { getByText } = render( + , + ); + + expect(getByText('Full name')).toBeInTheDocument(); + expect(getByText('Country')).toBeInTheDocument(); + expect(getByText('Bio')).toBeInTheDocument(); + expect(getByText('Education')).toBeInTheDocument(); + expect(getByText('Primary language spoken')).toBeInTheDocument(); + }); + }); + + describe('handles invalid user', () => { + it('navigates to not found page for invalid user', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const navigate = jest.fn(); + useNavigate.mockReturnValue(navigate); + render( + , + ); + + expect(navigate).toHaveBeenCalledWith('/notfound'); + }); + }); + + describe('handles empty profile', () => { + it('renders empty profile state', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only username', () => { + it('renders profile with only username', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with no social links', () => { + it('renders profile without social links', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only social links', () => { + it('renders profile with only social links', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only bio', () => { + it('renders profile with only bio', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only country', () => { + it('renders profile with only country', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only level of education', () => { + it('renders profile with only level of education', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only language proficiencies', () => { + it('renders profile with only language proficiencies', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only course certificates', () => { + it('renders profile with only course certificates', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only name', () => { + it('renders profile with only name', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with only username and no other fields', () => { + it('renders profile with only username', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); + + describe('handles profile with no fields and no username', () => { + it('renders empty profile state', () => { + const contextValue = { + authenticatedUser: { userId: 123, username: 'staff', administrator: true }, + config: getConfig(), + }; + const component = ( + + ); + const { container: tree } = render(component); + expect(tree).toMatchSnapshot(); + }); + }); +}); diff --git a/src/profile-v2/UserCertificateSummary.jsx b/src/profile-v2/UserCertificateSummary.jsx new file mode 100644 index 000000000..123ed8eca --- /dev/null +++ b/src/profile-v2/UserCertificateSummary.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const UserCertificateSummary = ({ count = 0 }) => { + if (count) { + return ( + + {count}, + }} + /> + + ); + } + return null; +}; + +UserCertificateSummary.propTypes = { + count: PropTypes.number, +}; + +export default UserCertificateSummary; diff --git a/src/profile-v2/__mocks__/invalidUser.mockStore.js b/src/profile-v2/__mocks__/invalidUser.mockStore.js new file mode 100644 index 000000000..253ef876d --- /dev/null +++ b/src/profile-v2/__mocks__/invalidUser.mockStore.js @@ -0,0 +1,42 @@ +module.exports = { + userAccount: { + loading: false, + error: null, + username: 'staff', + email: null, + bio: null, + name: null, + country: null, + socialLinks: null, + profileImage: { + imageUrlMedium: null, + imageUrlLarge: null + }, + levelOfEducation: null, + learningGoal: null + }, + profilePage: { + errors: {}, + saveState: 'error', + savePhotoState: null, + currentlyEditingField: null, + account: { + username: '', + socialLinks: [] + }, + preferences: {}, + courseCertificates: [], + drafts: {}, + isLoadingProfile: false, + isAuthenticatedUserProfile: true, + countriesCodesList: ['US', 'CA', 'GB', 'ME'] + }, + router: { + location: { + pathname: '/u/staffTest', + search: '', + hash: '' + }, + action: 'POP' + } +}; diff --git a/src/profile-v2/__mocks__/loadingApp.mockStore.js b/src/profile-v2/__mocks__/loadingApp.mockStore.js new file mode 100644 index 000000000..aaf1f6323 --- /dev/null +++ b/src/profile-v2/__mocks__/loadingApp.mockStore.js @@ -0,0 +1,42 @@ +module.exports = { + userAccount: { + loading: false, + error: null, + username: 'staff', + email: null, + bio: null, + name: null, + country: null, + socialLinks: null, + profileImage: { + imageUrlMedium: null, + imageUrlLarge: null + }, + levelOfEducation: null, + learningGoal: null + }, + profilePage: { + errors: {}, + saveState: null, + savePhotoState: null, + currentlyEditingField: null, + account: { + username: 'staff', + socialLinks: [] + }, + preferences: {}, + courseCertificates: [], + drafts: {}, + isLoadingProfile: true, + isAuthenticatedUserProfile: true, + countriesCodesList: ['US', 'CA', 'GB', 'ME'] + }, + router: { + location: { + pathname: '/u/staff', + search: '', + hash: '' + }, + action: 'POP' + } +}; 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..7afdac182 --- /dev/null +++ b/src/profile-v2/__mocks__/viewOtherProfile.mockStore.js @@ -0,0 +1,105 @@ +module.exports = { + userAccount: { + loading: false, + error: null, + username: 'staff', + email: 'staff@example.com', + bio: 'This is my bio', + name: 'Lemon Seltzer', + country: 'ME', + socialLinks: [ + { + platform: 'facebook', + socialLink: 'https://www.facebook.com/aloha' + }, + { + platform: 'twitter', + socialLink: 'https://www.twitter.com/ALOHA' + } + ], + profileImage: { + imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012', + imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012', + imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012', + imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012', + hasImage: true + }, + levelOfEducation: 'el', + mailingAddress: null, + extendedProfile: [], + dateJoined: '2017-06-07T00:44:23Z', + accomplishmentsShared: false, + isActive: true, + yearOfBirth: 1901, + goals: null, + languageProficiencies: [ + { + code: 'yo' + } + ], + courseCertificates: null, + requiresParentalConsent: false, + secondaryEmail: null, + timeZone: null, + gender: null, + accountPrivacy: 'custom', + learningGoal: 'advance_career', + }, + profilePage: { + errors: {}, + saveState: null, + savePhotoState: null, + currentlyEditingField: null, + isAuthenticatedUserProfile: false, + account: { + mailingAddress: null, + profileImage: { + imageUrlFull: 'http://localhost:18000/static/images/profiles/default_500.png', + imageUrlLarge: 'http://localhost:18000/static/images/profiles/default_120.png', + imageUrlMedium: 'http://localhost:18000/static/images/profiles/default_50.png', + imageUrlSmall: 'http://localhost:18000/static/images/profiles/default_30.png', + hasImage: false + }, + extendedProfile: [], + dateJoined: '2017-06-07T00:44:19Z', + accomplishmentsShared: false, + email: 'verified@example.com', + username: 'verified', + bio: null, + isActive: true, + yearOfBirth: null, + goals: null, + languageProficiencies: [], + courseCertificates: null, + requiresParentalConsent: true, + name: '', + secondaryEmail: null, + country: null, + socialLinks: [], + timeZone: null, + levelOfEducation: null, + gender: null, + accountPrivacy: 'private' + }, + preferences: { + visibilityName: 'all_users', + visibilityCountry: 'all_users', + visibilityLevelOfEducation: 'all_users', + visibilityLanguageProficiencies: 'all_users', + visibilitySocialLinks: 'all_users', + visibilityBio: 'all_users' + }, + courseCertificates: [], + drafts: {}, + isLoadingProfile: false, + countriesCodesList: ['US', 'CA', 'GB', 'ME'] + }, + router: { + location: { + pathname: '/u/verified', + search: '', + hash: '' + }, + action: 'POP' + } +}; diff --git a/src/profile-v2/__mocks__/viewOwnProfile.mockStore.js b/src/profile-v2/__mocks__/viewOwnProfile.mockStore.js new file mode 100644 index 000000000..4fb251090 --- /dev/null +++ b/src/profile-v2/__mocks__/viewOwnProfile.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: 'advance_career' + }, + profilePage: { + errors: {}, + saveState: null, + savePhotoState: null, + currentlyEditingField: null, + isAuthenticatedUserProfile: true, + account: { + mailingAddress: null, + profileImage: { + imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012', + imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012', + imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012', + imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012', + hasImage: true + }, + extendedProfile: [], + dateJoined: '2017-06-07T00:44:23Z', + accomplishmentsShared: false, + email: 'staff@example.com', + username: 'staff', + bio: 'This is my bio', + isActive: true, + yearOfBirth: 1901, + goals: null, + languageProficiencies: [ + { + code: 'yo' + } + ], + courseCertificates: null, + requiresParentalConsent: false, + name: 'Lemon Seltzer', + secondaryEmail: null, + country: 'ME', + socialLinks: [ + { + platform: 'facebook', + socialLink: 'https://www.facebook.com/aloha' + }, + { + platform: 'twitter', + socialLink: 'https://www.twitter.com/ALOHA' + } + ], + timeZone: null, + levelOfEducation: 'el', + gender: null, + accountPrivacy: 'custom', + learningGoal: 'advance_career' + }, + preferences: { + visibilityUserLocation: 'all_users', + visibilitySocialLinks: 'all_users', + visibilityCertificates: 'private', + visibilityLevelOfEducation: 'private', + visibilityCourseCertificates: 'all_users', + prefLang: 'en', + visibilityBio: 'all_users', + visibilityName: 'private', + visibilityLanguageProficiencies: 'all_users', + visibilityCountry: 'all_users', + accountPrivacy: 'custom', + visibilityLearningGoal: 'private', + }, + courseCertificates: [ + { + username: 'staff', + status: 'downloadable', + courseDisplayName: 'edX Demonstration Course', + grade: '0.89', + courseId: 'course-v1:edX+DemoX+Demo_Course', + courseOrganization: 'edX', + modifiedDate: '2019-03-04T19:31:39.930255Z', + isPassing: true, + downloadUrl: 'http://www.example.com/', + certificateType: 'verified', + createdDate: '2019-03-04T19:31:39.896806Z' + } + ], + drafts: {}, + isLoadingProfile: false, + countriesCodesList: ['US', 'CA', 'GB', 'ME'] + }, + router: { + location: { + pathname: '/u/staff', + search: '', + hash: '' + }, + action: 'POP' + } +}; diff --git a/src/profile-v2/__snapshots__/NotFoundPage.test.jsx.snap b/src/profile-v2/__snapshots__/NotFoundPage.test.jsx.snap new file mode 100644 index 000000000..60bc1d4b2 --- /dev/null +++ b/src/profile-v2/__snapshots__/NotFoundPage.test.jsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NotFoundPage Snapshot Tests renders correctly 1`] = ` + +
+

+ The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again. +

+
+
+`; + +exports[`NotFoundPage Snapshot Tests renders with custom props 1`] = ` + +
+

+ The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again. +

+
+
+`; 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..377fb6319 --- /dev/null +++ b/src/profile-v2/__snapshots__/ProfilePage.test.jsx.snap @@ -0,0 +1,3671 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Renders correctly in various states app loading 1`] = ` +
+
+
+
+
+ + Profile loading... + +
+
+
+
+
+`; + +exports[` Renders correctly in various states successfully redirected to not found page 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ staffTest +

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

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ staffTest +

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

+ verified +

+

+ Verified User +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ verified +

+
+
+
+
+

+ Full name +

+ + + +
+
+
+

+ Verified User +

+
+
+
+
+
+
+
+
+

+ Country +

+
+
+

+ United States of America +

+
+
+
+
+
+
+
+
+

+ Primary language spoken +

+
+
+

+ English +

+
+
+
+
+
+
+
+
+

+ Education +

+
+
+

+ Other education +

+
+
+
+
+
+
+
+
+
+
+

+ Bio +

+
+
+

+ About me +

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

+ X +

+
+
+

+ https://twitter.com/user +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` Renders correctly in various states viewing own profile 1`] = ` +
+
+
+
+
+
+
+
+ profile avatar +
+
+ +
+
+ +
+
+
+

+ staff +

+

+ Lemon Seltzer +

+
+ + Member since + + + 2017 + + + + + + 1 + + certifications + +
+
+ +
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ staff +

+
+
+
+
+

+ Full name +

+ + + +
+
+
+

+ Lemon Seltzer +

+
+
+ +
+
+
+
+
+
+
+

+ Country +

+
+
+

+ Montenegro +

+
+
+ +
+
+
+
+
+
+
+

+ Primary language spoken +

+
+
+

+ Yoruba +

+
+
+ +
+
+
+
+
+
+
+

+ Education +

+
+
+

+ Elementary/primary school +

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

+ Bio +

+
+
+

+ This is my bio +

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

+ X +

+
+
+
+

+ https://www.twitter.com/ALOHA +

+
+
+ +
+
+
+
+
+
+

+ Facebook +

+
+
+
+

+ https://www.facebook.com/aloha +

+
+
+ +
+
+
+
+
+
+

+ LinkedIn +

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

+ Your certificates +

+
+
+

+ Your learner records information is only visible to you. Only your username and profile image are visible to others on localhost. +

+
+
+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+

+ From +

+
+ edX +
+

+ Completed on + 3/4/2019 +

+
+ +

+ Credential ID +

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

+ staff +

+

+ Lemon Seltzer +

+
+ + Member since + + + 2017 + + + + + + 1 + + certifications + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ staff +

+
+
+
+
+

+ Full name +

+ + + +
+
+
+

+ Lemon Seltzer +

+
+
+ +
+
+
+
+
+
+
+

+ Country +

+
+
+

+ Montenegro +

+
+
+ +
+
+
+
+
+
+
+

+ Primary language spoken +

+
+
+

+ Yoruba +

+
+
+ +
+
+
+
+
+
+
+

+ Education +

+
+
+

+ Elementary/primary school +

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

+ Bio +

+
+
+

+ This is my bio +

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

+ X +

+
+
+
+

+ https://www.twitter.com/ALOHA +

+
+
+ +
+
+
+
+
+
+

+ Facebook +

+
+
+
+

+ https://www.facebook.com/aloha +

+
+
+ +
+
+
+
+
+
+

+ LinkedIn +

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

+ Your certificates +

+
+
+

+ Your learner records information is only visible to you. Only your username and profile image are visible to others on localhost. +

+
+
+
+
+
+
+
+
+
+

+ Verified Certificate +

+

+ edX Demonstration Course +

+

+ From +

+
+ edX +
+

+ Completed on + 3/4/2019 +

+
+ +

+ Credential ID +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles empty profile renders empty profile state 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ empty +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ empty +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with no fields and no username renders empty profile state 1`] = `
`; + +exports[` handles profile with no social links renders profile without social links 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ noSocialLinks +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ noSocialLinks +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only bio renders profile with only bio 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyBio +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyBio +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only country renders profile with only country 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyCountry +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyCountry +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only course certificates renders profile with only course certificates 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyCourseCertificates +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyCourseCertificates +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only language proficiencies renders profile with only language proficiencies 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyLanguageProficiencies +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyLanguageProficiencies +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only level of education renders profile with only level of education 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyLevelOfEducation +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyLevelOfEducation +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only name renders profile with only name 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyName +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyName +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only social links renders profile with only social links 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlySocialLinks +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlySocialLinks +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only username and no other fields renders profile with only username 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyUsernameNoFields +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyUsernameNoFields +

+
+
+
+
+
+
+
+
+
+`; + +exports[` handles profile with only username renders profile with only username 1`] = ` +
+
+
+
+
+
+
+
+ +
+ +
+
+
+

+ onlyUsername +

+
+ + Member since + + + 2017 + + + +
+
+
+
+
+
+
+
+
+
+
+
+

+ Profile information +

+
+
+
+
+
+
+

+ Username +

+ + + +
+

+ onlyUsername +

+
+
+
+
+
+
+
+
+
+`; 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 000000000..c84a3c52a Binary files /dev/null and b/src/profile-v2/assets/dot-pattern-light.png differ diff --git a/src/profile-v2/assets/micro-masters.svg b/src/profile-v2/assets/micro-masters.svg new file mode 100644 index 000000000..bca900de9 --- /dev/null +++ b/src/profile-v2/assets/micro-masters.svg @@ -0,0 +1,13 @@ + + + + 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..b9604001a --- /dev/null +++ b/src/profile-v2/data/actions.js @@ -0,0 +1,137 @@ +import { AsyncActionType } from '../utils'; + +export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE'); +export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE'); +export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO'); +export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO'); +export const OPEN_FORM = 'OPEN_FORM'; +export const CLOSE_FORM = 'CLOSE_FORM'; +export const UPDATE_DRAFT = 'UPDATE_DRAFT'; +export const RESET_DRAFTS = 'RESET_DRAFTS'; + +export const fetchProfile = username => ({ + type: FETCH_PROFILE.BASE, + payload: { username }, +}); + +export const fetchProfileBegin = () => ({ + type: FETCH_PROFILE.BEGIN, +}); + +export const fetchProfileSuccess = ( + account, + preferences, + courseCertificates, + isAuthenticatedUserProfile, +) => ({ + type: FETCH_PROFILE.SUCCESS, + account, + preferences, + courseCertificates, + isAuthenticatedUserProfile, +}); + +export const fetchProfileReset = () => ({ + type: FETCH_PROFILE.RESET, +}); + +export const saveProfile = (formId, username) => ({ + type: SAVE_PROFILE.BASE, + payload: { + formId, + username, + }, +}); + +export const saveProfileBegin = () => ({ + type: SAVE_PROFILE.BEGIN, +}); + +export const saveProfileSuccess = (account, preferences) => ({ + type: SAVE_PROFILE.SUCCESS, + payload: { + account, + preferences, + }, +}); + +export const saveProfileReset = () => ({ + type: SAVE_PROFILE.RESET, +}); + +export const saveProfileFailure = errors => ({ + type: SAVE_PROFILE.FAILURE, + payload: { errors }, +}); + +export const saveProfilePhoto = (username, formData) => ({ + type: SAVE_PROFILE_PHOTO.BASE, + payload: { + username, + formData, + }, +}); + +export const saveProfilePhotoBegin = () => ({ + type: SAVE_PROFILE_PHOTO.BEGIN, +}); + +export const saveProfilePhotoSuccess = profileImage => ({ + type: SAVE_PROFILE_PHOTO.SUCCESS, + payload: { profileImage }, +}); + +export const saveProfilePhotoReset = () => ({ + type: SAVE_PROFILE_PHOTO.RESET, +}); + +export const saveProfilePhotoFailure = error => ({ + type: SAVE_PROFILE_PHOTO.FAILURE, + payload: { error }, +}); + +export const deleteProfilePhoto = username => ({ + type: DELETE_PROFILE_PHOTO.BASE, + payload: { + username, + }, +}); + +export const deleteProfilePhotoBegin = () => ({ + type: DELETE_PROFILE_PHOTO.BEGIN, +}); + +export const deleteProfilePhotoSuccess = profileImage => ({ + type: DELETE_PROFILE_PHOTO.SUCCESS, + payload: { profileImage }, +}); + +export const deleteProfilePhotoReset = () => ({ + type: DELETE_PROFILE_PHOTO.RESET, +}); + +export const openForm = formId => ({ + type: OPEN_FORM, + payload: { + formId, + }, +}); + +export const closeForm = formId => ({ + type: CLOSE_FORM, + payload: { + formId, + }, +}); + +export const updateDraft = (name, value) => ({ + type: UPDATE_DRAFT, + payload: { + name, + value, + }, +}); + +export const resetDrafts = () => ({ + type: RESET_DRAFTS, +}); diff --git a/src/profile-v2/data/actions.test.js b/src/profile-v2/data/actions.test.js new file mode 100644 index 000000000..275d695ca --- /dev/null +++ b/src/profile-v2/data/actions.test.js @@ -0,0 +1,98 @@ +import { + SAVE_PROFILE_PHOTO, + saveProfilePhotoBegin, + saveProfilePhotoSuccess, + saveProfilePhotoFailure, + saveProfilePhotoReset, + saveProfilePhoto, + DELETE_PROFILE_PHOTO, + deleteProfilePhotoBegin, + deleteProfilePhotoSuccess, + deleteProfilePhotoReset, + deleteProfilePhoto, +} from './actions'; + +describe('SAVE profile photo actions', () => { + it('should create an action to signal the start of a profile photo save', () => { + const formData = 'multipart form data'; + const expectedAction = { + type: SAVE_PROFILE_PHOTO.BASE, + payload: { + username: 'myusername', + formData, + }, + }; + expect(saveProfilePhoto('myusername', formData)).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo save beginning', () => { + const expectedAction = { + type: SAVE_PROFILE_PHOTO.BEGIN, + }; + expect(saveProfilePhotoBegin()).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo save success', () => { + const newPhotoData = { hasImage: true }; + const expectedAction = { + type: SAVE_PROFILE_PHOTO.SUCCESS, + payload: { + profileImage: newPhotoData, + }, + }; + expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo save reset', () => { + const expectedAction = { + type: SAVE_PROFILE_PHOTO.RESET, + }; + expect(saveProfilePhotoReset()).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo save failure', () => { + const error = 'Test failure'; + const expectedAction = { + type: SAVE_PROFILE_PHOTO.FAILURE, + payload: { error }, + }; + expect(saveProfilePhotoFailure(error)).toEqual(expectedAction); + }); +}); + +describe('DELETE profile photo actions', () => { + it('should create an action to signal the start of a profile photo deletion', () => { + const expectedAction = { + type: DELETE_PROFILE_PHOTO.BASE, + payload: { + username: 'myusername', + }, + }; + expect(deleteProfilePhoto('myusername')).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo deletion beginning', () => { + const expectedAction = { + type: DELETE_PROFILE_PHOTO.BEGIN, + }; + expect(deleteProfilePhotoBegin()).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo deletion success', () => { + const defaultPhotoData = { hasImage: false }; + const expectedAction = { + type: DELETE_PROFILE_PHOTO.SUCCESS, + payload: { + profileImage: defaultPhotoData, + }, + }; + expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction); + }); + + it('should create an action to signal user profile photo deletion reset', () => { + const expectedAction = { + type: DELETE_PROFILE_PHOTO.RESET, + }; + expect(deleteProfilePhotoReset()).toEqual(expectedAction); + }); +}); diff --git a/src/profile-v2/data/constants.js b/src/profile-v2/data/constants.js new file mode 100644 index 000000000..de97069fc --- /dev/null +++ b/src/profile-v2/data/constants.js @@ -0,0 +1,33 @@ +const EDUCATION_LEVELS = [ + 'p', + 'm', + 'b', + 'a', + 'hs', + 'jhs', + 'el', + 'none', + 'other', +]; + +const SOCIAL = { + linkedin: { + title: 'LinkedIn', + }, + twitter: { + title: 'Twitter', + }, + facebook: { + title: 'Facebook', + }, +}; + +const FIELD_LABELS = { + COUNTRY: 'country', +}; + +export { + EDUCATION_LEVELS, + SOCIAL, + FIELD_LABELS, +}; diff --git a/src/profile-v2/data/hooks.js b/src/profile-v2/data/hooks.js new file mode 100644 index 000000000..161a436be --- /dev/null +++ b/src/profile-v2/data/hooks.js @@ -0,0 +1,34 @@ +import { breakpoints, useWindowSize } from '@openedx/paragon'; +import { getConfig } from '@edx/frontend-platform'; + +export function useIsOnTabletScreen() { + const windowSize = useWindowSize(); + return windowSize.width <= breakpoints.medium.minWidth; +} + +export function useIsOnMobileScreen() { + const windowSize = useWindowSize(); + return windowSize.width <= breakpoints.small.minWidth; +} + +export function useIsVisibilityEnabled() { + return getConfig().DISABLE_VISIBILITY_EDITING === 'true'; +} + +export function useHandleChange(changeHandler) { + return (e) => { + const { name, value } = e.target; + changeHandler(name, value); + }; +} + +export function useHandleSubmit(submitHandler, formId) { + return (e) => { + e.preventDefault(); + submitHandler(formId); + }; +} + +export function useCloseOpenHandler(handler, formId) { + return () => handler(formId); +} 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..abd14e624 --- /dev/null +++ b/src/profile-v2/data/pact-profile.test.js @@ -0,0 +1,84 @@ +// This test file simply creates a contract that defines +// expectations and correct responses from the Pact stub server. + +import path from 'path'; + +import { PactV3, MatchersV3 } from '@pact-foundation/pact'; + +import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform'; +import { getAccount } from './services'; + +const expectedUserInfo200 = { + username: 'staff', + email: 'staff@example.com', + bio: 'This is my bio', + name: 'Lemon Seltzer', + country: 'ME', + dateJoined: '2017-06-07T00:44:23Z', + isActive: true, + yearOfBirth: 1901, + languageProficiencies: [], + levelOfEducation: null, + profileImage: {}, + socialLinks: [], +}; + +const provider = new PactV3({ + log: path.resolve(process.cwd(), 'src/pact-logs/pact.log'), + dir: path.resolve(process.cwd(), 'src/pacts'), + consumer: 'frontend-app-profile', + provider: 'edx-platform', +}); + +describe('getAccount for one username', () => { + beforeAll(async () => { + initializeMockApp(); + }); + it('returns a HTTP 200 and user information', async () => { + const username200 = 'staff'; + await provider.addInteraction({ + states: [{ description: "I have a user's basic information" }], + uponReceiving: "A request for user's basic information", + withRequest: { + method: 'GET', + path: `/api/user/v1/accounts/${username200}`, + headers: {}, + }, + willRespondWith: { + status: 200, + headers: {}, + body: MatchersV3.like(expectedUserInfo200), + }, + }); + return provider.executeTest(async (mockserver) => { + setConfig({ + ...getConfig(), + LMS_BASE_URL: mockserver.url, + }); + const response = await getAccount(username200); + expect(response).toEqual(expectedUserInfo200); + }); + }); + + it('Account does not exist', async () => { + const username404 = 'staff_not_found'; + await provider.addInteraction({ + states: [{ description: "Account and user's information does not exist" }], + uponReceiving: "A request for user's basic information", + withRequest: { + method: 'GET', + path: `/api/user/v1/accounts/${username404}`, + }, + willRespondWith: { + status: 404, + }, + }); + await provider.executeTest(async (mockserver) => { + setConfig({ + ...getConfig(), + LMS_BASE_URL: mockserver.url, + }); + await expect(getAccount(username404).then((response) => response.data)).rejects.toThrow('Request failed with status code 404'); + }); + }); +}); diff --git a/src/profile-v2/data/reducers.js b/src/profile-v2/data/reducers.js new file mode 100644 index 000000000..3e4760ca9 --- /dev/null +++ b/src/profile-v2/data/reducers.js @@ -0,0 +1,181 @@ +import { + SAVE_PROFILE, + SAVE_PROFILE_PHOTO, + DELETE_PROFILE_PHOTO, + CLOSE_FORM, + OPEN_FORM, + FETCH_PROFILE, + UPDATE_DRAFT, + RESET_DRAFTS, +} from './actions'; + +export const initialState = { + errors: {}, + saveState: null, + savePhotoState: null, + currentlyEditingField: null, + account: { + socialLinks: [], + languageProficiencies: [], + name: '', + bio: '', + country: '', + levelOfEducation: '', + profileImage: {}, + yearOfBirth: '', + }, + preferences: { + visibilityName: '', + visibilityBio: '', + visibilityCountry: '', + visibilityLevelOfEducation: '', + visibilitySocialLinks: '', + visibilityLanguageProficiencies: '', + }, + courseCertificates: [], + drafts: {}, + isLoadingProfile: true, + isAuthenticatedUserProfile: false, + disabledCountries: ['RU'], + countriesCodesList: [], +}; + +const profilePage = (state = initialState, action = {}) => { + switch (action.type) { + case FETCH_PROFILE.BEGIN: + return { + ...state, + // TODO: uncomment this line after ARCH-438 Image Post API returns the url + // is complete. Right now we refetch the whole profile causing us to show a full reload + // instead of a partial one. + // isLoadingProfile: true, + }; + case FETCH_PROFILE.SUCCESS: + return { + ...state, + account: { + ...state.account, + ...action.account, + socialLinks: action.account.socialLinks || [], + languageProficiencies: action.account.languageProficiencies || [], + }, + preferences: action.preferences, + courseCertificates: action.courseCertificates || [], + isLoadingProfile: false, + isAuthenticatedUserProfile: action.isAuthenticatedUserProfile, + countriesCodesList: action.countriesCodesList || [], + }; + case SAVE_PROFILE.BEGIN: + return { + ...state, + saveState: 'pending', + errors: {}, + }; + case SAVE_PROFILE.SUCCESS: + return { + ...state, + saveState: 'complete', + errors: {}, + account: action.payload.account !== null ? { + ...state.account, + ...action.payload.account, + socialLinks: action.payload.account.socialLinks || [], + languageProficiencies: action.payload.account.languageProficiencies || [], + } : state.account, + preferences: { ...state.preferences, ...action.payload.preferences }, + }; + case SAVE_PROFILE.FAILURE: + return { + ...state, + saveState: 'error', + isLoadingProfile: false, + errors: { ...state.errors, ...action.payload.errors }, + }; + case SAVE_PROFILE.RESET: + return { + ...state, + saveState: null, + isLoadingProfile: false, + errors: {}, + }; + case SAVE_PROFILE_PHOTO.BEGIN: + return { + ...state, + savePhotoState: 'pending', + errors: {}, + }; + case SAVE_PROFILE_PHOTO.SUCCESS: + return { + ...state, + account: { ...state.account, profileImage: action.payload.profileImage }, + savePhotoState: 'complete', + errors: {}, + }; + case SAVE_PROFILE_PHOTO.FAILURE: + return { + ...state, + savePhotoState: 'error', + errors: { ...state.errors, photo: action.payload.error }, + }; + case SAVE_PROFILE_PHOTO.RESET: + return { + ...state, + savePhotoState: null, + errors: {}, + }; + case DELETE_PROFILE_PHOTO.BEGIN: + return { + ...state, + savePhotoState: 'pending', + errors: {}, + }; + case DELETE_PROFILE_PHOTO.SUCCESS: + return { + ...state, + account: { ...state.account, profileImage: action.payload.profileImage }, + savePhotoState: 'complete', + errors: {}, + }; + case DELETE_PROFILE_PHOTO.FAILURE: + return { + ...state, + savePhotoState: 'error', + errors: { ...state.errors, ...action.payload.errors }, + }; + case DELETE_PROFILE_PHOTO.RESET: + return { + ...state, + savePhotoState: null, + errors: {}, + }; + case UPDATE_DRAFT: + return { + ...state, + drafts: { ...state.drafts, [action.payload.name]: action.payload.value }, + }; + case RESET_DRAFTS: + return { + ...state, + drafts: {}, + }; + case OPEN_FORM: + return { + ...state, + currentlyEditingField: action.payload.formId, + drafts: {}, + }; + case CLOSE_FORM: + if (action.payload.formId === state.currentlyEditingField) { + return { + ...state, + currentlyEditingField: null, + drafts: {}, + }; + } + return state; + default: + return state; + } +}; + +export default profilePage; diff --git a/src/profile-v2/data/reducers.test.js b/src/profile-v2/data/reducers.test.js new file mode 100644 index 000000000..ec4bcc1d4 --- /dev/null +++ b/src/profile-v2/data/reducers.test.js @@ -0,0 +1,309 @@ +import profilePage, { initialState } from './reducers'; +import { + SAVE_PROFILE, + SAVE_PROFILE_PHOTO, + DELETE_PROFILE_PHOTO, + CLOSE_FORM, + OPEN_FORM, + FETCH_PROFILE, + UPDATE_DRAFT, + RESET_DRAFTS, +} from './actions'; + +describe('profilePage reducer', () => { + it('should return the initial state by default', () => { + expect(profilePage(undefined, {})).toEqual(initialState); + }); + + describe('FETCH_PROFILE actions', () => { + it('should handle FETCH_PROFILE.BEGIN', () => { + const action = { type: FETCH_PROFILE.BEGIN }; + const expectedState = { + ...initialState, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle FETCH_PROFILE.SUCCESS', () => { + const action = { + type: FETCH_PROFILE.SUCCESS, + account: { + name: 'John Doe', + bio: 'Software Engineer', + country: 'US', + levelOfEducation: 'bachelors', + socialLinks: [{ platform: 'twitter', link: 'twitter.com/johndoe' }], + languageProficiencies: [{ code: 'en', name: 'English' }], + profileImage: { url: 'profile.jpg' }, + yearOfBirth: 1990, + }, + preferences: { + visibilityName: 'public', + visibilityBio: 'public', + visibilityCountry: 'public', + visibilityLevelOfEducation: 'public', + visibilitySocialLinks: 'public', + visibilityLanguageProficiencies: 'public', + }, + courseCertificates: ['cert1', 'cert2'], + isAuthenticatedUserProfile: true, + countriesCodesList: ['US', 'CA'], + }; + const expectedState = { + ...initialState, + account: { + ...initialState.account, + ...action.account, + socialLinks: action.account.socialLinks, + languageProficiencies: action.account.languageProficiencies, + }, + preferences: action.preferences, + courseCertificates: action.courseCertificates, + isLoadingProfile: false, + isAuthenticatedUserProfile: action.isAuthenticatedUserProfile, + countriesCodesList: action.countriesCodesList, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + }); + + describe('SAVE_PROFILE actions', () => { + it('should handle SAVE_PROFILE.BEGIN', () => { + const action = { type: SAVE_PROFILE.BEGIN }; + const expectedState = { + ...initialState, + saveState: 'pending', + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE.SUCCESS', () => { + const action = { + type: SAVE_PROFILE.SUCCESS, + payload: { + account: { + name: 'Jane Doe', + bio: 'Updated bio', + socialLinks: [{ platform: 'linkedin', link: 'linkedin.com/janedoe' }], + languageProficiencies: [{ code: 'es', name: 'Spanish' }], + }, + preferences: { + visibilityName: 'private', + visibilityBio: 'private', + }, + }, + }; + const expectedState = { + ...initialState, + saveState: 'complete', + errors: {}, + account: { + ...initialState.account, + ...action.payload.account, + socialLinks: action.payload.account.socialLinks, + languageProficiencies: action.payload.account.languageProficiencies, + }, + preferences: { + ...initialState.preferences, + ...action.payload.preferences, + }, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE.FAILURE', () => { + const action = { + type: SAVE_PROFILE.FAILURE, + payload: { errors: { save: 'Failed to save profile' } }, + }; + const expectedState = { + ...initialState, + saveState: 'error', + isLoadingProfile: false, + errors: { save: action.payload.errors.save }, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE.RESET', () => { + const action = { type: SAVE_PROFILE.RESET }; + const expectedState = { + ...initialState, + saveState: null, + isLoadingProfile: false, + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + }); + + describe('SAVE_PROFILE_PHOTO actions', () => { + it('should handle SAVE_PROFILE_PHOTO.BEGIN', () => { + const action = { type: SAVE_PROFILE_PHOTO.BEGIN }; + const expectedState = { + ...initialState, + savePhotoState: 'pending', + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE_PHOTO.SUCCESS', () => { + const action = { + type: SAVE_PROFILE_PHOTO.SUCCESS, + payload: { profileImage: { url: 'new-image-url.jpg' } }, + }; + const expectedState = { + ...initialState, + account: { ...initialState.account, profileImage: action.payload.profileImage }, + savePhotoState: 'complete', + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE_PHOTO.FAILURE', () => { + const action = { + type: SAVE_PROFILE_PHOTO.FAILURE, + payload: { error: 'Photo upload failed' }, + }; + const expectedState = { + ...initialState, + savePhotoState: 'error', + errors: { photo: action.payload.error }, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle SAVE_PROFILE_PHOTO.RESET', () => { + const action = { type: SAVE_PROFILE_PHOTO.RESET }; + const expectedState = { + ...initialState, + savePhotoState: null, + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + }); + + describe('DELETE_PROFILE_PHOTO actions', () => { + it('should handle DELETE_PROFILE_PHOTO.BEGIN', () => { + const action = { type: DELETE_PROFILE_PHOTO.BEGIN }; + const expectedState = { + ...initialState, + savePhotoState: 'pending', + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle DELETE_PROFILE_PHOTO.SUCCESS', () => { + const action = { + type: DELETE_PROFILE_PHOTO.SUCCESS, + payload: { profileImage: { url: 'default-image-url.jpg' } }, + }; + const expectedState = { + ...initialState, + account: { ...initialState.account, profileImage: action.payload.profileImage }, + savePhotoState: 'complete', + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle DELETE_PROFILE_PHOTO.FAILURE', () => { + const action = { + type: DELETE_PROFILE_PHOTO.FAILURE, + payload: { errors: { delete: 'Failed to delete photo' } }, + }; + const expectedState = { + ...initialState, + savePhotoState: 'error', + errors: { delete: action.payload.errors.delete }, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle DELETE_PROFILE_PHOTO.RESET', () => { + const action = { type: DELETE_PROFILE_PHOTO.RESET }; + const expectedState = { + ...initialState, + savePhotoState: null, + errors: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + }); + + describe('Draft and Form actions', () => { + it('should handle UPDATE_DRAFT', () => { + const action = { + type: UPDATE_DRAFT, + payload: { name: 'bio', value: 'New bio draft' }, + }; + const expectedState = { + ...initialState, + drafts: { bio: 'New bio draft' }, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle RESET_DRAFTS', () => { + const initialStateWithDrafts = { + ...initialState, + drafts: { bio: 'New bio draft', name: 'New name' }, + }; + const action = { type: RESET_DRAFTS }; + const expectedState = { + ...initialStateWithDrafts, + drafts: {}, + }; + expect(profilePage(initialStateWithDrafts, action)).toEqual(expectedState); + }); + + it('should handle OPEN_FORM', () => { + const action = { + type: OPEN_FORM, + payload: { formId: 'bioForm' }, + }; + const expectedState = { + ...initialState, + currentlyEditingField: 'bioForm', + drafts: {}, + }; + expect(profilePage(initialState, action)).toEqual(expectedState); + }); + + it('should handle CLOSE_FORM when formId matches currentlyEditingField', () => { + const initialStateWithForm = { + ...initialState, + currentlyEditingField: 'bioForm', + drafts: { bio: 'New bio draft' }, + }; + const action = { + type: CLOSE_FORM, + payload: { formId: 'bioForm' }, + }; + const expectedState = { + ...initialStateWithForm, + currentlyEditingField: null, + drafts: {}, + }; + expect(profilePage(initialStateWithForm, action)).toEqual(expectedState); + }); + + it('should not handle CLOSE_FORM when formId does not match currentlyEditingField', () => { + const initialStateWithForm = { + ...initialState, + currentlyEditingField: 'bioForm', + drafts: { bio: 'New bio draft' }, + }; + const action = { + type: CLOSE_FORM, + payload: { formId: 'nameForm' }, + }; + expect(profilePage(initialStateWithForm, action)).toEqual(initialStateWithForm); + }); + }); +}); diff --git a/src/profile-v2/data/sagas.js b/src/profile-v2/data/sagas.js new file mode 100644 index 000000000..b64c3fe7a --- /dev/null +++ b/src/profile-v2/data/sagas.js @@ -0,0 +1,191 @@ +import { history } from '@edx/frontend-platform'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import pick from 'lodash.pick'; +import { + all, + call, + delay, + put, + select, + takeEvery, +} from 'redux-saga/effects'; +import { + closeForm, + deleteProfilePhotoBegin, + deleteProfilePhotoReset, + deleteProfilePhotoSuccess, + DELETE_PROFILE_PHOTO, + fetchProfileBegin, + fetchProfileReset, + fetchProfileSuccess, + FETCH_PROFILE, + resetDrafts, + saveProfileBegin, + saveProfileFailure, + saveProfileReset, + saveProfileSuccess, + SAVE_PROFILE, + saveProfilePhotoBegin, + saveProfilePhotoReset, + saveProfilePhotoSuccess, + SAVE_PROFILE_PHOTO, +} from './actions'; +import { handleSaveProfileSelector, userAccountSelector } from './selectors'; +import * as ProfileApiService from './services'; + +export function* handleFetchProfile(action) { + const { username } = action.payload; + const userAccount = yield select(userAccountSelector); + const isAuthenticatedUserProfile = username === getAuthenticatedUser().username; + let preferences = {}; + let account = userAccount; + let courseCertificates = null; + let countriesCodesList = []; + + try { + yield put(fetchProfileBegin()); + + const calls = [ + call(ProfileApiService.getAccount, username), + call(ProfileApiService.getCourseCertificates, username), + call(ProfileApiService.getCountryList), + ]; + + if (isAuthenticatedUserProfile) { + calls.push(call(ProfileApiService.getPreferences, username)); + } + + const result = yield all(calls); + + if (isAuthenticatedUserProfile) { + [account, courseCertificates, countriesCodesList, preferences] = result; + } else { + [account, courseCertificates, countriesCodesList] = result; + } + + if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') { + yield call(ProfileApiService.patchPreferences, action.payload.username, { + account_privacy: 'custom', + 'visibility.name': 'all_users', + 'visibility.bio': 'all_users', + 'visibility.course_certificates': 'all_users', + 'visibility.country': 'all_users', + 'visibility.date_joined': 'all_users', + 'visibility.level_of_education': 'all_users', + 'visibility.language_proficiencies': 'all_users', + 'visibility.social_links': 'all_users', + 'visibility.time_zone': 'all_users', + }); + } + + yield put(fetchProfileSuccess( + account, + preferences, + courseCertificates, + isAuthenticatedUserProfile, + countriesCodesList, + )); + + yield put(fetchProfileReset()); + } catch (e) { + if (e.response.status === 404) { + history.push('/notfound'); + } else { + throw e; + } + } +} + +export function* handleSaveProfile(action) { + try { + const { drafts, preferences } = yield select(handleSaveProfileSelector); + + const accountDrafts = pick(drafts, [ + 'bio', + 'country', + 'levelOfEducation', + 'languageProficiencies', + 'name', + 'socialLinks', + ]); + + const preferencesDrafts = pick(drafts, [ + 'visibilityBio', + 'visibilityCountry', + 'visibilityLevelOfEducation', + 'visibilityLanguageProficiencies', + 'visibilityName', + 'visibilitySocialLinks', + ]); + + if (Object.keys(preferencesDrafts).length > 0) { + preferencesDrafts.accountPrivacy = 'custom'; + } + + yield put(saveProfileBegin()); + let accountResult = null; + + if (Object.keys(accountDrafts).length > 0) { + accountResult = yield call( + ProfileApiService.patchProfile, + action.payload.username, + accountDrafts, + ); + } + + let preferencesResult = preferences; + if (Object.keys(preferencesDrafts).length > 0) { + yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts); + // TODO: Temporary deoptimization since the patchPreferences call doesn't return anything. + + preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username); + } + + yield put(saveProfileSuccess(accountResult, preferencesResult)); + yield delay(1000); + yield put(closeForm(action.payload.formId)); + yield delay(300); + yield put(saveProfileReset()); + yield put(resetDrafts()); + } catch (e) { + if (e.processedData && e.processedData.fieldErrors) { + yield put(saveProfileFailure(e.processedData.fieldErrors)); + } else { + yield put(saveProfileReset()); + throw e; + } + } +} + +export function* handleSaveProfilePhoto(action) { + const { username, formData } = action.payload; + + try { + yield put(saveProfilePhotoBegin()); + const photoResult = yield call(ProfileApiService.postProfilePhoto, username, formData); + yield put(saveProfilePhotoSuccess(photoResult)); + yield put(saveProfilePhotoReset()); + } catch (e) { + yield put(saveProfilePhotoReset()); + } +} + +export function* handleDeleteProfilePhoto(action) { + const { username } = action.payload; + + try { + yield put(deleteProfilePhotoBegin()); + const photoResult = yield call(ProfileApiService.deleteProfilePhoto, username); + yield put(deleteProfilePhotoSuccess(photoResult)); + yield put(deleteProfilePhotoReset()); + } catch (e) { + yield put(deleteProfilePhotoReset()); + } +} + +export default function* profileSaga() { + yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile); + yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile); + yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto); + yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto); +} diff --git a/src/profile-v2/data/sagas.test.js b/src/profile-v2/data/sagas.test.js new file mode 100644 index 000000000..2da09b37a --- /dev/null +++ b/src/profile-v2/data/sagas.test.js @@ -0,0 +1,167 @@ +import { + takeEvery, + put, + call, + delay, + select, + all, +} from 'redux-saga/effects'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import * as profileActions from './actions'; +import { handleSaveProfileSelector, userAccountSelector } from './selectors'; + +jest.mock('./services', () => ({ + getProfile: jest.fn(), + patchProfile: jest.fn(), + postProfilePhoto: jest.fn(), + deleteProfilePhoto: jest.fn(), + getPreferences: jest.fn(), + getAccount: jest.fn(), + getCourseCertificates: jest.fn(), + getCountryList: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedUser: jest.fn(), +})); + +/* eslint-disable import/first */ +import profileSaga, { + handleFetchProfile, + handleSaveProfile, + handleSaveProfilePhoto, + handleDeleteProfilePhoto, +} from './sagas'; +import * as ProfileApiService from './services'; +/* eslint-enable import/first */ + +describe('RootSaga', () => { + describe('profileSaga', () => { + it('should pass actions to the correct sagas', () => { + const gen = profileSaga(); + + expect(gen.next().value) + .toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto)); + expect(gen.next().value) + .toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto)); + + expect(gen.next().value).toBeUndefined(); + }); + }); + + describe('handleFetchProfile', () => { + it('should fetch certificates and preferences for the current user profile', () => { + const userAccount = { + username: 'gonzo', + other: 'data', + }; + getAuthenticatedUser.mockReturnValue(userAccount); + const selectorData = { + userAccount, + }; + + const action = profileActions.fetchProfile('gonzo'); + const gen = handleFetchProfile(action); + + const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }]; + + expect(gen.next().value).toEqual(select(userAccountSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin())); + expect(gen.next().value).toEqual(all([ + call(ProfileApiService.getAccount, 'gonzo'), + call(ProfileApiService.getCourseCertificates, 'gonzo'), + call(ProfileApiService.getCountryList), + call(ProfileApiService.getPreferences, 'gonzo'), + ])); + expect(gen.next(result).value) + .toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, []))); + expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset())); + expect(gen.next().value).toBeUndefined(); + }); + + it('should fetch certificates and profile for some other user profile', () => { + const userAccount = { + username: 'gonzo', + other: 'data', + }; + const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }]; + getAuthenticatedUser.mockReturnValue(userAccount); + const selectorData = { + userAccount, + }; + + const action = profileActions.fetchProfile('booyah'); + const gen = handleFetchProfile(action); + + const result = [{}, [1, 2, 3], countriesCodesList]; + + expect(gen.next().value).toEqual(select(userAccountSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin())); + expect(gen.next().value).toEqual(all([ + call(ProfileApiService.getAccount, 'booyah'), + call(ProfileApiService.getCourseCertificates, 'booyah'), + call(ProfileApiService.getCountryList), + ])); + expect(gen.next(result).value) + .toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList))); + expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset())); + expect(gen.next().value).toBeUndefined(); + }); + }); + + describe('handleSaveProfile', () => { + const selectorData = { + username: 'my username', + drafts: { + name: 'Full Name', + }, + preferences: {}, + }; + + it('should successfully process a saveProfile request if there are no exceptions', () => { + const action = profileActions.saveProfile('ze form id', 'my username'); + const gen = handleSaveProfile(action); + const profile = { + name: 'Full Name', + levelOfEducation: 'b', + }; + expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', { + name: 'Full Name', + })); + expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {}))); + expect(gen.next().value).toEqual(delay(1000)); + expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id'))); + expect(gen.next().value).toEqual(delay(300)); + expect(gen.next().value).toEqual(put(profileActions.saveProfileReset())); + expect(gen.next().value).toEqual(put(profileActions.resetDrafts())); + expect(gen.next().value).toBeUndefined(); + }); + + it('should successfully publish a failure action on exception', () => { + const error = new Error('uhoh'); + error.processedData = { + fieldErrors: { + uhoh: 'not good', + }, + }; + const action = profileActions.saveProfile( + 'ze form id', + 'my username', + ); + const gen = handleSaveProfile(action); + + expect(gen.next().value).toEqual(select(handleSaveProfileSelector)); + expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin())); + const result = gen.throw(error); + expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' }))); + expect(gen.next().value).toBeUndefined(); + }); + }); +}); diff --git a/src/profile-v2/data/selectors.js b/src/profile-v2/data/selectors.js new file mode 100644 index 000000000..d3982952c --- /dev/null +++ b/src/profile-v2/data/selectors.js @@ -0,0 +1,338 @@ +import { createSelector } from 'reselect'; +import { + getLocale, + getLanguageList, + getCountryList, + getCountryMessages, + getLanguageMessages, +} from '@edx/frontend-platform/i18n'; + +export const formIdSelector = (state, props) => props.formId; +export const userAccountSelector = state => state.userAccount; +export const profileAccountSelector = state => state.profilePage.account; +export const profileDraftsSelector = state => state.profilePage.drafts; +export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy; +export const profilePreferencesSelector = state => state.profilePage.preferences; +export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates; +export const saveStateSelector = state => state.profilePage.saveState; +export const savePhotoStateSelector = state => state.profilePage.savePhotoState; +export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile; +export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField; +export const accountErrorsSelector = state => state.profilePage.errors; +export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile; +export const countriesCodesListSelector = state => state.profilePage.countriesCodesList; + +export const editableFormModeSelector = createSelector( + profileAccountSelector, + isAuthenticatedUserProfileSelector, + profileCourseCertificatesSelector, + formIdSelector, + currentlyEditingFieldSelector, + (account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => { + let propExists = account[formId] != null && account[formId].length > 0; + propExists = formId === 'certificates' ? certificates.length > 0 : propExists; + if (!isAuthenticatedUserProfile) { + return 'static'; + } + if (formId === currentlyEditingField) { + return 'editing'; + } + + if (!propExists) { + return 'empty'; + } + + return 'editable'; + }, +); + +export const accountDraftsFieldSelector = createSelector( + formIdSelector, + profileDraftsSelector, + (formId, drafts) => drafts[formId], +); + +export const visibilityDraftsFieldSelector = createSelector( + formIdSelector, + profileDraftsSelector, + (formId, drafts) => drafts[`visibility${formId.charAt(0).toUpperCase() + formId.slice(1)}`], +); + +export const formErrorSelector = createSelector( + accountErrorsSelector, + formIdSelector, + (errors, formId) => (errors[formId] ? errors[formId].userMessage : null), +); + +export const editableFormSelector = createSelector( + editableFormModeSelector, + formErrorSelector, + saveStateSelector, + (editMode, error, saveState) => ({ + editMode, + error, + saveState, + }), +); + +export const localeSelector = () => getLocale(); +export const countryMessagesSelector = createSelector( + localeSelector, + locale => getCountryMessages(locale), +); +export const languageMessagesSelector = createSelector( + localeSelector, + locale => getLanguageMessages(locale), +); + +export const sortedLanguagesSelector = createSelector( + localeSelector, + locale => getLanguageList(locale), +); + +export const sortedCountriesSelector = createSelector( + localeSelector, + countriesCodesListSelector, + profileAccountSelector, + (locale, countriesCodesList, profileAccount) => { + const countryList = getCountryList(locale); + const userCountry = profileAccount.country; + + return countryList.filter(({ code }) => code === userCountry || countriesCodesList.find(x => x === code)); + }, +); + +export const preferredLanguageSelector = createSelector( + editableFormSelector, + sortedLanguagesSelector, + languageMessagesSelector, + (editableForm, sortedLanguages, languageMessages) => ({ + ...editableForm, + sortedLanguages, + languageMessages, + }), +); + +export const countrySelector = createSelector( + editableFormSelector, + sortedCountriesSelector, + countryMessagesSelector, + countriesCodesListSelector, + profileAccountSelector, + (editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({ + ...editableForm, + translatedCountries, + countryMessages, + countriesCodesList, + committedCountry: account.country, + }), +); + +export const certificatesSelector = createSelector( + editableFormSelector, + profileCourseCertificatesSelector, + (editableForm, certificates) => ({ + ...editableForm, + certificates, + value: certificates, + }), +); + +export const profileImageSelector = createSelector( + profileAccountSelector, + account => (account.profileImage != null + ? { + src: account.profileImage.imageUrlFull, + isDefault: !account.profileImage.hasImage, + } + : {}), +); + +export const handleSaveProfileSelector = createSelector( + profileDraftsSelector, + profilePreferencesSelector, + (drafts, preferences) => ({ + drafts, + preferences, + }), +); + +const socialLinksByPlatformSelector = createSelector( + profileAccountSelector, + (account) => { + const linksByPlatform = {}; + if (Array.isArray(account.socialLinks)) { + account.socialLinks.forEach((socialLink) => { + linksByPlatform[socialLink.platform] = socialLink; + }); + } + return linksByPlatform; + }, +); + +const draftSocialLinksByPlatformSelector = createSelector( + profileDraftsSelector, + (drafts) => { + const linksByPlatform = {}; + if (Array.isArray(drafts.socialLinks)) { + drafts.socialLinks.forEach((socialLink) => { + linksByPlatform[socialLink.platform] = socialLink; + }); + } + return linksByPlatform; + }, +); + +export const formSocialLinksSelector = createSelector( + socialLinksByPlatformSelector, + draftSocialLinksByPlatformSelector, + (linksByPlatform, draftLinksByPlatform) => { + const knownPlatforms = ['twitter', 'facebook', 'linkedin']; + const socialLinks = []; + knownPlatforms.forEach((platform) => { + if (draftLinksByPlatform[platform] !== undefined) { + socialLinks.push(draftLinksByPlatform[platform]); + } else if (linksByPlatform[platform] !== undefined) { + socialLinks.push(linksByPlatform[platform]); + } else { + socialLinks.push({ + platform, + socialLink: null, + }); + } + }); + return socialLinks; + }, +); + +export const visibilitiesSelector = createSelector( + profilePreferencesSelector, + accountPrivacySelector, + (preferences, accountPrivacy) => { + switch (accountPrivacy) { + case 'custom': + return { + visibilityBio: preferences.visibilityBio || 'all_users', + visibilityCountry: preferences.visibilityCountry || 'all_users', + visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users', + visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', + visibilityName: preferences.visibilityName || 'all_users', + visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users', + }; + case 'private': + return { + visibilityBio: 'private', + visibilityCountry: 'private', + visibilityLevelOfEducation: 'private', + visibilityLanguageProficiencies: 'private', + visibilityName: 'private', + visibilitySocialLinks: 'private', + }; + case 'all_users': + default: + return { + visibilityBio: 'all_users', + visibilityCountry: 'all_users', + visibilityLevelOfEducation: 'all_users', + visibilityLanguageProficiencies: 'all_users', + visibilityName: 'all_users', + visibilitySocialLinks: 'all_users', + }; + } + }, +); + +function chooseFormValue(draft, committed) { + return draft !== undefined ? draft : committed; +} + +export const formValuesSelector = createSelector( + profileAccountSelector, + visibilitiesSelector, + profileDraftsSelector, + profileCourseCertificatesSelector, + formSocialLinksSelector, + (account, visibilities, drafts, courseCertificates, socialLinks) => ({ + bio: chooseFormValue(drafts.bio, account.bio), + visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio), + courseCertificates, + country: chooseFormValue(drafts.country, account.country), + visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry), + levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation), + visibilityLevelOfEducation: chooseFormValue( + drafts.visibilityLevelOfEducation, + visibilities.visibilityLevelOfEducation, + ), + languageProficiencies: chooseFormValue( + drafts.languageProficiencies, + account.languageProficiencies, + ), + visibilityLanguageProficiencies: chooseFormValue( + drafts.visibilityLanguageProficiencies, + visibilities.visibilityLanguageProficiencies, + ), + name: chooseFormValue(drafts.name, account.name), + visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName), + socialLinks, + visibilitySocialLinks: chooseFormValue( + drafts.visibilitySocialLinks, + visibilities.visibilitySocialLinks, + ), + }), +); + +export const profilePageSelector = createSelector( + profileAccountSelector, + formValuesSelector, + profileImageSelector, + saveStateSelector, + savePhotoStateSelector, + isLoadingProfileSelector, + draftSocialLinksByPlatformSelector, + accountErrorsSelector, + isAuthenticatedUserProfileSelector, + ( + account, + formValues, + profileImage, + saveState, + savePhotoState, + isLoadingProfile, + draftSocialLinksByPlatform, + errors, + isAuthenticatedUserProfile, + ) => ({ + username: account.username, + profileImage, + requiresParentalConsent: account.requiresParentalConsent, + dateJoined: account.dateJoined, + yearOfBirth: account.yearOfBirth, + + bio: formValues.bio, + visibilityBio: formValues.visibilityBio, + + courseCertificates: formValues.courseCertificates, + + country: formValues.country, + visibilityCountry: formValues.visibilityCountry, + + levelOfEducation: formValues.levelOfEducation, + visibilityLevelOfEducation: formValues.visibilityLevelOfEducation, + + languageProficiencies: formValues.languageProficiencies, + visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies, + + name: formValues.name, + visibilityName: formValues.visibilityName, + + socialLinks: formValues.socialLinks, + visibilitySocialLinks: formValues.visibilitySocialLinks, + draftSocialLinksByPlatform, + + saveState, + savePhotoState, + isLoadingProfile, + photoUploadError: errors.photo || null, + isAuthenticatedUserProfile, + }), +); diff --git a/src/profile-v2/data/services.js b/src/profile-v2/data/services.js new file mode 100644 index 000000000..e6104df81 --- /dev/null +++ b/src/profile-v2/data/services.js @@ -0,0 +1,168 @@ +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; +import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils'; +import { FIELD_LABELS } from './constants'; + +ensureConfig(['LMS_BASE_URL'], 'Profile API service'); + +function processAccountData(data) { + const processedData = camelCaseObject(data); + return { + ...processedData, + socialLinks: Array.isArray(processedData.socialLinks) ? processedData.socialLinks : [], + languageProficiencies: Array.isArray(processedData.languageProficiencies) + ? processedData.languageProficiencies : [], + name: processedData.name || null, + bio: processedData.bio || null, + country: processedData.country || null, + levelOfEducation: processedData.levelOfEducation || null, + profileImage: processedData.profileImage || {}, + yearOfBirth: processedData.yearOfBirth || null, + }; +} + +function processAndThrowError(error, errorDataProcessor) { + const processedError = Object.create(error); + if (error.response && error.response.data && typeof error.response.data === 'object') { + processedError.processedData = errorDataProcessor(error.response.data); + throw processedError; + } else { + throw error; + } +} + +export async function getAccount(username) { + const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`); + + return processAccountData(data); +} + +export async function patchProfile(username, params) { + const processedParams = snakeCaseObject(params); + + const { data } = await getHttpClient() + .patch(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`, processedParams, { + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + }) + .catch((error) => { + processAndThrowError(error, processAccountData); + }); + + return processAccountData(data); +} + +export async function postProfilePhoto(username, formData) { + // eslint-disable-next-line no-unused-vars + const { data } = await getHttpClient().post( + `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }, + ).catch((error) => { + processAndThrowError(error, camelCaseObject); + }); + + // TODO: Someday in the future the POST photo endpoint + // will return the new values. At that time we should + // use the commented line below instead of the separate + // getAccount request that follows. + // return camelCaseObject(data); + const updatedData = await getAccount(username); + return updatedData.profileImage; +} + +export async function deleteProfilePhoto(username) { + // eslint-disable-next-line no-unused-vars + const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`); + + // TODO: Someday in the future the POST photo endpoint + // will return the new values. At that time we should + // use the commented line below instead of the separate + // getAccount request that follows. + // return camelCaseObject(data); + const updatedData = await getAccount(username); + return updatedData.profileImage; +} + +export async function getPreferences(username) { + const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`); + + return camelCaseObject(data); +} + +export async function patchPreferences(username, params) { + let processedParams = snakeCaseObject(params); + processedParams = convertKeyNames(processedParams, { + visibility_bio: 'visibility.bio', + visibility_course_certificates: 'visibility.course_certificates', + visibility_country: 'visibility.country', + visibility_date_joined: 'visibility.date_joined', + visibility_level_of_education: 'visibility.level_of_education', + visibility_language_proficiencies: 'visibility.language_proficiencies', + visibility_name: 'visibility.name', + visibility_social_links: 'visibility.social_links', + visibility_time_zone: 'visibility.time_zone', + }); + + await getHttpClient().patch(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`, processedParams, { + headers: { 'Content-Type': 'application/merge-patch+json' }, + }); + + return params; // TODO: Once the server returns the updated preferences object, return that. +} + +function transformCertificateData(data) { + const transformedData = []; + data.forEach((cert) => { + // download_url may be full url or absolute path. + // note: using the URL() api breaks in ie 11 + const urlIsPath = typeof cert.download_url === 'string' + && cert.download_url.search(/http[s]?:\/\//) !== 0; + + const downloadUrl = urlIsPath + ? `${getConfig().LMS_BASE_URL}${cert.download_url}` + : cert.download_url; + + transformedData.push({ + ...camelCaseObject(cert), + certificateType: cert.certificate_type, + downloadUrl, + }); + }); + return transformedData; +} + +export async function getCourseCertificates(username) { + const url = `${getConfig().LMS_BASE_URL}/api/certificates/v0/certificates/${username}/`; + try { + const { data } = await getHttpClient().get(url); + return transformCertificateData(data); + } catch (e) { + logError(e); + return []; + } +} + +function extractCountryList(data) { + return data?.fields + .find(({ name }) => name === FIELD_LABELS.COUNTRY) + ?.options?.map(({ value }) => (value)) || []; +} + +export async function getCountryList() { + const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`; + + try { + const { data } = await getHttpClient().get(url); + return extractCountryList(data); + } catch (e) { + logError(e); + return []; + } +} diff --git a/src/profile-v2/forms/Bio.jsx b/src/profile-v2/forms/Bio.jsx new file mode 100644 index 000000000..797bbb6c9 --- /dev/null +++ b/src/profile-v2/forms/Bio.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Form } from '@openedx/paragon'; + +import classNames from 'classnames'; +import messages from './Bio.messages'; + +import FormControls from './elements/FormControls'; +import EditableItemHeader from './elements/EditableItemHeader'; +import EmptyContent from './elements/EmptyContent'; +import SwitchContent from './elements/SwitchContent'; + +import { editableFormSelector } from '../data/selectors'; +import { + useCloseOpenHandler, + useHandleChange, + useHandleSubmit, + useIsOnMobileScreen, + useIsVisibilityEnabled, +} from '../data/hooks'; + +const Bio = ({ + formId, + bio, + visibilityBio, + editMode, + saveState, + error, + changeHandler, + submitHandler, + closeHandler, + openHandler, +}) => { + const isMobileView = useIsOnMobileScreen(); + const isVisibilityEnabled = useIsVisibilityEnabled(); + const intl = useIntl(); + + const handleChange = useHandleChange(changeHandler); + const handleSubmit = useHandleSubmit(submitHandler, formId); + const handleOpen = useCloseOpenHandler(openHandler, formId); + const handleClose = useCloseOpenHandler(closeHandler, formId); + + return ( + +
+ +

+ {intl.formatMessage(messages['profile.bio.about.me'])} +

+