Re-re-rewire data/form state up to redux (#40)

This PR rewires how our forms get their data. It also folds in changes from #35 because @abutterworth and I had a pretty hefty conflict between our two branches.

This turned into a broader effort than I intended, admittedly.

The original goal was to take our form state out of component state and put it into redux state for all six forms on the profile page. This has been done, but doing so begged a bit of broader refactoring and renaming as well.

In no particular order:

1. We were referring to our forms as fields - that language has been fixed.  They're forms now.
2. We were putting 'draft' form values in component state - that state has been moved up to the redux level.
3. The fetchProfile action is now responsible for making several underlying calls to the system for the profile's account information, preferences (if it's your own account), and certificates.  Before, we had multiple actions responsible for each part, and coordination was hard.
4. `react-router-redux` has been replaced with `connected-react-router`.  This is admittedly not something that ultimately needed to be in this PR - I _thought_ I was going to need it, but after the work was done, the need for the change fell out and it's essentially unrelated.  That said, `react-router-redux` is deprecated and no longer supported, and `connected-react-router` has taken its place.  I didn't see any reason to throw away the work, so here it is.
5. I updated some packages as part of number 4 above which didn't strictly need to be updated.  I _thought_ I needed to update them w/r/t changing the router, but I didn't.  As above, didn't see any reason to throw away the work, though.
6. Introduced `reselect` to handle derived state.  This helps keep components clean.
7. Directly connected the forms to the store.  This makes ProfilePage.jsx a lot cleaner.
8. The ProfilePage.jsx file still manages calling action creators to put stuff in redux - the forms are connected for their data, but are otherwise "dumb" about how the data gets back into redux.  It felt weird, for instance, to have the Name.jsx component call an action creator called `saveProfile`... just seemed above its pay grade.  Admittedly this is a bit asymmetrical, but it allowed ProfilePage.jsx to be a lot shorter/have less responsibility for passing data down.
This commit is contained in:
David Joy
2019-02-27 16:23:41 -05:00
committed by GitHub
parent 3a0564e8a8
commit baf6e83f73
30 changed files with 1452 additions and 1084 deletions

75
package-lock.json generated
View File

@@ -2437,7 +2437,7 @@
}
},
"@edx/edx-bootstrap": {
"version": "git://github.com/edx/edx-bootstrap.git#e14bb7b678037a675e26c1b196f800e0f573f22e",
"version": "git://github.com/edx/edx-bootstrap.git#71fd3272d235eb133cccefc1ce63f35a7696bf28",
"from": "git://github.com/edx/edx-bootstrap.git#update-with-documentation-site",
"requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.13",
@@ -6109,6 +6109,15 @@
"integrity": "sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==",
"dev": true
},
"connected-react-router": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-5.0.1.tgz",
"integrity": "sha512-0QwWYPRGZQ7f284lmqc5kwC4T3iW3zrAH3zzi6uUMzTOxbA+mn38tAgMOoVo9m3pbskvONFtXiajgVkCElE9EQ==",
"requires": {
"immutable": "^3.8.1",
"seamless-immutable": "^7.1.3"
}
},
"console-browserify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
@@ -11263,6 +11272,11 @@
"is-cwebp-readable": "^2.0.1"
}
},
"immutable": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
"integrity": "sha1-wkOZUUVbs5kT2vKBN28VMOEErfM="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@@ -17134,14 +17148,25 @@
}
},
"react": {
"version": "16.8.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.8.0.tgz",
"integrity": "sha512-g+nikW2D48kqgWSPwNo0NH9tIGG3DsQFlrtrQ1kj6W77z5ahyIHG0w8kPpz4Sdj6gyLnz0lEd/xsjOoGge2MYQ==",
"version": "16.8.3",
"resolved": "https://registry.npmjs.org/react/-/react-16.8.3.tgz",
"integrity": "sha512-3UoSIsEq8yTJuSu0luO1QQWYbgGEILm+eJl2QN/VLDi7hL+EN18M3q3oVZwmVzzBJ3DkM7RMdRwBmZZ+b4IzSA==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.13.0"
"scheduler": "^0.13.3"
},
"dependencies": {
"scheduler": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.3.tgz",
"integrity": "sha512-UxN5QRYWtpR1egNWzJcVLk8jlegxAugswQc984lD3kU7NuobsO37/sRfbpTdBjtnD5TBNFA2Q2oLV5+UmPSmEQ==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
}
}
},
"react-dev-utils": {
@@ -17362,14 +17387,25 @@
}
},
"react-dom": {
"version": "16.8.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.0.tgz",
"integrity": "sha512-dBzoAGYZpW9Yggp+CzBPC7q1HmWSeRc93DWrwbskmG1eHJWznZB/p0l/Sm+69leIGUS91AXPB/qB3WcPnKx8Sw==",
"version": "16.8.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.3.tgz",
"integrity": "sha512-ttMem9yJL4/lpItZAQ2NTFAbV7frotHk5DZEHXUOws2rMmrsvh1Na7ThGT0dTzUIl6pqTOi5tYREfL8AEna3lA==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"scheduler": "^0.13.0"
"scheduler": "^0.13.3"
},
"dependencies": {
"scheduler": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.3.tgz",
"integrity": "sha512-UxN5QRYWtpR1egNWzJcVLk8jlegxAugswQc984lD3kU7NuobsO37/sRfbpTdBjtnD5TBNFA2Q2oLV5+UmPSmEQ==",
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
}
}
}
},
"react-element-proptypes": {
@@ -17495,16 +17531,6 @@
"prop-types": "^15.6.0"
}
},
"react-router-redux": {
"version": "5.0.0-alpha.9",
"resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.9.tgz",
"integrity": "sha512-euSgNIANnRXr4GydIuwA7RZCefrLQzIw5WdXspS8NPYbV+FxrKSS9MKG7U9vb6vsKHONnA4VxrVNWfnMUnUQAw==",
"requires": {
"history": "^4.7.2",
"prop-types": "^15.6.0",
"react-router": "^4.2.0"
}
},
"react-test-renderer": {
"version": "16.8.0",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.0.tgz",
@@ -18507,6 +18533,11 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"reselect": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz",
"integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA=="
},
"resolve": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz",
@@ -19198,6 +19229,7 @@
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.0.tgz",
"integrity": "sha512-w7aJnV30jc7OsiZQNPVmBc+HooZuvQZIZIShKutC3tnMFMkcwVN9CZRRSSNw03OnSCKmEkK8usmwcw6dqBaLzw==",
"dev": true,
"requires": {
"loose-envify": "^1.1.0",
"object-assign": "^4.1.1"
@@ -19242,6 +19274,11 @@
}
}
},
"seamless-immutable": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz",
"integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A=="
},
"seek-bzip": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.5.tgz",

View File

@@ -39,6 +39,7 @@
"bootstrap": "^4.2.1",
"camelcase-keys": "^5.0.0",
"classnames": "^2.2.5",
"connected-react-router": "^5.0.1",
"copy-webpack-plugin": "^4.6.0",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
@@ -48,13 +49,12 @@
"lodash.set": "^4.3.2",
"prop-types": "^15.5.10",
"query-string": "^5.1.1",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"react-intl": "^2.8.0",
"react-redux": "^5.0.7",
"react-redux": "^5.1.1",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-router-redux": "^5.0.0-alpha.9",
"react-transition-group": "^2.5.3",
"reactstrap": "^7.1.0",
"redux": "^4.0.1",
@@ -62,6 +62,7 @@
"redux-logger": "^3.0.6",
"redux-saga": "^1.0.1",
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"snakecase-keys": "^2.1.0",
"whatwg-fetch": "^2.0.3"
},

View File

