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', + }, + ], + }); + }); +});