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