@@ -1,42 +1,31 @@
import AsyncActionType from './AsyncActionType';
export const FIELD_OPEN = 'FIELD_OPEN';
export const FIELD_CLOSE = 'FIELD_CLOSE';
export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
export const SAVE_PROFILE = new AsyncActionType('PROFILE', 'SAVE_PROFILE');
export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO');
export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
export const UPDATE_DRAFTS = 'UPDATE_DRAFTS';
export const RECEIVE_PREFERENCES = 'RECEIVE_PREFERENCES';
export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_ACCOUNT_DRAFT = 'UPDATE_ACCOUNT_DRAFT';
export const UPDATE_VISIBILITY_DRAFT = 'UPDATE_VISIBILITY_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH PROFILE ACTIONS
export const openField = fieldName => ({
type: FIELD_OPEN,
fieldName,
});
export const closeField = fieldName => ({
type: FIELD_CLOSE,
fieldName,
});
export const updateDrafts = drafts => ({
type: UPDATE_DRAFTS,
drafts,
export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE,
payload: { username },
});
export const fetchProfileBegin = () => ({
type: FETCH_PROFILE.BEGIN,
});
export const fetchProfileSuccess = profile => ({
export const fetchProfileSuccess = (account, preferences, certificates) => ({
type: FETCH_PROFILE.SUCCESS,
profile,
});
export const receivePreferences = preferences => ({
type: RECEIVE_PREFERENCES,
account,
preferences,
certificates,
});
export const fetchProfileFailure = error => ({
@@ -48,17 +37,25 @@ export const fetchProfileReset = () => ({
type: FETCH_PROFILE.RESET,
});
export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE,
payload: { username },
// SAVE PROFILE ACTIONS
export const saveProfile = formId => ({
type: SAVE_PROFILE.BASE,
payload: {
formId,
},
});
export const saveProfileBegin = () => ({
type: SAVE_PROFILE.BEGIN,
});
export const saveProfileSuccess = () => ({
export const saveProfileSuccess = (account, preferences) => ({
type: SAVE_PROFILE.SUCCESS,
payload: {
account,
preferences,
},
});
export const saveProfileReset = () => ({
@@ -70,13 +67,13 @@ export const saveProfileFailure = error => ({
payload: { error },
});
export const saveProfile = (username, { profileData, preferencesData }, fieldName) => ({
type: SAVE_PROFILE.BASE,
// SAVE PROFILE PHOTO ACTIONS
export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
fieldName,
username,
profileData,
preferencesData,
formData,
},
});
@@ -97,11 +94,12 @@ export const saveProfilePhotoFailure = error => ({
payload: { error },
});
export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE,
// DELETE PROFILE PHOTO ACTIONS
export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
username,
formData,
},
});
@@ -122,9 +120,40 @@ export const deleteProfilePhotoFailure = error => ({
payload: { error },
});
export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE,
// FIELD STATE ACTIONS
export const openForm = formId => ({
type: OPEN_FORM,
payload: {
username,
formId,
},
});
export const closeForm = formId => ({
type: CLOSE_FORM,
payload: {
formId,
},
});
// FORM STATE ACTIONS
export const updateAccountDraft = (name, value) => ({
type: UPDATE_ACCOUNT_DRAFT,
payload: {
name,
value,
},
});
export const updateVisibilityDraft = (name, value) => ({
type: UPDATE_VISIBILITY_DRAFT,
payload: {
name,
value,
},
});
export const resetDrafts = () => ({
type: RESET_DRAFTS,
});

View File

@@ -1,8 +1,8 @@
import {
openField,
closeField,
FIELD_OPEN,
FIELD_CLOSE,
openForm,
closeForm,
OPEN_FORM,
CLOSE_FORM,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
@@ -26,51 +26,47 @@ import {
describe('editable field actions', () => {
it('should create an open action', () => {
const expectedAction = {
type: FIELD_OPEN,
fieldName: 'name',
type: OPEN_FORM,
payload: {
formId: 'name',
},
};
expect(openField('name')).toEqual(expectedAction);
expect(openForm('name')).toEqual(expectedAction);
});
it('should create a closed action', () => {
const expectedAction = {
type: FIELD_CLOSE,
fieldName: 'name',
type: CLOSE_FORM,
payload: {
formId: 'name',
},
};
expect(closeField('name')).toEqual(expectedAction);
expect(closeForm('name')).toEqual(expectedAction);
});
});
describe('SAVE profile actions', () => {
const profileData = {
username: 'verified',
email: 'verified@example.com',
bio: 'A great bio.',
name: 'Veri Fied',
country: 'US',
// Good enough for testing / and since we have no factories
};
const preferencesData = {};
it('should create an action to signal the start of a profile save', () => {
const expectedAction = {
type: SAVE_PROFILE.BASE,
payload: {
username: 'user person',
fieldName: 'fullName',
profileData,
preferencesData,
formId: 'name',
},
};
expect(saveProfile('user person', { profileData, preferencesData }, 'fullName')).toEqual(expectedAction);
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()).toEqual(expectedAction);
expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction);
});
it('should create an action to signal user profile save beginning', () => {
@@ -187,21 +183,25 @@ describe('DELETE profile photo actions', () => {
describe('Editable field opening and closing actions', () => {
const fieldName = 'fullName';
const formId = 'name';
it('should create an action to signal the opening a field', () => {
const expectedAction = {
type: FIELD_OPEN,
fieldName,
type: OPEN_FORM,
payload: {
formId,
},
};
expect(openField(fieldName)).toEqual(expectedAction);
expect(openForm(formId)).toEqual(expectedAction);
});
it('should create an action to signal the closing a field', () => {
const expectedAction = {
type: FIELD_CLOSE,
fieldName,
type: CLOSE_FORM,
payload: {
formId,
},
};
expect(closeField(fieldName)).toEqual(expectedAction);
expect(closeForm(formId)).toEqual(expectedAction);
});
});

View File

@@ -2,7 +2,8 @@ import React, { Component } from 'react';
import { connect, Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { IntlProvider } from 'react-intl';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import SiteFooter from '@edx/frontend-component-footer';
import { fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
@@ -27,7 +28,7 @@ class App extends Component {
return (
<IntlProvider locale={getLocale()} messages={getMessages()}>
<Provider store={this.props.store}>
<Router>
<ConnectedRouter history={this.props.history}>
<div>
<SiteHeader
logo={HeaderLogo}
@@ -60,7 +61,7 @@ class App extends Component {
handleAllTrackEvents={handleTrackEvents}
/>
</div>
</Router>
</ConnectedRouter>
</Provider>
</IntlProvider>
);
@@ -71,6 +72,7 @@ App.propTypes = {
fetchUserAccount: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
store: PropTypes.object.isRequired, // eslint-disable-line
history: PropTypes.object.isRequired, // eslint-disable-line
};
const mapStateToProps = state => ({

View File

@@ -9,153 +9,79 @@ import {
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openField,
closeField,
openForm,
closeForm,
updateVisibilityDraft,
updateAccountDraft,
} from '../actions/ProfileActions';
// Components
import ProfileAvatar from './ProfilePage/ProfileAvatar';
import FullName from './ProfilePage/FullName';
import UserLocation from './ProfilePage/UserLocation';
import Name from './ProfilePage/Name';
import Country from './ProfilePage/Country';
import Education from './ProfilePage/Education';
import SocialLinks from './ProfilePage/SocialLinks';
import Bio from './ProfilePage/Bio';
import MyCertificates from './ProfilePage/MyCertificates';
import Certificates from './ProfilePage/Certificates';
import AgeMessage from './ProfilePage/AgeMessage';
export class ProfilePage extends React.Component {
constructor(props) {
super(props);
this.state = {
fullName: { value: null, visibility: null },
userLocation: { value: null, visibility: null },
education: { value: null, visibility: null },
bio: { value: null, visibility: null },
socialLinks: { value: null, visibility: null },
certificates: { value: null, visibility: null },
};
this.onCancel = this.onCancel.bind(this);
this.onEdit = this.onEdit.bind(this);
this.onSave = this.onSave.bind(this);
this.onSaveProfilePhoto = this.onSaveProfilePhoto.bind(this);
this.onDeleteProfilePhoto = this.onDeleteProfilePhoto.bind(this);
this.onChange = this.onChange.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
this.handleSaveProfilePhoto = this.handleSaveProfilePhoto.bind(this);
this.handleDeleteProfilePhoto = this.handleDeleteProfilePhoto.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
}
componentDidMount() {
this.props.fetchProfile(this.props.match.params.username);
}
onCancel() {
this.props.closeField(this.props.currentlyEditingField);
}
onEdit(fieldName) {
this.props.openField(fieldName);
}
onSave(fieldName) {
const {
value,
visibility,
} = this.state[fieldName];
const data = {};
if (value != null) {
data.profileData = { [fieldName]: value };
}
if (visibility != null) {
data.preferencesData = { visibility: { [fieldName]: visibility } };
}
if (value == null && visibility == null) {
this.onCancel();
} else {
this.props.saveProfile(this.props.username, data, fieldName);
}
}
onSaveProfilePhoto(formData) {
handleSaveProfilePhoto(formData) {
this.props.saveProfilePhoto(this.props.username, formData);
}
onDeleteProfilePhoto() {
handleDeleteProfilePhoto() {
this.props.deleteProfilePhoto(this.props.username);
}
onChange(fieldName, value) {
this.setState({
[fieldName]: {
value,
visibility: this.state[fieldName].visibility,
},
});
handleClose(formId) {
this.props.closeForm(formId);
}
onVisibilityChange(fieldName, visibility) {
this.setState({
[fieldName]: {
value: this.state[fieldName].value,
visibility,
},
});
handleOpen(formId) {
this.props.openForm(formId);
}
handleSubmit(formId) {
this.props.saveProfile(formId);
}
handleChange(formId, name, value) {
if (name === 'visibility') {
this.props.updateVisibilityDraft(formId, value);
} else {
this.props.updateAccountDraft(formId, value);
}
}
render() {
const {
saveState,
error,
profileImage,
username,
fullName,
userLocation,
bio,
education,
socialLinks,
certificates,
isCurrentUserProfile,
profileImage, username, errors,
} = this.props;
const commonProps = {
onSave: this.onSave,
onEdit: this.onEdit,
onCancel: this.onCancel,
onChange: this.onChange,
onVisibilityChange: this.onVisibilityChange,
saveState,
error,
const commonFormProps = {
openHandler: this.handleOpen,
closeHandler: this.handleClose,
submitHandler: this.handleSubmit,
changeHandler: this.handleChange,
errors,
};
const getMode = (name) => {
// 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)
const propExists = this.props[name] != null && this.props[name].length > 0;
// If this isn't the current user's profile...
if (!isCurrentUserProfile) {
// 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 (name === this.props.currentlyEditingField) {
return 'editing';
}
if (!propExists) {
return 'empty';
}
return 'editable';
};
const getVisibility = name => this.props.visibility[name];
return (
<div className="profile-page">
<div className="bg-banner bg-program-micro-masters d-none d-md-block p-relative" />
@@ -166,8 +92,8 @@ export class ProfilePage extends React.Component {
<ProfileAvatar
className="mb-md-3"
src={profileImage}
onSave={this.onSaveProfilePhoto}
onDelete={this.onDeleteProfilePhoto}
onSave={this.handleSaveProfilePhoto}
onDelete={this.handleDeleteProfilePhoto}
savePhotoState={this.props.savePhotoState}
/>
<div>
@@ -179,54 +105,20 @@ export class ProfilePage extends React.Component {
</Row>
<Row>
<Col xs={{ order: 2 }} md={{ size: 4, order: 1 }} lg={3} className="mt-md-4">
<FullName
fullName={fullName}
visibility={getVisibility('fullName')}
editMode={getMode('fullName')}
{...commonProps}
/>
<UserLocation
userLocation={userLocation}
visibility={getVisibility('userLocation')}
editMode={getMode('userLocation')}
{...commonProps}
/>
<Education
education={education}
visibility={getVisibility('education')}
editMode={getMode('education')}
{...commonProps}
/>
<SocialLinks
socialLinks={socialLinks}
visibility={getVisibility('socialLinks')}
editMode={getMode('socialLinks')}
{...commonProps}
/>
<Name formId="name" {...commonFormProps} />
<Country formId="country" {...commonFormProps} />
<Education formId="education" {...commonFormProps} />
<SocialLinks formId="socialLinks" {...commonFormProps} />
</Col>
<Col xs={{ order: 1 }} md={{ size: 8, order: 2 }} lg={{ size: 8, offset: 1 }} className="mt-4 mt-md-n5">
<Col
xs={{ order: 1 }}
md={{ size: 8, order: 2 }}
lg={{ size: 8, offset: 1 }}
className="mt-4 mt-md-n5"
>
{this.props.requiresParentalConsent ? <AgeMessage accountURL="#account" /> : null}
<Bio
bio={bio}
visibility={getVisibility('bio')}
editMode={getMode('bio')}
{...commonProps}
/>
<MyCertificates
certificates={certificates}
visibility={getVisibility('certificates')}
editMode={getMode('certificates')}
{...commonProps}
/>
<Bio formId="bio" {...commonFormProps} />
<Certificates formId="certificates" {...commonFormProps} />
</Col>
</Row>
</Container>
@@ -236,39 +128,46 @@ export class ProfilePage extends React.Component {
}
ProfilePage.propTypes = {
// Page state helpers
currentlyEditingField: PropTypes.string,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
error: PropTypes.string,
isCurrentUserProfile: PropTypes.bool.isRequired,
profileImage: PropTypes.string,
fullName: PropTypes.string,
errors: PropTypes.objectOf(PropTypes.string),
// Profile data
username: PropTypes.string,
userLocation: PropTypes.string,
profileImage: PropTypes.string,
accountPrivacy: PropTypes.string,
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
// Profile data for form fields
education: PropTypes.string,
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
aboutMe: PropTypes.string,
bio: PropTypes.string,
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibility: PropTypes.objectOf(PropTypes.string),
// Actions
fetchProfile: PropTypes.func.isRequired,
saveProfile: PropTypes.func.isRequired,
saveProfilePhoto: PropTypes.func.isRequired,
deleteProfilePhoto: PropTypes.func.isRequired,
openField: PropTypes.func.isRequired,
closeField: PropTypes.func.isRequired,
openForm: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
updateVisibilityDraft: PropTypes.func.isRequired,
updateAccountDraft: PropTypes.func.isRequired,
// Router
match: PropTypes.shape({
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
accountPrivacy: PropTypes.string,
visibility: PropTypes.object, // eslint-disable-line
yearOfBirth: PropTypes.number,
requiresParentalConsent: PropTypes.bool,
};
@@ -277,14 +176,11 @@ ProfilePage.defaultProps = {
currentlyEditingField: null,
saveState: null,
savePhotoState: null,
error: null,
errors: {},
profileImage: null,
fullName: null,
username: null,
userLocation: null,
education: null,
socialLinks: [],
aboutMe: null,
bio: null,
certificates: null,
accountPrivacy: null,
@@ -295,27 +191,26 @@ ProfilePage.defaultProps = {
const mapStateToProps = (state) => {
const profileImage =
state.profilePage.profile.profileImage != null
? state.profilePage.profile.profileImage.imageUrlLarge
state.profilePage.account.profileImage != null
? state.profilePage.account.profileImage.imageUrlLarge
: null;
return {
isCurrentUserProfile: state.userAccount.username === state.profilePage.profile.username,
isCurrentUserProfile: state.userAccount.username === state.profilePage.account.username,
currentlyEditingField: state.profilePage.currentlyEditingField,
saveState: state.profilePage.saveState,
savePhotoState: state.profilePage.savePhotoState,
error: state.profilePage.error,
profileImage,
fullName: state.profilePage.profile.name,
username: state.profilePage.profile.username,
userLocation: state.profilePage.profile.country,
education: state.profilePage.profile.levelOfEducation,
socialLinks: state.profilePage.profile.socialLinks,
bio: state.profilePage.profile.bio,
certificates: state.profilePage.profile.certificates,
username: state.profilePage.account.username,
education: state.profilePage.account.levelOfEducation,
socialLinks: state.profilePage.account.socialLinks,
bio: state.profilePage.account.bio,
certificates: state.profilePage.account.certificates,
accountPrivacy: state.profilePage.preferences.accountPrivacy,
visibility: state.profilePage.preferences.visibility || {},
yearOfBirth: state.profilePage.profile.yearOfBirth,
requiresParentalConsent: state.profilePage.profile.requiresParentalConsent,
yearOfBirth: state.profilePage.account.yearOfBirth,
requiresParentalConsent: state.profilePage.account.requiresParentalConsent,
};
};
@@ -323,10 +218,12 @@ export default connect(
mapStateToProps,
{
fetchProfile,
saveProfile,
saveProfilePhoto,
deleteProfilePhoto,
openField,
closeField,
saveProfile,
openForm,
closeForm,
updateVisibilityDraft,
updateAccountDraft,
},
)(ProfilePage);

View File

@@ -1,98 +1,142 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, FormFeedback, FormGroup, Input, Label } from 'reactstrap';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { Input } from 'reactstrap';
import EditControls from './elements/EditControls';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
function Bio({
bio,
visibility,
editMode,
onEdit,
onChange,
onSave,
onCancel,
onVisibilityChange,
saveState,
}) {
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<React.Fragment>
<EditableItemHeader content="About Me" />
<Input
type="textarea"
name="bio"
defaultValue={bio}
onChange={e => onChange('bio', e.target.value)}
/>
<EditControls
onCancel={() => onCancel('bio')}
onSave={() => onSave('bio')}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('bio', e.target.value)}
/>
</React.Fragment>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="About Me"
showEditButton
onClickEdit={() => onEdit('bio')}
showVisibility={Boolean(bio)}
visibility={visibility}
/>
<p className="lead">{bio}</p>
</React.Fragment>
),
empty: (
<EmptyContent onClick={() => onEdit('bio')}>
<FormattedMessage
id="profile.bio.empty"
defaultMessage="Add a short bio"
description="instructions when the user hasn't written an About Me"
/>
</EmptyContent>
),
static: (
<React.Fragment>
<EditableItemHeader content="About Me" />
<p className="lead">{bio}</p>,
</React.Fragment>
),
}}
/>
);
class Bio 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(this.props.formId, 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, value, visibility, editMode, saveState, error,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for={formId}>About Me</Label>
<Input
type="textarea"
id={formId}
name={formId}
value={value}
invalid={error != null}
onChange={this.handleChange}
/>
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</Form>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="About Me"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
<p className="lead">{value}</p>
</React.Fragment>
),
empty: (
<EmptyContent onClick={this.handleOpen}>
<FormattedMessage
id="profile.bio.empty"
defaultMessage="Add a short bio"
description="instructions when the user hasn't written an About Me"
/>
</EmptyContent>
),
static: (
<React.Fragment>
<EditableItemHeader content="About Me" />
<p className="lead">{value}</p>
</React.Fragment>
),
}}
/>
);
}
}
Bio.propTypes = {
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
saveState: PropTypes.string,
bio: PropTypes.string,
// 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
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Bio.defaultProps = {
editMode: 'static',
saveState: null,
bio: null,
value: null,
visibility: 'private',
error: null,
};
export default Bio;
export default connect(
editableFormSelector,
{},
)(Bio);

View File

@@ -0,0 +1,168 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Row, Col, Card, CardBody, CardTitle, Button, Form } from 'reactstrap';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { certificatesSelector } from '../../selectors/ProfilePageSelector';
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(this.props.formId, 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({
type: { name }, title, organization, downloadUrl,
}) {
return (
<Col key={downloadUrl} sm={6}>
<Card className="mb-4 certificate">
<CardBody>
<CardTitle>
<p className="small mb-0">{name}</p>
<h4 className="certificate-title">{title}</h4>
</CardTitle>
<p className="small mb-0">From</p>
<h6 className="mb-4">{organization}</h6>
<div>
<Button outline color="primary" href={downloadUrl} target="blank">
<FontAwesomeIcon className="ml-n1 mr-2" icon={faDownload} />
Download
</Button>
</div>
</CardBody>
</Card>
</Col>
);
}
renderCertificates() {
if (this.props.certificates === null) {
return null;
}
return (
<Row>{this.props.certificates.map(certificate => this.renderCertificate(certificate))}</Row>
);
}
render() {
const {
formId, visibility, editMode, saveState,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<Form onSubmit={this.handleSubmit}>
<EditableItemHeader content="My Certificates" />
{this.renderCertificates()}
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</Form>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="My Certificates"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
{this.renderCertificates()}
</React.Fragment>
),
empty: (
<div>
<FormattedMessage
id="profile.no.certificates"
defaultMessage="You don't have any certificates yet."
description="displays when user has no course completion certificates"
/>
</div>
),
static: (
<React.Fragment>
<EditableItemHeader content="My Certificates" />
{this.renderCertificates()}
</React.Fragment>
),
}}
/>
);
}
}
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,
})),
visibility: 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,
};
Certificates.defaultProps = {
editMode: 'static',
saveState: null,
visibility: 'private',
certificates: null,
};
export default connect(
certificatesSelector,
{},
)(Certificates);

View File

@@ -0,0 +1,143 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, FormFeedback, FormGroup, Input, Label } from 'reactstrap';
import { connect } from 'react-redux';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Constants
import { ALL_COUNTRIES } from '../../constants/countries';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
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(this.props.formId, 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, value, visibility, editMode, saveState, error,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="country">Location</Label>
<Input
type="select"
name={formId}
className="w-100"
value={value}
invalid={error != null}
onChange={this.handleChange}
>
{Object.keys(ALL_COUNTRIES).map(key => (
<option key={key} value={key}>{ALL_COUNTRIES[key]}</option>
))}
</Input>
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</Form>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Location"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
<h5>{ALL_COUNTRIES[value]}</h5>
</React.Fragment>
),
empty: <EmptyContent onClick={this.handleOpen}>Add location</EmptyContent>,
static: (
<React.Fragment>
<EditableItemHeader content="Location" />
<h5>{ALL_COUNTRIES[value]}</h5>
</React.Fragment>
),
}}
/>
);
}
}
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,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Country.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
error: null,
};
export default connect(
editableFormSelector,
{},
)(Country);

View File

@@ -1,98 +1,143 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Input } from 'reactstrap';
import { Form, FormFeedback, FormGroup, Input, Label } from 'reactstrap';
import { connect } from 'react-redux';
import EditControls from './elements/EditControls';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Constants
import EDUCATION from '../../constants/education';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
function Education({
education,
visibility,
editMode,
onEdit,
onChange,
onSave,
onCancel,
onVisibilityChange,
saveState,
}) {
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<React.Fragment>
<EditableItemHeader content="Education" />
<Input
type="select"
name="education"
className="w-100"
defaultValue={education}
onChange={e => onChange('education', e.target.value)}
>
{Object.keys(EDUCATION).map(key => (
<option key={key} value={key}>{EDUCATION[key]}</option>
))}
</Input>
<EditControls
onCancel={() => onCancel('education')}
onSave={() => onSave('education')}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('education', e.target.value)}
/>
</React.Fragment>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Education"
showEditButton
onClickEdit={() => onEdit('education')}
showVisibility={Boolean(education)}
visibility={visibility}
/>
<h5>{EDUCATION[education]}</h5>
</React.Fragment>
),
empty: (
<EmptyContent onClick={() => onEdit('education')}>Add education</EmptyContent>
),
static: (
<React.Fragment>
<EditableItemHeader content="Education" />
<h5>{EDUCATION[education]}</h5>
</React.Fragment>
),
}}
/>
);
class Education 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(this.props.formId, 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, value, visibility, editMode, saveState, error,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="education">Education</Label>
<Input
type="select"
name={formId}
className="w-100"
value={value}
invalid={error != null}
onChange={this.handleChange}
>
{Object.keys(EDUCATION).map(key => (
<option key={key} value={key}>{EDUCATION[key]}</option>
))}
</Input>
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</Form>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Education"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
<h5>{EDUCATION[value]}</h5>
</React.Fragment>
),
empty: <EmptyContent onClick={this.handleOpen}>Add education</EmptyContent>,
static: (
<React.Fragment>
<EditableItemHeader content="Education" />
<h5>{EDUCATION[value]}</h5>
</React.Fragment>
),
}}
/>
);
}
}
Education.propTypes = {
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
saveState: PropTypes.string,
education: PropTypes.string,
// 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
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Education.defaultProps = {
editMode: 'static',
saveState: null,
education: null,
value: null,
visibility: 'private',
error: null,
};
export default Education;
export default connect(
editableFormSelector,
{},
)(Education);

View File

@@ -1,92 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Input } from 'reactstrap';
import EditControls from './elements/EditControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
function FullName({
fullName,
visibility,
editMode,
onEdit,
onChange,
onSave,
onCancel,
onVisibilityChange,
saveState,
}) {
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<React.Fragment>
<EditableItemHeader content="Full Name" />
<Input
type="text"
name="fullName"
defaultValue={fullName}
onChange={e => onChange('fullName', e.target.value)}
/>
<EditControls
onCancel={() => onCancel('fullName')}
onSave={() => onSave('fullName')}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('fullName', e.target.value)}
/>
</React.Fragment>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Full Name"
showEditButton
onClickEdit={() => onEdit('fullName')}
showVisibility={Boolean(fullName)}
visibility={visibility}
/>
<h5>{fullName}</h5>
</React.Fragment>
),
empty: (
<EmptyContent onClick={() => onEdit('fullName')}>Add name</EmptyContent>
),
static: (
<React.Fragment>
<EditableItemHeader content="Full Name" />
<h5>{fullName}</h5>
</React.Fragment>
),
}}
/>
);
}
FullName.propTypes = {
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
saveState: PropTypes.string,
fullName: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
};
FullName.defaultProps = {
editMode: 'static',
saveState: null,
fullName: null,
visibility: 'private',
};
export default FullName;

