Add saving of profile photo (#19)

* Add saving of profile photo

* Add removal of profile photo

Needs work on knowing when a default photo has been supplied.

* Add action creator tests

* Fix some reference issues after merge

* Fix broken test
This commit is contained in:
Adam Butterworth
2019-02-20 11:44:47 -05:00
committed by GitHub
parent f1b6af4975
commit e7ffc6fe0c
9 changed files with 412 additions and 46 deletions

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="profile-avatar rounded-circle overflow-hidden">
<button
className="text-white profile-avatar-edit-button"
onClick={this.onClick}
>
Update
</button>
<img className="w-100" src={src} alt="profile avatar" />
<form
ref={this.form}
onSubmit={this.onSubmit}
method="post"
encType="multipart/form-data"
>
<Input
className="d-none"
innerRef={this.fileInput}
type="file"
name="file"
id="exampleFile"
onInput={this.onInput}
onChange={this.onChange}
accept=".jpg, .jpeg, .png"
/>
</form>
<div className="profile-avatar-wrap position-relative">
<div className="profile-avatar rounded-circle overflow-hidden bg-dark">
{this.props.savePhotoState === 'pending' ? (
<div
className="p-absolute w-100 h-100 d-flex justify-content-center align-items-center"
style={{ backgroundColor: 'rgba(255,255,255,.5)' }}
>
<Spinner color="primary" />
</div>
) : null}
<button
className="text-white profile-avatar-edit-button"
onClick={this.onClick}
>
Change
</button>
<form
ref={this.form}
onSubmit={this.onSubmit}
encType="multipart/form-data"
>
<img className="w-100" src={src} alt="profile avatar" />
{/* The name of this input must be 'file' */}
<Input
className="d-none"
innerRef={this.fileInput}
type="file"
name="file"
id="exampleFile"
onInput={this.onInput}
accept=".jpg, .jpeg, .png"
/>
</form>
</div>
{src ? (
<button
className="position-absolute btn btn-link w-100 btn-sm"
onClick={this.onClickDelete}
>
Remove
</button>
) : null}
</div>
);
}
@@ -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,
};

View File

@@ -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 {
<ProfileAvatar
className="mb-md-3"
src={profileImage}
{...commonProps}
onSave={this.onSaveProfilePhoto}
onDelete={this.onDeleteProfilePhoto}
savePhotoState={this.props.savePhotoState}
/>
<div>
<h2 className="mb-0">{username}</h2>
@@ -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,

View File

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

View File

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

View File

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

View File

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