From c46d8506e473ebf2308f0e4e060a34d6478fb6e1 Mon Sep 17 00:00:00 2001 From: David Joy Date: Wed, 20 Feb 2019 13:40:49 -0500 Subject: [PATCH] Viewing others profiles and naming refactor. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR has a refactoring of our actions/sagas/services. Rather than continue to put profile-related API actions into the frontend-auth project, we decided (against prior decision) to put them in this app, as that feels more correct, long term. This also let us unroll the service creation and decouple the services from the components. The “fetchProfile” API is hitting the same ‘accounts’ API as fetchUserAccount from frontend-auth. This is an intentional abstraction for clarity of purpose and assuming they may diverge. It also lets us group all the profile app’s API methods together, letting the ‘session’ API stay in frontend-auth. We also decided to do some renaming: - “user profile” -> “profile” cause there’s no other type of profile here. - The saga handlers are prefixed with “handle” - The API methods use proper HTTP verbs. - The actions keep fetch and save as verbs as they’re clearer in components than “get” and “patch” - The goal here is to differentiate different parts of the code, making it easier to remember where you are. --- config/webpack.common.config.js | 1 + config/webpack.dev.config.js | 6 + config/webpack.prod.config.js | 4 + src/actions/profile.js | 94 +++++--- src/actions/profile.test.js | 96 ++++---- src/components/NotFoundPage.jsx | 14 ++ src/components/UserProfile/index.jsx | 24 +- src/containers/UserProfile/index.jsx | 59 +++-- ...rofileReducer.js => ProfilePageReducer.js} | 43 ++-- src/data/reducers/RootReducer.js | 4 +- src/data/store.js | 6 +- src/index.jsx | 4 +- src/sagas/RootSaga.js | 209 ++++++++---------- src/sagas/RootSaga.test.js | 125 ++++------- src/services/ProfileApiService.js | 86 +++++++ src/services/ProfileApiService.test.js | 39 ++++ 16 files changed, 475 insertions(+), 339 deletions(-) create mode 100644 src/components/NotFoundPage.jsx rename src/data/reducers/{ProfileReducer.js => ProfilePageReducer.js} (73%) create mode 100644 src/services/ProfileApiService.js create mode 100644 src/services/ProfileApiService.test.js diff --git a/config/webpack.common.config.js b/config/webpack.common.config.js index c2d341d..4a19aa6 100755 --- a/config/webpack.common.config.js +++ b/config/webpack.common.config.js @@ -9,6 +9,7 @@ module.exports = { }, output: { path: path.resolve(__dirname, '../dist'), + publicPath: '/', }, resolve: { extensions: ['.js', '.jsx'], diff --git a/config/webpack.dev.config.js b/config/webpack.dev.config.js index d2da721..a51870b 100755 --- a/config/webpack.dev.config.js +++ b/config/webpack.dev.config.js @@ -9,6 +9,7 @@ const commonConfig = require('./webpack.common.config.js'); module.exports = Merge.smart(commonConfig, { mode: 'development', + devtool: 'eval-source-map', entry: [ // enable react's custom hot dev client so we get errors reported in the browser require.resolve('react-dev-utils/webpackHotDevClient'), @@ -24,6 +25,10 @@ module.exports = Merge.smart(commonConfig, { test: /\.(js|jsx)$/, include: [ path.resolve(__dirname, '../src'), + path.resolve(__dirname, 'node_modules/camelcase-keys'), + path.resolve(__dirname, 'node_modules/camelcase'), + path.resolve(__dirname, 'node_modules/map-obj'), + path.resolve(__dirname, 'node_modules/quick-lru'), ], loader: 'babel-loader', options: { @@ -136,5 +141,6 @@ module.exports = Merge.smart(commonConfig, { historyApiFallback: true, hot: true, inline: true, + publicPath: '/', }, }); diff --git a/config/webpack.prod.config.js b/config/webpack.prod.config.js index 08d02c5..036551a 100755 --- a/config/webpack.prod.config.js +++ b/config/webpack.prod.config.js @@ -24,6 +24,10 @@ module.exports = Merge.smart(commonConfig, { test: /\.(js|jsx)$/, include: [ path.resolve(__dirname, '../src'), + path.resolve(__dirname, 'node_modules/camelcase-keys'), + path.resolve(__dirname, 'node_modules/camelcase'), + path.resolve(__dirname, 'node_modules/map-obj'), + path.resolve(__dirname, 'node_modules/quick-lru'), ], loader: 'babel-loader', }, diff --git a/src/actions/profile.js b/src/actions/profile.js index 98c0e4f..b7538b3 100644 --- a/src/actions/profile.js +++ b/src/actions/profile.js @@ -2,9 +2,10 @@ import AsyncActionType from './AsyncActionType'; export const EDITABLE_FIELD_OPEN = 'EDITABLE_FIELD_OPEN'; export const EDITABLE_FIELD_CLOSE = 'EDITABLE_FIELD_CLOSE'; -export const SAVE_USER_PROFILE = new AsyncActionType('PROFILE', 'SAVE_USER_PROFILE'); -export const SAVE_USER_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_USER_PROFILE_PHOTO'); -export const DELETE_USER_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_USER_PROFILE_PHOTO'); +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 openEditableField = fieldName => ({ type: EDITABLE_FIELD_OPEN, @@ -16,25 +17,48 @@ export const closeEditableField = fieldName => ({ fieldName, }); -export const saveUserProfileBegin = () => ({ - type: SAVE_USER_PROFILE.BEGIN, +export const fetchProfileBegin = () => ({ + type: FETCH_PROFILE.BEGIN, }); -export const saveUserProfileSuccess = () => ({ - type: SAVE_USER_PROFILE.SUCCESS, +export const fetchProfileSuccess = profile => ({ + type: FETCH_PROFILE.SUCCESS, + payload: { profile }, }); -export const saveUserProfileReset = () => ({ - type: SAVE_USER_PROFILE.RESET, -}); - -export const saveUserProfileFailure = error => ({ - type: SAVE_USER_PROFILE.FAILURE, +export const fetchProfileFailure = error => ({ + type: FETCH_PROFILE.FAILURE, payload: { error }, }); -export const saveUserProfile = (username, userAccountState, fieldName) => ({ - type: SAVE_USER_PROFILE.BASE, +export const fetchProfileReset = () => ({ + type: FETCH_PROFILE.RESET, +}); + +export const fetchProfile = username => ({ + type: FETCH_PROFILE.BASE, + payload: { username }, +}); + +export const saveProfileBegin = () => ({ + type: SAVE_PROFILE.BEGIN, +}); + +export const saveProfileSuccess = () => ({ + type: SAVE_PROFILE.SUCCESS, +}); + +export const saveProfileReset = () => ({ + type: SAVE_PROFILE.RESET, +}); + +export const saveProfileFailure = error => ({ + type: SAVE_PROFILE.FAILURE, + payload: { error }, +}); + +export const saveProfile = (username, userAccountState, fieldName) => ({ + type: SAVE_PROFILE.BASE, payload: { fieldName, username, @@ -42,50 +66,50 @@ export const saveUserProfile = (username, userAccountState, fieldName) => ({ }, }); -export const saveUserProfilePhotoBegin = () => ({ - type: SAVE_USER_PROFILE_PHOTO.BEGIN, +export const saveProfilePhotoBegin = () => ({ + type: SAVE_PROFILE_PHOTO.BEGIN, }); -export const saveUserProfilePhotoSuccess = () => ({ - type: SAVE_USER_PROFILE_PHOTO.SUCCESS, +export const saveProfilePhotoSuccess = () => ({ + type: SAVE_PROFILE_PHOTO.SUCCESS, }); -export const saveUserProfilePhotoReset = () => ({ - type: SAVE_USER_PROFILE_PHOTO.RESET, +export const saveProfilePhotoReset = () => ({ + type: SAVE_PROFILE_PHOTO.RESET, }); -export const saveUserProfilePhotoFailure = error => ({ - type: SAVE_USER_PROFILE_PHOTO.FAILURE, +export const saveProfilePhotoFailure = error => ({ + type: SAVE_PROFILE_PHOTO.FAILURE, payload: { error }, }); -export const saveUserProfilePhoto = (username, formData) => ({ - type: SAVE_USER_PROFILE_PHOTO.BASE, +export const saveProfilePhoto = (username, formData) => ({ + type: SAVE_PROFILE_PHOTO.BASE, payload: { username, formData, }, }); -export const deleteUserProfilePhotoBegin = () => ({ - type: DELETE_USER_PROFILE_PHOTO.BEGIN, +export const deleteProfilePhotoBegin = () => ({ + type: DELETE_PROFILE_PHOTO.BEGIN, }); -export const deleteUserProfilePhotoSuccess = () => ({ - type: DELETE_USER_PROFILE_PHOTO.SUCCESS, +export const deleteProfilePhotoSuccess = () => ({ + type: DELETE_PROFILE_PHOTO.SUCCESS, }); -export const deleteUserProfilePhotoReset = () => ({ - type: DELETE_USER_PROFILE_PHOTO.RESET, +export const deleteProfilePhotoReset = () => ({ + type: DELETE_PROFILE_PHOTO.RESET, }); -export const deleteUserProfilePhotoFailure = error => ({ - type: DELETE_USER_PROFILE_PHOTO.FAILURE, +export const deleteProfilePhotoFailure = error => ({ + type: DELETE_PROFILE_PHOTO.FAILURE, payload: { error }, }); -export const deleteUserProfilePhoto = username => ({ - type: DELETE_USER_PROFILE_PHOTO.BASE, +export const deleteProfilePhoto = username => ({ + type: DELETE_PROFILE_PHOTO.BASE, payload: { username, }, diff --git a/src/actions/profile.test.js b/src/actions/profile.test.js index b69c1a7..25b3093 100644 --- a/src/actions/profile.test.js +++ b/src/actions/profile.test.js @@ -3,24 +3,24 @@ import { closeEditableField, EDITABLE_FIELD_OPEN, EDITABLE_FIELD_CLOSE, - SAVE_USER_PROFILE, - saveUserProfileBegin, - saveUserProfileSuccess, - saveUserProfileFailure, - saveUserProfileReset, - saveUserProfile, - SAVE_USER_PROFILE_PHOTO, - saveUserProfilePhotoBegin, - saveUserProfilePhotoSuccess, - saveUserProfilePhotoFailure, - saveUserProfilePhotoReset, - saveUserProfilePhoto, - DELETE_USER_PROFILE_PHOTO, - deleteUserProfilePhotoBegin, - deleteUserProfilePhotoSuccess, - deleteUserProfilePhotoFailure, - deleteUserProfilePhotoReset, - deleteUserProfilePhoto, + SAVE_PROFILE, + saveProfileBegin, + saveProfileSuccess, + saveProfileFailure, + saveProfileReset, + saveProfile, + SAVE_PROFILE_PHOTO, + saveProfilePhotoBegin, + saveProfilePhotoSuccess, + saveProfilePhotoFailure, + saveProfilePhotoReset, + saveProfilePhoto, + DELETE_PROFILE_PHOTO, + deleteProfilePhotoBegin, + deleteProfilePhotoSuccess, + deleteProfilePhotoFailure, + deleteProfilePhotoReset, + deleteProfilePhoto, } from './profile'; describe('editable field actions', () => { @@ -53,44 +53,44 @@ describe('SAVE profile actions', () => { it('should create an action to signal the start of a profile save', () => { const expectedAction = { - type: SAVE_USER_PROFILE.BASE, + type: SAVE_PROFILE.BASE, payload: { username: 'user person', userAccountState, fieldName: 'fullName', }, }; - expect(saveUserProfile('user person', userAccountState, 'fullName')).toEqual(expectedAction); + expect(saveProfile('user person', userAccountState, 'fullName')).toEqual(expectedAction); }); it('should create an action to signal user profile save success', () => { const expectedAction = { - type: SAVE_USER_PROFILE.SUCCESS, + type: SAVE_PROFILE.SUCCESS, }; - expect(saveUserProfileSuccess()).toEqual(expectedAction); + expect(saveProfileSuccess()).toEqual(expectedAction); }); it('should create an action to signal user profile save beginning', () => { const expectedAction = { - type: SAVE_USER_PROFILE.BEGIN, + type: SAVE_PROFILE.BEGIN, }; - expect(saveUserProfileBegin()).toEqual(expectedAction); + expect(saveProfileBegin()).toEqual(expectedAction); }); it('should create an action to signal user profile save success', () => { const expectedAction = { - type: SAVE_USER_PROFILE.RESET, + type: SAVE_PROFILE.RESET, }; - expect(saveUserProfileReset()).toEqual(expectedAction); + expect(saveProfileReset()).toEqual(expectedAction); }); it('should create an action to signal user account save failure', () => { const error = 'Test failure'; const expectedAction = { - type: SAVE_USER_PROFILE.FAILURE, + type: SAVE_PROFILE.FAILURE, payload: { error }, }; - expect(saveUserProfileFailure(error)).toEqual(expectedAction); + expect(saveProfileFailure(error)).toEqual(expectedAction); }); }); @@ -99,43 +99,43 @@ describe('SAVE profile photo actions', () => { it('should create an action to signal the start of a profile photo save', () => { const formData = 'multipart form data'; const expectedAction = { - type: SAVE_USER_PROFILE_PHOTO.BASE, + type: SAVE_PROFILE_PHOTO.BASE, payload: { username: 'myusername', formData, }, }; - expect(saveUserProfilePhoto('myusername', formData)).toEqual(expectedAction); + expect(saveProfilePhoto('myusername', formData)).toEqual(expectedAction); }); it('should create an action to signal user profile photo save beginning', () => { const expectedAction = { - type: SAVE_USER_PROFILE_PHOTO.BEGIN, + type: SAVE_PROFILE_PHOTO.BEGIN, }; - expect(saveUserProfilePhotoBegin()).toEqual(expectedAction); + expect(saveProfilePhotoBegin()).toEqual(expectedAction); }); it('should create an action to signal user profile photo save success', () => { const expectedAction = { - type: SAVE_USER_PROFILE_PHOTO.SUCCESS, + type: SAVE_PROFILE_PHOTO.SUCCESS, }; - expect(saveUserProfilePhotoSuccess()).toEqual(expectedAction); + expect(saveProfilePhotoSuccess()).toEqual(expectedAction); }); it('should create an action to signal user profile photo save success', () => { const expectedAction = { - type: SAVE_USER_PROFILE_PHOTO.RESET, + type: SAVE_PROFILE_PHOTO.RESET, }; - expect(saveUserProfilePhotoReset()).toEqual(expectedAction); + expect(saveProfilePhotoReset()).toEqual(expectedAction); }); it('should create an action to signal user profile photo save failure', () => { const error = 'Test failure'; const expectedAction = { - type: SAVE_USER_PROFILE_PHOTO.FAILURE, + type: SAVE_PROFILE_PHOTO.FAILURE, payload: { error }, }; - expect(saveUserProfilePhotoFailure(error)).toEqual(expectedAction); + expect(saveProfilePhotoFailure(error)).toEqual(expectedAction); }); }); @@ -143,42 +143,42 @@ describe('SAVE profile photo actions', () => { describe('DELETE profile photo actions', () => { it('should create an action to signal the start of a profile photo deletion', () => { const expectedAction = { - type: DELETE_USER_PROFILE_PHOTO.BASE, + type: DELETE_PROFILE_PHOTO.BASE, payload: { username: 'myusername', }, }; - expect(deleteUserProfilePhoto('myusername')).toEqual(expectedAction); + expect(deleteProfilePhoto('myusername')).toEqual(expectedAction); }); it('should create an action to signal user profile photo deletion beginning', () => { const expectedAction = { - type: DELETE_USER_PROFILE_PHOTO.BEGIN, + type: DELETE_PROFILE_PHOTO.BEGIN, }; - expect(deleteUserProfilePhotoBegin()).toEqual(expectedAction); + expect(deleteProfilePhotoBegin()).toEqual(expectedAction); }); it('should create an action to signal user profile photo deletion success', () => { const expectedAction = { - type: DELETE_USER_PROFILE_PHOTO.SUCCESS, + type: DELETE_PROFILE_PHOTO.SUCCESS, }; - expect(deleteUserProfilePhotoSuccess()).toEqual(expectedAction); + expect(deleteProfilePhotoSuccess()).toEqual(expectedAction); }); it('should create an action to signal user profile photo deletion success', () => { const expectedAction = { - type: DELETE_USER_PROFILE_PHOTO.RESET, + type: DELETE_PROFILE_PHOTO.RESET, }; - expect(deleteUserProfilePhotoReset()).toEqual(expectedAction); + expect(deleteProfilePhotoReset()).toEqual(expectedAction); }); it('should create an action to signal user profile photo deletion failure', () => { const error = 'Test failure'; const expectedAction = { - type: DELETE_USER_PROFILE_PHOTO.FAILURE, + type: DELETE_PROFILE_PHOTO.FAILURE, payload: { error }, }; - expect(deleteUserProfilePhotoFailure(error)).toEqual(expectedAction); + expect(deleteProfilePhotoFailure(error)).toEqual(expectedAction); }); }); diff --git a/src/components/NotFoundPage.jsx b/src/components/NotFoundPage.jsx new file mode 100644 index 0000000..bcd9e5f --- /dev/null +++ b/src/components/NotFoundPage.jsx @@ -0,0 +1,14 @@ +import React, { Component } from 'react'; + +export default class NotFoundPage extends Component { + componentDidMount() {} + + render() { + return ( +
+ The page you're looking for is unavailable or there's an error in the URL. + Please check the URL and try again. +
+ ); + } +} diff --git a/src/components/UserProfile/index.jsx b/src/components/UserProfile/index.jsx index 38f2bf3..29df547 100644 --- a/src/components/UserProfile/index.jsx +++ b/src/components/UserProfile/index.jsx @@ -10,7 +10,6 @@ import SocialLinks from './SocialLinks'; import Bio from './Bio'; import MyCertificates from './MyCertificates'; - class UserProfile extends React.Component { constructor(props) { super(props); @@ -33,6 +32,10 @@ class UserProfile extends React.Component { this.onVisibilityChange = this.onVisibilityChange.bind(this); } + componentDidMount() { + this.props.fetchProfile(this.props.match.params.username); + } + onCancel() { this.props.closeEditableField(this.props.currentlyEditingField); } @@ -45,15 +48,15 @@ class UserProfile extends React.Component { const userAccountData = { [fieldName]: value || this.state[fieldName].value, }; - this.props.saveUserProfile(this.props.username, userAccountData, fieldName); + this.props.saveProfile(this.props.username, userAccountData, fieldName); } onSaveProfilePhoto(formData) { - this.props.saveUserProfilePhoto(this.props.username, formData); + this.props.saveProfilePhoto(this.props.username, formData); } onDeleteProfilePhoto() { - this.props.deleteUserProfilePhoto(this.props.username); + this.props.deleteProfilePhoto(this.props.username); } onChange(fieldName, value) { @@ -195,11 +198,17 @@ UserProfile.propTypes = { certificates: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string, })), - saveUserProfile: PropTypes.func, - saveUserProfilePhoto: PropTypes.func.isRequired, - deleteUserProfilePhoto: PropTypes.func.isRequired, + fetchProfile: PropTypes.func.isRequired, + saveProfile: PropTypes.func.isRequired, + saveProfilePhoto: PropTypes.func.isRequired, + deleteProfilePhoto: PropTypes.func.isRequired, openEditableField: PropTypes.func.isRequired, closeEditableField: PropTypes.func.isRequired, + match: PropTypes.shape({ + params: PropTypes.shape({ + username: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, }; UserProfile.defaultProps = { @@ -216,5 +225,4 @@ UserProfile.defaultProps = { aboutMe: null, bio: null, certificates: null, - saveUserProfile: null, }; diff --git a/src/containers/UserProfile/index.jsx b/src/containers/UserProfile/index.jsx index 73c3aac..98fccc5 100644 --- a/src/containers/UserProfile/index.jsx +++ b/src/containers/UserProfile/index.jsx @@ -2,32 +2,43 @@ import { connect } from 'react-redux'; import UserProfile from '../../components/UserProfile'; import { - saveUserProfile, - saveUserProfilePhoto, - deleteUserProfilePhoto, + fetchProfile, + saveProfile, + saveProfilePhoto, + deleteProfilePhoto, openEditableField, closeEditableField, } from '../../actions/profile'; -const mapStateToProps = state => ({ - currentlyEditingField: state.profile.currentlyEditingField, - saveState: state.profile.saveState, - savePhotoState: state.profile.savePhotoState, - error: state.profile.error, - profileImage: state.userAccount.profileImage.imageUrlLarge, - fullName: state.userAccount.name, - username: state.userAccount.username, - userLocation: state.userAccount.country, - education: state.userAccount.levelOfEducation, - socialLinks: state.userAccount.socialLinks, - bio: state.userAccount.bio, - certificates: null, -}); +const mapStateToProps = (state) => { + const profileImage = + state.profilePage.profile.profileImage != null + ? state.profilePage.profile.profileImage.imageUrlLarge + : null; + return { + 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: null, + }; +}; -export default connect(mapStateToProps, { - saveUserProfile, - saveUserProfilePhoto, - deleteUserProfilePhoto, - openEditableField, - closeEditableField, -})(UserProfile); +export default connect( + mapStateToProps, + { + fetchProfile, + saveProfile, + saveProfilePhoto, + deleteProfilePhoto, + openEditableField, + closeEditableField, + }, +)(UserProfile); diff --git a/src/data/reducers/ProfileReducer.js b/src/data/reducers/ProfilePageReducer.js similarity index 73% rename from src/data/reducers/ProfileReducer.js rename to src/data/reducers/ProfilePageReducer.js index f165f9e..5bcf6c5 100644 --- a/src/data/reducers/ProfileReducer.js +++ b/src/data/reducers/ProfilePageReducer.js @@ -1,21 +1,21 @@ import { - SAVE_USER_PROFILE, - SAVE_USER_PROFILE_PHOTO, - DELETE_USER_PROFILE_PHOTO, + SAVE_PROFILE, + SAVE_PROFILE_PHOTO, + DELETE_PROFILE_PHOTO, EDITABLE_FIELD_CLOSE, EDITABLE_FIELD_OPEN, + FETCH_PROFILE, } from '../../actions/profile'; - const initialState = { error: null, saveState: null, savePhotoState: null, currentlyEditingField: null, + profile: {}, }; - -const profile = (state = initialState, action) => { +const profilePage = (state = initialState, action) => { switch (action.type) { case EDITABLE_FIELD_OPEN: return { @@ -32,75 +32,80 @@ const profile = (state = initialState, action) => { } return state; - case SAVE_USER_PROFILE.BEGIN: + case FETCH_PROFILE.SUCCESS: + return { + profile: action.payload.profile, + }; + + case SAVE_PROFILE.BEGIN: return { ...state, saveState: 'pending', error: null, }; - case SAVE_USER_PROFILE.SUCCESS: + case SAVE_PROFILE.SUCCESS: return { ...state, saveState: 'complete', error: null, }; - case SAVE_USER_PROFILE.FAILURE: + case SAVE_PROFILE.FAILURE: return { ...state, saveState: 'error', error: action.payload.error, }; - case SAVE_USER_PROFILE.RESET: + case SAVE_PROFILE.RESET: return { ...state, saveState: null, error: null, }; - case SAVE_USER_PROFILE_PHOTO.BEGIN: + case SAVE_PROFILE_PHOTO.BEGIN: return { ...state, savePhotoState: 'pending', error: null, }; - case SAVE_USER_PROFILE_PHOTO.SUCCESS: + case SAVE_PROFILE_PHOTO.SUCCESS: return { ...state, savePhotoState: 'complete', error: null, }; - case SAVE_USER_PROFILE_PHOTO.FAILURE: + case SAVE_PROFILE_PHOTO.FAILURE: return { ...state, savePhotoState: 'error', error: action.payload.error, }; - case SAVE_USER_PROFILE_PHOTO.RESET: + case SAVE_PROFILE_PHOTO.RESET: return { ...state, savePhotoState: null, error: null, }; - case DELETE_USER_PROFILE_PHOTO.BEGIN: + case DELETE_PROFILE_PHOTO.BEGIN: return { ...state, savePhotoState: 'pending', error: null, }; - case DELETE_USER_PROFILE_PHOTO.SUCCESS: + case DELETE_PROFILE_PHOTO.SUCCESS: return { ...state, savePhotoState: 'complete', error: null, }; - case DELETE_USER_PROFILE_PHOTO.FAILURE: + case DELETE_PROFILE_PHOTO.FAILURE: return { ...state, savePhotoState: 'error', error: action.payload.error, }; - case DELETE_USER_PROFILE_PHOTO.RESET: + case DELETE_PROFILE_PHOTO.RESET: return { ...state, savePhotoState: null, @@ -112,4 +117,4 @@ const profile = (state = initialState, action) => { } }; -export default profile; +export default profilePage; diff --git a/src/data/reducers/RootReducer.js b/src/data/reducers/RootReducer.js index ab23c1f..f7365b9 100755 --- a/src/data/reducers/RootReducer.js +++ b/src/data/reducers/RootReducer.js @@ -1,6 +1,6 @@ import { combineReducers } from 'redux'; import { userAccount } from '@edx/frontend-auth'; -import profile from './ProfileReducer'; +import profilePage from './ProfilePageReducer'; const identityReducer = (state) => { const newState = { ...state }; @@ -12,7 +12,7 @@ const rootReducer = combineReducers({ // creating the store in data/store.js. authentication: identityReducer, userAccount, - profile, + profilePage, }); export default rootReducer; diff --git a/src/data/store.js b/src/data/store.js index 0716c62..43523b0 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -3,7 +3,6 @@ import createSagaMiddleware from 'redux-saga'; import thunkMiddleware from 'redux-thunk'; import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; import { createLogger } from 'redux-logger'; -import { UserAccountApiService } from '@edx/frontend-auth'; import apiClient from './apiClient'; import reducers from './reducers/RootReducer'; @@ -18,9 +17,6 @@ const store = createStore( composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware)), ); -const apiService = new UserAccountApiService(apiClient, process.env.LMS_BASE_URL); -apiService.saveUserAccount = apiService.saveUserAccount.bind(apiService); - -sagaMiddleware.run(rootSaga, apiService); +sagaMiddleware.run(rootSaga); export default store; diff --git a/src/index.jsx b/src/index.jsx index a7e3449..c678d9c 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -17,6 +17,7 @@ import store from './data/store'; import HeaderLogo from '../assets/edx-sm.png'; import FooterLogo from '../assets/edx-footer.png'; import './App.scss'; +import NotFoundPage from './components/NotFoundPage'; class App extends Component { componentDidMount() { @@ -38,7 +39,8 @@ class App extends Component { />
- + +
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* saveUserProfile(action) { - const { username, userAccountState } = action.payload; - - try { - yield put(saveUserProfileBegin()); - - // Use call([context, fnName], ...args) for proper context on apiService - const userAccount = yield call( - [userAccountApiService, 'saveUserAccount'], - username, - mapDataForRequest(userAccountState), - ); - - // Tells the profile form that - yield put(saveUserProfileSuccess()); - // TODO: export the fetchUserAccountSuccess action from frontend-auth so we can - // dry this up. - yield put({ - type: 'FETCH_USER_ACCOUNT_SUCCESS', - payload: { userAccount }, - }); - yield delay(300); - yield put(closeEditableField(action.payload.fieldName)); - yield delay(300); - yield put(saveUserProfileReset()); - } catch (e) { - yield put(saveUserProfileFailure(e)); - } -} - - -export function* saveUserProfilePhoto(action) { - const { username, formData } = action.payload; - - try { - yield put(saveUserProfilePhotoBegin()); - - // Use call([context, fnName], ...args) for proper context on apiService - yield call([userAccountApiService, 'saveUserProfilePhoto'], username, formData); - - // Get the account data. Saving doesn't return anything on success. - const userAccount = yield call( - [userAccountApiService, 'getUserAccount'], - username, - ); - - yield put(saveUserProfilePhotoSuccess()); - // TODO: export the fetchUserAccountSuccess action from frontend-auth so we can - // dry this up. - yield put({ - type: 'FETCH_USER_ACCOUNT_SUCCESS', - payload: { userAccount }, - }); - yield put(saveUserProfilePhotoReset()); - } catch (e) { - yield put(saveUserProfilePhotoFailure(e)); - } -} - - -export function* deleteUserProfilePhoto(action) { +export function* handleFetchProfile(action) { const { username } = action.payload; try { - yield put(deleteUserProfilePhotoBegin()); - - // Use call([context, fnName], ...args) for proper context on apiService - yield call([userAccountApiService, 'deleteUserProfilePhoto'], username); - - // Get the account data. Saving doesn't return anything on success. - const userAccount = yield call( - [userAccountApiService, 'getUserAccount'], + yield put(fetchProfileBegin()); + const profile = yield call( + ProfileApiService.getProfile, username, ); - yield put(deleteUserProfilePhotoSuccess()); - // TODO: export the fetchUserAccountSuccess action from frontend-auth so we can - // dry this up. - yield put({ - type: 'FETCH_USER_ACCOUNT_SUCCESS', - payload: { userAccount }, - }); - yield put(deleteUserProfilePhotoReset()); + yield put(fetchProfileSuccess(profile)); + yield put(fetchProfileReset()); } catch (e) { - yield put(deleteUserProfilePhotoFailure(e)); + yield put(fetchProfileFailure(e.message)); } } -export default function* rootSaga(apiService) { - userAccountApiService = apiService; - yield takeEvery(SAVE_USER_PROFILE.BASE, saveUserProfile); - yield takeEvery(SAVE_USER_PROFILE_PHOTO.BASE, saveUserProfilePhoto); - yield takeEvery(DELETE_USER_PROFILE_PHOTO.BASE, deleteUserProfilePhoto); +export function* handleSaveProfile(action) { + const { username, userAccountState } = action.payload; + try { + yield put(saveProfileBegin()); + const profile = yield call( + ProfileApiService.patchProfile, + username, + userAccountState, + ); + + yield put(saveProfileSuccess()); + yield put(fetchProfileSuccess(profile)); + yield delay(300); + yield put(closeEditableField(action.payload.fieldName)); + yield delay(300); + yield put(saveProfileReset()); + } catch (e) { + yield put(saveProfileFailure(e.message)); + } +} + +export function* handleSaveProfilePhoto(action) { + const { username, formData } = action.payload; + + try { + yield put(saveProfilePhotoBegin()); + yield call(ProfileApiService.postProfilePhoto, username, formData); + + // Get the account data. Saving doesn't return anything on success. + yield handleFetchProfile(fetchProfileAction); + + yield put(saveProfilePhotoSuccess()); + yield put(saveProfilePhotoReset()); + } catch (e) { + yield put(saveProfilePhotoFailure(e.message)); + } +} + +export function* handleDeleteProfilePhoto(action) { + const { username } = action.payload; + + try { + yield put(deleteProfilePhotoBegin()); + yield call(ProfileApiService.deleteProfilePhoto, username); + + // Get the account data. Saving doesn't return anything on success. + yield handleFetchProfile(fetchProfileAction); + + yield put(deleteProfilePhotoSuccess()); + yield put(deleteProfilePhotoReset()); + } catch (e) { + yield put(deleteProfilePhotoFailure(e.message)); + } +} + +export default function* rootSaga() { + yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile); + yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile); + yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto); + yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto); } diff --git a/src/sagas/RootSaga.test.js b/src/sagas/RootSaga.test.js index 7740270..ae81419 100644 --- a/src/sagas/RootSaga.test.js +++ b/src/sagas/RootSaga.test.js @@ -1,39 +1,55 @@ import { takeEvery, put, call, delay } from 'redux-saga/effects'; -import rootSaga, { saveUserProfile, saveUserProfilePhoto, deleteUserProfilePhoto, mapDataForRequest } from './RootSaga'; import * as profileActions from '../actions/profile'; -class MockUserAccountApiService { - constructor() { - this.saveUserAccount = jest.fn(); - } -} +jest.mock('../services/ProfileApiService', () => ({ + getProfile: jest.fn(), + patchProfile: jest.fn(), + postProfilePhoto: jest.fn(), + deleteProfilePhoto: jest.fn(), + getUserPreference: jest.fn(), +})); + +// RootSaga and ProfileApiService must be imported AFTER the mock above. +/* eslint-disable import/first */ +import rootSaga, { + handleFetchProfile, + handleSaveProfile, + handleSaveProfilePhoto, + handleDeleteProfilePhoto, +} from './RootSaga'; +import * as ProfileApiService from '../services/ProfileApiService'; +/* eslint-enable import/first */ describe('RootSaga', () => { describe('rootSaga', () => { it('should pass actions to the correct sagas', () => { const gen = rootSaga(); - // There is only one. - expect(gen.next().value) - .toEqual(takeEvery(profileActions.SAVE_USER_PROFILE.BASE, saveUserProfile)); - expect(gen.next().value) - .toEqual(takeEvery(profileActions.SAVE_USER_PROFILE_PHOTO.BASE, saveUserProfilePhoto)); + expect(gen.next().value).toEqual(takeEvery( + profileActions.FETCH_PROFILE.BASE, + handleFetchProfile, + )); + expect(gen.next().value).toEqual(takeEvery( + profileActions.SAVE_PROFILE.BASE, + handleSaveProfile, + )); + expect(gen.next().value).toEqual(takeEvery( + profileActions.SAVE_PROFILE_PHOTO.BASE, + handleSaveProfilePhoto, + )); + expect(gen.next().value).toEqual(takeEvery( + profileActions.DELETE_PROFILE_PHOTO.BASE, + handleDeleteProfilePhoto, + )); - expect(gen.next().value) - .toEqual(takeEvery(profileActions.DELETE_USER_PROFILE_PHOTO.BASE, deleteUserProfilePhoto)); - // ... and done. expect(gen.next().value).toBeUndefined(); }); }); - describe('saveUserProfile', () => { - it('should successfully process a saveUserProfile request if there are no exceptions', () => { - const service = new MockUserAccountApiService(); - const rootGen = rootSaga(service); - rootGen.next(); // Causes the service to be set. - - const action = profileActions.saveUserProfile( + describe('handleSaveProfile', () => { + it('should successfully process a saveProfile request if there are no exceptions', () => { + const action = profileActions.saveProfile( 'my username', { fullName: 'Full Name', @@ -41,34 +57,27 @@ describe('RootSaga', () => { }, 'ze field', ); - const gen = saveUserProfile(action); - const userAccount = { + const gen = handleSaveProfile(action); + const profile = { name: 'Full Name', levelOfEducation: 'b', }; - expect(gen.next().value).toEqual(put(profileActions.saveUserProfileBegin())); - expect(gen.next().value).toEqual(call([service, 'saveUserAccount'], 'my username', userAccount)); + expect(gen.next().value).toEqual(put(profileActions.saveProfileBegin())); + expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', action.payload.userAccountState)); // The library would supply the result of the above call // as the parameter to the NEXT yield. Here: - expect(gen.next(userAccount).value).toEqual(put(profileActions.saveUserProfileSuccess())); - expect(gen.next().value).toEqual(put({ - type: 'FETCH_USER_ACCOUNT_SUCCESS', - payload: { userAccount }, - })); + expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess())); + expect(gen.next().value).toEqual(put(profileActions.fetchProfileSuccess(profile))); expect(gen.next().value).toEqual(delay(300)); expect(gen.next().value).toEqual(put(profileActions.closeEditableField('ze field'))); expect(gen.next().value).toEqual(delay(300)); - expect(gen.next().value).toEqual(put(profileActions.saveUserProfileReset())); + expect(gen.next().value).toEqual(put(profileActions.saveProfileReset())); expect(gen.next().value).toBeUndefined(); }); it('should successfully publish a failure action on exception', () => { - const service = new MockUserAccountApiService(); const error = new Error('uhoh'); - const rootGen = rootSaga(service); - rootGen.next(); // Causes the service to be set. - - const action = profileActions.saveUserProfile( + const action = profileActions.saveProfile( 'my username', { fullName: 'Full Name', @@ -76,50 +85,12 @@ describe('RootSaga', () => { }, 'ze field', ); - const gen = saveUserProfile(action); + const gen = handleSaveProfile(action); - expect(gen.next().value).toEqual(put(profileActions.saveUserProfileBegin())); + expect(gen.next().value).toEqual(put(profileActions.saveProfileBegin())); const result = gen.throw(error); - expect(result.value).toEqual(put(profileActions.saveUserProfileFailure(error))); + expect(result.value).toEqual(put(profileActions.saveProfileFailure('uhoh'))); expect(gen.next().value).toBeUndefined(); }); }); - - describe('mapDataForRequest', () => { - it('should modify props according to prop modifier strings and functions', () => { - const props = { - favoriteColor: 'red', - age: 30, - petName: 'Donkey', - fullName: 'Donkey McWafflebatter', - userLocation: 'US', - education: 'BS', - socialLinks: [ - { - platform: 'twitter', - socialLink: null, - }, - { - platform: 'facebook', - socialLink: 'https://www.facebook.com', - }, - ], - }; - const result = mapDataForRequest(props); - expect(result).toEqual({ - favoriteColor: 'red', - age: 30, - petName: 'Donkey', - name: 'Donkey McWafflebatter', - country: 'US', - levelOfEducation: 'BS', - socialLinks: [ - { - platform: 'facebook', - socialLink: 'https://www.facebook.com', - }, - ], - }); - }); - }); }); diff --git a/src/services/ProfileApiService.js b/src/services/ProfileApiService.js new file mode 100644 index 0000000..4aeeb13 --- /dev/null +++ b/src/services/ProfileApiService.js @@ -0,0 +1,86 @@ +import camelcaseKeys from 'camelcase-keys'; +import snakecaseKeys from 'snakecase-keys'; + +import apiClient from '../data/apiClient'; +import { configuration } from '../config'; + +const accountsApiBaseUrl = `${configuration.LMS_BASE_URL}/api/user/v1/accounts`; +const preferencesApiBaseUrl = `${configuration.LMS_BASE_URL}/api/user/v1/preferences`; + +export function getProfile(username) { + return new Promise((resolve, reject) => { + apiClient + .get(`${accountsApiBaseUrl}/${username}`) + .then((response) => { + resolve(camelcaseKeys(response.data, { deep: true })); + }) + .catch((error) => { + reject(error); + }); + }); +} + +export const mapSaveProfileRequestData = (props) => { + const PROFILE_REQUEST_DATA_MAP = { + fullName: 'name', + userLocation: 'country', + education: 'levelOfEducation', + socialLinks: socialLinks => socialLinks.filter(({ socialLink }) => socialLink !== null), + }; + const state = {}; + + Object.keys(props).forEach((prop) => { + const propModifier = PROFILE_REQUEST_DATA_MAP[prop] || prop; + if (typeof propModifier === 'function') { + state[prop] = propModifier(props[prop]); + } else { + state[propModifier] = props[prop]; + } + }); + return state; +}; + +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', + }, + }, + ) + .then((response) => { + resolve(camelcaseKeys(response.data, { deep: true })); + }) + .catch((error) => { + reject(error); + }); + }); +} + +export function postProfilePhoto(username, formData) { + return apiClient.post(`${accountsApiBaseUrl}/${username}/image`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); +} + +export function deleteProfilePhoto(username) { + return apiClient.delete(`${accountsApiBaseUrl}/${username}/image`); +} + +export function getUserPreference(username, preferenceKey) { + return new Promise((resolve, reject) => { + apiClient.get(`${preferencesApiBaseUrl}/${username}/${preferenceKey}`) + .then((response) => { + resolve(response.data); + }) + .catch((error) => { + reject(error); + }); + }); +} + diff --git a/src/services/ProfileApiService.test.js b/src/services/ProfileApiService.test.js new file mode 100644 index 0000000..1a7336c --- /dev/null +++ b/src/services/ProfileApiService.test.js @@ -0,0 +1,39 @@ +import { mapSaveProfileRequestData } from './ProfileApiService'; + +describe('mapDataForRequest', () => { + it('should modify props according to prop modifier strings and functions', () => { + const props = { + favoriteColor: 'red', + age: 30, + petName: 'Donkey', + fullName: 'Donkey McWafflebatter', + userLocation: 'US', + education: 'BS', + socialLinks: [ + { + platform: 'twitter', + socialLink: null, + }, + { + platform: 'facebook', + socialLink: 'https://www.facebook.com', + }, + ], + }; + const result = mapSaveProfileRequestData(props); + expect(result).toEqual({ + favoriteColor: 'red', + age: 30, + petName: 'Donkey', + name: 'Donkey McWafflebatter', + country: 'US', + levelOfEducation: 'BS', + socialLinks: [ + { + platform: 'facebook', + socialLink: 'https://www.facebook.com', + }, + ], + }); + }); +});