Compare commits

..

3 Commits

Author SHA1 Message Date
Brayan Ceron
e7cddb60f7 chore: update snapshots 2026-01-28 19:48:32 +05:30
Brayan Ceron
9b6e928260 feat: wrap AdditionalProfileFieldsSlot in a div for improved layout 2026-01-28 19:48:32 +05:30
Brayan Cerón
0e676ffff3 feat: add slot to extend the profile fields (#1211)
* feat: add extended profile fields functionality with context and form components

* refactor: replace string literals with FORM_MODE constants in profile fields components

* feat: implement BaseField component and refactor field elements to use it

* chore: remove unused webpack development configuration file

* feat: refactor extended profile fields implementation and remove unused components

* feat: update dependencies for frontend-plugin-framework and remove unused dompurify

* refactor: simplify pluginProps structure in ExtendedProfileFieldsSlot component

* feat: add README and example images for Extended Profile Fields slot

* refactor: improve performance & keep consistency

* feat: add Additional Profile Fields slot with example implementation and documentation

* feat: update custom fields image for Additional Profile Fields slot

* fix: reorder import of AdditionalProfileFieldsSlot for consistency

* test: fix snapshot

* fix: adjust margin in example to avoid oddities on mobile

* fix: remove unnecessary empty divs from ProfilePage snapshots
2026-01-28 19:48:32 +05:30
75 changed files with 15420 additions and 17252 deletions

3
.env
View File

@@ -29,6 +29,3 @@ APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='' SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER_PROFILE='' ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

View File

@@ -30,6 +30,3 @@ APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='http://localhost:18000/courses' SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER_PROFILE='' ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

View File

@@ -25,5 +25,3 @@ LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
COLLECT_YEAR_OF_BIRTH=true COLLECT_YEAR_OF_BIRTH=true
APP_ID='' APP_ID=''
MFE_CONFIG_API_URL='' MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @openedx/2U-infinity

2
.nvmrc
View File

@@ -1 +1 @@
24 20

14891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,13 +38,13 @@
"@fortawesome/free-brands-svg-icons": "6.7.2", "@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2", "@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.6", "@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0", "@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5", "@openedx/paragon": "^22.17.0",
"@pact-foundation/pact": "^11.0.2", "@pact-foundation/pact": "^11.0.2",
"@redux-devtools/extension": "3.3.0", "@redux-devtools/extension": "3.3.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"core-js": "3.46.0", "core-js": "3.41.0",
"history": "5.3.0", "history": "5.3.0",
"lodash.camelcase": "4.3.0", "lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2", "lodash.get": "4.4.2",
@@ -55,8 +55,8 @@
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-helmet": "6.1.0", "react-helmet": "6.1.0",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router": "6.30.1", "react-router": "6.30.0",
"react-router-dom": "6.30.1", "react-router-dom": "6.30.0",
"redux": "4.2.1", "redux": "4.2.1",
"redux-logger": "3.0.6", "redux-logger": "3.0.6",
"redux-saga": "1.3.0", "redux-saga": "1.3.0",
@@ -66,13 +66,15 @@
"universal-cookie": "4.0.4" "universal-cookie": "4.0.4"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "19.8.1", "@commitlint/cli": "19.8.0",
"@commitlint/config-angular": "19.8.1", "@commitlint/config-angular": "19.8.0",
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@openedx/frontend-build": "^14.6.2", "@edx/reactifex": "2.2.0",
"@testing-library/jest-dom": "6.9.1", "@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1", "@testing-library/react": "14.3.1",
"glob": "11.0.3", "glob": "11.0.1",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.5" "redux-mock-store": "1.5.5"
} }
} }

View File

@@ -1,9 +1,9 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { reducer as profilePageReducer } from '../profile'; import { reducer as profilePage } from '../profile';
const createRootReducer = () => combineReducers({ const createRootReducer = () => combineReducers({
profilePage: profilePageReducer, profilePage,
}); });
export default createRootReducer; export default createRootReducer;

View File

