From baf6e83f73c4bc45ece74bb1cad26f606043ea77 Mon Sep 17 00:00:00 2001 From: David Joy Date: Wed, 27 Feb 2019 16:23:41 -0500 Subject: [PATCH] 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. --- package-lock.json | 75 +++-- package.json | 9 +- src/actions/ProfileActions.js | 105 ++++--- src/actions/ProfileActions.test.js | 68 ++--- src/components/App.jsx | 8 +- src/components/ProfilePage.jsx | 279 ++++++------------ src/components/ProfilePage/Bio.jsx | 198 ++++++++----- src/components/ProfilePage/Certificates.jsx | 168 +++++++++++ src/components/ProfilePage/Country.jsx | 143 +++++++++ src/components/ProfilePage/Education.jsx | 197 ++++++++----- src/components/ProfilePage/FullName.jsx | 92 ------ src/components/ProfilePage/MyCertificates.jsx | 135 --------- src/components/ProfilePage/Name.jsx | 132 +++++++++ src/components/ProfilePage/SocialLinks.jsx | 209 +++++++------ src/components/ProfilePage/UserLocation.jsx | 98 ------ .../elements/AsyncActionButton.jsx | 7 +- .../{EditControls.jsx => FormControls.jsx} | 49 +-- src/config/configureStore.dev.js | 12 +- src/config/configureStore.prod.js | 14 +- src/config/environment.js | 2 + src/index.jsx | 4 +- src/reducers/ProfilePageReducer.js | 120 +++++--- src/reducers/RootReducer.js | 19 +- src/sagas/RootSaga.js | 112 +++---- src/sagas/RootSaga.test.js | 45 +-- src/selectors/ProfilePageSelector.js | 119 ++++++++ src/services/ProfileApiService.js | 85 +++--- src/services/ProfileApiService.test.js | 20 +- src/services/utils.js | 4 +- src/services/utils.test.js | 8 +- 30 files changed, 1452 insertions(+), 1084 deletions(-) create mode 100644 src/components/ProfilePage/Certificates.jsx create mode 100644 src/components/ProfilePage/Country.jsx delete mode 100644 src/components/ProfilePage/FullName.jsx delete mode 100644 src/components/ProfilePage/MyCertificates.jsx create mode 100644 src/components/ProfilePage/Name.jsx delete mode 100644 src/components/ProfilePage/UserLocation.jsx rename src/components/ProfilePage/elements/{EditControls.jsx => FormControls.jsx} (54%) create mode 100644 src/selectors/ProfilePageSelector.js diff --git a/package-lock.json b/package-lock.json index c699112..575f443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f0bbebb..ce221c3 100755 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/actions/ProfileActions.js b/src/actions/ProfileActions.js index a051936..afa4b23 100644 --- a/src/actions/ProfileActions.js +++ b/src/actions/ProfileActions.js @@ -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, +}); diff --git a/src/actions/ProfileActions.test.js b/src/actions/ProfileActions.test.js index a21917f..d373ff2 100644 --- a/src/actions/ProfileActions.test.js +++ b/src/actions/ProfileActions.test.js @@ -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); }); }); diff --git a/src/components/App.jsx b/src/components/App.jsx index 3ad047f..fe307cf 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -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 ( - +
-
+
); @@ -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 => ({ diff --git a/src/components/ProfilePage.jsx b/src/components/ProfilePage.jsx index 75a7ae8..a8a7054 100644 --- a/src/components/ProfilePage.jsx +++ b/src/components/ProfilePage.jsx @@ -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 (
@@ -166,8 +92,8 @@ export class ProfilePage extends React.Component {
@@ -179,54 +105,20 @@ export class ProfilePage extends React.Component { - - - - - - - - - + + + + - - + {this.props.requiresParentalConsent ? : null} - - - - - + + @@ -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); diff --git a/src/components/ProfilePage/Bio.jsx b/src/components/ProfilePage/Bio.jsx index 423e832..9355df5 100644 --- a/src/components/ProfilePage/Bio.jsx +++ b/src/components/ProfilePage/Bio.jsx @@ -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 ( - - - onChange('bio', e.target.value)} - /> - onCancel('bio')} - onSave={() => onSave('bio')} - saveState={saveState} - visibility={visibility} - onVisibilityChange={e => onVisibilityChange('bio', e.target.value)} - /> - - ), - editable: ( - - onEdit('bio')} - showVisibility={Boolean(bio)} - visibility={visibility} - /> -

{bio}

-
- ), - empty: ( - onEdit('bio')}> - - - ), - static: ( - - -

{bio}

, -
- ), - }} - /> - ); +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 ( + + + + + {error} + + + + ), + editable: ( + + +

{value}

+
+ ), + empty: ( + + + + ), + static: ( + + +

{value}

+
+ ), + }} + /> + ); + } } 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); diff --git a/src/components/ProfilePage/Certificates.jsx b/src/components/ProfilePage/Certificates.jsx new file mode 100644 index 0000000..5b545c3 --- /dev/null +++ b/src/components/ProfilePage/Certificates.jsx @@ -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 ( + + + + +

{name}

+

{title}

+
+

From

+
{organization}
+
+ +
+
+
+ + ); + } + + renderCertificates() { + if (this.props.certificates === null) { + return null; + } + + return ( + {this.props.certificates.map(certificate => this.renderCertificate(certificate))} + ); + } + + render() { + const { + formId, visibility, editMode, saveState, + } = this.props; + + return ( + + + {this.renderCertificates()} + + + ), + editable: ( + + + {this.renderCertificates()} + + ), + empty: ( +
+ +
+ ), + static: ( + + + {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, + })), + 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); diff --git a/src/components/ProfilePage/Country.jsx b/src/components/ProfilePage/Country.jsx new file mode 100644 index 0000000..a41a4d8 --- /dev/null +++ b/src/components/ProfilePage/Country.jsx @@ -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 ( + + + + + {Object.keys(ALL_COUNTRIES).map(key => ( + + ))} + + {error} + + + + ), + editable: ( + + +
{ALL_COUNTRIES[value]}
+
+ ), + empty: Add location, + static: ( + + +
{ALL_COUNTRIES[value]}
+
+ ), + }} + /> + ); + } +} + +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); diff --git a/src/components/ProfilePage/Education.jsx b/src/components/ProfilePage/Education.jsx index 94cb6e1..86e7f75 100644 --- a/src/components/ProfilePage/Education.jsx +++ b/src/components/ProfilePage/Education.jsx @@ -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 ( - - - onChange('education', e.target.value)} - > - {Object.keys(EDUCATION).map(key => ( - - ))} - - onCancel('education')} - onSave={() => onSave('education')} - saveState={saveState} - visibility={visibility} - onVisibilityChange={e => onVisibilityChange('education', e.target.value)} - /> - - ), - editable: ( - - onEdit('education')} - showVisibility={Boolean(education)} - visibility={visibility} - /> -
{EDUCATION[education]}
-
- ), - empty: ( - onEdit('education')}>Add education - ), - static: ( - - -
{EDUCATION[education]}
-
- ), - }} - /> - ); +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 ( + + + + + {Object.keys(EDUCATION).map(key => ( + + ))} + + {error} + + + + ), + editable: ( + + +
{EDUCATION[value]}
+
+ ), + empty: Add education, + static: ( + + +
{EDUCATION[value]}
+
+ ), + }} + /> + ); + } } 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); diff --git a/src/components/ProfilePage/FullName.jsx b/src/components/ProfilePage/FullName.jsx deleted file mode 100644 index c434837..0000000 --- a/src/components/ProfilePage/FullName.jsx +++ /dev/null @@ -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 ( - - - onChange('fullName', e.target.value)} - /> - onCancel('fullName')} - onSave={() => onSave('fullName')} - saveState={saveState} - visibility={visibility} - onVisibilityChange={e => onVisibilityChange('fullName', e.target.value)} - /> - - ), - editable: ( - - onEdit('fullName')} - showVisibility={Boolean(fullName)} - visibility={visibility} - /> -
{fullName}
-
- ), - empty: ( - onEdit('fullName')}>Add name - ), - static: ( - - -
{fullName}
-
- ), - }} - /> - ); -} - - -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; diff --git a/src/components/ProfilePage/MyCertificates.jsx b/src/components/ProfilePage/MyCertificates.jsx deleted file mode 100644 index cf632be..0000000 --- a/src/components/ProfilePage/MyCertificates.jsx +++ /dev/null @@ -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 ( - - {certificates.map(({ - type: { key, name }, // eslint-disable-line no-unused-vars - title, - organization, - downloadUrl, - }) => ( - - - - -

{name}

-

{title}

-
- -

From

-
{organization}
-
- -
-
-
-
- - ))} -
- ); - }; - - return ( - - - {renderCertificates()} - onCancel('certificates')} - onSave={() => onSave('certificates')} - saveState={saveState} - visibility={visibility} - onVisibilityChange={e => onVisibilityChange('certificates', e.target.value)} - /> - - ), - editable: ( - - onEdit('certificates')} - showVisibility={Boolean(certificates)} - visibility={visibility} - /> - {renderCertificates()} - - ), - empty: ( -
- -
- ), - static: ( - - - {renderCertificates()} - - ), - }} - /> - ); -} - - -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; diff --git a/src/components/ProfilePage/Name.jsx b/src/components/ProfilePage/Name.jsx new file mode 100644 index 0000000..e7db1d7 --- /dev/null +++ b/src/components/ProfilePage/Name.jsx @@ -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 ( + + + + + + This is the name that appears in your account and on your certificates. + + {error} + + + + ), + editable: ( + + +
{value}
+
+ ), + empty: Add name, + static: ( + + +
{value}
+
+ ), + }} + /> + ); + } +} + +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); diff --git a/src/components/ProfilePage/SocialLinks.jsx b/src/components/ProfilePage/SocialLinks.jsx index 1b9efa4..7d7aa20 100644 --- a/src/components/ProfilePage/SocialLinks.jsx +++ b/src/components/ProfilePage/SocialLinks.jsx @@ -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 ( - {platforms.map(({ key, name }) => ( + {values.map(({ platform }) => ( ))} @@ -80,10 +96,10 @@ class SocialLinks extends React.Component {
    - {socialLinks.map(({ platform, socialLink }) => ( + {values.map(({ platform, social_link: socialLink }) => ( @@ -96,45 +112,46 @@ class SocialLinks extends React.Component {
      - {platforms.map(({ key, name }) => ( + {values.map(({ platform, social_link: socialLink }) => ( ))}
    ), editing: ( - +
      - {platforms.map(({ key, name }) => ( + {values.map(({ platform, social_link: socialLink }) => ( ))}
    - onCancel('socialLinks')} - onSave={this.onSave} + onVisibilityChange('socialLinks', e.target.value)} + cancelHandler={this.handleClose} + changeHandler={this.handleChange} /> - + ), }} /> @@ -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 ( - + {name} ); @@ -197,7 +223,7 @@ function EditableListItem({ onClickEmptyContent, name, }) { - const linkDisplay = url != null ? + const linkDisplay = url ? : Add {name}; @@ -219,29 +245,36 @@ EditableListItem.defaultProps = { function EditingListItem({ platform, name, - defaultValue, + value, onChange, + error, }) { return (
  • {name}
    onChange(platform, e.target.value)} + name={platform} + value={value} + onChange={onChange} + invalid={error != null} /> + {error}
  • ); } 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, }; diff --git a/src/components/ProfilePage/UserLocation.jsx b/src/components/ProfilePage/UserLocation.jsx deleted file mode 100644 index 9f69ab7..0000000 --- a/src/components/ProfilePage/UserLocation.jsx +++ /dev/null @@ -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 ( - - - onChange('userLocation', e.target.value)} - > - {Object.keys(ALL_COUNTRIES).map(key => ( - - ))} - - onCancel('userLocation')} - onSave={() => onSave('userLocation')} - saveState={saveState} - visibility={visibility} - onVisibilityChange={e => onVisibilityChange('userLocation', e.target.value)} - /> -
    - ), - editable: ( - - onEdit('userLocation')} - showVisibility={Boolean(userLocation)} - visibility={visibility} - /> -
    {ALL_COUNTRIES[userLocation]}
    -
    - ), - empty: ( - onEdit('userLocation')}>Add location - ), - static: ( - - -
    {ALL_COUNTRIES[userLocation]}
    -
    - ), - }} - /> - ); -} - -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; diff --git a/src/components/ProfilePage/elements/AsyncActionButton.jsx b/src/components/ProfilePage/elements/AsyncActionButton.jsx index a1df039..2dd7e52 100644 --- a/src/components/ProfilePage/elements/AsyncActionButton.jsx +++ b/src/components/ProfilePage/elements/AsyncActionButton.jsx @@ -11,6 +11,7 @@ function AsyncActionButton({ style, variant, labels, + type, }) { const renderIcon = () => { if (variant === 'error') return ; @@ -30,6 +31,7 @@ function AsyncActionButton({ return ( + , document.getElementById('root')); + ReactDOM.render(, document.getElementById('root')); // identify user for future analytics calls // TODO: Call before each page call. diff --git a/src/reducers/ProfilePageReducer.js b/src/reducers/ProfilePageReducer.js index a35e205..c7a8dd2 100644 --- a/src/reducers/ProfilePageReducer.js +++ b/src/reducers/ProfilePageReducer.js @@ -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; } diff --git a/src/reducers/RootReducer.js b/src/reducers/RootReducer.js index bca1391..dbd3cb4 100755 --- a/src/reducers/RootReducer.js +++ b/src/reducers/RootReducer.js @@ -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; diff --git a/src/sagas/RootSaga.js b/src/sagas/RootSaga.js index 5365634..1f0ab0c 100644 --- a/src/sagas/RootSaga.js +++ b/src/sagas/RootSaga.js @@ -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()); diff --git a/src/sagas/RootSaga.test.js b/src/sagas/RootSaga.test.js index fac245a..8514a7b 100644 --- a/src/sagas/RootSaga.test.js +++ b/src/sagas/RootSaga.test.js @@ -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(); diff --git a/src/selectors/ProfilePageSelector.js b/src/selectors/ProfilePageSelector.js new file mode 100644 index 0000000..f4916b2 --- /dev/null +++ b/src/selectors/ProfilePageSelector.js @@ -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, + }), +); diff --git a/src/services/ProfileApiService.js b/src/services/ProfileApiService.js index b7e0d18..2f14ee5 100644 --- a/src/services/ProfileApiService.js +++ b/src/services/ProfileApiService.js @@ -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); diff --git a/src/services/ProfileApiService.test.js b/src/services/ProfileApiService.test.js index 1a7336c..af0ea7a 100644 --- a/src/services/ProfileApiService.test.js +++ b/src/services/ProfileApiService.test.js @@ -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({ diff --git a/src/services/utils.js b/src/services/utils.js index 35bb595..f273239 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -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; diff --git a/src/services/utils.test.js b/src/services/utils.test.js index 4463e61..581ac86 100644 --- a/src/services/utils.test.js +++ b/src/services/utils.test.js @@ -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',