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',