@@ -1,4 +1,5 @@
import { all } from 'redux-saga/effects'; import { all } from 'redux-saga/effects';
import { saga as profileSaga } from '../profile'; import { saga as profileSaga } from '../profile';
export default function* rootSaga() { export default function* rootSaga() {

View File

@@ -1,26 +1,21 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import messages from './messages'; import messages from './messages';
const Head = () => { const Head = ({ intl }) => (
const intl = useIntl();
return (
<Helmet> <Helmet>
<title> <title>
{intl.formatMessage(messages['profile.page.title'], { {intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
siteName: getConfig().SITE_NAME,
})}
</title> </title>
<link <link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
rel="shortcut icon"
href={getConfig().FAVICON_URL}
type="image/x-icon"
/>
</Helmet> </Helmet>
); );
Head.propTypes = {
intl: intlShape.isRequired,
}; };
export default Head; export default injectIntl(Head);

View File

@@ -13,7 +13,7 @@ import {
ErrorPage, ErrorPage,
} from '@edx/frontend-platform/react'; } from '@edx/frontend-platform/react';
import React from 'react'; import React, { StrictMode } from 'react';
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@@ -23,15 +23,15 @@ import { FooterSlot } from '@edx/frontend-component-footer';
import messages from './i18n'; import messages from './i18n';
import configureStore from './data/configureStore'; import configureStore from './data/configureStore';
import './index.scss';
import Head from './head/Head'; import Head from './head/Head';
import AppRoutes from './routes/AppRoutes'; import AppRoutes from './routes/AppRoutes';
import './index.scss';
const rootNode = createRoot(document.getElementById('root')); const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, async () => { subscribe(APP_READY, () => {
rootNode.render( rootNode.render(
<StrictMode>
<AppProvider store={configureStore()}> <AppProvider store={configureStore()}>
<Head /> <Head />
<Header /> <Header />
@@ -39,13 +39,13 @@ subscribe(APP_READY, async () => {
<AppRoutes /> <AppRoutes />
</main> </main>
<FooterSlot /> <FooterSlot />
</AppProvider>, </AppProvider>
document.getElementById('root'), </StrictMode>,
); );
}); });
subscribe(APP_INIT_ERROR, (error) => { subscribe(APP_INIT_ERROR, (error) => {
rootNode.render(<ErrorPage message={error.message} />, document.getElementById('root')); rootNode.render(<ErrorPage message={error.message} />);
}); });
initialize({ initialize({
@@ -56,7 +56,6 @@ initialize({
mergeConfig({ mergeConfig({
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH, COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE, ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
DISABLE_VISIBILITY_EDITING: process.env.DISABLE_VISIBILITY_EDITING,
}, 'App loadConfig override handler'); }, 'App loadConfig override handler');
}, },
}, },

View File

@@ -1,6 +1,8 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints; @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-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer"; @import "~@edx/frontend-component-footer/dist/footer";
@import 'profile/index'; @import './profile/index';

View File

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

View File

@@ -0,0 +1,43 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
const AgeMessage = ({ accountSettingsUrl }) => (
<Alert
variant="info"
dismissible={false}
show
>
<Alert.Heading id="profile.age.headline">
<FormattedMessage
id="profile.age.cannotShare"
defaultMessage="Your profile cannot be shared."
description="Error message indicating that the user's profile cannot be shared"
/>
</Alert.Heading>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
description="Error message"
tagName="p"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Alert.Link href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="Label on a link to set birthday"
/>
</Alert.Link>
</Alert>
);
AgeMessage.propTypes = {
accountSettingsUrl: PropTypes.string.isRequired,
};
export default AgeMessage;

5
src/profile/Banner.jsx Normal file
View File

@@ -0,0 +1,5 @@
import React from 'react';
const Banner = () => <div className="profile-page-bg-banner bg-primary d-md-block p-relative" />;
export default Banner;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,14 @@
import React, { import React from 'react';
useEffect, useState, useContext, useCallback,
} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { ensureConfig } from '@edx/frontend-platform'; import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { import { Alert, Hyperlink } from '@openedx/paragon';
Alert, Hyperlink, OverlayTrigger, Tooltip,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import classNames from 'classnames';
// Actions
import { import {
fetchProfile, fetchProfile,
saveProfile, saveProfile,
@@ -25,6 +19,7 @@ import {
updateDraft, updateDraft,
} from './data/actions'; } from './data/actions';
// Components
import ProfileAvatar from './forms/ProfileAvatar'; import ProfileAvatar from './forms/ProfileAvatar';
import Name from './forms/Name'; import Name from './forms/Name';
import Country from './forms/Country'; import Country from './forms/Country';
@@ -32,124 +27,122 @@ import PreferredLanguage from './forms/PreferredLanguage';
import Education from './forms/Education'; import Education from './forms/Education';
import SocialLinks from './forms/SocialLinks'; import SocialLinks from './forms/SocialLinks';
import Bio from './forms/Bio'; import Bio from './forms/Bio';
import Certificates from './forms/Certificates';
import AgeMessage from './AgeMessage';
import DateJoined from './DateJoined'; import DateJoined from './DateJoined';
import UserCertificateSummary from './UserCertificateSummary'; import UsernameDescription from './UsernameDescription';
import PageLoading from './PageLoading'; import PageLoading from './PageLoading';
import Certificates from './Certificates'; import Banner from './Banner';
import LearningGoal from './forms/LearningGoal';
// Selectors
import { profilePageSelector } from './data/selectors'; import { profilePageSelector } from './data/selectors';
// i18n
import messages from './ProfilePage.messages'; import messages from './ProfilePage.messages';
import withParams from '../utils/hoc'; import withParams from '../utils/hoc';
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot'; import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage'); ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
const ProfilePage = ({ params }) => { class ProfilePage extends React.Component {
const dispatch = useDispatch(); constructor(props, context) {
const intl = useIntl(); super(props, context);
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 credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null); this.state = {
const isMobileView = useIsOnMobileScreen(); viewMyRecordsUrl: credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null,
const isTabletView = useIsOnTabletScreen(); accountSettingsUrl: context.config.ACCOUNT_SETTINGS_URL,
};
useEffect(() => { this.handleSaveProfilePhoto = this.handleSaveProfilePhoto.bind(this);
const { CREDENTIALS_BASE_URL } = context.config; this.handleDeleteProfilePhoto = this.handleDeleteProfilePhoto.bind(this);
if (CREDENTIALS_BASE_URL) { this.handleClose = this.handleClose.bind(this);
setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`); this.handleOpen = this.handleOpen.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
} }
dispatch(fetchProfile(params.username)); componentDidMount() {
this.props.fetchProfile(this.props.params.username);
sendTrackingLogEvent('edx.profile.viewed', { sendTrackingLogEvent('edx.profile.viewed', {
username: params.username, username: this.props.params.username,
}); });
}, [dispatch, params.username, context.config]);
useEffect(() => {
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
} }
}, [username, saveState, navigate]);
const authenticatedUserName = context.authenticatedUser.username; handleSaveProfilePhoto(formData) {
this.props.saveProfilePhoto(this.context.authenticatedUser.username, formData);
}
const handleSaveProfilePhoto = useCallback((formData) => { handleDeleteProfilePhoto() {
dispatch(saveProfilePhoto(authenticatedUserName, formData)); this.props.deleteProfilePhoto(this.context.authenticatedUser.username);
}, [dispatch, authenticatedUserName]); }
const handleDeleteProfilePhoto = useCallback(() => { handleClose(formId) {
dispatch(deleteProfilePhoto(authenticatedUserName)); this.props.closeForm(formId);
}, [dispatch, authenticatedUserName]); }
const handleClose = useCallback((formId) => { handleOpen(formId) {
dispatch(closeForm(formId)); this.props.openForm(formId);
}, [dispatch]); }
const handleOpen = useCallback((formId) => { handleSubmit(formId) {
dispatch(openForm(formId)); this.props.saveProfile(formId, this.context.authenticatedUser.username);
}, [dispatch]); }
const handleSubmit = useCallback((formId) => { handleChange(name, value) {
dispatch(saveProfile(formId, authenticatedUserName)); this.props.updateDraft(name, value);
}, [dispatch, authenticatedUserName]); }
const handleChange = useCallback((fieldName, value) => { isYOBDisabled() {
dispatch(updateDraft(fieldName, value)); const { yearOfBirth } = this.props;
}, [dispatch]); const currentYear = new Date().getFullYear();
const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
const isAuthenticatedUserProfile = () => params.username === authenticatedUserName; return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
}
const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile() isAuthenticatedUserProfile() {
|| (!isAuthenticatedUserProfile() && Boolean(blockInfo)); return this.props.params.username === this.context.authenticatedUser.username;
}
const renderViewMyRecordsButton = () => { // Inserted into the DOM in two places (for responsive layout)
if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) { renderViewMyRecordsButton() {
if (!(this.state.viewMyRecordsUrl && this.isAuthenticatedUserProfile())) {
return null; return null;
} }
return ( return (
<Hyperlink <Hyperlink className="btn btn-primary" destination={this.state.viewMyRecordsUrl} target="_blank">
className={classNames( {this.props.intl.formatMessage(messages['profile.viewMyRecords'])}
'btn btn-brand bg-brand-500 font-weight-normal px-4 py-10px text-nowrap',
{ 'w-100': isMobileView },
)}
target="_blank"
showLaunchIcon={false}
destination={viewMyRecordsUrl}
>
{intl.formatMessage(messages['profile.viewMyRecords'])}
</Hyperlink> </Hyperlink>
); );
}; }
const renderPhotoUploadErrorMessage = () => ( // Inserted into the DOM in two places (for responsive layout)
photoUploadError && ( renderHeadingLockup() {
const { dateJoined } = this.props;
return (
<span data-hj-suppress>
<h1 className="h2 mb-0 font-weight-bold text-truncate">{this.props.params.username}</h1>
<DateJoined date={dateJoined} />
{this.isYOBDisabled() && <UsernameDescription />}
<hr className="d-none d-md-block" />
</span>
);
}
renderPhotoUploadErrorMessage() {
const { photoUploadError } = this.props;
if (photoUploadError === null) {
return null;
}
return (
<div className="row"> <div className="row">
<div className="col-md-4 col-lg-3"> <div className="col-md-4 col-lg-3">
<Alert variant="danger" dismissible={false} show> <Alert variant="danger" dismissible={false} show>
@@ -157,175 +150,115 @@ const ProfilePage = ({ params }) => {
</Alert> </Alert>
</div> </div>
</div> </div>
)
); );
}
renderAgeMessage() {
const { requiresParentalConsent } = this.props;
const shouldShowAgeMessage = requiresParentalConsent && this.isAuthenticatedUserProfile();
if (!shouldShowAgeMessage) {
return null;
}
return <AgeMessage accountSettingsUrl={this.state.accountSettingsUrl} />;
}
renderContent() {
const {
profileImage,
name,
visibilityName,
country,
visibilityCountry,
levelOfEducation,
visibilityLevelOfEducation,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
learningGoal,
visibilityLearningGoal,
languageProficiencies,
visibilityLanguageProficiencies,
courseCertificates,
visibilityCourseCertificates,
bio,
visibilityBio,
requiresParentalConsent,
isLoadingProfile,
username,
saveState,
navigate,
} = this.props;
if (isLoadingProfile) {
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
}
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
}
const commonFormProps = { const commonFormProps = {
openHandler: handleOpen, openHandler: this.handleOpen,
closeHandler: handleClose, closeHandler: this.handleClose,
submitHandler: handleSubmit, submitHandler: this.handleSubmit,
changeHandler: handleChange, changeHandler: this.handleChange,
}; };
const isBlockVisible = (blockInfo) => this.isAuthenticatedUserProfile()
|| (!this.isAuthenticatedUserProfile() && Boolean(blockInfo));
const isLanguageBlockVisible = isBlockVisible(languageProficiencies.length);
const isEducationBlockVisible = isBlockVisible(levelOfEducation);
const isSocialLinksBLockVisible = isBlockVisible(socialLinks.some((link) => link.socialLink !== null));
const isBioBlockVisible = isBlockVisible(bio);
const isCertificatesBlockVisible = isBlockVisible(courseCertificates.length);
const isNameBlockVisible = isBlockVisible(name);
const isLocationBlockVisible = isBlockVisible(country);
return ( return (
<div className="profile-page"> <div className="container-fluid">
{isLoadingProfile ? ( <div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
<PageLoading srMessage={intl.formatMessage(messages['profile.loading'])} /> <div className="col-auto col-md-4 col-lg-3">
) : ( <div className="d-flex align-items-center d-md-block">
<>
<div
className={classNames(
'profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100',
{ 'px-3 py-4': isMobileView },
{ 'px-120px py-5.5': !isMobileView },
)}
>
<div
className={classNames([
'col container-fluid w-100 h-100 bg-white py-0 rounded-75',
{
'px-3': isMobileView,
'px-40px': !isMobileView,
},
])}
>
<div
className={classNames([
'col h-100 w-100 px-0 justify-content-start g-15rem',
{
'py-4': isMobileView,
'py-36px': !isMobileView,
},
])}
>
<div
className={classNames([
'row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem',
isMobileView || isTabletView ? 'flex-column' : 'flex-row',
])}
>
<ProfileAvatar <ProfileAvatar
className="col p-0" className="mb-md-3"
src={profileImage.src} src={profileImage.src}
isDefault={profileImage.isDefault} isDefault={profileImage.isDefault}
onSave={handleSaveProfilePhoto} onSave={this.handleSaveProfilePhoto}
onDelete={handleDeleteProfilePhoto} onDelete={this.handleDeleteProfilePhoto}
savePhotoState={savePhotoState} savePhotoState={this.props.savePhotoState}
isEditable={isAuthenticatedUserProfile()} isEditable={this.isAuthenticatedUserProfile() && !requiresParentalConsent}
/> />
<div
className={classNames([
'col h-100 w-100 m-0 p-0',
isMobileView || isTabletView
? 'd-flex flex-column justify-content-center align-items-center'
: 'justify-content-start align-items-start',
])}
>
<p className="row m-0 font-weight-bold text-truncate text-primary-500 h3">
{params.username}
</p>
{isBlockVisible(name) && (
<p className="row pt-2 text-gray-800 font-weight-normal m-0 p">
{name}
</p>
)}
<div className={classNames(
'row pt-2 m-0',
isMobileView
? 'd-flex justify-content-center align-items-center flex-column'
: 'g-1rem',
)}
>
<DateJoined date={dateJoined} />
<UserCertificateSummary count={courseCertificates?.length || 0} />
</div> </div>
</div> </div>
<div className={classNames([ <div className="col">
'p-0 ', <div className="d-md-none">
isMobileView || isTabletView ? 'col d-flex justify-content-center' : 'col-auto', {this.renderHeadingLockup()}
])} </div>
> <div className="d-none d-md-block float-right">
{renderViewMyRecordsButton()} {this.renderViewMyRecordsButton()}
</div> </div>
</div> </div>
</div> </div>
<div className="ml-auto"> {this.renderPhotoUploadErrorMessage()}
{renderPhotoUploadErrorMessage()} <div className="row">
<div className="col-md-4 col-lg-4">
<div className="d-none d-md-block mb-4">
{this.renderHeadingLockup()}
</div> </div>
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div> </div>
</div> {isNameBlockVisible && (
<div
className={classNames([
'col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem',
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
])}
>
<div className="w-100 p-0">
<div className="col justify-content-start align-items-start p-0">
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
<p className="font-weight-bold text-primary-500 m-0 h2">
{isMobileView ? (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile"
description="heading for the editable profile section in mobile view"
/>
)
: (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile information"
description="heading for the editable profile section"
/>
)}
</p>
</div>
</div>
<div
className={classNames([
'row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start',
isMobileView ? 'pt-4' : 'pt-5.5',
])}
>
<div
className={classNames([
'col p-0',
isMobileView ? 'col-12' : 'col-6',
])}
>
<div className="m-0">
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.username'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.username.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<h4 className="edit-section-header text-gray-700">
{params.username}
</h4>
</div>
{isBlockVisible(name) && (
<Name <Name
name={name} name={name}
accountSettingsUrl={context.config.ACCOUNT_SETTINGS_URL}
visibilityName={visibilityName} visibilityName={visibilityName}
formId="name" formId="name"
{...commonFormProps} {...commonFormProps}
/> />
)} )}
{isBlockVisible(country) && ( {isLocationBlockVisible && (
<Country <Country
country={country} country={country}
visibilityCountry={visibilityCountry} visibilityCountry={visibilityCountry}
@@ -333,15 +266,15 @@ const ProfilePage = ({ params }) => {
{...commonFormProps} {...commonFormProps}
/> />
)} )}
{isBlockVisible((languageProficiencies || []).length) && ( {isLanguageBlockVisible && (
<PreferredLanguage <PreferredLanguage
languageProficiencies={languageProficiencies || []} languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies} visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies" formId="languageProficiencies"
{...commonFormProps} {...commonFormProps}
/> />
)} )}
{isBlockVisible(levelOfEducation) && ( {isEducationBlockVisible && (
<Education <Education
levelOfEducation={levelOfEducation} levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation} visibilityLevelOfEducation={visibilityLevelOfEducation}
@@ -349,16 +282,22 @@ const ProfilePage = ({ params }) => {
{...commonFormProps} {...commonFormProps}
/> />
)} )}
{isSocialLinksBLockVisible && (
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
)}
<div className="mb-4">
<AdditionalProfileFieldsSlot /> <AdditionalProfileFieldsSlot />
</div> </div>
<div </div>
className={classNames([ <div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
'col m-0 pr-0', {!this.isYOBDisabled() && this.renderAgeMessage()}
isMobileView ? 'pl-0 col-12' : 'pl-40px col-6', {isBioBlockVisible && (
])}
>
{isBlockVisible(bio) && (
<Bio <Bio
bio={bio} bio={bio}
visibilityBio={visibilityBio} visibilityBio={visibilityBio}
@@ -366,61 +305,75 @@ const ProfilePage = ({ params }) => {
{...commonFormProps} {...commonFormProps}
/> />
)} )}
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && ( <LearningGoal
<SocialLinks learningGoal={learningGoal}
socialLinks={socialLinks || []} visibilityLearningGoal={visibilityLearningGoal}
draftSocialLinksByPlatform={draftSocialLinksByPlatform || {}} formId="learningGoal"
visibilitySocialLinks={visibilitySocialLinks} {...commonFormProps}
formId="socialLinks" />
)}
{isCertificatesBlockVisible && (
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps} {...commonFormProps}
/> />
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> );
<div }
className={classNames([
'col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem', render() {
isMobileView ? 'py-4 px-3' : 'px-120px py-6', return (
])} <div className="profile-page">
> <Banner />
{isBlockVisible((courseCertificates || []).length) && ( {this.renderContent()}
<Certificates
certificates={courseCertificates || []}
formId="certificates"
/>
)}
</div>
</>
)}
</div> </div>
); );
}; }
}
ProfilePage.contextType = AppContext;
ProfilePage.propTypes = { ProfilePage.propTypes = {
params: PropTypes.shape({ // Account data
username: PropTypes.string.isRequired,
}).isRequired,
requiresParentalConsent: PropTypes.bool, requiresParentalConsent: PropTypes.bool,
dateJoined: PropTypes.string, dateJoined: PropTypes.string,
username: PropTypes.string, username: PropTypes.string,
// Bio form data
bio: PropTypes.string, bio: PropTypes.string,
visibilityBio: PropTypes.string, yearOfBirth: PropTypes.number,
visibilityBio: PropTypes.string.isRequired,
// Certificates form data
courseCertificates: PropTypes.arrayOf(PropTypes.shape({ courseCertificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string, title: PropTypes.string,
})), })),
visibilityCourseCertificates: PropTypes.string.isRequired,
// Country form data
country: PropTypes.string, country: PropTypes.string,
visibilityCountry: PropTypes.string, visibilityCountry: PropTypes.string.isRequired,
// Education form data
levelOfEducation: PropTypes.string, levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.string, visibilityLevelOfEducation: PropTypes.string.isRequired,
// Language proficiency form data
languageProficiencies: PropTypes.arrayOf(PropTypes.shape({ languageProficiencies: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired, code: PropTypes.string.isRequired,
})), })),
visibilityLanguageProficiencies: PropTypes.string, visibilityLanguageProficiencies: PropTypes.string.isRequired,
// Name form data
name: PropTypes.string, name: PropTypes.string,
visibilityName: PropTypes.string, visibilityName: PropTypes.string.isRequired,
// Social links form data
socialLinks: PropTypes.arrayOf(PropTypes.shape({ socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string, platform: PropTypes.string,
socialLink: PropTypes.string, socialLink: PropTypes.string,
@@ -429,15 +382,41 @@ ProfilePage.propTypes = {
platform: PropTypes.string, platform: PropTypes.string,
socialLink: PropTypes.string, socialLink: PropTypes.string,
})), })),
visibilitySocialLinks: PropTypes.string, visibilitySocialLinks: PropTypes.string.isRequired,
// Learning Goal form data
learningGoal: PropTypes.string,
visibilityLearningGoal: PropTypes.string.isRequired,
// Other data we need
profileImage: PropTypes.shape({ profileImage: PropTypes.shape({
src: PropTypes.string, src: PropTypes.string,
isDefault: PropTypes.bool, isDefault: PropTypes.bool,
}), }),
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']), saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']), savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isLoadingProfile: PropTypes.bool, isLoadingProfile: PropTypes.bool.isRequired,
// Page state helpers
photoUploadError: PropTypes.objectOf(PropTypes.string), photoUploadError: PropTypes.objectOf(PropTypes.string),
// Actions
fetchProfile: PropTypes.func.isRequired,
saveProfile: PropTypes.func.isRequired,
saveProfilePhoto: PropTypes.func.isRequired,
deleteProfilePhoto: PropTypes.func.isRequired,
openForm: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
// Router
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
// i18n
intl: intlShape.isRequired,
}; };
ProfilePage.defaultProps = { ProfilePage.defaultProps = {
@@ -447,22 +426,28 @@ ProfilePage.defaultProps = {
photoUploadError: {}, photoUploadError: {},
profileImage: {}, profileImage: {},
name: null, name: null,
yearOfBirth: null,
levelOfEducation: null, levelOfEducation: null,
country: null, country: null,
socialLinks: [], socialLinks: [],
draftSocialLinksByPlatform: {}, draftSocialLinksByPlatform: {},
bio: null, bio: null,
learningGoal: null,
languageProficiencies: [], languageProficiencies: [],
courseCertificates: [], courseCertificates: null,
requiresParentalConsent: null, requiresParentalConsent: null,
dateJoined: null, dateJoined: null,
visibilityName: null,
visibilityCountry: null,
visibilityLevelOfEducation: null,
visibilitySocialLinks: null,
visibilityLanguageProficiencies: null,
visibilityBio: null,
isLoadingProfile: false,
}; };
export default withParams(ProfilePage); export default connect(
profilePageSelector,
{
fetchProfile,
saveProfilePhoto,
deleteProfilePhoto,
saveProfile,
openForm,
closeForm,
updateDraft,
},
)(injectIntl(withParams(ProfilePage)));

View File

@@ -11,16 +11,6 @@ const messages = defineMessages({
defaultMessage: 'Profile loading...', defaultMessage: 'Profile loading...',
description: 'Message displayed when the profile data is 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; export default messages;

View File

@@ -1,3 +1,4 @@
/* eslint-disable global-require */
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import * as analytics from '@edx/frontend-platform/analytics'; import * as analytics from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext } from '@edx/frontend-platform/react';
@@ -8,38 +9,31 @@ import PropTypes from 'prop-types';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { import { BrowserRouter, useNavigate } from 'react-router-dom';
MemoryRouter,
Routes,
Route,
useNavigate,
} from 'react-router-dom';
import messages from '../i18n'; import messages from '../i18n';
import ProfilePage from './ProfilePage'; 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 mockStore = configureMockStore([thunk]);
const storeMocks = { const storeMocks = {
loadingApp, loadingApp: require('./__mocks__/loadingApp.mockStore'),
viewOwnProfile, invalidUser: require('./__mocks__/invalidUser.mockStore'),
viewOtherProfile, viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'),
invalidUser, viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'),
savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'),
}; };
const requiredProfilePageProps = { const requiredProfilePageProps = {
fetchUserAccount: () => {},
fetchProfile: () => {},
saveProfile: () => {},
saveProfilePhoto: () => {},
deleteProfilePhoto: () => {},
openField: () => {},
closeField: () => {},
params: { username: 'staff' }, params: { username: 'staff' },
}; };
// Mock language cookie
Object.defineProperty(global.document, 'cookie', { Object.defineProperty(global.document, 'cookie', {
writable: true, writable: true,
value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`, value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`,
@@ -71,39 +65,54 @@ configureI18n({
beforeEach(() => { beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset(); analytics.sendTrackingLogEvent.mockReset();
useNavigate.mockReset();
}); });
const ProfileWrapper = ({ params, requiresParentalConsent }) => {
const navigate = useNavigate();
return (
<ProfilePage
{...requiredProfilePageProps}
params={params}
requiresParentalConsent={requiresParentalConsent}
navigate={navigate}
/>
);
};
ProfileWrapper.propTypes = {
params: PropTypes.shape({}).isRequired,
requiresParentalConsent: PropTypes.bool.isRequired,
};
const ProfilePageWrapper = ({ const ProfilePageWrapper = ({
contextValue, store, params, contextValue, store, params, requiresParentalConsent,
}) => ( }) => (
<AppContext.Provider value={contextValue}> <AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en"> <IntlProvider locale="en">
<Provider store={store}> <Provider store={store}>
<MemoryRouter initialEntries={[`/profile/${params.username}`]}> <BrowserRouter>
<Routes> <ProfileWrapper
<Route params={params}
path="/profile/:username" requiresParentalConsent={requiresParentalConsent}
element={<ProfilePage {...requiredProfilePageProps} params={params} />}
/> />
</Routes> </BrowserRouter>
</MemoryRouter>
</Provider> </Provider>
</IntlProvider> </IntlProvider>
</AppContext.Provider> </AppContext.Provider>
); );
ProfilePageWrapper.defaultProps = { ProfilePageWrapper.defaultProps = {
// eslint-disable-next-line react/default-props-match-prop-types
params: { username: 'staff' }, params: { username: 'staff' },
requiresParentalConsent: null,
}; };
ProfilePageWrapper.propTypes = { ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired, contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired, store: PropTypes.shape({}).isRequired,
params: PropTypes.shape({ params: PropTypes.shape({}),
username: PropTypes.string.isRequired, requiresParentalConsent: PropTypes.bool,
}).isRequired,
}; };
describe('<ProfilePage />', () => { describe('<ProfilePage />', () => {
@@ -113,12 +122,17 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: null, username: null, administrator: false }, authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(), config: getConfig(),
}; };
const component = ( const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.loadingApp)} />;
<ProfilePageWrapper const { container: tree } = render(component);
contextValue={contextValue} expect(tree).toMatchSnapshot();
store={mockStore(storeMocks.loadingApp)} });
/>
); it('successfully redirected to not found page.', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.invalidUser)} />;
const { container: tree } = render(component); const { container: tree } = render(component);
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -128,12 +142,7 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: 123, username: 'staff', administrator: true }, authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(), config: getConfig(),
}; };
const component = ( const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.viewOwnProfile)} />;
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
);
const { container: tree } = render(component); const { container: tree } = render(component);
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
@@ -143,6 +152,7 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: 123, username: 'staff', administrator: true }, authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(), config: getConfig(),
}; };
const component = ( const component = (
<ProfilePageWrapper <ProfilePageWrapper
contextValue={contextValue} contextValue={contextValue}
@@ -152,26 +162,105 @@ describe('<ProfilePage />', () => {
...storeMocks.viewOtherProfile.profilePage, ...storeMocks.viewOtherProfile.profilePage,
account: { account: {
...storeMocks.viewOtherProfile.profilePage.account, ...storeMocks.viewOtherProfile.profilePage.account,
name: 'Verified User', name: 'user',
country: 'US', country: 'EN',
bio: 'About me', bio: 'bio',
courseCertificates: [{ title: 'Course 1' }], courseCertificates: ['course certificates'],
levelOfEducation: 'bachelors', levelOfEducation: 'some level',
languageProficiencies: [{ code: 'en' }], languageProficiencies: ['some lang'],
socialLinks: [{ platform: 'twitter', socialLink: 'https://twitter.com/user' }], socialLinks: ['twitter'],
}, timeZone: 'time zone',
preferences: { accountPrivacy: 'all_users',
...storeMocks.viewOtherProfile.profilePage.preferences,
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users',
}, },
}, },
})} })}
params={{ username: 'verified' }} match={{ params: { username: 'verified' } }} // Override default match
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.savingEditedBio)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.bio = { userMessage: 'bio error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test country edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.country = { userMessage: 'country error' };
storeData.profilePage.currentlyEditingField = 'country';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test education edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' };
storeData.profilePage.currentlyEditingField = 'levelOfEducation';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test preferreded language edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' };
storeData.profilePage.currentlyEditingField = 'languageProficiencies';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/> />
); );
const { container: tree } = render(component); const { container: tree } = render(component);
@@ -195,24 +284,40 @@ describe('<ProfilePage />', () => {
const { container: tree } = render(component); const { container: tree } = render(component);
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
it('test age message alert', () => {
it('successfully redirected to not found page', () => { const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.userAccount.requiresParentalConsent = true;
storeData.profilePage.account.requiresParentalConsent = true;
const contextValue = { const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true }, authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(), config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
}; };
const navigate = jest.fn(); const { container } = render(
useNavigate.mockReturnValue(navigate);
const component = (
<ProfilePageWrapper <ProfilePageWrapper
contextValue={contextValue} contextValue={contextValue}
store={mockStore(storeMocks.invalidUser)} store={mockStore(storeData)}
params={{ username: 'staffTest' }} requiresParentalConsent
/> />,
); );
const { container: tree } = render(component);
expect(tree).toMatchSnapshot(); expect(container.querySelector('.alert-info')).toHaveClass('show');
expect(navigate).toHaveBeenCalledWith('/notfound'); });
it('test photo error alert', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.profilePage.errors.photo = { userMessage: 'error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
};
const { container } = render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
requiresParentalConsent
/>,
);
expect(container.querySelector('.alert-danger')).toHaveClass('show');
}); });
}); });
@@ -230,30 +335,11 @@ describe('<ProfilePage />', () => {
/>, />,
); );
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledTimes(1); expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledWith('edx.profile.viewed', { expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');
expect(analytics.sendTrackingLogEvent.mock.calls[0][1]).toEqual({
username: 'test-username', username: 'test-username',
}); });
}); });
}); });
describe('handles navigation', () => {
it('navigates to notfound on save error with no username', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const navigate = jest.fn();
useNavigate.mockReturnValue(navigate);
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.invalidUser)}
params={{ username: 'staffTest' }}
/>,
);
expect(navigate).toHaveBeenCalledWith('/notfound');
});
});
}); });

View File

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

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { VisibilityOff } from '@openedx/paragon/icons';
import { Icon } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
const UsernameDescription = () => (
<div className="d-flex align-items-center mt-3 mb-2rem">
<Icon src={VisibilityOff} className="icon-visibility-off" />
<div className="username-description">
<FormattedMessage
id="profile.username.description"
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</div>
</div>
);
export default UsernameDescription;

View File

@@ -29,7 +29,6 @@ module.exports = {
drafts: {}, drafts: {},
isLoadingProfile: false, isLoadingProfile: false,
isAuthenticatedUserProfile: true, isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
}, },
router: { router: {
location: { location: {

View File

@@ -29,7 +29,6 @@ module.exports = {
drafts: {}, drafts: {},
isLoadingProfile: true, isLoadingProfile: true,
isAuthenticatedUserProfile: true, isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
}, },
router: { router: {
location: { location: {

View File

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

View File

@@ -81,18 +81,11 @@ module.exports = {
gender: null, gender: null,
accountPrivacy: 'private' accountPrivacy: 'private'
}, },
preferences: { preferences: {},
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users'
},
courseCertificates: [], courseCertificates: [],
drafts: {}, drafts: {},
isLoadingProfile: false, isLoadingProfile: false,
countriesCodesList: ['US', 'CA', 'GB', 'ME'] learningGoal: 'advance_career',
}, },
router: { router: {
location: { location: {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT'; export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS'; export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH PROFILE ACTIONS
export const fetchProfile = username => ({ export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE, type: FETCH_PROFILE.BASE,
payload: { username }, payload: { username },
@@ -23,20 +25,20 @@ export const fetchProfileSuccess = (
preferences, preferences,
courseCertificates, courseCertificates,
isAuthenticatedUserProfile, isAuthenticatedUserProfile,
countriesCodesList,
) => ({ ) => ({
type: FETCH_PROFILE.SUCCESS, type: FETCH_PROFILE.SUCCESS,
account, account,
preferences, preferences,
courseCertificates, courseCertificates,
isAuthenticatedUserProfile, isAuthenticatedUserProfile,
countriesCodesList,
}); });
export const fetchProfileReset = () => ({ export const fetchProfileReset = () => ({
type: FETCH_PROFILE.RESET, type: FETCH_PROFILE.RESET,
}); });
// SAVE PROFILE ACTIONS
export const saveProfile = (formId, username) => ({ export const saveProfile = (formId, username) => ({
type: SAVE_PROFILE.BASE, type: SAVE_PROFILE.BASE,
payload: { payload: {
@@ -66,6 +68,8 @@ export const saveProfileFailure = errors => ({
payload: { errors }, payload: { errors },
}); });
// SAVE PROFILE PHOTO ACTIONS
export const saveProfilePhoto = (username, formData) => ({ export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE, type: SAVE_PROFILE_PHOTO.BASE,
payload: { payload: {
@@ -92,6 +96,8 @@ export const saveProfilePhotoFailure = error => ({
payload: { error }, payload: { error },
}); });
// DELETE PROFILE PHOTO ACTIONS
export const deleteProfilePhoto = username => ({ export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE, type: DELETE_PROFILE_PHOTO.BASE,
payload: { payload: {
@@ -112,6 +118,8 @@ export const deleteProfilePhotoReset = () => ({
type: DELETE_PROFILE_PHOTO.RESET, type: DELETE_PROFILE_PHOTO.RESET,
}); });
// FIELD STATE ACTIONS
export const openForm = formId => ({ export const openForm = formId => ({
type: OPEN_FORM, type: OPEN_FORM,
payload: { payload: {
@@ -126,6 +134,8 @@ export const closeForm = formId => ({
}, },
}); });
// FORM STATE ACTIONS
export const updateDraft = (name, value) => ({ export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT, type: UPDATE_DRAFT,
payload: { payload: {

View File

@@ -1,4 +1,14 @@
import { import {
openForm,
closeForm,
OPEN_FORM,
CLOSE_FORM,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
saveProfileFailure,
saveProfileReset,
saveProfile,
SAVE_PROFILE_PHOTO, SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin, saveProfilePhotoBegin,
saveProfilePhotoSuccess, saveProfilePhotoSuccess,
@@ -12,6 +22,76 @@ import {
deleteProfilePhoto, deleteProfilePhoto,
} from './actions'; } from './actions';
describe('editable field actions', () => {
it('should create an open action', () => {
const expectedAction = {
type: OPEN_FORM,
payload: {
formId: 'name',
},
};
expect(openForm('name')).toEqual(expectedAction);
});
it('should create a closed action', () => {
const expectedAction = {
type: CLOSE_FORM,
payload: {
formId: 'name',
},
};
expect(closeForm('name')).toEqual(expectedAction);
});
});
describe('SAVE profile actions', () => {
it('should create an action to signal the start of a profile save', () => {
const expectedAction = {
type: SAVE_PROFILE.BASE,
payload: {
formId: 'name',
},
};
expect(saveProfile('name')).toEqual(expectedAction);
});
it('should create an action to signal user profile save success', () => {
const accountData = { name: 'Full Name' };
const preferencesData = { visibility: { name: 'private' } };
const expectedAction = {
type: SAVE_PROFILE.SUCCESS,
payload: {
account: accountData,
preferences: preferencesData,
},
};
expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction);
});
it('should create an action to signal user profile save beginning', () => {
const expectedAction = {
type: SAVE_PROFILE.BEGIN,
};
expect(saveProfileBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile save success', () => {
const expectedAction = {
type: SAVE_PROFILE.RESET,
};
expect(saveProfileReset()).toEqual(expectedAction);
});
it('should create an action to signal user account save failure', () => {
const errors = ['Test failure'];
const expectedAction = {
type: SAVE_PROFILE.FAILURE,
payload: { errors },
};
expect(saveProfileFailure(errors)).toEqual(expectedAction);
});
});
describe('SAVE profile photo actions', () => { describe('SAVE profile photo actions', () => {
it('should create an action to signal the start of a profile photo save', () => { it('should create an action to signal the start of a profile photo save', () => {
const formData = 'multipart form data'; const formData = 'multipart form data';
@@ -43,7 +123,7 @@ describe('SAVE profile photo actions', () => {
expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction); expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction);
}); });
it('should create an action to signal user profile photo save reset', () => { it('should create an action to signal user profile photo save success', () => {
const expectedAction = { const expectedAction = {
type: SAVE_PROFILE_PHOTO.RESET, type: SAVE_PROFILE_PHOTO.RESET,
}; };
@@ -89,10 +169,34 @@ describe('DELETE profile photo actions', () => {
expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction); expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction);
}); });
it('should create an action to signal user profile photo deletion reset', () => { it('should create an action to signal user profile photo deletion success', () => {
const expectedAction = { const expectedAction = {
type: DELETE_PROFILE_PHOTO.RESET, type: DELETE_PROFILE_PHOTO.RESET,
}; };
expect(deleteProfilePhotoReset()).toEqual(expectedAction); expect(deleteProfilePhotoReset()).toEqual(expectedAction);
}); });
}); });
describe('Editable field opening and closing actions', () => {
const formId = 'name';
it('should create an action to signal the opening a field', () => {
const expectedAction = {
type: OPEN_FORM,
payload: {
formId,
},
};
expect(openForm(formId)).toEqual(expectedAction);
});
it('should create an action to signal the closing a field', () => {
const expectedAction = {
type: CLOSE_FORM,
payload: {
formId,
},
};
expect(closeForm(formId)).toEqual(expectedAction);
});
});

View File

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

View File

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

View File

@@ -17,10 +17,6 @@ const expectedUserInfo200 = {
dateJoined: '2017-06-07T00:44:23Z', dateJoined: '2017-06-07T00:44:23Z',
isActive: true, isActive: true,
yearOfBirth: 1901, yearOfBirth: 1901,
languageProficiencies: [],
levelOfEducation: null,
profileImage: {},
socialLinks: [],
}; };
const provider = new PactV3({ const provider = new PactV3({

View File

@@ -16,28 +16,12 @@ export const initialState = {
currentlyEditingField: null, currentlyEditingField: null,
account: { account: {
socialLinks: [], socialLinks: [],
languageProficiencies: [],
name: '',
bio: '',
country: '',
levelOfEducation: '',
profileImage: {},
yearOfBirth: '',
},
preferences: {
visibilityName: '',
visibilityBio: '',
visibilityCountry: '',
visibilityLevelOfEducation: '',
visibilitySocialLinks: '',
visibilityLanguageProficiencies: '',
}, },
preferences: {},
courseCertificates: [], courseCertificates: [],
drafts: {}, drafts: {},
isLoadingProfile: true, isLoadingProfile: true,
isAuthenticatedUserProfile: false, isAuthenticatedUserProfile: false,
disabledCountries: ['RU'],
countriesCodesList: [],
}; };
const profilePage = (state = initialState, action = {}) => { const profilePage = (state = initialState, action = {}) => {
@@ -53,17 +37,11 @@ const profilePage = (state = initialState, action = {}) => {
case FETCH_PROFILE.SUCCESS: case FETCH_PROFILE.SUCCESS:
return { return {
...state, ...state,
account: { account: action.account,
...state.account,
...action.account,
socialLinks: action.account.socialLinks || [],
languageProficiencies: action.account.languageProficiencies || [],
},
preferences: action.preferences, preferences: action.preferences,
courseCertificates: action.courseCertificates || [], courseCertificates: action.courseCertificates,
isLoadingProfile: false, isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile, isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList || [],
}; };
case SAVE_PROFILE.BEGIN: case SAVE_PROFILE.BEGIN:
return { return {
@@ -76,12 +54,9 @@ const profilePage = (state = initialState, action = {}) => {
...state, ...state,
saveState: 'complete', saveState: 'complete',
errors: {}, errors: {},
account: action.payload.account !== null ? { // Account is always replaced completely.
...state.account, account: action.payload.account !== null ? action.payload.account : state.account,
...action.payload.account, // Preferences changes get merged in.
socialLinks: action.payload.account.socialLinks || [],
languageProficiencies: action.payload.account.languageProficiencies || [],
} : state.account,
preferences: { ...state.preferences, ...action.payload.preferences }, preferences: { ...state.preferences, ...action.payload.preferences },
}; };
case SAVE_PROFILE.FAILURE: case SAVE_PROFILE.FAILURE:
@@ -98,6 +73,7 @@ const profilePage = (state = initialState, action = {}) => {
isLoadingProfile: false, isLoadingProfile: false,
errors: {}, errors: {},
}; };
case SAVE_PROFILE_PHOTO.BEGIN: case SAVE_PROFILE_PHOTO.BEGIN:
return { return {
...state, ...state,
@@ -107,6 +83,7 @@ const profilePage = (state = initialState, action = {}) => {
case SAVE_PROFILE_PHOTO.SUCCESS: case SAVE_PROFILE_PHOTO.SUCCESS:
return { return {
...state, ...state,
// Merge in new profile image data
account: { ...state.account, profileImage: action.payload.profileImage }, account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete', savePhotoState: 'complete',
errors: {}, errors: {},
@@ -123,6 +100,7 @@ const profilePage = (state = initialState, action = {}) => {
savePhotoState: null, savePhotoState: null,
errors: {}, errors: {},
}; };
case DELETE_PROFILE_PHOTO.BEGIN: case DELETE_PROFILE_PHOTO.BEGIN:
return { return {
...state, ...state,
@@ -132,6 +110,7 @@ const profilePage = (state = initialState, action = {}) => {
case DELETE_PROFILE_PHOTO.SUCCESS: case DELETE_PROFILE_PHOTO.SUCCESS:
return { return {
...state, ...state,
// Merge in new profile image data (should be empty or default image)
account: { ...state.account, profileImage: action.payload.profileImage }, account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete', savePhotoState: 'complete',
errors: {}, errors: {},
@@ -148,11 +127,13 @@ const profilePage = (state = initialState, action = {}) => {
savePhotoState: null, savePhotoState: null,
errors: {}, errors: {},
}; };
case UPDATE_DRAFT: case UPDATE_DRAFT:
return { return {
...state, ...state,
drafts: { ...state.drafts, [action.payload.name]: action.payload.value }, drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
}; };
case RESET_DRAFTS: case RESET_DRAFTS:
return { return {
...state, ...state,
@@ -165,6 +146,7 @@ const profilePage = (state = initialState, action = {}) => {
drafts: {}, drafts: {},
}; };
case CLOSE_FORM: case CLOSE_FORM:
// Only close if the field to close is undefined or matches the field that is currently open
if (action.payload.formId === state.currentlyEditingField) { if (action.payload.formId === state.currentlyEditingField) {
return { return {
...state, ...state,

View File

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

View File

@@ -1,4 +1,3 @@
import { history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick'; import pick from 'lodash.pick';
import { import {
@@ -22,12 +21,13 @@ import {
resetDrafts, resetDrafts,
saveProfileBegin, saveProfileBegin,
saveProfileFailure, saveProfileFailure,
saveProfilePhotoBegin,
saveProfilePhotoFailure,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
saveProfileReset, saveProfileReset,
saveProfileSuccess, saveProfileSuccess,
SAVE_PROFILE, SAVE_PROFILE,
saveProfilePhotoBegin,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
SAVE_PROFILE_PHOTO, SAVE_PROFILE_PHOTO,
} from './actions'; } from './actions';
import { handleSaveProfileSelector, userAccountSelector } from './selectors'; import { handleSaveProfileSelector, userAccountSelector } from './selectors';
@@ -37,32 +37,38 @@ export function* handleFetchProfile(action) {
const { username } = action.payload; const { username } = action.payload;
const userAccount = yield select(userAccountSelector); const userAccount = yield select(userAccountSelector);
const isAuthenticatedUserProfile = username === getAuthenticatedUser().username; const isAuthenticatedUserProfile = username === getAuthenticatedUser().username;
// Default our data assuming the account is the current user's account.
let preferences = {}; let preferences = {};
let account = userAccount; let account = userAccount;
let courseCertificates = null; let courseCertificates = null;
let countriesCodesList = [];
try { try {
yield put(fetchProfileBegin()); yield put(fetchProfileBegin());
// Depending on which profile we're loading, we need to make different calls.
const calls = [ const calls = [
call(ProfileApiService.getAccount, username), call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username), call(ProfileApiService.getCourseCertificates, username),
call(ProfileApiService.getCountryList),
]; ];
if (isAuthenticatedUserProfile) { if (isAuthenticatedUserProfile) {
// If the profile is for the current user, get their preferences.
// We don't need them for other users.
calls.push(call(ProfileApiService.getPreferences, username)); calls.push(call(ProfileApiService.getPreferences, username));
} }
// Make all the calls in parallel.
const result = yield all(calls); const result = yield all(calls);
if (isAuthenticatedUserProfile) { if (isAuthenticatedUserProfile) {
[account, courseCertificates, countriesCodesList, preferences] = result; [account, courseCertificates, preferences] = result;
} else { } else {
[account, courseCertificates, countriesCodesList] = result; [account, courseCertificates] = result;
} }
// Set initial visibility values for account
// Set account_privacy as custom is necessary so that when viewing another user's profile,
// their full name is displayed and change visibility forms are worked correctly
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') { if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
yield call(ProfileApiService.patchPreferences, action.payload.username, { yield call(ProfileApiService.patchPreferences, action.payload.username, {
account_privacy: 'custom', account_privacy: 'custom',
@@ -83,13 +89,16 @@ export function* handleFetchProfile(action) {
preferences, preferences,
courseCertificates, courseCertificates,
isAuthenticatedUserProfile, isAuthenticatedUserProfile,
countriesCodesList,
)); ));
yield put(fetchProfileReset()); yield put(fetchProfileReset());
} catch (e) { } catch (e) {
if (e.response.status === 404) { if (e.response.status === 404) {
history.push('/notfound'); if (e.processedData && e.processedData.fieldErrors) {
yield put(saveProfileFailure(e.processedData.fieldErrors));
} else {
yield put(saveProfileFailure(e.customAttributes));
}
} else { } else {
throw e; throw e;
} }
@@ -102,6 +111,7 @@ export function* handleSaveProfile(action) {
const accountDrafts = pick(drafts, [ const accountDrafts = pick(drafts, [
'bio', 'bio',
'courseCertificates',
'country', 'country',
'levelOfEducation', 'levelOfEducation',
'languageProficiencies', 'languageProficiencies',
@@ -111,6 +121,7 @@ export function* handleSaveProfile(action) {
const preferencesDrafts = pick(drafts, [ const preferencesDrafts = pick(drafts, [
'visibilityBio', 'visibilityBio',
'visibilityCourseCertificates',
'visibilityCountry', 'visibilityCountry',
'visibilityLevelOfEducation', 'visibilityLevelOfEducation',
'visibilityLanguageProficiencies', 'visibilityLanguageProficiencies',
@@ -124,6 +135,7 @@ export function* handleSaveProfile(action) {
yield put(saveProfileBegin()); yield put(saveProfileBegin());
let accountResult = null; let accountResult = null;
// Build the visibility drafts into a structure the API expects.
if (Object.keys(accountDrafts).length > 0) { if (Object.keys(accountDrafts).length > 0) {
accountResult = yield call( accountResult = yield call(
@@ -133,14 +145,17 @@ export function* handleSaveProfile(action) {
); );
} }
let preferencesResult = preferences; let preferencesResult = preferences; // assume it hasn't changed.
if (Object.keys(preferencesDrafts).length > 0) { if (Object.keys(preferencesDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts); yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts);
// TODO: Temporary deoptimization since the patchPreferences call doesn't return anything. // TODO: Temporary deoptimization since the patchPreferences call doesn't return anything.
// Remove this second call once we can get a result from the one above.
preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username); preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username);
} }
// The account result is returned from the server.
// The preferences draft is valid if the server didn't complain, so
// pass it through directly.
yield put(saveProfileSuccess(accountResult, preferencesResult)); yield put(saveProfileSuccess(accountResult, preferencesResult));
yield delay(1000); yield delay(1000);
yield put(closeForm(action.payload.formId)); yield put(closeForm(action.payload.formId));
@@ -166,7 +181,12 @@ export function* handleSaveProfilePhoto(action) {
yield put(saveProfilePhotoSuccess(photoResult)); yield put(saveProfilePhotoSuccess(photoResult));
yield put(saveProfilePhotoReset()); yield put(saveProfilePhotoReset());
} catch (e) { } catch (e) {
if (e.processedData) {
yield put(saveProfilePhotoFailure(e.processedData));
} else {
yield put(saveProfilePhotoReset()); yield put(saveProfilePhotoReset());
throw e;
}
} }
} }
@@ -180,6 +200,7 @@ export function* handleDeleteProfilePhoto(action) {
yield put(deleteProfilePhotoReset()); yield put(deleteProfilePhotoReset());
} catch (e) { } catch (e) {
yield put(deleteProfilePhotoReset()); yield put(deleteProfilePhotoReset());
throw e;
} }
} }

View File

@@ -19,13 +19,13 @@ jest.mock('./services', () => ({
getPreferences: jest.fn(), getPreferences: jest.fn(),
getAccount: jest.fn(), getAccount: jest.fn(),
getCourseCertificates: jest.fn(), getCourseCertificates: jest.fn(),
getCountryList: jest.fn(),
})); }));
jest.mock('@edx/frontend-platform/auth', () => ({ jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(), getAuthenticatedUser: jest.fn(),
})); }));
// RootSaga and ProfileApiService must be imported AFTER the mock above.
/* eslint-disable import/first */ /* eslint-disable import/first */
import profileSaga, { import profileSaga, {
handleFetchProfile, handleFetchProfile,
@@ -68,18 +68,17 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('gonzo'); const action = profileActions.fetchProfile('gonzo');
const gen = handleFetchProfile(action); const gen = handleFetchProfile(action);
const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }]; const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }];
expect(gen.next().value).toEqual(select(userAccountSelector)); expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin())); expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([ expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'gonzo'), call(ProfileApiService.getAccount, 'gonzo'),
call(ProfileApiService.getCourseCertificates, 'gonzo'), call(ProfileApiService.getCourseCertificates, 'gonzo'),
call(ProfileApiService.getCountryList),
call(ProfileApiService.getPreferences, 'gonzo'), call(ProfileApiService.getPreferences, 'gonzo'),
])); ]));
expect(gen.next(result).value) expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, []))); .toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], result[1], true)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset())); expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined(); expect(gen.next().value).toBeUndefined();
}); });
@@ -89,7 +88,6 @@ describe('RootSaga', () => {
username: 'gonzo', username: 'gonzo',
other: 'data', other: 'data',
}; };
const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }];
getAuthenticatedUser.mockReturnValue(userAccount); getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = { const selectorData = {
userAccount, userAccount,
@@ -98,17 +96,16 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('booyah'); const action = profileActions.fetchProfile('booyah');
const gen = handleFetchProfile(action); const gen = handleFetchProfile(action);
const result = [{}, [1, 2, 3], countriesCodesList]; const result = [{}, [1, 2, 3]];
expect(gen.next().value).toEqual(select(userAccountSelector)); expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin())); expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([ expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'booyah'), call(ProfileApiService.getAccount, 'booyah'),
call(ProfileApiService.getCourseCertificates, 'booyah'), call(ProfileApiService.getCourseCertificates, 'booyah'),
call(ProfileApiService.getCountryList),
])); ]));
expect(gen.next(result).value) expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList))); .toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset())); expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined(); expect(gen.next().value).toBeUndefined();
}); });
@@ -135,6 +132,8 @@ describe('RootSaga', () => {
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', { expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', {
name: 'Full Name', name: 'Full Name',
})); }));
// The library would supply the result of the above call
// as the parameter to the NEXT yield. Here:
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {}))); expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {})));
expect(gen.next().value).toEqual(delay(1000)); expect(gen.next().value).toEqual(delay(1000));
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id'))); expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
@@ -163,67 +162,5 @@ describe('RootSaga', () => {
expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' }))); expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' })));
expect(gen.next().value).toBeUndefined(); expect(gen.next().value).toBeUndefined();
}); });
it('should reset profile if error has no processedData', () => {
const action = profileActions.saveProfile('formid', 'user1');
const gen = handleSaveProfile(action);
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
const err = new Error('oops');
const result = gen.throw(err);
expect(result.value).toEqual(put(profileActions.saveProfileReset()));
});
});
describe('handleSaveProfilePhoto', () => {
it('should save profile photo successfully', () => {
const action = profileActions.saveProfilePhoto('user1', { some: 'formdata' });
const gen = handleSaveProfilePhoto(action);
const fakePhoto = { url: 'photo.jpg' };
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.postProfilePhoto, 'user1', { some: 'formdata' }));
expect(gen.next(fakePhoto).value).toEqual(put(profileActions.saveProfilePhotoSuccess(fakePhoto)));
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
it('should reset photo state on error', () => {
const action = profileActions.saveProfilePhoto('user1', {});
const gen = handleSaveProfilePhoto(action);
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
const err = new Error('fail');
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().done).toBe(true);
});
});
describe('handleDeleteProfilePhoto', () => {
it('should delete profile photo successfully', () => {
const action = profileActions.deleteProfilePhoto('user1');
const gen = handleDeleteProfilePhoto(action);
const fakeResult = { ok: true };
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'user1'));
expect(gen.next(fakeResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(fakeResult)));
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
it('should reset photo state on error', () => {
const action = profileActions.saveProfilePhoto('user1', {});
const gen = handleSaveProfilePhoto(action);
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
const err = new Error('fail');
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().done).toBe(true);
});
}); });
}); });

View File

@@ -5,22 +5,24 @@ import {
getCountryList, getCountryList,
getCountryMessages, getCountryMessages,
getLanguageMessages, getLanguageMessages,
} from '@edx/frontend-platform/i18n'; } from '@edx/frontend-platform/i18n'; // eslint-disable-line
export const formIdSelector = (state, props) => props.formId; export const formIdSelector = (state, props) => props.formId;
export const userAccountSelector = state => state.userAccount; export const userAccountSelector = state => state.userAccount;
export const profileAccountSelector = state => state.profilePage.account; export const profileAccountSelector = state => state.profilePage.account;
export const profileDraftsSelector = state => state.profilePage.drafts; export const profileDraftsSelector = state => state.profilePage.drafts;
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy; export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
export const profilePreferencesSelector = state => state.profilePage.preferences; export const profilePreferencesSelector = state => state.profilePage.preferences;
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates; export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts;
export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts;
export const saveStateSelector = state => state.profilePage.saveState; export const saveStateSelector = state => state.profilePage.saveState;
export const savePhotoStateSelector = state => state.profilePage.savePhotoState; export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile; export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField; export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors; export const accountErrorsSelector = state => state.profilePage.errors;
export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile; export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile;
export const countriesCodesListSelector = state => state.profilePage.countriesCodesList;
export const editableFormModeSelector = createSelector( export const editableFormModeSelector = createSelector(
profileAccountSelector, profileAccountSelector,
@@ -29,11 +31,22 @@ export const editableFormModeSelector = createSelector(
formIdSelector, formIdSelector,
currentlyEditingFieldSelector, currentlyEditingFieldSelector,
(account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => { (account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => {
// If the prop doesn't exist, that means it hasn't been set (for the current user's profile)
// or is being hidden from us (for other users' profiles)
let propExists = account[formId] != null && account[formId].length > 0; let propExists = account[formId] != null && account[formId].length > 0;
propExists = formId === 'certificates' ? certificates.length > 0 : propExists; propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates
// If this isn't the current user's profile
if (!isAuthenticatedUserProfile) { if (!isAuthenticatedUserProfile) {
return 'static'; return 'static';
} }
// the current user has no age set / under 13 ...
if (account.requiresParentalConsent) {
// then there are only two options: static or nothing.
// We use 'null' as a return value because the consumers of
// getMode render nothing at all on a mode of null.
return propExists ? 'static' : null;
}
// Otherwise, if this is the current user's profile...
if (formId === currentlyEditingField) { if (formId === currentlyEditingField) {
return 'editing'; return 'editing';
} }
@@ -54,10 +67,12 @@ export const accountDraftsFieldSelector = createSelector(
export const visibilityDraftsFieldSelector = createSelector( export const visibilityDraftsFieldSelector = createSelector(
formIdSelector, formIdSelector,
profileDraftsSelector, profileVisibilityDraftsSelector,
(formId, drafts) => drafts[`visibility${formId.charAt(0).toUpperCase() + formId.slice(1)}`], (formId, visibilityDrafts) => visibilityDrafts[formId],
); );
// Note: Error messages are delivered from the server
// localized according to a user's account settings
export const formErrorSelector = createSelector( export const formErrorSelector = createSelector(
accountErrorsSelector, accountErrorsSelector,
formIdSelector, formIdSelector,
@@ -75,6 +90,11 @@ export const editableFormSelector = createSelector(
}), }),
); );
// Because this selector has no input selectors, it will only be evaluated once. This is fine
// for now because we don't allow users to change the locale after page load.
// Once we DO allow this, we should create an actual action which dispatches the locale into redux,
// then we can modify this to get the locale from state rather than from getLocale() directly.
// Once we do that, this will work as expected and be re-evaluated when the locale changes.
export const localeSelector = () => getLocale(); export const localeSelector = () => getLocale();
export const countryMessagesSelector = createSelector( export const countryMessagesSelector = createSelector(
localeSelector, localeSelector,
@@ -92,14 +112,7 @@ export const sortedLanguagesSelector = createSelector(
export const sortedCountriesSelector = createSelector( export const sortedCountriesSelector = createSelector(
localeSelector, localeSelector,
countriesCodesListSelector, locale => getCountryList(locale),
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( export const preferredLanguageSelector = createSelector(
@@ -117,14 +130,10 @@ export const countrySelector = createSelector(
editableFormSelector, editableFormSelector,
sortedCountriesSelector, sortedCountriesSelector,
countryMessagesSelector, countryMessagesSelector,
countriesCodesListSelector, (editableForm, sortedCountries, countryMessages) => ({
profileAccountSelector,
(editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({
...editableForm, ...editableForm,
translatedCountries, sortedCountries,
countryMessages, countryMessages,
countriesCodesList,
committedCountry: account.country,
}), }),
); );
@@ -148,6 +157,9 @@ export const profileImageSelector = createSelector(
: {}), : {}),
); );
/**
* This is used by a saga to pull out data to process.
*/
export const handleSaveProfileSelector = createSelector( export const handleSaveProfileSelector = createSelector(
profileDraftsSelector, profileDraftsSelector,
profilePreferencesSelector, profilePreferencesSelector,
@@ -157,6 +169,7 @@ export const handleSaveProfileSelector = createSelector(
}), }),
); );
// Reformats the social links in a platform-keyed hash.
const socialLinksByPlatformSelector = createSelector( const socialLinksByPlatformSelector = createSelector(
profileAccountSelector, profileAccountSelector,
(account) => { (account) => {
@@ -183,18 +196,24 @@ const draftSocialLinksByPlatformSelector = createSelector(
}, },
); );
// Fleshes out our list of existing social links with all the other ones the user can set.
export const formSocialLinksSelector = createSelector( export const formSocialLinksSelector = createSelector(
socialLinksByPlatformSelector, socialLinksByPlatformSelector,
draftSocialLinksByPlatformSelector, draftSocialLinksByPlatformSelector,
(linksByPlatform, draftLinksByPlatform) => { (linksByPlatform, draftLinksByPlatform) => {
const knownPlatforms = ['twitter', 'facebook', 'linkedin']; const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const socialLinks = []; const socialLinks = [];
// For each known platform
knownPlatforms.forEach((platform) => { knownPlatforms.forEach((platform) => {
// If the link is in our drafts.
if (draftLinksByPlatform[platform] !== undefined) { if (draftLinksByPlatform[platform] !== undefined) {
// Use the draft one.
socialLinks.push(draftLinksByPlatform[platform]); socialLinks.push(draftLinksByPlatform[platform]);
} else if (linksByPlatform[platform] !== undefined) { } else if (linksByPlatform[platform] !== undefined) {
// Otherwise use the real one.
socialLinks.push(linksByPlatform[platform]); socialLinks.push(linksByPlatform[platform]);
} else { } else {
// And if it's not in either, use a stub.
socialLinks.push({ socialLinks.push({
platform, platform,
socialLink: null, socialLink: null,
@@ -213,6 +232,7 @@ export const visibilitiesSelector = createSelector(
case 'custom': case 'custom':
return { return {
visibilityBio: preferences.visibilityBio || 'all_users', visibilityBio: preferences.visibilityBio || 'all_users',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users',
visibilityCountry: preferences.visibilityCountry || 'all_users', visibilityCountry: preferences.visibilityCountry || 'all_users',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users', visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
@@ -222,6 +242,7 @@ export const visibilitiesSelector = createSelector(
case 'private': case 'private':
return { return {
visibilityBio: 'private', visibilityBio: 'private',
visibilityCourseCertificates: 'private',
visibilityCountry: 'private', visibilityCountry: 'private',
visibilityLevelOfEducation: 'private', visibilityLevelOfEducation: 'private',
visibilityLanguageProficiencies: 'private', visibilityLanguageProficiencies: 'private',
@@ -230,8 +251,13 @@ export const visibilitiesSelector = createSelector(
}; };
case 'all_users': case 'all_users':
default: default:
// All users is intended to fall through to default.
// If there is no value for accountPrivacy in perferences, that means it has not been
// explicitly set yet. The server assumes - today - that this means "all_users",
// so we emulate that here in the client.
return { return {
visibilityBio: 'all_users', visibilityBio: 'all_users',
visibilityCourseCertificates: 'all_users',
visibilityCountry: 'all_users', visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users', visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users', visibilityLanguageProficiencies: 'all_users',
@@ -242,6 +268,9 @@ export const visibilitiesSelector = createSelector(
}, },
); );
/**
* If there's no draft present at all (undefined), use the original committed value.
*/
function chooseFormValue(draft, committed) { function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed; return draft !== undefined ? draft : committed;
} }
@@ -256,6 +285,10 @@ export const formValuesSelector = createSelector(
bio: chooseFormValue(drafts.bio, account.bio), bio: chooseFormValue(drafts.bio, account.bio),
visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio), visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio),
courseCertificates, courseCertificates,
visibilityCourseCertificates: chooseFormValue(
drafts.visibilityCourseCertificates,
visibilities.visibilityCourseCertificates,
),
country: chooseFormValue(drafts.country, account.country), country: chooseFormValue(drafts.country, account.country),
visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry), visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry),
levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation), levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation),
@@ -273,7 +306,7 @@ export const formValuesSelector = createSelector(
), ),
name: chooseFormValue(drafts.name, account.name), name: chooseFormValue(drafts.name, account.name),
visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName), visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName),
socialLinks, socialLinks, // Social links is calculated in its own selector, since it's complicated.
visibilitySocialLinks: chooseFormValue( visibilitySocialLinks: chooseFormValue(
drafts.visibilitySocialLinks, drafts.visibilitySocialLinks,
visibilities.visibilitySocialLinks, visibilities.visibilitySocialLinks,
@@ -290,7 +323,6 @@ export const profilePageSelector = createSelector(
isLoadingProfileSelector, isLoadingProfileSelector,
draftSocialLinksByPlatformSelector, draftSocialLinksByPlatformSelector,
accountErrorsSelector, accountErrorsSelector,
isAuthenticatedUserProfileSelector,
( (
account, account,
formValues, formValues,
@@ -300,39 +332,47 @@ export const profilePageSelector = createSelector(
isLoadingProfile, isLoadingProfile,
draftSocialLinksByPlatform, draftSocialLinksByPlatform,
errors, errors,
isAuthenticatedUserProfile,
) => ({ ) => ({
// Account data we need
username: account.username, username: account.username,
profileImage, profileImage,
requiresParentalConsent: account.requiresParentalConsent, requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined, dateJoined: account.dateJoined,
yearOfBirth: account.yearOfBirth, yearOfBirth: account.yearOfBirth,
// Bio form data
bio: formValues.bio, bio: formValues.bio,
visibilityBio: formValues.visibilityBio, visibilityBio: formValues.visibilityBio,
// Certificates form data
courseCertificates: formValues.courseCertificates, courseCertificates: formValues.courseCertificates,
visibilityCourseCertificates: formValues.visibilityCourseCertificates,
// Country form data
country: formValues.country, country: formValues.country,
visibilityCountry: formValues.visibilityCountry, visibilityCountry: formValues.visibilityCountry,
// Education form data
levelOfEducation: formValues.levelOfEducation, levelOfEducation: formValues.levelOfEducation,
visibilityLevelOfEducation: formValues.visibilityLevelOfEducation, visibilityLevelOfEducation: formValues.visibilityLevelOfEducation,
// Language proficiency form data
languageProficiencies: formValues.languageProficiencies, languageProficiencies: formValues.languageProficiencies,
visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies, visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies,
// Name form data
name: formValues.name, name: formValues.name,
visibilityName: formValues.visibilityName, visibilityName: formValues.visibilityName,
// Social links form data
socialLinks: formValues.socialLinks, socialLinks: formValues.socialLinks,
visibilitySocialLinks: formValues.visibilitySocialLinks, visibilitySocialLinks: formValues.visibilitySocialLinks,
draftSocialLinksByPlatform, draftSocialLinksByPlatform,
// Other data we need
saveState, saveState,
savePhotoState, savePhotoState,
isLoadingProfile, isLoadingProfile,
photoUploadError: errors.photo || null, photoUploadError: errors.photo || null,
isAuthenticatedUserProfile,
}), }),
); );

View File

@@ -2,24 +2,11 @@ import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging'; import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils'; import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
import { FIELD_LABELS } from './constants';
ensureConfig(['LMS_BASE_URL'], 'Profile API service'); ensureConfig(['LMS_BASE_URL'], 'Profile API service');
function processAccountData(data) { function processAccountData(data) {
const processedData = camelCaseObject(data); return 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) { function processAndThrowError(error, errorDataProcessor) {
@@ -32,12 +19,15 @@ function processAndThrowError(error, errorDataProcessor) {
} }
} }
// GET ACCOUNT
export async function getAccount(username) { export async function getAccount(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`); const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
// Process response data
return processAccountData(data); return processAccountData(data);
} }
// PATCH PROFILE
export async function patchProfile(username, params) { export async function patchProfile(username, params) {
const processedParams = snakeCaseObject(params); const processedParams = snakeCaseObject(params);
@@ -51,9 +41,12 @@ export async function patchProfile(username, params) {
processAndThrowError(error, processAccountData); processAndThrowError(error, processAccountData);
}); });
// Process response data
return processAccountData(data); return processAccountData(data);
} }
// POST PROFILE PHOTO
export async function postProfilePhoto(username, formData) { export async function postProfilePhoto(username, formData) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().post( const { data } = await getHttpClient().post(
@@ -77,6 +70,8 @@ export async function postProfilePhoto(username, formData) {
return updatedData.profileImage; return updatedData.profileImage;
} }
// DELETE PROFILE PHOTO
export async function deleteProfilePhoto(username) { export async function deleteProfilePhoto(username) {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`); const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`);
@@ -90,12 +85,14 @@ export async function deleteProfilePhoto(username) {
return updatedData.profileImage; return updatedData.profileImage;
} }
// GET PREFERENCES
export async function getPreferences(username) { export async function getPreferences(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`); const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return camelCaseObject(data); return camelCaseObject(data);
} }
// PATCH PREFERENCES
export async function patchPreferences(username, params) { export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params); let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, { processedParams = convertKeyNames(processedParams, {
@@ -117,6 +114,8 @@ export async function patchPreferences(username, params) {
return params; // TODO: Once the server returns the updated preferences object, return that. return params; // TODO: Once the server returns the updated preferences object, return that.
} }
// GET COURSE CERTIFICATES
function transformCertificateData(data) { function transformCertificateData(data) {
const transformedData = []; const transformedData = [];
data.forEach((cert) => { data.forEach((cert) => {
@@ -148,21 +147,3 @@ export async function getCourseCertificates(username) {
return []; return [];
} }
} }
function extractCountryList(data) {
return data?.fields
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
?.options?.map(({ value }) => (value)) || [];
}
export async function getCountryList() {
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
try {
const { data } = await getHttpClient().get(url);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -1,174 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import {
getAccount,
patchProfile,
postProfilePhoto,
deleteProfilePhoto,
getPreferences,
patchPreferences,
getCourseCertificates,
getCountryList,
} from './services';
import { FIELD_LABELS } from './constants';
import { camelCaseObject, snakeCaseObject, convertKeyNames } from '../utils';
// --- Mocks ---
jest.mock('@edx/frontend-platform', () => ({
ensureConfig: jest.fn(),
getConfig: jest.fn(() => ({ LMS_BASE_URL: 'http://fake-lms' })),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../utils', () => ({
camelCaseObject: jest.fn((obj) => obj),
snakeCaseObject: jest.fn((obj) => obj),
convertKeyNames: jest.fn((obj) => obj),
}));
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
// --- Tests ---
describe('services', () => {
describe('getAccount', () => {
it('should return processed account data', async () => {
const mockData = { name: 'John Doe', socialLinks: [] };
mockHttpClient.get.mockResolvedValue({ data: mockData });
const result = await getAccount('john');
expect(result).toMatchObject(mockData);
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://fake-lms/api/user/v1/accounts/john',
);
});
});
describe('patchProfile', () => {
it('should patch and return processed data', async () => {
const mockData = { bio: 'New Bio' };
mockHttpClient.patch.mockResolvedValue({ data: mockData });
const result = await patchProfile('john', { bio: 'New Bio' });
expect(result).toMatchObject(mockData);
expect(snakeCaseObject).toHaveBeenCalledWith({ bio: 'New Bio' });
});
it('should throw processed error on failure', async () => {
const error = { response: { data: { some: 'error' } } };
mockHttpClient.patch.mockRejectedValue(error);
await expect(patchProfile('john', {})).rejects.toMatchObject(error);
});
});
describe('postProfilePhoto', () => {
it('should post photo and return updated profile image', async () => {
mockHttpClient.post.mockResolvedValue({});
mockHttpClient.get.mockResolvedValue({
data: { profileImage: { url: 'img.png' } },
});
const result = await postProfilePhoto('john', new FormData());
expect(result).toEqual({ url: 'img.png' });
});
it('should throw error if API fails', async () => {
const error = { response: { data: { error: 'fail' } } };
mockHttpClient.post.mockRejectedValue(error);
await expect(postProfilePhoto('john', new FormData())).rejects.toMatchObject(error);
});
});
describe('deleteProfilePhoto', () => {
it('should delete photo and return updated profile image', async () => {
mockHttpClient.delete.mockResolvedValue({});
mockHttpClient.get.mockResolvedValue({
data: { profileImage: { url: 'deleted.png' } },
});
const result = await deleteProfilePhoto('john');
expect(result).toEqual({ url: 'deleted.png' });
});
});
describe('getPreferences', () => {
it('should return camelCased preferences', async () => {
mockHttpClient.get.mockResolvedValue({ data: { pref: 1 } });
const result = await getPreferences('john');
expect(result).toMatchObject({ pref: 1 });
expect(camelCaseObject).toHaveBeenCalledWith({ pref: 1 });
});
});
describe('patchPreferences', () => {
it('should patch preferences and return params', async () => {
mockHttpClient.patch.mockResolvedValue({});
const params = { visibility_bio: true };
const result = await patchPreferences('john', params);
expect(result).toBe(params);
expect(snakeCaseObject).toHaveBeenCalledWith(params);
expect(convertKeyNames).toHaveBeenCalled();
});
});
describe('getCourseCertificates', () => {
it('should return transformed certificates', async () => {
mockHttpClient.get.mockResolvedValue({
data: [{ download_url: '/path', certificate_type: 'type' }],
});
const result = await getCourseCertificates('john');
expect(result[0]).toHaveProperty('downloadUrl', 'http://fake-lms/path');
});
it('should log error and return empty array on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getCourseCertificates('john');
expect(result).toEqual([]);
expect(logError).toHaveBeenCalled();
});
});
describe('getCountryList', () => {
it('should extract country list', async () => {
mockHttpClient.get.mockResolvedValue({
data: {
fields: [
{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'US' }, { value: 'CA' }] },
],
},
});
const result = await getCountryList();
expect(result).toEqual(['US', 'CA']);
});
it('should log error and return empty array on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getCountryList();
expect(result).toEqual([]);
expect(logError).toHaveBeenCalled();
});
});
});

View File

@@ -1,71 +1,74 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import classNames from 'classnames';
import messages from './Bio.messages'; import messages from './Bio.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors'; import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsOnMobileScreen,
useIsVisibilityEnabled,
} from '../data/hooks';
const Bio = ({ class Bio extends React.Component {
formId, constructor(props) {
bio, super(props);
visibilityBio,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isMobileView = useIsOnMobileScreen();
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler); this.handleChange = this.handleChange.bind(this);
const handleSubmit = useHandleSubmit(submitHandler, formId); this.handleSubmit = this.handleSubmit.bind(this);
const handleOpen = useCloseOpenHandler(openHandler, formId); this.handleClose = this.handleClose.bind(this);
const handleClose = useCloseOpenHandler(closeHandler, formId); this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, bio, visibilityBio, editMode, saveState, error, intl,
} = this.props;
return ( return (
<SwitchContent <SwitchContent
className={classNames([ className="mb-5"
isMobileView ? 'pt-40px' : 'pt-0',
])}
expression={editMode} expression={editMode}
cases={{ cases={{
editing: ( editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}> <div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}> <form onSubmit={this.handleSubmit}>
<Form.Group <Form.Group
controlId={formId} controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null} isInvalid={error !== null}
> >
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5"> <label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.bio.about.me'])} {intl.formatMessage(messages['profile.bio.about.me'])}
</p> </label>
<textarea <textarea
className="form-control py-10px" className="form-control"
id={formId} id={formId}
name={formId} name={formId}
value={bio} value={bio}
onChange={handleChange} onChange={this.handleChange}
/> />
{error !== null && ( {error !== null && (
<Form.Control.Feedback hasIcon={false}> <Form.Control.Feedback hasIcon={false}>
@@ -77,32 +80,28 @@ const Bio = ({
visibilityId="visibilityBio" visibilityId="visibilityBio"
saveState={saveState} saveState={saveState}
visibility={visibilityBio} visibility={visibilityBio}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
/> />
</form> </form>
</div> </div>
), ),
editable: ( editable: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EditableItemHeader <EditableItemHeader
content={bio} content={intl.formatMessage(messages['profile.bio.about.me'])}
showEditButton showEditButton
onClickEdit={handleOpen} onClickEdit={this.handleOpen}
showVisibility={visibilityBio !== null && isVisibilityEnabled} showVisibility={visibilityBio !== null}
visibility={visibilityBio} visibility={visibilityBio}
/> />
<p data-hj-suppress className="lead">{bio}</p>
</> </>
), ),
empty: ( empty: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
{intl.formatMessage(messages['profile.bio.about.me'])} <EmptyContent onClick={this.handleOpen}>
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage <FormattedMessage
id="profile.bio.empty" id="profile.bio.empty"
defaultMessage="Add a short bio" defaultMessage="Add a short bio"
@@ -113,28 +112,38 @@ const Bio = ({
), ),
static: ( static: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
{intl.formatMessage(messages['profile.bio.about.me'])} <p data-hj-suppress className="lead">{bio}</p>
</p>
<EditableItemHeader content={bio} />
</> </>
), ),
}} }}
/> />
); );
}; }
}
Bio.propTypes = { Bio.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
bio: PropTypes.string, bio: PropTypes.string,
visibilityBio: PropTypes.oneOf(['private', 'all_users']), visibilityBio: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string, saveState: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
Bio.defaultProps = { Bio.defaultProps = {
@@ -148,4 +157,4 @@ Bio.defaultProps = {
export default connect( export default connect(
editableFormSelector, editableFormSelector,
{}, {},
)(Bio); )(injectIntl(Bio));

View File

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

View File

@@ -0,0 +1,231 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { connect } from 'react-redux';
import get from 'lodash.get';
import messages from './Certificates.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Assets
import professionalCertificateSVG from '../assets/professional-certificate.svg';
import verifiedCertificateSVG from '../assets/verified-certificate.svg';
// Selectors
import { certificatesSelector } from '../data/selectors';
class Certificates extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
renderCertificate({
certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId,
}) {
const { intl } = this.props;
const certificateIllustration = (() => {
switch (certificateType) {
case 'professional':
case 'no-id-professional':
return professionalCertificateSVG;
case 'verified':
return verifiedCertificateSVG;
case 'honor':
case 'audit':
default:
return null;
}
})();
return (
<div key={`${modifiedDate}-${courseId}`} className="col-12 col-sm-6 d-flex align-items-stretch">
<div className="card mb-4 certificate flex-grow-1">
<div
className="certificate-type-illustration"
style={{ backgroundImage: `url(${certificateIllustration})` }}
/>
<div className="card-body d-flex flex-column">
<div className="card-title">
<p className="small mb-0">
{intl.formatMessage(get(
messages,
`profile.certificates.types.${certificateType}`,
messages['profile.certificates.types.unknown'],
))}
</p>
<h4 className="certificate-title">{courseDisplayName}</h4>
</div>
<p className="small mb-0">
<FormattedMessage
id="profile.certificate.organization.label"
defaultMessage="From"
/>
</p>
<p className="h6 mb-4">{courseOrganization}</p>
<div className="flex-grow-1" />
<p className="small mb-2">
<FormattedMessage
id="profile.certificate.completion.date.label"
defaultMessage="Completed on {date}"
values={{
date: <FormattedDate value={new Date(modifiedDate)} />,
}}
/>
</p>
<div>
<Hyperlink destination={downloadUrl} className="btn btn-outline-primary" target="_blank">
{intl.formatMessage(messages['profile.certificates.view.certificate'])}
</Hyperlink>
</div>
</div>
</div>
</div>
);
}
renderCertificates() {
if (this.props.certificates === null || this.props.certificates.length === 0) {
return (
<FormattedMessage
id="profile.no.certificates"
defaultMessage="You don't have any certificates yet."
description="displays when user has no course completion certificates"
/>
);
}
return (
<div className="row align-items-stretch">{this.props.certificates.map(certificate => this.renderCertificate(certificate))}</div>
);
}
render() {
const {
visibilityCourseCertificates, editMode, saveState, intl,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby="course-certificates-label">
<form onSubmit={this.handleSubmit}>
<EditableItemHeader
headingId="course-certificates-label"
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
/>
<FormControls
visibilityId="visibilityCourseCertificates"
saveState={saveState}
visibility={visibilityCourseCertificates}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
{this.renderCertificates()}
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityCourseCertificates !== null}
visibility={visibilityCourseCertificates}
/>
{this.renderCertificates()}
</>
),
empty: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityCourseCertificates !== null}
visibility={visibilityCourseCertificates}
/>
{this.renderCertificates()}
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.certificates.my.certificates'])} />
{this.renderCertificates()}
</>
),
}}
/>
);
}
}
Certificates.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibilityCourseCertificates: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
Certificates.defaultProps = {
editMode: 'static',
saveState: null,
visibilityCourseCertificates: 'private',
certificates: null,
};
export default connect(
certificatesSelector,
{},
)(injectIntl(Certificates));

View File

@@ -1,80 +1,91 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import messages from './Country.messages'; import messages from './Country.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Selectors
import { countrySelector } from '../data/selectors'; import { countrySelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Country = ({ class Country extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, formId,
country, country,
visibilityCountry, visibilityCountry,
editMode, editMode,
saveState, saveState,
error, error,
translatedCountries, intl,
countriesCodesList, sortedCountries,
countryMessages, countryMessages,
changeHandler, } = this.props;
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
const isDisabledCountry = (countryCode) => countriesCodesList.length > 0
&& !countriesCodesList.find(code => code === countryCode);
return ( return (
<SwitchContent <SwitchContent
className="pt-40px" className="mb-5"
expression={editMode} expression={editMode}
cases={{ cases={{
editing: ( editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}> <div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}> <form onSubmit={this.handleSubmit}>
<Form.Group <Form.Group
controlId={formId} controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null} isInvalid={error !== null}
> >
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5"> <label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.country.label'])} {intl.formatMessage(messages['profile.country.label'])}
</p> </label>
<select <select
data-hj-suppress data-hj-suppress
className="form-control py-10px" className="form-control"
type="select" type="select"
id={formId} id={formId}
name={formId} name={formId}
value={country} value={country}
onChange={handleChange} onChange={this.handleChange}
> >
<option value=""> </option> <option value="">&nbsp;</option>
{translatedCountries.map(({ code, name }) => ( {sortedCountries.map(({ code, name }) => (
<option key={code} value={code} disabled={isDisabledCountry(code)}> <option key={code} value={code}>{name}</option>
{name}
</option>
))} ))}
</select> </select>
{error !== null && ( {error !== null && (
@@ -87,66 +98,75 @@ const Country = ({
visibilityId="visibilityCountry" visibilityId="visibilityCountry"
saveState={saveState} saveState={saveState}
visibility={visibilityCountry} visibility={visibilityCountry}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
/> />
</form> </form>
</div> </div>
), ),
editable: ( editable: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EditableItemHeader <EditableItemHeader
content={countryMessages[country]} content={intl.formatMessage(messages['profile.country.label'])}
showEditButton showEditButton
onClickEdit={handleOpen} onClickEdit={this.handleOpen}
showVisibility={visibilityCountry !== null && isVisibilityEnabled} showVisibility={visibilityCountry !== null}
visibility={visibilityCountry} visibility={visibilityCountry}
/> />
<p data-hj-suppress className="h5">{countryMessages[country]}</p>
</> </>
), ),
empty: ( empty: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader
{intl.formatMessage(messages['profile.country.label'])} content={intl.formatMessage(messages['profile.country.label'])}
</p> />
<EmptyContent onClick={handleOpen}> <EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.country.empty'])} {intl.formatMessage(messages['profile.country.empty'])}
</EmptyContent> </EmptyContent>
</> </>
), ),
static: ( static: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader
{intl.formatMessage(messages['profile.country.label'])} content={intl.formatMessage(messages['profile.country.label'])}
</p> />
<EditableItemHeader content={countryMessages[country]} /> <p data-hj-suppress className="h5">{countryMessages[country]}</p>
</> </>
), ),
}} }}
/> />
); );
}; }
}
Country.propTypes = { Country.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
country: PropTypes.string, country: PropTypes.string,
visibilityCountry: PropTypes.oneOf(['private', 'all_users']), visibilityCountry: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string, saveState: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
translatedCountries: PropTypes.arrayOf(PropTypes.shape({ sortedCountries: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired, code: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
})).isRequired, })).isRequired,
countriesCodesList: PropTypes.arrayOf(PropTypes.string).isRequired,
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired, countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
Country.defaultProps = { Country.defaultProps = {
@@ -160,4 +180,4 @@ Country.defaultProps = {
export default connect( export default connect(
countrySelector, countrySelector,
{}, {},
)(Country); )(injectIntl(Country));

View File

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

View File

@@ -1,72 +1,84 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get'; import get from 'lodash.get';
import { Form } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import messages from './Education.messages'; import messages from './Education.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Constants
import { EDUCATION_LEVELS } from '../data/constants'; import { EDUCATION_LEVELS } from '../data/constants';
// Selectors
import { editableFormSelector } from '../data/selectors'; import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Education = ({ class Education extends React.Component {
formId, constructor(props) {
levelOfEducation, super(props);
visibilityLevelOfEducation,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler); this.handleChange = this.handleChange.bind(this);
const handleSubmit = useHandleSubmit(submitHandler, formId); this.handleSubmit = this.handleSubmit.bind(this);
const handleOpen = useCloseOpenHandler(openHandler, formId); this.handleClose = this.handleClose.bind(this);
const handleClose = useCloseOpenHandler(closeHandler, formId); this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, levelOfEducation, visibilityLevelOfEducation, editMode, saveState, error, intl,
} = this.props;
return ( return (
<SwitchContent <SwitchContent
className="pt-40px" className="mb-5"
expression={editMode} expression={editMode}
cases={{ cases={{
editing: ( editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}> <div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}> <form onSubmit={this.handleSubmit}>
<Form.Group <Form.Group
controlId={formId} controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null} isInvalid={error !== null}
> >
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5"> <label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.education.education'])} {intl.formatMessage(messages['profile.education.education'])}
</p> </label>
<select <select
data-hj-suppress data-hj-suppress
className="form-control py-10px" className="form-control"
id={formId} id={formId}
name={formId} name={formId}
value={levelOfEducation} value={levelOfEducation}
onChange={handleChange} onChange={this.handleChange}
> >
<option value=""> </option> <option value="">&nbsp;</option>
{EDUCATION_LEVELS.map(level => ( {EDUCATION_LEVELS.map(level => (
<option key={level} value={level}> <option key={level} value={level}>
{intl.formatMessage(get( {intl.formatMessage(get(
@@ -87,39 +99,37 @@ const Education = ({
visibilityId="visibilityLevelOfEducation" visibilityId="visibilityLevelOfEducation"
saveState={saveState} saveState={saveState}
visibility={visibilityLevelOfEducation} visibility={visibilityLevelOfEducation}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
/> />
</form> </form>
</div> </div>
), ),
editable: ( editable: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EditableItemHeader <EditableItemHeader
content={intl.formatMessage(get( content={intl.formatMessage(messages['profile.education.education'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityLevelOfEducation !== null}
visibility={visibilityLevelOfEducation}
/>
<p data-hj-suppress className="h5">
{intl.formatMessage(get(
messages, messages,
`profile.education.levels.${levelOfEducation}`, `profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'], messages['profile.education.levels.o'],
))} ))}
showEditButton </p>
onClickEdit={handleOpen}
showVisibility={visibilityLevelOfEducation !== null && isVisibilityEnabled}
visibility={visibilityLevelOfEducation}
/>
</> </>
), ),
empty: ( empty: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
{intl.formatMessage(messages['profile.education.education'])} <EmptyContent onClick={this.handleOpen}>
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage <FormattedMessage
id="profile.education.empty" id="profile.education.empty"
defaultMessage="Add level of education" defaultMessage="Add education"
description="instructions when the user doesn't have their level of education set" description="instructions when the user doesn't have their level of education set"
/> />
</EmptyContent> </EmptyContent>
@@ -127,34 +137,44 @@ const Education = ({
), ),
static: ( static: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
{intl.formatMessage(messages['profile.education.education'])} <p data-hj-suppress className="h5">
</p> {intl.formatMessage(get(
<EditableItemHeader
content={intl.formatMessage(get(
messages, messages,
`profile.education.levels.${levelOfEducation}`, `profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'], messages['profile.education.levels.o'],
))} ))}
/> </p>
</> </>
), ),
}} }}
/> />
); );
}; }
}
Education.propTypes = { Education.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
levelOfEducation: PropTypes.string, levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.oneOf(['private', 'all_users']), visibilityLevelOfEducation: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string, saveState: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
Education.defaultProps = { Education.defaultProps = {
@@ -168,4 +188,4 @@ Education.defaultProps = {
export default connect( export default connect(
editableFormSelector, editableFormSelector,
{}, {},
)(Education); )(injectIntl(Education));

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
// Mock Data
import mockData from '../data/mock_data';
import messages from './LearningGoal.messages';
// Components
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
const LearningGoal = (props) => {
let { learningGoal, editMode, visibilityLearningGoal } = props;
const { intl } = props;
if (!learningGoal) {
learningGoal = mockData.learningGoal;
}
if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet
editMode = mockData.editMode;
}
if (!visibilityLearningGoal) {
visibilityLearningGoal = mockData.visibilityLearningGoal;
}
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])}
showVisibility={visibilityLearningGoal !== null}
visibility={visibilityLearningGoal}
/>
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])} />
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
}}
/>
);
};
LearningGoal.propTypes = {
// From Selector
learningGoal: PropTypes.oneOf(['advance_career', 'start_career', 'learn_something_new', 'something_else']),
visibilityLearningGoal: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editable', 'static']),
// i18n
intl: intlShape.isRequired,
};
LearningGoal.defaultProps = {
editMode: 'static',
learningGoal: null,
visibilityLearningGoal: 'private',
};
export default connect(
editableFormSelector,
{},
)(injectIntl(LearningGoal));

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.learningGoal.learningGoal': {
id: 'profile.learningGoal.learningGoal',
defaultMessage: 'Learning Goal',
description: 'A section of a user profile that displays their current learning goal.',
},
'profile.learningGoal.options.start_career': {
id: 'profile.learningGoal.options.start_career',
defaultMessage: 'I want to start my career',
description: 'Selected by user if their goal is to start their career.',
},
'profile.learningGoal.options.advance_career': {
id: 'profile.learningGoal.options.advance_career',
defaultMessage: 'I want to advance my career',
description: 'Selected by user if their goal is to advance their career.',
},
'profile.learningGoal.options.learn_something_new': {
id: 'profile.learningGoal.options.learn_something_new',
defaultMessage: 'I want to learn something new',
description: 'Selected by user if their goal is to learn something new.',
},
'profile.learningGoal.options.something_else': {
id: 'profile.learningGoal.options.something_else',
defaultMessage: 'Something else',
description: 'Selected by user if their goal is not described by the other choices.',
},
});
export default messages;

View File

@@ -0,0 +1,116 @@
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import messages from '../../i18n';
import viewOwnProfileMockStore from '../__mocks__/viewOwnProfile.mockStore';
import savingEditedBioMockStore from '../__mocks__/savingEditedBio.mockStore';
import LearningGoal from './LearningGoal';
const mockStore = configureMockStore([thunk]);
// props to be passed down to LearningGoal component
const requiredLearningGoalProps = {
formId: 'learningGoal',
learningGoal: 'advance_career',
drafts: {},
visibilityLearningGoal: 'private',
editMode: 'static',
saveState: null,
error: null,
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const LearningGoalWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<LearningGoal {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapper.defaultProps = {
store: mockStore(viewOwnProfileMockStore),
};
LearningGoalWrapper.propTypes = {
store: PropTypes.shape({}),
};
const LearningGoalWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<LearningGoal {...requiredLearningGoalProps} formId="learningGoal" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBioMockStore),
};
LearningGoalWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<LearningGoal />', () => {
describe('renders the current learning goal', () => {
it('renders "I want to advance my career"', () => {
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('I want to advance my career')).toBeTruthy();
});
it('renders "Something else"', () => {
requiredLearningGoalProps.learningGoal = 'something_else';
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('Something else')).toBeTruthy();
});
});
});

View File

@@ -1,182 +1,147 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { InfoOutline } from '@openedx/paragon/icons';
import { Hyperlink, OverlayTrigger, Tooltip } from '@openedx/paragon';
import messages from './Name.messages'; import messages from './Name.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors'; import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const Name = ({ class Name extends React.Component {
formId, constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const {
name, name,
visibilityName, value,
editMode, } = e.target;
saveState, this.props.changeHandler(name, value);
changeHandler, }
submitHandler,
closeHandler,
openHandler,
accountSettingsUrl,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = useHandleChange(changeHandler); handleSubmit(e) {
const handleSubmit = useHandleSubmit(submitHandler, formId); e.preventDefault();
const handleOpen = useCloseOpenHandler(openHandler, formId); this.props.submitHandler(this.props.formId);
const handleClose = useCloseOpenHandler(closeHandler, formId); }
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, name, visibilityName, editMode, saveState, intl,
} = this.props;
return ( return (
<SwitchContent <SwitchContent
className="pt-40px" className="mb-5"
expression={editMode} expression={editMode}
cases={{ cases={{
editing: ( editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}> <div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}> <form onSubmit={this.handleSubmit}>
<div className="form-group"> <div className="form-group">
<div className="row m-0 pb-2.5 align-items-center"> <EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<p data-hj-suppress className="h5 font-weight-bold m-0"> {/*
{intl.formatMessage(messages['profile.name.full.name'])} This isn't a mistake - the name field should not be editable. But if it were,
</p> you'd find the original code got deleted in the commit which added this comment.
<OverlayTrigger -djoy
key="top" TODO: Relatedly, the plumbing for editing the name field is still in place.
placement="top" Once we're super sure we don't want it back, you could delete the name props and
overlay={( such to fully get rid of it.
<Tooltip variant="light" id="tooltip-top"> */}
<p className="h5 font-weight-normal m-0 p-0"> <p data-hj-suppress className="h5">{name}</p>
{intl.formatMessage(messages['profile.name.tooltip'])} <small className="form-text text-muted" id={`${formId}-help-text`}>
</p> {intl.formatMessage(messages['profile.name.details'])}
</Tooltip> </small>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader content={name} />
<h4 className="font-weight-normal">
<Hyperlink destination={accountSettingsUrl} target="_blank">
{intl.formatMessage(messages['profile.name.redirect'])}
</Hyperlink>
</h4>
</div> </div>
<FormControls <FormControls
visibilityId="visibilityName" visibilityId="visibilityName"
saveState={saveState} saveState={saveState}
visibility={visibilityName} visibility={visibilityName}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
/> />
</form> </form>
</div> </div>
), ),
editable: ( editable: (
<> <>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader <EditableItemHeader
content={name} content={intl.formatMessage(messages['profile.name.full.name'])}
showEditButton showEditButton
onClickEdit={handleOpen} onClickEdit={this.handleOpen}
showVisibility={visibilityName !== null && isVisibilityEnabled} showVisibility={visibilityName !== null}
visibility={visibilityName} visibility={visibilityName}
/> />
<p data-hj-suppress className="h5">{name}</p>
<small className="form-text text-muted">
{intl.formatMessage(messages['profile.name.details'])}
</small>
</> </>
), ),
empty: ( empty: (
<> <>
<div className="row m-0 pb-1.5 align-items-center"> <EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<p data-hj-suppress className="h5 font-weight-bold m-0"> <EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.name.empty'])} {intl.formatMessage(messages['profile.name.empty'])}
</EmptyContent> </EmptyContent>
<small className="form-text text-muted">
{intl.formatMessage(messages['profile.name.details'])}
</small>
</> </>
), ),
static: ( static: (
<> <>
<div className="row m-0 pb-1.5 align-items-center"> <EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<p data-hj-suppress className="h5 font-weight-bold m-0"> <p data-hj-suppress className="h5">{name}</p>
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader content={name} />
</> </>
), ),
}} }}
/> />
); );
}; }
}
Name.propTypes = { Name.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
name: PropTypes.string, name: PropTypes.string,
visibilityName: PropTypes.oneOf(['private', 'all_users']), visibilityName: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string, saveState: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
accountSettingsUrl: PropTypes.string.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
Name.defaultProps = { Name.defaultProps = {
@@ -189,4 +154,4 @@ Name.defaultProps = {
export default connect( export default connect(
editableFormSelector, editableFormSelector,
{}, {},
)(Name); )(injectIntl(Name));

View File

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

View File

@@ -1,79 +1,98 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon'; import { Form } from '@openedx/paragon';
import messages from './PreferredLanguage.messages'; import messages from './PreferredLanguage.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Selectors
import { preferredLanguageSelector } from '../data/selectors'; import { preferredLanguageSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
const PreferredLanguage = ({ class PreferredLanguage extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
// Restructure the data.
// We deconstruct our value prop in render() so this
// changes our data's shape back to match what came in
if (name === this.props.formId) {
if (value !== '') {
this.props.changeHandler(name, [{ code: value }]);
} else {
this.props.changeHandler(name, []);
}
} else {
this.props.changeHandler(name, value);
}
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, formId,
languageProficiencies, languageProficiencies,
visibilityLanguageProficiencies, visibilityLanguageProficiencies,
editMode, editMode,
saveState, saveState,
error, error,
intl,
sortedLanguages, sortedLanguages,
languageMessages, languageMessages,
changeHandler, } = this.props;
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
const handleChange = ({ target: { name, value } }) => {
let newValue = value;
if (name === formId) {
newValue = value ? [{ code: value }] : [];
}
changeHandler(name, newValue);
};
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
const value = languageProficiencies.length ? languageProficiencies[0].code : ''; const value = languageProficiencies.length ? languageProficiencies[0].code : '';
return ( return (
<SwitchContent <SwitchContent
className="pt-40px" className="mb-5"
expression={editMode} expression={editMode}
cases={{ cases={{
editing: ( editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}> <div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}> <form onSubmit={this.handleSubmit}>
<Form.Group <Form.Group
controlId={formId} controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null} isInvalid={error !== null}
> >
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5"> <label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.preferredlanguage.label'])} {intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p> </label>
<select <select
data-hj-suppress data-hj-suppress
id={formId} id={formId}
name={formId} name={formId}
className="form-control py-10px" className="form-control"
value={value} value={value}
onChange={handleChange} onChange={this.handleChange}
> >
<option value=""> </option> <option value="">&nbsp;</option>
{sortedLanguages.map(({ code, name }) => ( {sortedLanguages.map(({ code, name }) => (
<option key={code} value={code}>{name}</option> <option key={code} value={code}>{name}</option>
))} ))}
@@ -88,53 +107,60 @@ const PreferredLanguage = ({
visibilityId="visibilityLanguageProficiencies" visibilityId="visibilityLanguageProficiencies"
saveState={saveState} saveState={saveState}
visibility={visibilityLanguageProficiencies} visibility={visibilityLanguageProficiencies}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
/> />
</form> </form>
</div> </div>
), ),
editable: ( editable: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EditableItemHeader <EditableItemHeader
content={languageMessages[value]} content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
showEditButton showEditButton
onClickEdit={handleOpen} onClickEdit={this.handleOpen}
showVisibility={visibilityLanguageProficiencies !== null && isVisibilityEnabled} showVisibility={visibilityLanguageProficiencies !== null}
visibility={visibilityLanguageProficiencies} visibility={visibilityLanguageProficiencies}
/> />
<p data-hj-suppress className="h5">{languageMessages[value]}</p>
</> </>
), ),
empty: ( empty: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader
{intl.formatMessage(messages['profile.preferredlanguage.label'])} content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p> />
<EmptyContent onClick={handleOpen}> <EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.preferredlanguage.empty'])} {intl.formatMessage(messages['profile.preferredlanguage.empty'])}
</EmptyContent> </EmptyContent>
</> </>
), ),
static: ( static: (
<> <>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5"> <EditableItemHeader
{intl.formatMessage(messages['profile.preferredlanguage.label'])} content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p> />
<EditableItemHeader content={languageMessages[value]} /> <p data-hj-suppress className="h5">{languageMessages[value]}</p>
</> </>
), ),
}} }}
/> />
); );
}; }
}
PreferredLanguage.propTypes = { PreferredLanguage.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
languageProficiencies: PropTypes.oneOfType([ languageProficiencies: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })), PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })),
// TODO: ProfilePageSelector should supply null values
// instead of empty strings when no value exists
PropTypes.oneOf(['']), PropTypes.oneOf(['']),
]), ]),
visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']), visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']),
@@ -146,10 +172,15 @@ PreferredLanguage.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
})).isRequired, })).isRequired,
languageMessages: PropTypes.objectOf(PropTypes.string).isRequired, languageMessages: PropTypes.objectOf(PropTypes.string).isRequired,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
PreferredLanguage.defaultProps = { PreferredLanguage.defaultProps = {
@@ -163,4 +194,4 @@ PreferredLanguage.defaultProps = {
export default connect( export default connect(
preferredLanguageSelector, preferredLanguageSelector,
{}, {},
)(PreferredLanguage); )(injectIntl(PreferredLanguage));

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
}, },
'profile.preferredlanguage.label': { 'profile.preferredlanguage.label': {
id: 'profile.preferredlanguage.label', id: 'profile.preferredlanguage.label',
defaultMessage: 'Primary language spoken', defaultMessage: 'Primary Language Spoken',
description: 'The label for a users primary spoken language.', description: 'The label for a users primary spoken language.',
}, },
}); });

View File

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

View File

@@ -11,16 +11,6 @@ const messages = defineMessages({
defaultMessage: 'Change', defaultMessage: 'Change',
description: 'Change photo button', description: 'Change photo button',
}, },
'profile.profileavatar.tooltip.edit': {
id: 'profile.profileavatar.tooltip.edit',
defaultMessage: 'Edit photo',
description: 'Tooltip for edit photo button',
},
'profile.profileavatar.tooltip.upload': {
id: 'profile.profileavatar.tooltip.upload',
defaultMessage: 'Upload photo',
description: 'Tooltip for upload photo button',
},
}); });
export default messages; export default messages;

View File

@@ -1,18 +1,22 @@
import React, { useState } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon'; import { Alert } from '@openedx/paragon';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons'; import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import classNames from 'classnames'; import classNames from 'classnames';
import messages from './SocialLinks.messages';
// Components
import FormControls from './elements/FormControls'; import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader'; import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent'; import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent'; import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors'; import { editableFormSelector } from '../data/selectors';
import { useIsVisibilityEnabled } from '../data/hooks';
const platformDisplayInfo = { const platformDisplayInfo = {
facebook: { facebook: {
@@ -21,7 +25,7 @@ const platformDisplayInfo = {
}, },
twitter: { twitter: {
icon: faTwitter, icon: faTwitter,
name: 'X', name: 'Twitter',
}, },
linkedin: { linkedin: {
icon: faLinkedin, icon: faLinkedin,
@@ -29,203 +33,283 @@ const platformDisplayInfo = {
}, },
}; };
const SocialLinks = ({ const SocialLink = ({ url, name, platform }) => (
formId, <a href={url} className="font-weight-bold">
socialLinks, <FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
draftSocialLinksByPlatform, {name}
visibilitySocialLinks, </a>
editMode, );
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const [activePlatform, setActivePlatform] = useState(null);
const mergeWithDrafts = (newSocialLink) => { SocialLink.propTypes = {
url: PropTypes.string.isRequired,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
};
const EditableListItem = ({
url, platform, onClickEmptyContent, name,
}) => {
const linkDisplay = url ? (
<SocialLink name={name} url={url} platform={platform} />
) : (
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>
);
return <li className="form-group">{linkDisplay}</li>;
};
EditableListItem.propTypes = {
url: PropTypes.string,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onClickEmptyContent: PropTypes.func,
};
EditableListItem.defaultProps = {
url: null,
onClickEmptyContent: null,
};
const EditingListItem = ({
platform, name, value, onChange, error,
}) => (
<li className="form-group">
<label htmlFor={`social-${platform}`}>{name}</label>
<input
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={value || ''}
onChange={onChange}
aria-describedby="social-error-feedback"
/>
</li>
);
EditingListItem.propTypes = {
platform: PropTypes.string.isRequired,
value: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
error: PropTypes.string,
};
EditingListItem.defaultProps = {
value: null,
error: null,
};
const EmptyListItem = ({ onClick, name }) => (
<li className="mb-4">
<EmptyContent onClick={onClick}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</li>
);
EmptyListItem.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const StaticListItem = ({ name, url, platform }) => (
<li className="mb-2">
<SocialLink name={name} url={url} platform={platform} />
</li>
);
StaticListItem.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string,
platform: PropTypes.string.isRequired,
};
StaticListItem.defaultProps = {
url: null,
};
class SocialLinks extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
// The social links are a bit special. If we're updating them, we need to merge them
// with any existing social link drafts, essentially sending a fresh copy of the whole
// data structure back to the reducer. This helps the reducer stay simple and keeps
// special cases out of it, concentrating them here, where they began.
if (name !== 'visibilitySocialLinks') {
this.props.changeHandler(
'socialLinks',
this.mergeWithDrafts({
platform: name,
// If it's an empty string, send it as null.
// The empty string is just for the input. We want nulls.
socialLink: value,
}),
);
} else {
this.props.changeHandler(name, value);
}
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
mergeWithDrafts(newSocialLink) {
const knownPlatforms = ['twitter', 'facebook', 'linkedin']; const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const updated = []; const updated = [];
knownPlatforms.forEach((platform) => { knownPlatforms.forEach((platform) => {
if (newSocialLink.platform === platform) { if (newSocialLink.platform === platform) {
updated.push(newSocialLink); updated.push(newSocialLink);
} else if (draftSocialLinksByPlatform[platform] !== undefined) { } else if (this.props.draftSocialLinksByPlatform[platform] !== undefined) {
updated.push(draftSocialLinksByPlatform[platform]); updated.push(this.props.draftSocialLinksByPlatform[platform]);
} }
}); });
return updated; return updated;
};
const handleChange = (e) => {
const { name, value } = e.target;
if (name !== 'visibilitySocialLinks') {
changeHandler(
'socialLinks',
mergeWithDrafts({
platform: name,
socialLink: value,
}),
);
} else {
changeHandler(name, value);
} }
};
const handleSubmit = (e) => { render() {
e.preventDefault(); const {
submitHandler(formId); socialLinks, visibilitySocialLinks, editMode, saveState, error, intl,
setActivePlatform(null); } = this.props;
};
const handleClose = () => {
closeHandler(formId);
setActivePlatform(null);
};
const handleOpen = (platform) => {
openHandler(formId);
setActivePlatform(platform);
};
const renderPlatformContent = (platform, socialLink, isEditing) => {
if (isEditing) {
return ( return (
<form onSubmit={handleSubmit}> <SwitchContent
<div className="form-group m-0"> className="mb-5"
{error !== null && ( expression={editMode}
cases={{
empty: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.sociallinks.social.links'])} />
<ul className="list-unstyled">
{socialLinks.map(({ platform }) => (
<EmptyListItem
key={platform}
onClick={this.handleOpen}
name={platformDisplayInfo[platform].name}
/>
))}
</ul>
</>
),
static: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
/>
<ul className="list-unstyled">
{socialLinks
.filter(({ socialLink }) => Boolean(socialLink))
.map(({ platform, socialLink }) => (
<StaticListItem
key={platform}
name={platformDisplayInfo[platform].name}
url={socialLink}
platform={platform}
/>
))}
</ul>
</>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilitySocialLinks !== null}
visibility={visibilitySocialLinks}
/>
<ul className="list-unstyled">
{socialLinks.map(({ platform, socialLink }) => (
<EditableListItem
key={platform}
platform={platform}
name={platformDisplayInfo[platform].name}
url={socialLink}
onClickEmptyContent={this.handleOpen}
/>
))}
</ul>
</>
),
editing: (
<div role="dialog" aria-labelledby="social-links-label">
<form aria-labelledby="editing-form" onSubmit={this.handleSubmit}>
<EditableItemHeader
headingId="social-links-label"
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
/>
{/* TODO: Replace this alert with per-field errors. Needs API update. */}
<div id="social-error-feedback"> <div id="social-error-feedback">
{error !== null
? (
<Alert variant="danger" dismissible={false} show> <Alert variant="danger" dismissible={false} show>
{error} {error}
</Alert> </Alert>
) : null}
</div> </div>
)} <ul className="list-unstyled">
<div className="pb-3"> {socialLinks.map(({ platform, socialLink }) => (
<input <EditingListItem
className={classNames('form-control py-10px', { 'is-invalid': Boolean(error) })} key={platform}
type="text" name={platformDisplayInfo[platform].name}
id={`social-${platform}`} platform={platform}
name={platform} value={socialLink}
value={socialLink || ''} /* TODO: Per-field errors: error={error !== null ? error[platform] : null} */
onChange={handleChange} onChange={this.handleChange}
aria-describedby="social-error-feedback"
/> />
</div> ))}
</ul>
<FormControls <FormControls
visibilityId="visibilitySocialLinks" visibilityId="visibilitySocialLinks"
saveState={saveState} saveState={saveState}
visibility={visibilitySocialLinks} visibility={visibilitySocialLinks}
cancelHandler={handleClose} cancelHandler={this.handleClose}
changeHandler={handleChange} changeHandler={this.handleChange}
submitHandler={handleSubmit}
/> />
</div>
</form> </form>
);
}
if (socialLink) {
return (
<div className="w-100 overflowWrap-breakWord">
<EditableItemHeader
content={socialLink}
showEditButton
onClickEdit={() => handleOpen(platform)}
showVisibility={visibilitySocialLinks !== null && isVisibilityEnabled}
visibility={visibilitySocialLinks}
/>
</div>
);
}
return (
<EmptyContent onClick={() => handleOpen(platform)}>
Add {platformDisplayInfo[platform].name}
</EmptyContent>
);
};
return (
<SwitchContent
className="p-0"
expression={editMode}
cases={{
empty: (
<div>
<div>
{socialLinks.map(({ platform }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
<EmptyContent onClick={() => handleOpen(platform)}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network} profile"
values={{
network: platformDisplayInfo[platform].name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</div>
))}
</div>
</div>
),
static: (
<div>
<div>
{socialLinks
.filter(({ socialLink }) => Boolean(socialLink))
.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
<EditableItemHeader
content={socialLink}
contentPrefix={`${platformDisplayInfo[platform].name}: `}
/>
</div>
))}
</div>
</div>
),
editable: (
<div>
<div>
{socialLinks.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{platformDisplayInfo[platform].name}
</p>
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
</div>
))}
</div>
</div>
),
editing: (
<div>
<div>
{socialLinks.map(({ platform, socialLink }) => (
<div key={platform} className="pt-40px">
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{platformDisplayInfo[platform].name}
</p>
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
</div>
))}
</div>
</div> </div>
), ),
}} }}
/> />
); );
}; }
}
SocialLinks.propTypes = { SocialLinks.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired, formId: PropTypes.string.isRequired,
// From Selector
socialLinks: PropTypes.arrayOf(PropTypes.shape({ socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string, platform: PropTypes.string,
socialLink: PropTypes.string, socialLink: PropTypes.string,
@@ -238,10 +322,15 @@ SocialLinks.propTypes = {
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string, saveState: PropTypes.string,
error: PropTypes.string, error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
}; };
SocialLinks.defaultProps = { SocialLinks.defaultProps = {
@@ -255,4 +344,4 @@ SocialLinks.defaultProps = {
export default connect( export default connect(
editableFormSelector, editableFormSelector,
{}, {},
)(SocialLinks); )(injectIntl(SocialLinks));

View File

@@ -0,0 +1,165 @@
import { render, fireEvent, screen } from '@testing-library/react';
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import SocialLinks from './SocialLinks';
import * as savingEditedBio from '../__mocks__/savingEditedBio.mockStore';
import messages from '../../i18n';
const mockStore = configureMockStore([thunk]);
const defaultProps = {
formId: 'socialLinks',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha',
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA',
},
],
drafts: {},
visibilitySocialLinks: 'private',
editMode: 'static',
saveState: null,
error: null,
changeHandler: jest.fn(),
submitHandler: jest.fn(),
closeHandler: jest.fn(),
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const SocialLinksWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<SocialLinks {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
SocialLinksWrapper.defaultProps = {
store: mockStore(savingEditedBio),
};
SocialLinksWrapper.propTypes = {
store: PropTypes.shape({}),
};
const SocialLinksWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<SocialLinks {...defaultProps} formId="bio" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
SocialLinksWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBio),
};
SocialLinksWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<SocialLinks />', () => {
['certificates', 'bio', 'goals', 'socialLinks'].forEach(editMode => (
it(`calls social links with edit mode ${editMode}`, () => {
const component = <SocialLinksWrapper {...defaultProps} formId={editMode} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
})
));
it('calls social links with editing', () => {
const changeHandler = jest.fn();
const submitHandler = jest.fn();
const closeHandler = jest.fn();
const { container } = render(
<SocialLinksWrapper
{...defaultProps}
formId="bio"
changeHandler={changeHandler}
submitHandler={submitHandler}
closeHandler={closeHandler}
/>,
);
const { platform } = defaultProps.socialLinks[0];
const inputField = container.querySelector(`#social-${platform}`);
fireEvent.change(inputField, { target: { value: 'test', name: platform } });
expect(changeHandler).toHaveBeenCalledTimes(1);
const selectElement = container.querySelector('#visibilitySocialLinks');
expect(selectElement.value).toBe('private');
fireEvent.change(selectElement, { target: { value: 'all_users', name: 'visibilitySocialLinks' } });
expect(changeHandler).toHaveBeenCalledTimes(2);
fireEvent.submit(container.querySelector('[aria-labelledby="editing-form"]'));
expect(submitHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(closeHandler).toHaveBeenCalledTimes(1);
});
it('calls social links with static', () => {
const openHandler = jest.fn();
render(
<SocialLinksWrapper
{...defaultProps}
formId="goals"
openHandler={openHandler}
/>,
);
const addFacebookButton = screen.getByRole('button', { name: 'Add Facebook' });
fireEvent.click(addFacebookButton);
expect(openHandler).toHaveBeenCalledTimes(1);
});
it('calls social links with error', () => {
const newStore = JSON.parse(JSON.stringify(savingEditedBio));
newStore.profilePage.errors.bio = { userMessage: 'error' };
const { container } = render(<SocialLinksWrapperWithStore store={newStore} />);
const alertDanger = container.querySelector('.alert-danger');
expect(alertDanger).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,504 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SocialLinks /> calls social links with edit mode bio 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
aria-labelledby="social-links-label"
role="dialog"
>
<form
aria-labelledby="editing-form"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
id="social-links-label"
>
Social Links
</h2>
</div>
<div
id="social-error-feedback"
/>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<label
for="social-facebook"
>
Facebook
</label>
<input
aria-describedby="social-error-feedback"
class="form-control"
id="social-facebook"
name="facebook"
type="text"
value="https://www.facebook.com/aloha"
/>
</li>
<li
class="form-group"
>
<label
for="social-twitter"
>
Twitter
</label>
<input
aria-describedby="social-error-feedback"
class="form-control"
id="social-twitter"
name="twitter"
type="text"
value="https://www.twitter.com/ALOHA"
/>
</li>
</ul>
<div
class="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"
>
<div
class="form-group d-flex flex-wrap"
>
<label
class="col-form-label"
for="visibilitySocialLinks"
>
Who can see this:
</label>
<span
class="d-flex align-items-center"
>
<span
class="d-inline-block ml-1 mr-2"
style="width: 1.5rem;"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
</span>
<select
class="d-inline-block form-control"
id="visibilitySocialLinks"
name="visibilitySocialLinks"
type="select"
>
<option
value="private"
>
Just me
</option>
<option
value="all_users"
>
Everyone on localhost
</option>
</select>
</span>
</div>
<div
class="form-group flex-shrink-0 flex-grow-1"
>
<button
aria-disabled="false"
aria-live="assertive"
class="pgn__stateful-btn pgn__stateful-btn-state-pending btn btn-primary"
type="submit"
>
<span
class="d-flex align-items-center justify-content-center"
>
<span
class="pgn__stateful-btn-icon"
>
<span
class="pgn__icon icon-spin"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
</span>
</span>
<span>
Saving
</span>
</span>
</button>
<button
class="btn btn-link"
type="button"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode certificates 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode goals 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
</h2>
</div>
<ul
class="list-unstyled"
>
<li
class="mb-4"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add Facebook
</button>
</div>
</li>
<li
class="mb-4"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add Twitter
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode socialLinks 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
</ul>
</div>
</div>
</div>
`;

View File

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

View File

@@ -1,22 +0,0 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import EditButton from './EditButton';
const messages = {
'profile.editbutton.edit': 'Edit',
};
describe('EditButton', () => {
it('renders and calls onClick when clicked', () => {
const onClick = jest.fn();
const { getByRole } = render(
<IntlProvider locale="en" messages={messages}>
<EditButton onClick={onClick} />
</IntlProvider>,
);
const button = getByRole('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -1,51 +1,25 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
import EditButton from './EditButton'; import EditButton from './EditButton';
import { Visibility } from './Visibility'; import { Visibility } from './Visibility';
import { useIsOnMobileScreen } from '../../data/hooks';
const EditableItemHeader = ({ const EditableItemHeader = ({
content = '', content,
showVisibility = false, showVisibility,
visibility = 'private', visibility,
showEditButton = false, showEditButton,
onClickEdit = () => {}, onClickEdit,
headingId = null, headingId,
}) => { }) => (
const isMobileView = useIsOnMobileScreen(); <div className="editable-item-header mb-2">
return ( <h2 className="edit-section-header" id={headingId}>
<>
<div className="row m-0 p-0 d-flex flex-nowrap align-items-center">
<div
className={classNames([
'm-0 p-0 col-auto',
isMobileView ? 'w-90' : '',
])}
>
<h4
className="edit-section-header text-gray-700"
id={headingId}
>
{content} {content}
</h4> {showEditButton ? <EditButton style={{ marginTop: '-.35rem' }} className="float-right px-0" onClick={onClickEdit} /> : null}
</div> </h2>
<div
className={classNames([
'col-auto m-0 p-0 d-flex align-items-center',
isMobileView ? 'col-1' : 'col-auto',
])}
>
{showEditButton ? <EditButton className="p-1.5" onClick={onClickEdit} /> : null}
</div>
</div>
<div className="row m-0 p-0">
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null} {showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div> </div>
</> );
);
};
export default EditableItemHeader; export default EditableItemHeader;
@@ -57,3 +31,12 @@ EditableItemHeader.propTypes = {
visibility: PropTypes.oneOf(['private', 'all_users']), visibility: PropTypes.oneOf(['private', 'all_users']),
headingId: PropTypes.string, headingId: PropTypes.string,
}; };
EditableItemHeader.defaultProps = {
onClickEdit: () => {},
showVisibility: false,
showEditButton: false,
content: '',
visibility: 'private',
headingId: null,
};

View File

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

View File

@@ -1,27 +1,20 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Button, StatefulButton } from '@openedx/paragon'; import { Button, StatefulButton } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './FormControls.messages'; import messages from './FormControls.messages';
import { VisibilitySelect } from './Visibility'; import { VisibilitySelect } from './Visibility';
import { useIsVisibilityEnabled } from '../../data/hooks';
const FormControls = ({ const FormControls = ({
cancelHandler, cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
changeHandler,
visibility = 'private',
visibilityId,
saveState = null,
}) => { }) => {
const intl = useIntl(); // Eliminate error/failed state for save button
const buttonState = saveState === 'error' ? null : saveState; const buttonState = saveState === 'error' ? null : saveState;
const isVisibilityEnabled = useIsVisibilityEnabled();
return ( return (
<div className="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"> <div className="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center">
{isVisibilityEnabled && (
<div className="form-group d-flex flex-wrap"> <div className="form-group d-flex flex-wrap">
<label className="col-form-label" htmlFor={visibilityId}> <label className="col-form-label" htmlFor={visibilityId}>
{intl.formatMessage(messages['profile.formcontrols.who.can.see'])} {intl.formatMessage(messages['profile.formcontrols.who.can.see'])}
@@ -35,27 +28,14 @@ const FormControls = ({
onChange={changeHandler} onChange={changeHandler}
/> />
</div> </div>
)} <div className="form-group flex-shrink-0 flex-grow-1">
<div className="row form-group flex-shrink-0 flex-grow-1 m-0 p-0">
<div className="pr-2 pl-0 m-0">
<Button variant="outline-primary" onClick={cancelHandler}>
{intl.formatMessage(messages['profile.formcontrols.button.cancel'])}
</Button>
</div>
<div className="p-0 m-0">
<StatefulButton <StatefulButton
type="submit" type="submit"
state={buttonState} state={buttonState}
labels={{ labels={{
default: intl.formatMessage( default: intl.formatMessage(messages['profile.formcontrols.button.save']),
messages['profile.formcontrols.button.save'], pending: intl.formatMessage(messages['profile.formcontrols.button.saving']),
), complete: intl.formatMessage(messages['profile.formcontrols.button.saved']),
pending: intl.formatMessage(
messages['profile.formcontrols.button.saving'],
),
complete: intl.formatMessage(
messages['profile.formcontrols.button.saved'],
),
}} }}
onClick={(e) => { onClick={(e) => {
// Swallow clicks if the state is pending. // Swallow clicks if the state is pending.
@@ -71,13 +51,15 @@ const FormControls = ({
}} }}
disabledStates={[]} disabledStates={[]}
/> />
</div> <Button variant="link" onClick={cancelHandler}>
{intl.formatMessage(messages['profile.formcontrols.button.cancel'])}
</Button>
</div> </div>
</div> </div>
); );
}; };
export default FormControls; export default injectIntl(FormControls);
FormControls.propTypes = { FormControls.propTypes = {
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']), saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
@@ -85,4 +67,12 @@ FormControls.propTypes = {
visibilityId: PropTypes.string.isRequired, visibilityId: PropTypes.string.isRequired,
cancelHandler: PropTypes.func.isRequired, cancelHandler: PropTypes.func.isRequired,
changeHandler: PropTypes.func.isRequired, changeHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
FormControls.defaultProps = {
visibility: 'private',
saveState: null,
}; };

View File

@@ -1,66 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import FormControls from './FormControls';
import messages from './FormControls.messages';
const defaultProps = {
cancelHandler: jest.fn(),
changeHandler: jest.fn(),
visibilityId: 'visibility-id',
visibility: 'private',
saveState: null,
};
jest.mock('@edx/frontend-platform/i18n', () => {
const actual = jest.requireActual('@edx/frontend-platform/i18n');
return {
...actual,
useIntl: () => ({
formatMessage: (msg) => msg.id, // returns id so we can assert on it
}),
injectIntl: (Component) => (props) => (
<Component
{...props}
intl={{
formatMessage: (msg) => msg.id, // returns id so we can assert on it
}}
/>
),
intlShape: {}, // optional, prevents prop-type warnings
};
});
jest.mock('../../data/hooks', () => ({
useIsVisibilityEnabled: () => true,
}));
describe('FormControls', () => {
it('renders Save button label when saveState is null', () => {
render(<FormControls {...defaultProps} />);
expect(
screen.getByRole('button', { name: messages['profile.formcontrols.button.save'].id }),
).toBeInTheDocument();
});
it('renders Saved label when saveState is complete', () => {
render(<FormControls {...defaultProps} saveState="complete" />);
expect(
screen.getByRole('button', { name: messages['profile.formcontrols.button.saved'].id }),
).toBeInTheDocument();
});
it('renders Saving label when saveState is pending', () => {
render(<FormControls {...defaultProps} saveState="pending" />);
expect(
screen.getByRole('button', { name: messages['profile.formcontrols.button.saving'].id }),
).toBeInTheDocument();
});
it('calls cancelHandler when Cancel button is clicked', () => {
render(<FormControls {...defaultProps} />);
fireEvent.click(
screen.getByRole('button', { name: messages['profile.formcontrols.button.cancel'].id }),
);
expect(defaultProps.cancelHandler).toHaveBeenCalled();
});
});

View File

@@ -3,13 +3,18 @@ import PropTypes from 'prop-types';
import { TransitionReplace } from '@openedx/paragon'; import { TransitionReplace } from '@openedx/paragon';
const onChildExit = (htmlNode) => { const onChildExit = (htmlNode) => {
// If the leaving child has focus, take control and redirect it
if (htmlNode.contains(document.activeElement)) { if (htmlNode.contains(document.activeElement)) {
// Get the newly entering sibling.
// It's the previousSibling, but not for any explicit reason. So checking for both.
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling; const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
// There's no replacement, do nothing.
if (!enteringChild) { if (!enteringChild) {
return; return;
} }
// Get all the focusable elements in the entering child and focus the first one
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusableElements.length) { if (focusableElements.length) {
focusableElements[0].focus(); focusableElements[0].focus();
@@ -17,7 +22,7 @@ const onChildExit = (htmlNode) => {
} }
}; };
const SwitchContent = ({ expression = null, cases, className = null }) => { const SwitchContent = ({ expression, cases, className }) => {
const getContent = (caseKey) => { const getContent = (caseKey) => {
if (cases[caseKey]) { if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') { if (typeof cases[caseKey] === 'string') {
@@ -51,4 +56,9 @@ SwitchContent.propTypes = {
className: PropTypes.string, className: PropTypes.string,
}; };
SwitchContent.defaultProps = {
expression: null,
className: null,
};
export default SwitchContent; export default SwitchContent;

View File

@@ -1,80 +0,0 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { render, screen } from '@testing-library/react';
import SwitchContent from './SwitchContent';
jest.mock('@openedx/paragon', () => ({
TransitionReplace: ({ children, onChildExit, className }) => (
<div data-testid="transition" data-class={className} data-onchildexit={!!onChildExit}>
{children}
</div>
),
}));
describe('SwitchContent', () => {
const makeElement = (text) => <div>{text}</div>;
it('renders matching case element directly', () => {
render(
<SwitchContent
expression="one"
cases={{ one: makeElement('Case One') }}
/>,
);
expect(screen.getByText('Case One')).toBeInTheDocument();
});
it('renders case via string alias', () => {
render(
<SwitchContent
expression="alias"
cases={{
alias: 'target',
target: makeElement('Target Case'),
}}
/>,
);
expect(screen.getByText('Target Case')).toBeInTheDocument();
});
it('renders default alias when expression not found', () => {
render(
<SwitchContent
expression="missing"
cases={{
default: 'target',
target: makeElement('Target via Default'),
}}
/>,
);
expect(screen.getByText('Target via Default')).toBeInTheDocument();
});
it('renders null when no matching case and no default', () => {
const { container } = render(
<SwitchContent
expression="missing"
cases={{ something: makeElement('Something') }}
/>,
);
expect(container.querySelector('[data-testid="transition"]').textContent).toBe('');
});
it('calls onChildExit when child exits', () => {
const onChildExit = jest.fn();
render(
<SwitchContent
expression="one"
cases={{ one: makeElement('Case One') }}
className="test-class"
/>,
);
const transition = screen.getByTestId('transition');
transition.dataset.onchildexit = onChildExit;
// Simulate child exit
onChildExit(transition);
expect(onChildExit).toHaveBeenCalledWith(transition);
});
});

View File

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

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Visibility, VisibilitySelect } from './Visibility';
import '@testing-library/jest-dom';
const messages = {
'profile.visibility.who.just.me': 'Just me',
'profile.visibility.who.everyone': 'Everyone',
};
describe('Visibility', () => {
it('shows the correct icon and label for private', () => {
const { getByText } = render(
<IntlProvider locale="en" messages={messages}>
<Visibility to="private" />
</IntlProvider>,
);
expect(getByText(/just me/i)).toBeInTheDocument();
});
it('shows the correct icon and label for all_users', () => {
const { getByText } = render(
<IntlProvider locale="en" messages={messages}>
<Visibility to="all_users" />
</IntlProvider>,
);
expect(getByText(/everyone/i)).toBeInTheDocument();
});
});
describe('VisibilitySelect', () => {
it('renders both options', () => {
const { getByRole, getAllByRole } = render(
<IntlProvider locale="en" messages={messages}>
<VisibilitySelect value="private" onChange={() => {}} />
</IntlProvider>,
);
const select = getByRole('combobox');
const options = getAllByRole('option');
expect(select).toBeInTheDocument();
expect(options.length).toBe(2);
});
});

View File

@@ -17,46 +17,56 @@
} }
.profile-page-bg-banner { .profile-page-bg-banner {
height: 298px; height: 12rem;
width: 100%;
background-image: url('./assets/dot-pattern-light.png'); background-image: url('./assets/dot-pattern-light.png');
background-repeat: repeat-x; background-repeat: repeat-x;
background-size: auto 85%; background-size: auto 85%;
} }
.username-description {
width: auto;
position: absolute;
left: 1.5rem;
top: 5.25rem;
color: $gray-500;
line-height: 0.9rem;
font-size: 0.8rem;
font-style: normal;
font-weight: 400;
margin-left: 0.9rem;
}
.mb-2rem {
margin-bottom: 2rem;
}
.icon-visibility-off { .icon-visibility-off {
height: 1rem; height: 1rem;
color: var(--pgn-color-gray-500); color: $gray-500;
} }
.profile-page { .profile-page {
.edit-section-header { .edit-section-header {
font-size: var(--pgn-typography-font-size-h4-base); @extend .h6;
display: block; display: block;
font-weight: 400; font-weight: normal;
letter-spacing: 0; letter-spacing: 0;
margin: 0; margin: 0;
line-height: 2.25rem;
} }
label.edit-section-header { label.edit-section-header {
margin-bottom: calc(var(--pgn-spacing-spacer-base) * .5); margin-bottom: $spacer * .5;
} }
.profile-avatar-wrap { .profile-avatar-wrap {
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
max-width: 12rem; max-width: 12rem;
margin-right: 0; margin-right: 0;
height: auto; margin-top: -8rem;
margin-bottom: 2rem;
} }
} }
.profile-avatar-button {
position: absolute;
left: 76px;
top: 76px;
}
.profile-avatar-menu-container { .profile-avatar-menu-container {
background: rgba(0,0,0,.65); background: rgba(0,0,0,.65);
position: absolute; position: absolute;
@@ -67,25 +77,25 @@
align-items: center; align-items: center;
border-radius: 50%; border-radius: 50%;
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
background: linear-gradient(to top, rgba(0,0,0,.65) 4rem, rgba(0,0,0,0) 4rem); background: linear-gradient(to top, rgba(0,0,0,.65) 4rem, rgba(0,0,0,0) 4rem);
align-items: flex-end; align-items: flex-end;
} }
.btn { .btn {
text-decoration: none; text-decoration: none;
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
} }
.dropdown { .dropdown {
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;
} }
.btn { .btn {
color:var(--pgn-color-white);; color: $white;
background: transparent; background: transparent;
border-color: transparent; border-color: transparent;
margin: 0; margin: 0;
@@ -94,13 +104,13 @@
} }
.profile-avatar { .profile-avatar {
width: 7.5rem; width: 5rem;
height: 7.5rem; height: 5rem;
position: relative; position: relative;
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
width: 7.5rem; width: 12rem;
height: 7.5rem; height: 12rem;
} }
.profile-avatar-edit-button { .profile-avatar-edit-button {
@@ -118,7 +128,7 @@
border-radius:0; border-radius:0;
transition: opacity 200ms ease; transition: opacity 200ms ease;
@media (--pgn-size-breakpoint-min-width-md) { @include media-breakpoint-up(md) {
height: 4rem; height: 4rem;
} }
@@ -129,133 +139,27 @@
} }
.certificate { .certificate {
background-color: #F3F1ED; position: relative;
border-radius: 0.75rem;
overflow: hidden; .certificate-title {
border: 1px #E7E4DB solid; font-family: $font-family-serif;
font-weight: 400;
}
.certificate-type-illustration { .certificate-type-illustration {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
bottom: 0; bottom: 0;
width: 15.15rem; width: 12rem;
opacity: .06; opacity: .06;
background-size: 90%; background-size: 90%;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right top; background-position: right top;
} }
.card-body {
position: relative;
}
} }
} }
.info-icon {
width: 1.5rem;
height: 1.5rem;
padding-left: 0.125rem;
}
.max-width-32em {
max-width: 32em;
}
.height-50vh {
height: 50vh;
}
// Todo: Move the following to edx-paragon
.min-width-179px {
min-width: 179px;
}
.max-width-304px{
max-width: 304px;
}
.width-314px {
width: 314px;
}
.w-90{
max-width: 90%;
}
.width-24px{
width: 24px;
}
.height-42px {
height: 42px;
}
.rounded-75 {
border-radius: 0.75rem;
}
.pt-40px{
padding-top: 40px;
}
.pl-40px {
padding-left: 40px;
}
.py-10px{
padding-top: 10px;
padding-bottom: 10px;
}
.py-36px {
padding-top: 36px;
padding-bottom: 36px;
}
.px-120px {
padding-left: 120px;
padding-right: 120px;
}
.px-40px {
padding-left: 40px;
padding-right: 40px;
}
.g-15rem {
gap: 1.5rem;
}
.g-5rem {
gap: 0.5rem;
}
.g-1rem {
gap: 1rem;
}
.g-3rem {
gap: 3rem;
}
.color-black {
color: #000;
}
.bg-color-grey-FBFAF9 {
background-color: #FBFAF9;
}
.background-black-65 {
background-color: rgba(0,0,0,.65)
}
.object-fit-cover {
object-fit: cover;
}
.lh-36px {
line-height: 36px;
}
.overflowWrap-breakWord {
overflow-wrap: break-word;
}

View File

@@ -2,6 +2,7 @@ import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase'; import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) { export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if ( if (
object === undefined object === undefined
|| object === null || object === null
@@ -14,6 +15,7 @@ export function modifyObjectKeys(object, modify) {
return object.map(value => modifyObjectKeys(value, modify)); return object.map(value => modifyObjectKeys(value, modify));
} }
// Otherwise, process all its keys.
const result = {}; const result = {};
Object.entries(object).forEach(([key, value]) => { Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify); result[modify(key)] = modifyObjectKeys(value, modify);

View File

@@ -1,5 +1,8 @@
import React from 'react'; import React from 'react';
import { AuthenticatedPageRoute, PageWrap } from '@edx/frontend-platform/react'; import {
AuthenticatedPageRoute,
PageWrap,
} from '@edx/frontend-platform/react';
import { Routes, Route, useNavigate } from 'react-router-dom'; import { Routes, Route, useNavigate } from 'react-router-dom';
import { ProfilePage, NotFoundPage } from '../profile'; import { ProfilePage, NotFoundPage } from '../profile';