View File

@@ -1,135 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { Row, Col, Card, CardBody, CardTitle, CardText, Button } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faDownload } from '@fortawesome/free-solid-svg-icons';
import EditControls from './elements/EditControls';
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
function MyCertificates({
certificates,
visibility,
editMode,
onEdit,
onSave,
onCancel,
onVisibilityChange,
saveState,
}) {
const renderCertificates = () => {
if (!certificates) return null;
return (
<Row>
{certificates.map(({
type: { key, name }, // eslint-disable-line no-unused-vars
title,
organization,
downloadUrl,
}) => (
<Col key={downloadUrl} sm={6}>
<Card className="mb-4 certificate">
<CardBody>
<CardTitle>
<p className="small mb-0">{name}</p>
<h4 className="certificate-title">{title}</h4>
</CardTitle>
<CardText>
<p className="small mb-0">From</p>
<h6 className="mb-4">{organization}</h6>
<div>
<Button
outline
color="primary"
href={downloadUrl}
target="blank"
>
<FontAwesomeIcon className="ml-n1 mr-2" icon={faDownload} />
Download
</Button>
</div>
</CardText>
</CardBody>
</Card>
</Col>
))}
</Row>
);
};
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<React.Fragment>
<EditableItemHeader content="My Certificates" />
{renderCertificates()}
<EditControls
onCancel={() => onCancel('certificates')}
onSave={() => onSave('certificates')}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('certificates', e.target.value)}
/>
</React.Fragment>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="My Certificates"
showEditButton
onClickEdit={() => onEdit('certificates')}
showVisibility={Boolean(certificates)}
visibility={visibility}
/>
{renderCertificates()}
</React.Fragment>
),
empty: (
<div>
<FormattedMessage
id="profile.no.certificates"
defaultMessage="You don't have any certificates yet."
description="displays when user has no course completion certificates"
/>
</div>
),
static: (
<React.Fragment>
<EditableItemHeader content="My Certificates" />
{renderCertificates()}
</React.Fragment>
),
}}
/>
);
}
MyCertificates.propTypes = {
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
saveState: PropTypes.string,
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibility: PropTypes.oneOf(['private', 'all_users']),
};
MyCertificates.defaultProps = {
editMode: 'static',
saveState: null,
certificates: null,
visibility: 'private',
};
export default MyCertificates;

