diff --git a/src/App.scss b/src/App.scss
index 2b4842b..86cfb56 100755
--- a/src/App.scss
+++ b/src/App.scss
@@ -21,20 +21,28 @@ $fa-font-path: "~font-awesome/fonts";
}
+
+.profile-avatar-wrap {
+ margin-right: 1rem;
+
+ @include media-breakpoint-up(md) {
+ max-width: 12rem;
+ margin-right: 0;
+ margin-top: -8rem;
+ margin-bottom: 2rem;
+ }
+}
+
.profile-avatar {
max-width: 50%;
width: 5rem;
height: 5rem;
- margin-right: 1rem;
position: relative;
@include media-breakpoint-up(md) {
width: 12rem;
max-width: none;
height: 12rem;
- margin-right: 0;
- margin-top: -8rem;
- margin-bottom: 2rem;
}
.profile-avatar-edit-button {
diff --git a/src/actions/profile.js b/src/actions/profile.js
index bfa6669..98c0e4f 100644
--- a/src/actions/profile.js
+++ b/src/actions/profile.js
@@ -3,6 +3,8 @@ 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 openEditableField = fieldName => ({
type: EDITABLE_FIELD_OPEN,
@@ -40,3 +42,51 @@ export const saveUserProfile = (username, userAccountState, fieldName) => ({
},
});
+export const saveUserProfilePhotoBegin = () => ({
+ type: SAVE_USER_PROFILE_PHOTO.BEGIN,
+});
+
+export const saveUserProfilePhotoSuccess = () => ({
+ type: SAVE_USER_PROFILE_PHOTO.SUCCESS,
+});
+
+export const saveUserProfilePhotoReset = () => ({
+ type: SAVE_USER_PROFILE_PHOTO.RESET,
+});
+
+export const saveUserProfilePhotoFailure = error => ({
+ type: SAVE_USER_PROFILE_PHOTO.FAILURE,
+ payload: { error },
+});
+
+export const saveUserProfilePhoto = (username, formData) => ({
+ type: SAVE_USER_PROFILE_PHOTO.BASE,
+ payload: {
+ username,
+ formData,
+ },
+});
+
+export const deleteUserProfilePhotoBegin = () => ({
+ type: DELETE_USER_PROFILE_PHOTO.BEGIN,
+});
+
+export const deleteUserProfilePhotoSuccess = () => ({
+ type: DELETE_USER_PROFILE_PHOTO.SUCCESS,
+});
+
+export const deleteUserProfilePhotoReset = () => ({
+ type: DELETE_USER_PROFILE_PHOTO.RESET,
+});
+
+export const deleteUserProfilePhotoFailure = error => ({
+ type: DELETE_USER_PROFILE_PHOTO.FAILURE,
+ payload: { error },
+});
+
+export const deleteUserProfilePhoto = username => ({
+ type: DELETE_USER_PROFILE_PHOTO.BASE,
+ payload: {
+ username,
+ },
+});
diff --git a/src/actions/profile.test.js b/src/actions/profile.test.js
index 56b6afb..b69c1a7 100644
--- a/src/actions/profile.test.js
+++ b/src/actions/profile.test.js
@@ -9,6 +9,18 @@ import {
saveUserProfileFailure,
saveUserProfileReset,
saveUserProfile,
+ SAVE_USER_PROFILE_PHOTO,
+ saveUserProfilePhotoBegin,
+ saveUserProfilePhotoSuccess,
+ saveUserProfilePhotoFailure,
+ saveUserProfilePhotoReset,
+ saveUserProfilePhoto,
+ DELETE_USER_PROFILE_PHOTO,
+ deleteUserProfilePhotoBegin,
+ deleteUserProfilePhotoSuccess,
+ deleteUserProfilePhotoFailure,
+ deleteUserProfilePhotoReset,
+ deleteUserProfilePhoto,
} from './profile';
describe('editable field actions', () => {
@@ -81,3 +93,112 @@ describe('SAVE profile actions', () => {
expect(saveUserProfileFailure(error)).toEqual(expectedAction);
});
});
+
+
+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,
+ payload: {
+ username: 'myusername',
+ formData,
+ },
+ };
+ expect(saveUserProfilePhoto('myusername', formData)).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo save beginning', () => {
+ const expectedAction = {
+ type: SAVE_USER_PROFILE_PHOTO.BEGIN,
+ };
+ expect(saveUserProfilePhotoBegin()).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo save success', () => {
+ const expectedAction = {
+ type: SAVE_USER_PROFILE_PHOTO.SUCCESS,
+ };
+ expect(saveUserProfilePhotoSuccess()).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo save success', () => {
+ const expectedAction = {
+ type: SAVE_USER_PROFILE_PHOTO.RESET,
+ };
+ expect(saveUserProfilePhotoReset()).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,
+ payload: { error },
+ };
+ expect(saveUserProfilePhotoFailure(error)).toEqual(expectedAction);
+ });
+});
+
+
+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,
+ payload: {
+ username: 'myusername',
+ },
+ };
+ expect(deleteUserProfilePhoto('myusername')).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo deletion beginning', () => {
+ const expectedAction = {
+ type: DELETE_USER_PROFILE_PHOTO.BEGIN,
+ };
+ expect(deleteUserProfilePhotoBegin()).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo deletion success', () => {
+ const expectedAction = {
+ type: DELETE_USER_PROFILE_PHOTO.SUCCESS,
+ };
+ expect(deleteUserProfilePhotoSuccess()).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal user profile photo deletion success', () => {
+ const expectedAction = {
+ type: DELETE_USER_PROFILE_PHOTO.RESET,
+ };
+ expect(deleteUserProfilePhotoReset()).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,
+ payload: { error },
+ };
+ expect(deleteUserProfilePhotoFailure(error)).toEqual(expectedAction);
+ });
+});
+
+
+describe('Editable field opening and closing actions', () => {
+ const fieldName = 'fullName';
+
+ it('should create an action to signal the opening a field', () => {
+ const expectedAction = {
+ type: EDITABLE_FIELD_OPEN,
+ fieldName,
+ };
+ expect(openEditableField(fieldName)).toEqual(expectedAction);
+ });
+
+ it('should create an action to signal the closing a field', () => {
+ const expectedAction = {
+ type: EDITABLE_FIELD_CLOSE,
+ fieldName,
+ };
+ expect(closeEditableField(fieldName)).toEqual(expectedAction);
+ });
+});
diff --git a/src/components/UserProfile/ProfileAvatar.jsx b/src/components/UserProfile/ProfileAvatar.jsx
index fe41e7a..09791e0 100644
--- a/src/components/UserProfile/ProfileAvatar.jsx
+++ b/src/components/UserProfile/ProfileAvatar.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { Input } from 'reactstrap';
+import { Input, Spinner } from 'reactstrap';
class ProfileAvatar extends React.Component {
constructor(props) {
@@ -10,8 +10,8 @@ class ProfileAvatar extends React.Component {
this.form = React.createRef();
this.onClick = this.onClick.bind(this);
+ this.onClickDelete = this.onClickDelete.bind(this);
this.onInput = this.onInput.bind(this);
- this.onChange = this.onChange.bind(this);
this.onSubmit = this.onSubmit.bind(this);
}
@@ -19,17 +19,17 @@ class ProfileAvatar extends React.Component {
this.fileInput.current.click();
}
- onInput(e) { // eslint-disable-line no-unused-vars
- // console.log('input', e)
- this.form.current.submit();
+ onClickDelete() {
+ this.props.onDelete();
}
- onChange(e) { // eslint-disable-line no-unused-vars
- // console.log('change', e)
+ onInput() {
+ this.onSubmit();
}
- onSubmit(e) { // eslint-disable-line no-unused-vars
- // console.log('onsubmit', e);
+ onSubmit(e) {
+ if (e) e.preventDefault();
+ this.props.onSave(new FormData(this.form.current));
}
render() {
@@ -38,31 +38,52 @@ class ProfileAvatar extends React.Component {
} = this.props;
return (
-
-
-

-
+
+
+ {this.props.savePhotoState === 'pending' ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ {src ? (
+
+ ) : null}
);
}
@@ -73,8 +94,12 @@ export default ProfileAvatar;
ProfileAvatar.propTypes = {
src: PropTypes.string,
+ onSave: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
};
ProfileAvatar.defaultProps = {
src: null,
+ savePhotoState: null,
};
diff --git a/src/components/UserProfile/index.jsx b/src/components/UserProfile/index.jsx
index aca6977..38f2bf3 100644
--- a/src/components/UserProfile/index.jsx
+++ b/src/components/UserProfile/index.jsx
@@ -27,6 +27,8 @@ class UserProfile extends React.Component {
this.onCancel = this.onCancel.bind(this);
this.onEdit = this.onEdit.bind(this);
this.onSave = this.onSave.bind(this);
+ this.onSaveProfilePhoto = this.onSaveProfilePhoto.bind(this);
+ this.onDeleteProfilePhoto = this.onDeleteProfilePhoto.bind(this);
this.onChange = this.onChange.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
}
@@ -46,6 +48,14 @@ class UserProfile extends React.Component {
this.props.saveUserProfile(this.props.username, userAccountData, fieldName);
}
+ onSaveProfilePhoto(formData) {
+ this.props.saveUserProfilePhoto(this.props.username, formData);
+ }
+
+ onDeleteProfilePhoto() {
+ this.props.deleteUserProfilePhoto(this.props.username);
+ }
+
onChange(fieldName, value) {
this.setState({
[fieldName]: {
@@ -103,7 +113,9 @@ class UserProfile extends React.Component {
{username}
@@ -167,6 +179,7 @@ export default UserProfile;
UserProfile.propTypes = {
currentlyEditingField: PropTypes.string,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
+ savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
error: PropTypes.string,
profileImage: PropTypes.string,
fullName: PropTypes.string,
@@ -183,6 +196,8 @@ UserProfile.propTypes = {
title: PropTypes.string,
})),
saveUserProfile: PropTypes.func,
+ saveUserProfilePhoto: PropTypes.func.isRequired,
+ deleteUserProfilePhoto: PropTypes.func.isRequired,
openEditableField: PropTypes.func.isRequired,
closeEditableField: PropTypes.func.isRequired,
};
@@ -190,6 +205,7 @@ UserProfile.propTypes = {
UserProfile.defaultProps = {
currentlyEditingField: null,
saveState: null,
+ savePhotoState: null,
error: null,
profileImage: null,
fullName: null,
diff --git a/src/containers/UserProfile/index.jsx b/src/containers/UserProfile/index.jsx
index 6971226..73c3aac 100644
--- a/src/containers/UserProfile/index.jsx
+++ b/src/containers/UserProfile/index.jsx
@@ -3,6 +3,8 @@ import { connect } from 'react-redux';
import UserProfile from '../../components/UserProfile';
import {
saveUserProfile,
+ saveUserProfilePhoto,
+ deleteUserProfilePhoto,
openEditableField,
closeEditableField,
} from '../../actions/profile';
@@ -10,6 +12,7 @@ import {
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,
@@ -23,6 +26,8 @@ const mapStateToProps = state => ({
export default connect(mapStateToProps, {
saveUserProfile,
+ saveUserProfilePhoto,
+ deleteUserProfilePhoto,
openEditableField,
closeEditableField,
})(UserProfile);
diff --git a/src/data/reducers/ProfileReducer.js b/src/data/reducers/ProfileReducer.js
index cfbb500..f165f9e 100644
--- a/src/data/reducers/ProfileReducer.js
+++ b/src/data/reducers/ProfileReducer.js
@@ -1,11 +1,20 @@
-import { SAVE_USER_PROFILE, EDITABLE_FIELD_CLOSE, EDITABLE_FIELD_OPEN } from '../../actions/profile';
+import {
+ SAVE_USER_PROFILE,
+ SAVE_USER_PROFILE_PHOTO,
+ DELETE_USER_PROFILE_PHOTO,
+ EDITABLE_FIELD_CLOSE,
+ EDITABLE_FIELD_OPEN,
+} from '../../actions/profile';
+
const initialState = {
error: null,
saveState: null,
+ savePhotoState: null,
currentlyEditingField: null,
};
+
const profile = (state = initialState, action) => {
switch (action.type) {
case EDITABLE_FIELD_OPEN:
@@ -22,6 +31,7 @@ const profile = (state = initialState, action) => {
};
}
return state;
+
case SAVE_USER_PROFILE.BEGIN:
return {
...state,
@@ -46,6 +56,57 @@ const profile = (state = initialState, action) => {
saveState: null,
error: null,
};
+
+ case SAVE_USER_PROFILE_PHOTO.BEGIN:
+ return {
+ ...state,
+ savePhotoState: 'pending',
+ error: null,
+ };
+ case SAVE_USER_PROFILE_PHOTO.SUCCESS:
+ return {
+ ...state,
+ savePhotoState: 'complete',
+ error: null,
+ };
+ case SAVE_USER_PROFILE_PHOTO.FAILURE:
+ return {
+ ...state,
+ savePhotoState: 'error',
+ error: action.payload.error,
+ };
+ case SAVE_USER_PROFILE_PHOTO.RESET:
+ return {
+ ...state,
+ savePhotoState: null,
+ error: null,
+ };
+
+ case DELETE_USER_PROFILE_PHOTO.BEGIN:
+ return {
+ ...state,
+ savePhotoState: 'pending',
+ error: null,
+ };
+ case DELETE_USER_PROFILE_PHOTO.SUCCESS:
+ return {
+ ...state,
+ savePhotoState: 'complete',
+ error: null,
+ };
+ case DELETE_USER_PROFILE_PHOTO.FAILURE:
+ return {
+ ...state,
+ savePhotoState: 'error',
+ error: action.payload.error,
+ };
+ case DELETE_USER_PROFILE_PHOTO.RESET:
+ return {
+ ...state,
+ savePhotoState: null,
+ error: null,
+ };
+
default:
return state;
}
diff --git a/src/sagas/RootSaga.js b/src/sagas/RootSaga.js
index c957fac..899717f 100644
--- a/src/sagas/RootSaga.js
+++ b/src/sagas/RootSaga.js
@@ -7,6 +7,16 @@ import {
saveUserProfileFailure,
saveUserProfileReset,
closeEditableField,
+ SAVE_USER_PROFILE_PHOTO,
+ saveUserProfilePhotoBegin,
+ saveUserProfilePhotoSuccess,
+ saveUserProfilePhotoFailure,
+ saveUserProfilePhotoReset,
+ DELETE_USER_PROFILE_PHOTO,
+ deleteUserProfilePhotoBegin,
+ deleteUserProfilePhotoSuccess,
+ deleteUserProfilePhotoFailure,
+ deleteUserProfilePhotoReset,
} from '../actions/profile';
let userAccountApiService = null;
@@ -31,14 +41,18 @@ export const mapDataForRequest = (props) => {
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(
- // Because apiService is a class, 'this' needs to be bound.
- userAccountApiService.saveUserAccount,
- action.payload.username,
- mapDataForRequest(action.payload.userAccountState),
+ [userAccountApiService, 'saveUserAccount'],
+ username,
+ mapDataForRequest(userAccountState),
);
// Tells the profile form that
@@ -58,7 +72,67 @@ export function* saveUserProfile(action) {
}
}
+
+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) {
+ 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'],
+ 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());
+ } catch (e) {
+ yield put(deleteUserProfilePhotoFailure(e));
+ }
+}
+
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);
}
diff --git a/src/sagas/RootSaga.test.js b/src/sagas/RootSaga.test.js
index 1ef16fd..7740270 100644
--- a/src/sagas/RootSaga.test.js
+++ b/src/sagas/RootSaga.test.js
@@ -1,6 +1,6 @@
import { takeEvery, put, call, delay } from 'redux-saga/effects';
-import rootSaga, { saveUserProfile, mapDataForRequest } from './RootSaga';
+import rootSaga, { saveUserProfile, saveUserProfilePhoto, deleteUserProfilePhoto, mapDataForRequest } from './RootSaga';
import * as profileActions from '../actions/profile';
class MockUserAccountApiService {
@@ -16,6 +16,12 @@ describe('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.DELETE_USER_PROFILE_PHOTO.BASE, deleteUserProfilePhoto));
// ... and done.
expect(gen.next().value).toBeUndefined();
});
@@ -41,7 +47,7 @@ describe('RootSaga', () => {
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(call([service, 'saveUserAccount'], 'my username', userAccount));
// 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()));