diff --git a/package.json b/package.json index 70cf57b24..bfeb3bfcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@entando/app-builder", - "version": "7.3.0", + "version": "7.3.0-SNAPSHOT", "author": "Entando", "homepage": "https://github.com/entando/app-builder", "license": "MIT", diff --git a/src/api/avatar.js b/src/api/avatar.js new file mode 100644 index 000000000..35b331652 --- /dev/null +++ b/src/api/avatar.js @@ -0,0 +1,39 @@ +import { makeRequest, METHODS } from '@entando/apimanager'; +import { FILE_BROWSER_FILE } from 'test/mocks/fileBrowser'; + +const AVATAR_ENDPOINT = '/api/userProfiles/avatar'; + +export const getAvatar = () => + makeRequest({ + uri: AVATAR_ENDPOINT, + method: METHODS.GET, + mockResponse: FILE_BROWSER_FILE, + useAuthentication: true, + }); + +export const postAvatar = avatar => + makeRequest({ + uri: AVATAR_ENDPOINT, + method: METHODS.POST, + body: avatar, + mockResponse: FILE_BROWSER_FILE, + useAuthentication: true, + }); + +export const updateAvatar = avatar => + makeRequest({ + uri: AVATAR_ENDPOINT, + method: METHODS.PUT, + body: avatar, + mockResponse: FILE_BROWSER_FILE, + useAuthentication: true, + }); + +export const deleteAvatar = () => + makeRequest({ + uri: AVATAR_ENDPOINT, + method: METHODS.DELETE, + mockResponse: FILE_BROWSER_FILE, + useAuthentication: true, + }); + diff --git a/src/state/avatar/actions.js b/src/state/avatar/actions.js new file mode 100644 index 000000000..05fb9a974 --- /dev/null +++ b/src/state/avatar/actions.js @@ -0,0 +1,51 @@ +import { postAvatar, getAvatar } from 'api/avatar'; +import { getBase64 } from 'state/file-browser/actions'; +import { toggleLoading } from 'state/loading/actions'; +import { addToast, addErrors, TOAST_SUCCESS, TOAST_ERROR } from '@entando/messages'; +import { SET_AVATAR_FILE_NAME } from './types'; + +const setAvatarFilename = filename => ({ + type: SET_AVATAR_FILE_NAME, + payload: { + filename, + }, +}); + + +export const createFileObject = async (avatar) => { + const base64 = await getBase64(avatar); + return { filename: avatar.name, base64 }; +}; + +export const uploadAvatar = + (avatar, loader = 'uploadAvatar') => + async (dispatch) => { + try { + dispatch(toggleLoading(loader)); + const requestObject = await createFileObject(avatar); + const response = await postAvatar(requestObject); + const avatarCreated = await response.json(); + dispatch(setAvatarFilename(avatarCreated.payload.filename)); + dispatch(toggleLoading(loader)); + dispatch(addToast({ id: 'fileBrowser.uploadFileComplete' }, TOAST_SUCCESS)); + } catch (error) { + dispatch(toggleLoading(loader)); + const message = { id: 'fileBrowser.uploadFileError', values: { errmsg: error } }; + dispatch(addErrors(error)); + dispatch(addToast(message, TOAST_ERROR)); + } + }; + +export const fetchAvatar = (loader = 'fetchAvatar') => async (dispatch) => { + try { + dispatch(toggleLoading(loader)); + const response = await getAvatar(); + const avatar = await response.json(); + if (avatar.payload.filename) dispatch(setAvatarFilename(avatar.payload.filename)); + dispatch(toggleLoading(loader)); + } catch (error) { + dispatch(toggleLoading(loader)); + dispatch(addErrors(error)); + dispatch(addToast(error.message, TOAST_ERROR)); + } +}; diff --git a/src/state/avatar/reducer.js b/src/state/avatar/reducer.js new file mode 100644 index 000000000..95d4372e8 --- /dev/null +++ b/src/state/avatar/reducer.js @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux'; +import { SET_AVATAR_FILE_NAME } from './types'; + +const initialState = { + filename: '', +}; + +const filename = (state = initialState.filename, action = {}) => { + switch (action.type) { + case SET_AVATAR_FILE_NAME: { + return action.payload.filename; + } + default: + return state; + } +}; + +export default combineReducers({ + filename, +}); diff --git a/src/state/avatar/selectors.js b/src/state/avatar/selectors.js new file mode 100644 index 000000000..65fe4c3f9 --- /dev/null +++ b/src/state/avatar/selectors.js @@ -0,0 +1,8 @@ +import { createSelector } from 'reselect'; + +export const getAvatar = state => state.avatar; + +export const getAvatarFilename = createSelector( + getAvatar, + avatar => avatar.filename, +); diff --git a/src/state/avatar/types.js b/src/state/avatar/types.js new file mode 100644 index 000000000..6cbffea83 --- /dev/null +++ b/src/state/avatar/types.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const SET_AVATAR_FILE_NAME = 'avatar/filename'; diff --git a/src/state/file-browser/actions.js b/src/state/file-browser/actions.js index fbd8b8c73..4d7555877 100644 --- a/src/state/file-browser/actions.js +++ b/src/state/file-browser/actions.js @@ -34,7 +34,7 @@ const wrapApiCall = apiFunc => (...args) => async (dispatch) => { // thunks -const getBase64 = file => ( +export const getBase64 = file => ( new Promise((resolve) => { const reader = new FileReader(); reader.readAsDataURL(file); diff --git a/src/state/rootReducer.js b/src/state/rootReducer.js index 2b52b0217..c0fc0f633 100644 --- a/src/state/rootReducer.js +++ b/src/state/rootReducer.js @@ -44,6 +44,7 @@ import editContent from 'state/edit-content/reducer'; import contents from 'state/contents/reducer'; import system from 'state/system/reducer'; import currentTenant from 'state/multi-tenancy/reducer'; +import avatar from 'state/avatar/reducer'; import entandoApps from 'entando-apps'; import hub from 'state/component-repository/hub/reducer'; @@ -103,6 +104,7 @@ const reducerDef = { mfe, currentTenant, currentSystemConfiguration, + avatar, }; // app root reducer diff --git a/src/ui/users/common/ProfileImageUploader.js b/src/ui/users/common/ProfileImageUploader.js index ae3a87b6b..a156ddde3 100644 --- a/src/ui/users/common/ProfileImageUploader.js +++ b/src/ui/users/common/ProfileImageUploader.js @@ -4,8 +4,8 @@ import { Dropdown, MenuItem } from 'patternfly-react'; import { useDispatch } from 'react-redux'; import md5 from 'md5'; -import { uploadFile } from 'state/file-browser/actions'; import { useDynamicResourceUrl } from 'hooks/useDynamicResourceUrl'; +import { uploadAvatar } from 'state/avatar/actions'; const FILE_BROWSER_PATH = 'static/profile'; const GRAVATAR = 'GRAVATAR'; @@ -24,7 +24,7 @@ const ProfileImageUploader = ({ const imageProvider = useDynamicResourceUrl(FILE_BROWSER_PATH); const onFileChange = ({ target: { files } }) => { - dispatch(uploadFile(files[0], FILE_BROWSER_PATH)).then(() => onChange(files[0].name)); + dispatch(uploadAvatar(files[0])).then(() => onChange(files[0].name)); }; const handleUploadClick = () => { diff --git a/src/ui/users/my-profile/MyProfileEditForm.js b/src/ui/users/my-profile/MyProfileEditForm.js index 899767e16..ae4f91102 100644 --- a/src/ui/users/my-profile/MyProfileEditForm.js +++ b/src/ui/users/my-profile/MyProfileEditForm.js @@ -151,7 +151,7 @@ export class MyProfileEditFormBody extends Component { render() { const { profileTypesAttributes, defaultLanguage, languages, intl, userEmail, onChangeProfilePicture, - userProfileForm, + avatar, } = this.props; const { editMode } = this.state; @@ -226,13 +226,12 @@ export class MyProfileEditFormBody extends Component { return field(intl, attribute, !editMode); }); - const { profilepicture } = userProfileForm; return (
- + ({ username: getUsername(state), @@ -24,12 +26,14 @@ export const mapStateToProps = state => ({ ), userEmail: getUserEmail(state), userProfileForm: getUserProfileForm(state), + avatar: getAvatarFilename(state), }); export const mapDispatchToProps = dispatch => ({ onMount: () => { dispatch(fetchLanguages({ page: 1, pageSize: 0 })); dispatch(fetchMyUserProfile()); + dispatch(fetchAvatar()); }, onSubmit: (userprofile) => { dispatch(updateMyUserProfile(userprofile, false)); diff --git a/test/state/avatar/actions.test.js b/test/state/avatar/actions.test.js new file mode 100644 index 000000000..26ebaf138 --- /dev/null +++ b/test/state/avatar/actions.test.js @@ -0,0 +1,69 @@ +import { createFileObject, uploadAvatar, fetchAvatar } from 'state/avatar/actions'; +import * as actionsHelper from 'state/avatar/actions'; +import * as apiHelper from 'api/avatar'; + +describe('createFileObject', () => { + it('should create a file object', async () => { + const avatar = new File(['test data'], 'test.png', { type: 'image/png' }); + const result = await createFileObject(avatar); + expect(result).toEqual({ filename: 'test.png', base64: result.base64 }); + }); +}); + +describe('uploadAvatar', () => { + it('should upload avatar successfully', async () => { + const avatar = new File(['test data'], 'test.png', { type: 'image/png' }); + const dispatch = jest.fn(); + jest.spyOn(actionsHelper, 'createFileObject').mockResolvedValue({ filename: 'test.png', base64: 'base64encodedstring' }); + jest.spyOn(apiHelper, 'postAvatar').mockResolvedValue({ json: () => Promise.resolve({ payload: { filename: 'test.png' } }) }); + + await uploadAvatar(avatar)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'uploadAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { filename: 'test.png' }, type: 'avatar/filename' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'uploadAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { message: { id: 'fileBrowser.uploadFileComplete' }, type: 'success' }, type: 'toasts/add-toast' }); + expect(dispatch).toHaveBeenCalledTimes(4); + }); + + it('should handle avatar upload error', async () => { + const avatar = { name: 'test.jpg' }; + const dispatch = jest.fn(); + const error = new Error('Upload failed'); + jest.spyOn(actionsHelper, 'createFileObject').mockResolvedValue({ filename: avatar.name, base64: 'base64encodedstring' }); + jest.spyOn(apiHelper, 'postAvatar').mockRejectedValue(error); + + await uploadAvatar(avatar)(dispatch); + + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'uploadAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'uploadAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { message: { id: 'fileBrowser.uploadFileError', values: { errmsg: TypeError('[TypeError: Failed to execute \'readAsDataURL\' on \'FileReader\': parameter 1 is not of type \'Blob\'.]') } }, type: 'error' }, type: 'toasts/add-toast' }); + expect(dispatch).toHaveBeenCalledWith({ type: 'errors/add-errors', payload: { errors: TypeError('[TypeError: Failed to execute \'readAsDataURL\' on \'FileReader\': parameter 1 is not of type \'Blob\'.]') } }); + expect(dispatch).toHaveBeenCalledTimes(4); + }); +}); + + +describe('fetchAvatar', () => { + it('should retrieve the avatar successfully', async () => { + const dispatch = jest.fn(); + jest.spyOn(apiHelper, 'getAvatar').mockResolvedValue({ json: () => Promise.resolve({ payload: { filename: 'test.png' } }) }); + await fetchAvatar()(dispatch); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'fetchAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { filename: 'test.png' }, type: 'avatar/filename' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'fetchAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledTimes(3); + }); + + it('should handle avatar fetch error', async () => { + const dispatch = jest.fn(); + const error = new Error('Fetch failed'); + jest.spyOn(apiHelper, 'getAvatar').mockRejectedValue(error); + await fetchAvatar()(dispatch); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'fetchAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { id: 'fetchAvatar' }, type: 'loading/toggle-loading' }); + expect(dispatch).toHaveBeenCalledWith({ payload: { message: 'Fetch failed', type: 'error' }, type: 'toasts/add-toast' }); + expect(dispatch).toHaveBeenCalledTimes(4); + }); +}); + diff --git a/test/state/avatar/reducer.test.js b/test/state/avatar/reducer.test.js new file mode 100644 index 000000000..133047b20 --- /dev/null +++ b/test/state/avatar/reducer.test.js @@ -0,0 +1,25 @@ +import { createStore } from 'redux'; +import rootReducer from 'state/rootReducer'; +import { SET_AVATAR_FILE_NAME } from 'state/avatar/types'; + +describe('avatarReducer', () => { + it('should handle SET_AVATAR_FILE_NAME action', () => { + const store = createStore(rootReducer); + const setAvatarFileNameAction = { + type: SET_AVATAR_FILE_NAME, + payload: { filename: 'test.jpg' }, + }; + store.dispatch(setAvatarFileNameAction); + expect(store.getState().avatar.filename).toBe('test.jpg'); + }); + + it('should handle unknown action type', () => { + const store = createStore(rootReducer); + const unknownAction = { + type: 'UNKNOWN_ACTION', + payload: { filename: 'test.jpg' }, + }; + store.dispatch(unknownAction); + expect(store.getState().avatar.filename).toBe(''); + }); +}); diff --git a/test/state/avatar/selectors.test.js b/test/state/avatar/selectors.test.js new file mode 100644 index 000000000..36bcd75b7 --- /dev/null +++ b/test/state/avatar/selectors.test.js @@ -0,0 +1,22 @@ +import { createStore } from 'redux'; +import { getAvatar, getAvatarFilename } from 'state/avatar/selectors'; +import rootReducer from 'state/rootReducer'; + +describe('getAvatar', () => { + it('should return the avatar state', () => { + const initialState = { avatar: { filename: 'test.jpg' } }; + const store = createStore(rootReducer, initialState); + const result = getAvatar(store.getState()); + expect(result).toEqual(initialState.avatar); + }); +}); + +describe('getAvatarFilename selector', () => { + it('should return the avatar filename', () => { + const initialState = { avatar: { filename: 'test.jpg' } }; + const store = createStore(rootReducer, initialState); + const result = getAvatarFilename(store.getState()); + expect(result).toBe('test.jpg'); + }); +}); +