Skip to content

Commit

Permalink
Feat: #211 유저 프로필 이미지 업로드 기능 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
Yoonyesol committed Oct 12, 2024
1 parent aa79669 commit 6427a01
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 28 deletions.
22 changes: 14 additions & 8 deletions src/components/user/auth-form/ProfileImageContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { useEffect } from 'react';
import { GoPlusCircle } from 'react-icons/go';
import { FaRegTrashCan } from 'react-icons/fa6';
import { useFormContext } from 'react-hook-form';
import { convertBytesToString } from '@utils/converter';
import { USER_SETTINGS } from '@constants/settings';
import useToast from '@hooks/useToast';
import { useEffect } from 'react';
import { useUploadProfileImage } from '@hooks/query/useUserQuery';
import useStore from '@stores/useStore';

type ProfileImageContainerProps = {
imageUrl: string | null;
setImageUrl: (url: string) => void;
};

export default function ProfileImageContainer({ imageUrl, setImageUrl }: ProfileImageContainerProps) {
export default function ProfileImageContainer({ imageUrl }: ProfileImageContainerProps) {
const { setValue } = useFormContext();
const { toastWarn } = useToast();
const { mutate: uploadImage } = useUploadProfileImage();
const { editUserInfo } = useStore();

useEffect(() => {
return () => {
Expand All @@ -32,14 +35,17 @@ export default function ProfileImageContainer({ imageUrl, setImageUrl }: Profile
);
}

const image = URL.createObjectURL(file);
setImageUrl(image);
setValue('profileImageName', image);
uploadImage({ file });

const localImageUrl = URL.createObjectURL(file);
const uniqueFileName = `PROFILE_IMAGE_${Date.now()}.jpg`;
setValue('profileImageName', localImageUrl);
editUserInfo({ profileImageName: uniqueFileName });
};

const handleRemoveImg = () => {
setImageUrl('');
setValue('profileImageName', '');
setValue('profileImageName', null);
editUserInfo({ profileImageName: null });
};

return (
Expand Down
26 changes: 24 additions & 2 deletions src/hooks/query/useUserQuery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import useToast from '@hooks/useToast';
import { updateUserInfo } from '@services/userService';
import { generateUserInfoQueryKey } from '@/utils/queryKeyGenerator';
import { updateUserInfo, uploadProfileImage } from '@services/userService';
import useStore from '@stores/useStore';
import { generateProfileFileQueryKey, generateUserInfoQueryKey } from '@utils/queryKeyGenerator';
import type { EditUserInfoRequest } from '@/types/UserType';

export function useUpdateUserInfo() {
Expand All @@ -20,3 +21,24 @@ export function useUpdateUserInfo() {

return mutation;
}

export function useUploadProfileImage() {
const queryClient = useQueryClient();
const { toastSuccess, toastError } = useToast();
const { userInfo } = useStore();
const userProfileImageQueryKey = generateProfileFileQueryKey(userInfo.userId);

const mutation = useMutation({
mutationFn: ({ file }: { file: File }) =>
uploadProfileImage(file, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
onError: () => toastError('이미지 업로드에 실패했습니다. 다시 시도해 주세요.'),
onSuccess: () => {
toastSuccess('이미지가 업로드되었습니다.');
queryClient.invalidateQueries({ queryKey: userProfileImageQueryKey });
},
});

return mutation;
}
19 changes: 19 additions & 0 deletions src/mocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,3 +837,22 @@ export const FILE_DUMMY: FileInfo[] = [
uploadName: 'FILE_UUID_3.txt',
},
];

// MSW 프로필 이미지 임시 저장을 위한 변수
export const PROFILE_IMAGE_DUMMY = [
{
userId: 1,
file: new Blob([''], { type: 'image/jpeg' }),
uploadName: 'PROFILE_IMAGE_UUID_1.jpg',
},
{
userId: 2,
file: new Blob([''], { type: 'image/jpeg' }),
uploadName: 'PROFILE_IMAGE_UUID_2.jpg',
},
{
userId: 3,
file: new Blob([''], { type: 'image/png' }),
uploadName: 'PROFILE_IMAGE_UUID_3.jpg',
},
];
65 changes: 55 additions & 10 deletions src/mocks/services/userServiceHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { http, HttpResponse } from 'msw';
import { JWT_TOKEN_DUMMY, ROLE_DUMMY, TEAM_DUMMY, TEAM_USER_DUMMY, USER_DUMMY } from '@mocks/mockData';
import {
JWT_TOKEN_DUMMY,
PROFILE_IMAGE_DUMMY,
ROLE_DUMMY,
TEAM_DUMMY,
TEAM_USER_DUMMY,
USER_DUMMY,
} from '@mocks/mockData';
import { NICKNAME_REGEX } from '@constants/regex';
import { convertTokenToUserId } from '@utils/converter';
import type { Team } from '@/types/TeamType';
Expand Down Expand Up @@ -51,19 +58,57 @@ const userServiceHandler = [

return HttpResponse.json(userInfo, { status: 200 });
}),
// 유저 검색 API
http.get(`${BASE_URL}/user/search`, ({ request }) => {
const url = new URL(request.url);
const nickname = url.searchParams.get('nickname');
// 유저 프로필 이미지 업로드 API
http.post(`${BASE_URL}/user/profile/image`, async ({ request }) => {
const accessToken = request.headers.get('Authorization');

if (!accessToken) return new HttpResponse(null, { status: 401 });

// 접두사(nickname)과 일치하는 유저 정보 최대 5명 추출
const prefixRegex = new RegExp(`^${nickname}`);
const filteredUsers = USER_DUMMY.filter((user) => prefixRegex.test(user.nickname)).slice(0, 5);
const formData = await request.formData();
const file = formData.get('file');

if (!file) return new HttpResponse(null, { status: 400 });
if (!(file instanceof File)) return new HttpResponse('업로드된 문서는 파일이 아닙니다.', { status: 400 });

let userId;
// ToDo: 추후 삭제
if (accessToken === JWT_TOKEN_DUMMY) {
const payload = JWT_TOKEN_DUMMY.split('.')[1];
userId = Number(payload.replace('mocked-payload-', ''));
} else {
// 토큰에서 userId 추출
userId = convertTokenToUserId(accessToken);
}

const userIndex = userId ? USER_DUMMY.findIndex((user) => user.userId === userId) : -1;

if (!userId || userIndex === -1) {
return HttpResponse.json(
{ message: '해당 사용자를 찾을 수 없습니다. 입력 정보를 확인해 주세요.' },
{ status: 401 },
);
}

const uploadName = `PROFILE_IMAGE_UUID_${PROFILE_IMAGE_DUMMY.length + 1}.jpg`;

const profileImageIndex = PROFILE_IMAGE_DUMMY.findIndex((user) => user.userId === userId);
if (profileImageIndex !== -1) {
PROFILE_IMAGE_DUMMY[profileImageIndex].uploadName = uploadName;
} else {
PROFILE_IMAGE_DUMMY.push({
userId,
file: new Blob([file], { type: file.type }),
uploadName,
});
}

console.log(PROFILE_IMAGE_DUMMY);

// URL 경로 설정
const imageUrl = `${BASE_URL}/images/${uploadName}`;

USER_DUMMY[userIndex].profileImageName = imageUrl;

return HttpResponse.json(filteredUsers);
return HttpResponse.json({ imageUrl }, { status: 200 });
}),
// 가입한 팀 목록 조회 API
http.get(`${BASE_URL}/user/team`, ({ request }) => {
Expand Down
7 changes: 2 additions & 5 deletions src/pages/setting/UserSettingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function UserSettingPage() {
profileImageName: userInfoData.profileImageName,
},
});
const { formState, register, setValue, watch, handleSubmit } = methods;
const { formState, register, watch, handleSubmit } = methods;
const nickname = watch('nickname');

const { checkedNickname, lastCheckedNickname, handleCheckNickname } = useNicknameDuplicateCheck(
Expand All @@ -52,10 +52,7 @@ export default function UserSettingPage() {
<div className="mx-auto max-w-300 py-30">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* 프로필 이미지 */}
<ProfileImageContainer
imageUrl={watch('profileImageName')}
setImageUrl={(url: string) => setValue('profileImageName', url)}
/>
<ProfileImageContainer imageUrl={watch('profileImageName')} />

{/* 아이디 */}
<ValidationInput
Expand Down
15 changes: 15 additions & 0 deletions src/services/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ export async function updateUserInfo(userInfoForm: EditUserInfoRequest, axiosCon
return authAxios.patch<EditUserInfoResponse>('/user', userInfoForm, axiosConfig);
}

/**
* 유저 프로필 업로드 API
*
* @export
* @async
* @param {File} file - 파일 객체
* @param {AxiosRequestConfig} [axiosConfig={}] - axios 요청 옵션 설정 객체
* @returns {Promise<AxiosResponse<void>>}
*/
export async function uploadProfileImage(file: File, axiosConfig: AxiosRequestConfig = {}) {
const fileFormData = new FormData();
fileFormData.append('file', file);
return authAxios.postForm(`/user/profile/image`, fileFormData, axiosConfig);
}

/**
* 유저 목록을 검색하는 API
*
Expand Down
6 changes: 3 additions & 3 deletions src/stores/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { create, StateCreator } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { AUTH_SETTINGS } from '@constants/settings';
import { decrypt, encrypt } from '@utils/cryptoHelper';
import { EditUserInfoRequest, User } from '@/types/UserType';
import { EditUserInfoRequest, User, UserProfileImageForm } from '@/types/UserType';

// Auth Slice
type AuthStore = {
Expand All @@ -20,7 +20,7 @@ type AuthStore = {
type UserStore = {
userInfo: User;
setUserInfo: (newUserInfo: User) => void;
editUserInfo: (newUserInfo: EditUserInfoRequest) => void;
editUserInfo: (newUserInfo: EditUserInfoRequest | UserProfileImageForm) => void;
clearUserInfo: () => void;
};

Expand Down Expand Up @@ -90,7 +90,7 @@ const createUserSlice: StateCreator<Store, [], [], UserStore> = (set) => ({
userInfo: newUserInfo,
}),

editUserInfo: (newUserInfo: EditUserInfoRequest) =>
editUserInfo: (newUserInfo: EditUserInfoRequest | UserProfileImageForm) =>
set((state) => ({
userInfo: { ...state.userInfo, ...newUserInfo },
})),
Expand Down
2 changes: 2 additions & 0 deletions src/types/UserType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type UserInfo = User & {
password: string | null;
};

export type UserProfileImageForm = Pick<User, 'profileImageName'>;

export type SearchUser = Pick<User, 'userId' | 'nickname'>;
export type UserWithRole = SearchUser & Pick<Role, 'roleName'>;

Expand Down
11 changes: 11 additions & 0 deletions src/utils/queryKeyGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Project } from '@/types/ProjectType';

export const queryKeys = {
userInfo: 'userInfo',
profileImage: 'profileImage',
users: 'users',
teams: 'teams',
projects: 'projects',
Expand All @@ -24,6 +25,16 @@ export function generateUserInfoQueryKey() {
return [queryKeys.userInfo];
}

/**
* 유저 프로필 이미지 queryKey 생성 함수
*
* @export
* @returns {(string | number)[]}
*/
export function generateProfileFileQueryKey(userId: number) {
return [queryKeys.profileImage, userId];
}

/**
* 유저의 팀 목록 queryKey 생성 함수
*
Expand Down

0 comments on commit 6427a01

Please sign in to comment.