From e7ffc6fe0cb4ea87380397289377cd68d15899df Mon Sep 17 00:00:00 2001 From: Adam Butterworth Date: Wed, 20 Feb 2019 11:44:47 -0500 Subject: [PATCH] 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 --- src/App.scss | 16 ++- src/actions/profile.js | 50 ++++++++ src/actions/profile.test.js | 121 +++++++++++++++++++ src/components/UserProfile/ProfileAvatar.jsx | 93 ++++++++------ src/components/UserProfile/index.jsx | 18 ++- src/containers/UserProfile/index.jsx | 5 + src/data/reducers/ProfileReducer.js | 63 +++++++++- src/sagas/RootSaga.js | 82 ++++++++++++- src/sagas/RootSaga.test.js | 10 +- 9 files changed, 412 insertions(+), 46 deletions(-) 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 ( -
- - profile avatar -
- -
+
+
+ {this.props.savePhotoState === 'pending' ? ( +
+ +
+ ) : null} + + + +
+ profile avatar + + {/* The name of this input must be 'file' */} + +
+ +
+ {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()));