Viewing others profiles and naming refactor.

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.
This commit is contained in:
David Joy
2019-02-20 13:40:49 -05:00
committed by David Joy
parent e7ffc6fe0c
commit c46d8506e4
16 changed files with 475 additions and 339 deletions

View File

@@ -9,6 +9,7 @@ module.exports = {
},
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/',
},
resolve: {
extensions: ['.js', '.jsx'],

View File

@@ -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: '/',
},
});

View File

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

View File

@@ -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,
},

View File

@@ -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);
});
});

View File

@@ -0,0 +1,14 @@
import React, { Component } from 'react';
export default class NotFoundPage extends Component {
componentDidMount() {}
render() {
return (
<div>
The page you&apos;re looking for is unavailable or there&apos;s an error in the URL.
Please check the URL and try again.
</div>
);
}
}

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {
/>
<main>
<Switch>
<Route exact path="/" component={UserProfile} />
<Route path="/u/:username" component={UserProfile} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<SiteFooter

View File

@@ -1,138 +1,107 @@
import { call, put, takeEvery, delay } from 'redux-saga/effects';
import {
SAVE_USER_PROFILE,
saveUserProfileBegin,
saveUserProfileSuccess,
saveUserProfileFailure,
saveUserProfileReset,
FETCH_PROFILE,
fetchProfileBegin,
fetchProfileSuccess,
fetchProfileFailure,
fetchProfileReset,
fetchProfile as fetchProfileAction,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
saveProfileFailure,
saveProfileReset,
closeEditableField,
SAVE_USER_PROFILE_PHOTO,
saveUserProfilePhotoBegin,
saveUserProfilePhotoSuccess,
saveUserProfilePhotoFailure,
saveUserProfilePhotoReset,
DELETE_USER_PROFILE_PHOTO,
deleteUserProfilePhotoBegin,
deleteUserProfilePhotoSuccess,
deleteUserProfilePhotoFailure,
deleteUserProfilePhotoReset,
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
saveProfilePhotoFailure,
saveProfilePhotoReset,
DELETE_PROFILE_PHOTO,
deleteProfilePhotoBegin,
deleteProfilePhotoSuccess,
deleteProfilePhotoFailure,
deleteProfilePhotoReset,
} from '../actions/profile';
let userAccountApiService = null;
import * as ProfileApiService from '../services/ProfileApiService';
const PROP_TO_STATE_MAP = {
fullName: 'name',
userLocation: 'country',
education: 'levelOfEducation',
socialLinks: socialLinks => socialLinks.filter(({ socialLink }) => socialLink !== null),
};
export const mapDataForRequest = (props) => {
const state = {};
Object.keys(props).forEach((prop) => {
const propModifier = PROP_TO_STATE_MAP[prop] || prop;
if (typeof propModifier === 'function') {
state[prop] = propModifier(props[prop]);
} else {
state[propModifier] = props[prop];
}
});
return state;
};
export function* 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);
}

View File

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

View File

@@ -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);
});
});
}

View File

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