View File

@@ -0,0 +1,132 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, FormFeedback, FormGroup, FormText, Input, Label } from 'reactstrap';
import { connect } from 'react-redux';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
class Name 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(this.props.formId, 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, value, visibility, editMode, saveState, error,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="name">Full Name</Label>
<Input type="text" name={formId} value={value} invalid={error != null} onChange={this.handleChange} />
<FormText>
This is the name that appears in your account and on your certificates.
</FormText>
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</Form>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Full Name"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
<h5>{value}</h5>
</React.Fragment>
),
empty: <EmptyContent onClick={this.handleOpen}>Add name</EmptyContent>,
static: (
<React.Fragment>
<EditableItemHeader content="Full Name" />
<h5>{value}</h5>
</React.Fragment>
),
}}
/>
);
}
}
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,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
Name.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
error: null,
};
export default connect(
editableFormSelector,
{},
)(Name);

View File

@@ -1,64 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Input } from 'reactstrap';
import { Form, Input, FormFeedback } from 'reactstrap';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import EditControls from './elements/EditControls';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
const brandIcons = {
facebook: faFacebook,
twitter: faTwitter,
linkedin: faLinkedin,
// Selectors
import { editableFormSelector } from '../../selectors/ProfilePageSelector';
const platformDisplayInfo = {
facebook: {
icon: faFacebook,
name: 'Facebook',
},
twitter: {
icon: faTwitter,
name: 'Twitter',
},
linkedin: {
icon: faLinkedin,
name: 'LinkedIn',
},
};
class SocialLinks extends React.Component {
constructor(props) {
super(props);
this.state = {};
this.onSave = this.onSave.bind(this);
this.onEdit = this.onEdit.bind(this);
this.onInputChange = this.onInputChange.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
onSave() {
const values = this.props.platforms.filter(({ key }) => typeof this.state[key] !== 'undefined').map(({ key }) => ({
platform: key,
socialLink: this.state[key],
}));
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.onSave('socialLinks', values);
if (name !== 'visibility') {
const updatedList = this.props.committedValue.map((socialLink) => {
if (socialLink.platform === name) {
return { platform: name, social_link: value };
}
return socialLink;
});
this.props.changeHandler(this.props.formId, 'socialLinks', updatedList);
} else {
this.props.changeHandler(this.props.formId, name, value);
}
}
onEdit() {
this.props.onEdit('socialLinks');
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
onInputChange(platform, value) {
this.setState({ [platform]: value });
this.props.onChange('socialLinks', this.state);
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
platforms,
socialLinks,
editMode,
visibility,
saveState,
onVisibilityChange,
onCancel,
formId, value: values, visibility, editMode, saveState, error,
} = this.props;
const { onInputChange, onEdit } = this;
const socialLinksMap = socialLinks.reduce((acc, { platform, socialLink }) => {
acc[platform] = socialLink;
return acc;
}, {});
const isEmpty = socialLinks && socialLinks.length > 0;
return (
<SwitchContent
@@ -67,11 +83,11 @@ class SocialLinks extends React.Component {
cases={{
empty: (
<ul className="list-unstyled">
{platforms.map(({ key, name }) => (
{values.map(({ platform }) => (
<EmptyListItem
key={key}
onClick={onEdit}
name={name}
key={platform}
onClick={this.handleOpen}
name={platformDisplayInfo[platform].name}
/>
))}
</ul>
@@ -80,10 +96,10 @@ class SocialLinks extends React.Component {
<React.Fragment>
<EditableItemHeader content="Social Links" />
<ul className="list-unstyled">
{socialLinks.map(({ platform, socialLink }) => (
{values.map(({ platform, social_link: socialLink }) => (
<StaticListItem
key={platform}
name={platforms[platform]}
name={platformDisplayInfo[platform].name}
url={socialLink}
platform={platform}
/>
@@ -96,45 +112,46 @@ class SocialLinks extends React.Component {
<EditableItemHeader
content="Social Links"
showEditButton
onClickEdit={onEdit}
showVisibility={isEmpty}
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
/>
<ul className="list-unstyled">
{platforms.map(({ key, name }) => (
{values.map(({ platform, social_link: socialLink }) => (
<EditableListItem
key={key}
platform={key}
name={name}
url={socialLinksMap[key]}
onClickEmptyContent={onEdit}
key={platform}
platform={platform}
name={platformDisplayInfo[platform].name}
url={socialLink}
onClickEmptyContent={this.handleOpen}
/>
))}
</ul>
</React.Fragment>
),
editing: (
<React.Fragment>
<Form onSubmit={this.handleSubmit}>
<EditableItemHeader content="Social Links" />
<ul className="list-unstyled">
{platforms.map(({ key, name }) => (
{values.map(({ platform, social_link: socialLink }) => (
<EditingListItem
key={key}
name={name}
platform={key}
defaultValue={socialLinksMap[key]}
onChange={onInputChange}
key={platform}
name={platformDisplayInfo[platform].name}
platform={platform}
value={socialLink}
error={error !== null ? error[platform] : null}
onChange={this.handleChange}
/>
))}
</ul>
<EditControls
onCancel={() => onCancel('socialLinks')}
onSave={this.onSave}
<FormControls
formId={formId}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('socialLinks', e.target.value)}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</React.Fragment>
</Form>
),
}}
/>
@@ -143,42 +160,51 @@ class SocialLinks extends React.Component {
}
SocialLinks.propTypes = {
socialLinks: PropTypes.arrayOf(PropTypes.shape({
// 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
value: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
platforms: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
name: PropTypes.string,
committedValue: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibility: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
};
SocialLinks.defaultProps = {
socialLinks: [],
platforms: [
{ key: 'twitter', name: 'Twitter' },
{ key: 'linkedin', name: 'LinkedIn' },
{ key: 'facebook', name: 'Facebook' },
],
visibility: 'private',
editMode: 'static',
saveState: null,
value: [],
committedValue: [],
visibility: 'private',
error: null,
};
export default SocialLinks;
export default connect(
editableFormSelector,
{},
)(SocialLinks);
function SocialLink({ url, name, platform }) {
return (
<a href={url} className="font-weight-bold">
<FontAwesomeIcon className="mr-2" icon={brandIcons[platform]} />
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
@@ -197,7 +223,7 @@ function EditableListItem({
onClickEmptyContent,
name,
}) {
const linkDisplay = url != null ?
const linkDisplay = url ?
<SocialLink name={name} url={url} platform={platform} /> :
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>;
@@ -219,29 +245,36 @@ EditableListItem.defaultProps = {
function EditingListItem({
platform,
name,
defaultValue,
value,
onChange,
error,
}) {
return (
<li className="form-group">
<h6>{name}</h6>
<Input
type="text"
defaultValue={defaultValue}
onChange={e => onChange(platform, e.target.value)}
name={platform}
value={value}
onChange={onChange}
invalid={error != null}
/>
<FormFeedback>{error}</FormFeedback>
</li>
);
}
EditingListItem.propTypes = {
platform: PropTypes.string.isRequired,
defaultValue: PropTypes.string,
value: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
error: PropTypes.string,
};
EditingListItem.defaultProps = {
defaultValue: null,
value: null,
error: null,
};

View File

@@ -1,98 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Input } from 'reactstrap';
import EditControls from './elements/EditControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
import { ALL_COUNTRIES } from '../../constants/countries';
function UserLocation({
userLocation,
visibility,
editMode,
onEdit,
onChange,
onSave,
onCancel,
onVisibilityChange,
saveState,
}) {
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<React.Fragment>
<EditableItemHeader content="Location" />
<Input
type="select"
name="userLocation"
className="w-100"
defaultValue={userLocation}
onChange={e => onChange('userLocation', e.target.value)}
>
{Object.keys(ALL_COUNTRIES).map(key => (
<option key={key} value={key}>{ALL_COUNTRIES[key]}</option>
))}
</Input>
<EditControls
onCancel={() => onCancel('userLocation')}
onSave={() => onSave('userLocation')}
saveState={saveState}
visibility={visibility}
onVisibilityChange={e => onVisibilityChange('userLocation', e.target.value)}
/>
</React.Fragment>
),
editable: (
<React.Fragment>
<EditableItemHeader
content="Location"
showEditButton
onClickEdit={() => onEdit('userLocation')}
showVisibility={Boolean(userLocation)}
visibility={visibility}
/>
<h5>{ALL_COUNTRIES[userLocation]}</h5>
</React.Fragment>
),
empty: (
<EmptyContent onClick={() => onEdit('userLocation')}>Add location</EmptyContent>
),
static: (
<React.Fragment>
<EditableItemHeader content="Location" />
<h5>{ALL_COUNTRIES[userLocation]}</h5>
</React.Fragment>
),
}}
/>
);
}
UserLocation.propTypes = {
editMode: PropTypes.string,
onEdit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onVisibilityChange: PropTypes.func.isRequired,
saveState: PropTypes.string,
userLocation: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
};
UserLocation.defaultProps = {
editMode: 'static',
saveState: null,
userLocation: null,
visibility: 'private',
};
export default UserLocation;

View File

@@ -11,6 +11,7 @@ function AsyncActionButton({
style,
variant,
labels,
type,
}) {
const renderIcon = () => {
if (variant === 'error') return <Icon className="icon fa fa-times-circle" />;
@@ -30,6 +31,7 @@ function AsyncActionButton({
return (
<Button
type={type}
aria-live="assertive"
onClick={onClick}
disabled={variant === 'pending' || variant === 'complete' || variant === 'error'}
@@ -59,7 +61,8 @@ export default AsyncActionButton;
AsyncActionButton.propTypes = {
onClick: PropTypes.func.isRequired,
type: PropTypes.string,
onClick: PropTypes.func,
color: PropTypes.string,
className: PropTypes.string,
style: PropTypes.object, // eslint-disable-line
@@ -73,6 +76,8 @@ AsyncActionButton.propTypes = {
};
AsyncActionButton.defaultProps = {
onClick: null,
type: 'button',
className: null,
color: 'primary',
style: null,

View File

@@ -3,35 +3,41 @@ import PropTypes from 'prop-types';
import { Input, Button, Label, Row, Col } from 'reactstrap';
import AsyncActionButton from './AsyncActionButton';
function EditControls({
onCancel,
onSave,
visibility,
onVisibilityChange,
saveState,
function FormControls({
formId, cancelHandler, changeHandler, visibility, saveState,
}) {
const visibilityId = `${formId}-visibility`;
return (
<Row className="align-items-center flex-wrap-1 pt-3">
<Col xs="auto" className="d-flex mb-3">
<Label className="flex-shrink-0 d-inline-block mb-0 mr-2" size="sm" for="exampleSelect">Who can see this:</Label>
<Label className="flex-shrink-0 d-inline-block mb-0 mr-2" size="sm" for={visibilityId}>
Who can see this:
</Label>
<span>
<Input
id={visibilityId}
className="d-inline-block"
bsSize="sm"
type="select"
name="select"
defaultValue={visibility}
onChange={onVisibilityChange}
name="visibility"
value={visibility}
onChange={changeHandler}
>
<option key="private" value="private">Just me</option>
<option key="all_users" value="all_users">Everyone on edX</option>
<option key="private" value="private">
Just me
</option>
<option key="all_users" value="all_users">
Everyone on edX
</option>
</Input>
</span>
</Col>
<Col xs="auto" className="flex-grow-1 d-flex justify-content-end mb-3">
<Button color="link" onClick={onCancel}>Cancel</Button>
<Button color="link" onClick={cancelHandler}>
Cancel
</Button>
<AsyncActionButton
onClick={onSave}
type="submit"
variant={saveState}
labels={{
default: 'Save',
@@ -45,18 +51,17 @@ function EditControls({
);
}
export default EditControls;
export default FormControls;
EditControls.propTypes = {
onCancel: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired,
visibility: PropTypes.oneOf(['private', 'all_users']),
onVisibilityChange: PropTypes.func,
FormControls.propTypes = {
formId: PropTypes.string.isRequired,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
visibility: PropTypes.oneOf(['private', 'all_users']),
cancelHandler: PropTypes.func.isRequired,
changeHandler: PropTypes.func.isRequired,
};
EditControls.defaultProps = {
FormControls.defaultProps = {
visibility: 'private',
onVisibilityChange: null,
saveState: null,
};

View File

@@ -1,25 +1,29 @@
import { applyMiddleware, createStore } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import apiClient from './apiClient';
import reducers from '../reducers/RootReducer';
import createRootReducer from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
export default function configureStore() {
const history = createBrowserHistory();
const loggerMiddleware = createLogger();
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
reducers,
createRootReducer(history),
initialState,
composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)),
composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, routerMiddleware(history), loggerMiddleware)), // eslint-disable-line
);
sagaMiddleware.run(rootSaga);
return store;
return { store, history };
}

View File

@@ -1,22 +1,26 @@
import { applyMiddleware, createStore } from 'redux';
import { applyMiddleware, createStore, compose } from 'redux';
import createSagaMiddleware from 'redux-saga';
import thunkMiddleware from 'redux-thunk';
import { createBrowserHistory } from 'history';
import { routerMiddleware } from 'connected-react-router';
import apiClient from './apiClient';
import reducers from '../reducers/RootReducer';
import createRootReducer from '../reducers/RootReducer';
import rootSaga from '../sagas/RootSaga';
export default function configureStore() {
const history = createBrowserHistory();
const sagaMiddleware = createSagaMiddleware();
const initialState = apiClient.getAuthenticationState();
const store = createStore(
reducers,
createRootReducer(history),
initialState,
applyMiddleware(thunkMiddleware, sagaMiddleware),
compose(applyMiddleware(thunkMiddleware, sagaMiddleware, routerMiddleware(history))),
);
sagaMiddleware.run(rootSaga);
return store;
return { store, history };
}

View File

@@ -11,6 +11,8 @@ export const configuration = {
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
CSRF_COOKIE_NAME: process.env.CSRF_COOKIE_NAME,
ENVIRONMENT: process.env.NODE_ENV,
ACCOUNTS_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/accounts`,
PREFERENCES_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/preferences`,
};
export const features = {};

View File

@@ -12,9 +12,9 @@ import './index.scss';
import App from './components/App';
if (apiClient.ensurePublicOrAuthencationAndCookies(window.location.pathname)) {
const store = configureStore();
const { store, history } = configureStore();
ReactDOM.render(<App store={store} />, document.getElementById('root'));
ReactDOM.render(<App store={store} history={history} />, document.getElementById('root'));
// identify user for future analytics calls
// TODO: Call before each page call.

View File

@@ -1,129 +1,161 @@
import defaultsDeep from 'lodash.defaultsdeep';
import {
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
FIELD_CLOSE,
FIELD_OPEN,
CLOSE_FORM,
OPEN_FORM,
FETCH_PROFILE,
RECEIVE_PREFERENCES,
UPDATE_ACCOUNT_DRAFT,
UPDATE_VISIBILITY_DRAFT,
RESET_DRAFTS,
} from '../actions/ProfileActions';
const initialState = {
error: null,
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
profile: {},
preferences: {},
account: {
socialLinks: [],
},
preferences: {
visibility: {},
},
certificates: [],
accountDrafts: {},
visibilityDrafts: {},
};
const profilePage = (state = initialState, action) => {
switch (action.type) {
case FIELD_OPEN:
return {
...state,
currentlyEditingField: action.fieldName,
};
case FIELD_CLOSE:
// Only close if the field to close is undefined or matches the field that is currently open
if (action.fieldName === state.currentlyEditingField) {
return {
...state,
currentlyEditingField: null,
};
}
return state;
case FETCH_PROFILE.SUCCESS:
return {
...state,
profile: action.profile,
account: action.account,
preferences: action.preferences,
certificates: action.certificates,
};
case RECEIVE_PREFERENCES:
return {
...state,
preferences: defaultsDeep({}, action.preferences, state.preferences),
};
case SAVE_PROFILE.BEGIN:
return {
...state,
saveState: 'pending',
error: null,
errors: {},
};
case SAVE_PROFILE.SUCCESS:
return {
...state,
saveState: 'complete',
error: null,
errors: {},
// Account is always replaced completely.
account: action.payload.account !== null ? action.payload.account : state.account,
// Preferences changes get merged in.
preferences: Object.assign({}, state.preferences, {
visibility: Object.assign(
{},
state.preferences.visibility,
action.payload.preferences.visibility,
),
}),
};
case SAVE_PROFILE.FAILURE:
return {
...state,
saveState: 'error',
error: action.payload.error,
errors: Object.assign({}, state.errors, action.payload.errors),
};
case SAVE_PROFILE.RESET:
return {
...state,
saveState: null,
error: null,
errors: {},
};
case SAVE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
error: null,
errors: {},
};
case SAVE_PROFILE_PHOTO.SUCCESS:
return {
...state,
savePhotoState: 'complete',
error: null,
errors: {},
};
case SAVE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
error: action.payload.error,
errors: Object.assign({}, state.errors, action.payload.errors),
};
case SAVE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
error: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
error: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.SUCCESS:
return {
...state,
savePhotoState: 'complete',
error: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
error: action.payload.error,
errors: Object.assign({}, state.errors, action.payload.errors),
};
case DELETE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
error: null,
errors: {},
};
case UPDATE_ACCOUNT_DRAFT:
return {
...state,
accountDrafts: Object.assign({}, state.accountDrafts, {
[action.payload.name]: action.payload.value,
}),
};
case UPDATE_VISIBILITY_DRAFT:
return {
...state,
visibilityDrafts: Object.assign({}, state.visibilityDrafts, {
[action.payload.name]: action.payload.value,
}),
};
case RESET_DRAFTS:
return {
...state,
accountDrafts: {},
visibilityDrafts: {},
};
case OPEN_FORM:
return {
...state,
currentlyEditingField: action.payload.formId,
};
case CLOSE_FORM:
// Only close if the field to close is undefined or matches the field that is currently open
if (action.payload.formId === state.currentlyEditingField) {
return {
...state,
currentlyEditingField: null,
};
}
return state;
default:
return state;
}

View File

@@ -1,5 +1,6 @@
import { combineReducers } from 'redux';
import { userAccount } from '@edx/frontend-auth';
import { connectRouter } from 'connected-react-router';
import profilePage from './ProfilePageReducer';
@@ -8,12 +9,14 @@ const identityReducer = (state) => {
return newState;
};
const rootReducer = combineReducers({
// The authentication state is added as initialState when
// creating the store in data/store.js.
authentication: identityReducer,
userAccount,
profilePage,
});
const createRootReducer = history =>
combineReducers({
// The authentication state is added as initialState when
// creating the store in data/store.js.
authentication: identityReducer,
userAccount,
profilePage,
router: connectRouter(history),
});
export default rootReducer;
export default createRootReducer;

View File

@@ -1,19 +1,19 @@
import { call, put, takeEvery, delay } from 'redux-saga/effects';
import { all, call, delay, put, select, takeEvery } from 'redux-saga/effects';
// Actions
import {
FETCH_PROFILE,
fetchProfileBegin,
fetchProfileSuccess,
receivePreferences,
fetchProfileFailure,
fetchProfileReset,
fetchProfile as fetchProfileAction,
fetchProfile,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
saveProfileFailure,
saveProfileReset,
closeField,
closeForm,
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
@@ -24,52 +24,41 @@ import {
deleteProfilePhotoSuccess,
deleteProfilePhotoFailure,
deleteProfilePhotoReset,
resetDrafts,
} from '../actions/ProfileActions';
// Selectors
import { handleSaveProfileSelector } from '../selectors/ProfilePageSelector';
// Services
import * as ProfileApiService from '../services/ProfileApiService';
const PROP_TO_STATE_MAP = {
fullName: 'name',
userLocation: 'country',
education: 'levelOfEducation',
socialLinks: socialLinks => socialLinks.filter(({ socialLink }) => socialLink !== null),
};
export const mapDataForRequest = (props) => {
const state = {};
Object.keys(props).forEach((prop) => {
const propModifier = PROP_TO_STATE_MAP[prop] || prop;
if (typeof propModifier === 'function') {
state[prop] = propModifier(props[prop]);
} else {
state[propModifier] = props[prop];
}
});
return state;
};
export function* handleFetchProfile(action) {
const { username } = action.payload;
const currentUsername = yield select(state => state.authentication.username); // eslint-disable-line
try {
yield put(fetchProfileBegin());
const profile = yield call(
ProfileApiService.getProfile,
username,
);
const preferences = yield call(
ProfileApiService.getPreferences,
username,
);
profile.certificates = yield call(
ProfileApiService.getCourseCertificates,
username,
);
const calls = [
call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username),
];
if (username === currentUsername) {
calls.push(call(ProfileApiService.getPreferences, username));
}
const result = yield all(calls);
if (result.length > 2) {
const [account, certificates, preferences] = result;
yield put(fetchProfileSuccess(account, preferences, certificates));
} else {
const [account, certificates] = result;
yield put(fetchProfileSuccess(account, { visibility: {} }, certificates));
}
yield put(fetchProfileSuccess(profile));
yield put(receivePreferences(preferences));
yield put(fetchProfileReset());
} catch (e) {
yield put(fetchProfileFailure(e.message));
@@ -77,40 +66,33 @@ export function* handleFetchProfile(action) {
}
export function* handleSaveProfile(action) {
const { username, profileData, preferencesData } = action.payload;
const { username, accountDrafts, visibilityDrafts } = yield select(handleSaveProfileSelector);
try {
yield put(saveProfileBegin());
const responseData = {};
let accountResult = null;
// Build the visibility drafts into a structure the API expects.
const preferences = {
visibility: visibilityDrafts,
};
if (profileData != null) {
responseData.profile = yield call(
ProfileApiService.patchProfile,
username,
profileData,
);
}
if (preferencesData != null) {
responseData.preferences = yield call(
ProfileApiService.patchPreferences,
username,
preferencesData,
);
if (Object.keys(accountDrafts).length > 0) {
accountResult = yield call(ProfileApiService.patchProfile, username, accountDrafts);
}
const { profile, preferences } = responseData;
if (Object.keys(visibilityDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, username, preferences);
}
yield put(saveProfileSuccess());
if (profile != null) {
yield put(fetchProfileSuccess(profile));
}
if (preferences != null) {
yield put(receivePreferences(preferences));
}
// 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, preferences));
yield delay(300);
yield put(closeField(action.payload.fieldName));
yield put(closeForm(action.payload.formId));
yield delay(300);
yield put(saveProfileReset());
yield put(resetDrafts());
} catch (e) {
yield put(saveProfileFailure(e.message));
}
@@ -124,7 +106,7 @@ export function* handleSaveProfilePhoto(action) {
yield call(ProfileApiService.postProfilePhoto, username, formData);
// Get the account data. Saving doesn't return anything on success.
yield handleFetchProfile(fetchProfileAction(username));
yield handleFetchProfile(fetchProfile(username));
yield put(saveProfilePhotoSuccess());
yield put(saveProfilePhotoReset());
@@ -141,7 +123,7 @@ export function* handleDeleteProfilePhoto(action) {
yield call(ProfileApiService.deleteProfilePhoto, username);
// Get the account data. Saving doesn't return anything on success.
yield handleFetchProfile(fetchProfileAction(username));
yield handleFetchProfile(fetchProfile(username));
yield put(deleteProfilePhotoSuccess());
yield put(deleteProfilePhotoReset());

View File

@@ -1,6 +1,7 @@
import { takeEvery, put, call, delay } from 'redux-saga/effects';
import { takeEvery, put, call, delay, select } from 'redux-saga/effects';
import * as profileActions from '../actions/ProfileActions';
import { handleSaveProfileSelector } from '../selectors/ProfilePageSelector';
jest.mock('../services/ProfileApiService', () => ({
getProfile: jest.fn(),
@@ -36,33 +37,36 @@ describe('RootSaga', () => {
});
describe('handleSaveProfile', () => {
const selectorData = {
username: 'my username',
accountDrafts: {
name: 'Full Name',
},
visibilityDrafts: {},
};
beforeEach(() => {});
it('should successfully process a saveProfile request if there are no exceptions', () => {
const action = profileActions.saveProfile(
'my username',
{
profileData: {
fullName: 'Full Name',
education: 'b',
},
preferencesData: null,
},
'ze field',
);
const action = profileActions.saveProfile('ze form id');
const gen = handleSaveProfile(action);
const profile = {
name: 'Full Name',
levelOfEducation: 'b',
};
expect(gen.next().value).toEqual(put(profileActions.saveProfileBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', action.payload.profileData));
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', {
name: 'Full Name',
}));
// The library would supply the result of the above call
// as the parameter to the NEXT yield. Here:
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess()));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileSuccess(profile)));
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {
visibility: {},
})));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.closeField('ze field')));
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.saveProfileReset()));
expect(gen.next().value).toEqual(put(profileActions.resetDrafts()));
expect(gen.next().value).toBeUndefined();
});
@@ -71,14 +75,15 @@ describe('RootSaga', () => {
const action = profileActions.saveProfile(
'my username',
{
fullName: 'Full Name',
name: 'Full Name',
education: 'b',
},
'ze field',
'ze form id',
);
const gen = handleSaveProfile(action);
expect(gen.next().value).toEqual(put(profileActions.saveProfileBegin()));
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
const result = gen.throw(error);
expect(result.value).toEqual(put(profileActions.saveProfileFailure('uhoh')));
expect(gen.next().value).toBeUndefined();

View File

@@ -0,0 +1,119 @@
import { createSelector } from 'reselect';
export const formIdSelector = (state, props) => props.formId;
export const authenticationUsernameSelector = state => state.authentication.username;
export const profileAccountSelector = state => state.profilePage.account;
export const profileCertificatesSelector = state => state.profilePage.certificates;
export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts;
export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts;
export const profileVisibilitySelector = state => state.profilePage.preferences.visibility;
export const saveStateSelector = state => state.profilePage.saveState;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
export const isCurrentUserProfileSelector = createSelector(
authenticationUsernameSelector,
profileAccountSelector,
(username, profileAccount) => username === profileAccount.username,
);
export const editableFormModeSelector = createSelector(
profileAccountSelector,
profileCertificatesSelector,
formIdSelector,
isCurrentUserProfileSelector,
currentlyEditingFieldSelector,
(account, certificates, formId, isCurrentUserProfile, currentlyEditingField) => {
// If the prop doesn't exist, that means it hasn't been set (for the current user's profile)
// or is being hidden from us (for other users' profiles)
let propExists = account[formId] != null && account[formId].length > 0;
propExists = formId === 'certificates' ? certificates !== null : propExists; // overwrite for certificates
// If this isn't the current user's profile...
if (!isCurrentUserProfile) {
// then there are only two options: static or nothing.
// We use 'null' as a return value because the consumers of
// getMode render nothing at all on a mode of null.
return propExists ? 'static' : null;
}
// Otherwise, if this is the current user's profile...
if (formId === currentlyEditingField) {
return 'editing';
}
if (!propExists) {
return 'empty';
}
return 'editable';
},
);
export const accountDraftsFieldSelector = createSelector(
formIdSelector,
profileAccountDraftsSelector,
(formId, accountDrafts) => accountDrafts[formId],
);
export const visibilityDraftsFieldSelector = createSelector(
formIdSelector,
profileVisibilityDraftsSelector,
(formId, visibilityDrafts) => visibilityDrafts[formId],
);
export const formErrorSelector = createSelector(
accountErrorsSelector,
formIdSelector,
(errors, formId) => errors[formId] || null,
);
export const editableFormSelector = createSelector(
editableFormModeSelector,
profileAccountSelector,
profileVisibilitySelector,
formIdSelector,
formErrorSelector,
saveStateSelector,
accountDraftsFieldSelector,
visibilityDraftsFieldSelector,
(
editMode,
account,
visibility,
formId,
error,
saveState,
accountDraftsField,
visibilityDraftsField,
) => ({
value: accountDraftsField || account[formId] || '',
committedValue: account[formId] || '',
visibility: visibilityDraftsField || visibility[formId] || 'private',
editMode,
error,
saveState,
}),
);
export const certificatesSelector = createSelector(
editableFormSelector,
profileCertificatesSelector,
(editableForm, certificates) => ({
...editableForm,
certificates,
value: certificates,
}),
);
/**
* This is used by a saga to pull out data to process.
*/
export const handleSaveProfileSelector = createSelector(
authenticationUsernameSelector,
profileAccountDraftsSelector,
profileVisibilityDraftsSelector,
(username, accountDrafts, visibilityDrafts) => ({
username,
accountDrafts,
visibilityDrafts,
}),
);

View File

@@ -1,37 +1,34 @@
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';
import { unflattenAndTransformKeys, flattenAndTransformKeys } from './utils';
const accountsApiBaseUrl = `${configuration.LMS_BASE_URL}/api/user/v1/accounts`;
const preferencesApiBaseUrl = `${configuration.LMS_BASE_URL}/api/user/v1/preferences`;
const clientServerKeyMap = {
bio: 'bio',
const clientToServerKeyMap = {
socialLinks: 'social_links',
country: 'country',
education: 'level_of_education',
fullName: 'name',
username: 'username',
profileImage: 'profile_image',
dateJoined: 'date_joined',
languageProficiencies: 'language_proficiencies',
accountPrivacy: 'account_privacy',
userLocation: 'user_location',
};
const serverClientKeyMap = Object.entries(clientServerKeyMap).reduce((acc, [key, value]) => {
const serverToClientKeyMap = Object.entries(clientToServerKeyMap).reduce((acc, [key, value]) => {
acc[value] = key;
return acc;
}, {});
export function mapServerKey(key) {
return serverToClientKeyMap[key] || key;
}
export function getProfile(username) {
export function mapClientKey(key) {
return clientToServerKeyMap[key] || key;
}
export function getAccount(username) {
return new Promise((resolve, reject) => {
apiClient
.get(`${accountsApiBaseUrl}/${username}`)
.get(`${configuration.ACCOUNTS_API_BASE_URL}/${username}`)
.then((response) => {
resolve(camelcaseKeys(response.data, { deep: true }));
resolve(unflattenAndTransformKeys(response.data, key => mapServerKey(key)));
})
.catch((error) => {
reject(error);
@@ -41,10 +38,14 @@ export function getProfile(username) {
export const mapSaveProfileRequestData = (props) => {
const PROFILE_REQUEST_DATA_MAP = {
fullName: 'name',
userLocation: 'country',
education: 'levelOfEducation',
socialLinks: socialLinks => socialLinks.filter(({ socialLink }) => socialLink !== null),
socialLinks: socialLinks =>
Object.entries(socialLinks)
.filter(([platform, value]) => value !== null) // eslint-disable-line no-unused-vars
.reduce((acc, [platform, value]) => {
acc.push({ socialLink: value, platform });
return acc;
}, []),
};
const state = {};
@@ -61,17 +62,18 @@ export const mapSaveProfileRequestData = (props) => {
export function patchProfile(username, data) {
return new Promise((resolve, reject) => {
apiClient.patch(
`${accountsApiBaseUrl}/${username}`,
snakecaseKeys(mapSaveProfileRequestData(data), { deep: true }),
{
headers: {
'Content-Type': 'application/merge-patch+json',
apiClient
.patch(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}`,
flattenAndTransformKeys(data, key => mapClientKey(key)),
{
headers: {
'Content-Type': 'application/merge-patch+json',
},
},
},
)
)
.then((response) => {
resolve(camelcaseKeys(response.data, { deep: true }));
resolve(unflattenAndTransformKeys(response.data, key => mapServerKey(key)));
})
.catch((error) => {
reject(error);
@@ -80,7 +82,7 @@ export function patchProfile(username, data) {
}
export function postProfilePhoto(username, formData) {
return apiClient.post(`${accountsApiBaseUrl}/${username}/image`, formData, {
return apiClient.post(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
@@ -88,18 +90,23 @@ export function postProfilePhoto(username, formData) {
}
export function deleteProfilePhoto(username) {
return apiClient.delete(`${accountsApiBaseUrl}/${username}/image`);
return apiClient.delete(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`);
}
export function getPreferences(username) {
const url = `${preferencesApiBaseUrl}/${username}`;
const url = `${configuration.PREFERENCES_API_BASE_URL}/${username}`;
return new Promise((resolve, reject) => {
apiClient.get(url)
apiClient
.get(url)
.then(({ data }) => {
// Unflatten server response
// visibility.social_links: 'value' becomes { visibility: { socialLinks: 'value' }}
resolve(unflattenAndTransformKeys(data, key => serverClientKeyMap[key] || key));
const preferences = unflattenAndTransformKeys(data, key => mapServerKey(key));
if (preferences.visibility === undefined) {
preferences.visibility = {};
}
resolve(preferences);
})
.catch((error) => {
reject(error);
@@ -108,19 +115,17 @@ export function getPreferences(username) {
}
export function patchPreferences(username, preferences) {
const url = `${preferencesApiBaseUrl}/${username}`;
const url = `${configuration.PREFERENCES_API_BASE_URL}/${username}`;
// Flatten object for server
// { visibility: { socialLinks: 'value' }} becomes visibility.social_links: 'value'
const data = flattenAndTransformKeys(preferences, key => clientServerKeyMap[key] || key);
const data = flattenAndTransformKeys(preferences, key => mapClientKey(key));
return new Promise((resolve, reject) => {
apiClient.patch(
url,
data,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
)
.then((response) => { // eslint-disable-line no-unused-vars
apiClient
.patch(url, data, { headers: { 'Content-Type': 'application/merge-patch+json' } })
.then(() => {
// eslint-disable-line no-unused-vars
// Server response is blank on success
// resolve(response.data);
resolve(preferences);

View File

@@ -1,24 +1,18 @@
import { mapSaveProfileRequestData } from './ProfileApiService';
describe('mapDataForRequest', () => {
describe('mapSaveProfileRequestData', () => {
it('should modify props according to prop modifier strings and functions', () => {
const props = {
favoriteColor: 'red',
age: 30,
petName: 'Donkey',
fullName: 'Donkey McWafflebatter',
userLocation: 'US',
name: 'Donkey McWafflebatter',
country: 'US',
education: 'BS',
socialLinks: [
{
platform: 'twitter',
socialLink: null,
},
{
platform: 'facebook',
socialLink: 'https://www.facebook.com',
},
],
socialLinks: {
twitter: null,
facebook: 'https://www.facebook.com',
},
};
const result = mapSaveProfileRequestData(props);
expect(result).toEqual({

View File

@@ -15,7 +15,9 @@ export function flattenAndTransformKeys(srcObj, transformer = key => key) {
const tKey = transformer(key);
const keys = prevKeys.concat(tKey);
if (value && typeof value === 'object') {
if (Array.isArray(value)) {
acc[keys.join('.')] = value;
} else if (value && typeof value === 'object') {
Object.assign(acc, flatten(value, keys));
} else {
acc[keys.join('.')] = value;

View File

@@ -4,7 +4,7 @@ import { flattenAndTransformKeys, unflattenAndTransformKeys } from './utils';
describe('unflattenAndTransformKeys', () => {
it('should unflatten objects and transform keys', () => {
const sourceObject = {
userlocation: 'US',
country: 'US',
'visibility.sociallinks': 'private',
'visibility.education': 'private',
'visibility.bio': 'private',
@@ -13,7 +13,7 @@ describe('unflattenAndTransformKeys', () => {
const result = unflattenAndTransformKeys(sourceObject, key => key.toUpperCase());
expect(result).toEqual({
USERLOCATION: 'US',
COUNTRY: 'US',
VISIBILITY: {
SOCIALLINKS: 'private',
EDUCATION: 'private',
@@ -27,7 +27,7 @@ describe('unflattenAndTransformKeys', () => {
describe('flattenAndTransformKeys', () => {
it('should flatten objects and transform keys', () => {
const sourceObject = {
USERLOCATION: 'US',
COUNTRY: 'US',
VISIBILITY: {
SOCIALLINKS: 'private',
EDUCATION: 'private',
@@ -38,7 +38,7 @@ describe('flattenAndTransformKeys', () => {
const result = flattenAndTransformKeys(sourceObject, key => key.toLowerCase());
expect(result).toEqual({
userlocation: 'US',
country: 'US',
'visibility.sociallinks': 'private',
'visibility.education': 'private',
'visibility.bio': 'private',