Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FE-81 ♻️ 마이페이지 프로필 수정 기능 리팩토링 #180

Merged
merged 3 commits into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Image from 'next/image';
import { subMonths } from 'date-fns';
import { EmotionLog, EmotionTypeEN } from '@/types/emotion';
import useCalendar from '../../hooks/useCalendar';
import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../utill/constants';
import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../../user/utill/constants';
import CalendarHeader from './CalendarHeader';

interface CalendarProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { EmotionTypeEN } from '@/types/emotion';
import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg';
import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg';
import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg';
import { iconPaths } from '../utill/constants';
import { iconPaths } from '../../user/utill/constants';

interface CalendarHeaderProps {
currentDate: Date;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EmotionLog, EmotionTypeEN } from '@/types/emotion';
import Image from 'next/image';
import { iconPaths } from '../utill/constants';
import { iconPaths } from '../../user/utill/constants';

interface ChartProps {
monthlyEmotionLogs: EmotionLog[];
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { UserProfileProps } from '@/types/user';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog';
import { sampleImage } from '../utill/constants';
import { sampleImage } from '../../user/utill/constants';
import ProfileEdit from './ProfileEdit';

export default function Profile({ image, nickname }: UserProfileProps) {
Expand Down
118 changes: 118 additions & 0 deletions src/components/mypage/ProfileEdit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Image from 'next/image';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useRef, useState } from 'react';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useCreatePresignedUrl, useUpdateMe } from '@/hooks/userQueryHooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { PatchMeRequestType, PatchMeRequest } from '@/schema/user';
import fileNameChange from '../../user/utill/fileNameChange';

interface UserProfileEditProps {
initialValues: {
image: string;
nickname: string;
};
onModalClose: () => void;
}

export default function ProfileEdit({ initialValues, onModalClose }: UserProfileEditProps) {
// 이미지 업로드 훅
const createPresignedUrl = useCreatePresignedUrl();
// 닉네임 중복 처리를 위한 변수
const [focusedField, setFocusedField] = useState<boolean>(false);

const fileInputRef = useRef<HTMLInputElement | null>(null);

const form = useForm<PatchMeRequestType>({
resolver: zodResolver(PatchMeRequest),
mode: 'onBlur',
defaultValues: initialValues,
});

const { setValue, getValues, setFocus } = form;

// error 반환 시 닉네임 focus를 위한 함수
const handleFieldError = () => {
setFocus('nickname');
setFocusedField(true);
};

// 회원정보 수정 훅
const updateMe = useUpdateMe(handleFieldError, onModalClose);

// 프로필 사진 변경 클릭
const handleImageEditClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};

// 이미지 변경 시
async function handleImageChange(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const { files } = e.currentTarget;

if (files && files.length > 0) {
const file = files[0];

// 중복된 파일명 및 한글파일이 저장되지 않도록 파일이름 포멧 변경
const newFileName = fileNameChange();
const newFile = new File([file], `${newFileName}.${file.name.split('.').pop()}`, { type: file.type });

createPresignedUrl.mutate(
{ image: newFile },
{
onSuccess: (data) => {
setValue('image', data.url);
},
},
);
}
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit((values: PatchMeRequestType) => updateMe.mutate(values))}>
<DialogHeader>
<DialogTitle>프로필 수정</DialogTitle>
<div className='flex flex-col justify-center items-center pt-8'>
<div className='w-[200px] h-[200px] rounded-full overflow-hidden cursor-pointer border border-gray-300 shadow-sm'>
<Image src={getValues('image') || initialValues.image} alt='유저 프로필' className='w-full h-full object-cover' width={200} height={200} priority onClick={handleImageEditClick} />
<Input type='file' accept='image/*' name='image' onChange={(e) => handleImageChange(e)} className='hidden' ref={fileInputRef} />
</div>
<div className='mt-10 flex flex-col items-start gap-4'>
<FormField
control={form.control}
name='nickname'
render={({ field, fieldState }) => (
<FormItem className='flex flex-col w-full lg:max-w-[640px] md:max-w-[384px] space-y-0 md:mb-10 mb-5'>
<FormLabel className={`md:mb-5 mb-4 font-pretendard lg:text-xl md:text-base sm:text-sm ${fieldState.invalid || focusedField ? 'text-state-error' : 'text-blue-900'}`}>
닉네임
</FormLabel>
<FormControl>
<Input
{...field}
type='text'
placeholder='닉네임'
onBlur={(e) => setValue('nickname', e.target.value.trim())}
className={`lg:h-16 h-11 px-4 lg:text-xl md:text-base placeholder-blue-400 rounded-xl bg-blue-200 font-pretendard ${fieldState.invalid || focusedField ? 'border-2 border-state-error' : 'focus:border-blue-500'}`}
/>
</FormControl>
<FormMessage className='flex justify-end text-[13px] text-state-error' />
</FormItem>
)}
/>
</div>
</div>
<DialogFooter>
<Button type='submit' className='bg-slate-600 text-white' disabled={!form.formState.isValid}>
수정하기
</Button>
</DialogFooter>
</DialogHeader>
</form>
</Form>
);
}
60 changes: 52 additions & 8 deletions src/hooks/userQueryHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,57 @@ import { updateMe, createPresignedUrl } from '@/apis/user';
import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user';
import { MutationOptions } from '@/types/query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { toast } from '@/components/ui/use-toast';

export const useMeQuery = () => useQuery(queries.user.getMe());

export const useUserQuery = (request: GetUserRequestType) => useQuery(queries.user.getUser(request));

export const useUpdateMe = (options: MutationOptions<PatchMeRequestType>) => {
export const useUpdateMe = (onRegisterError: () => void, onModalClose: () => void) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: PatchMeRequestType) => updateMe(request),
...options,
onSuccess: (...arg) => {
onSuccess: () => {
queryClient.invalidateQueries(queries.user.getMe());
if (options?.onSuccess) {
options?.onSuccess(...arg);
onModalClose();
toast({
title: '프로필 수정 완료',
description: '프로필 수정이 완료되었습니다.',
className: 'bg-illust-green text-white font-semibold',
});
},
onError: (error) => {
if (!isAxiosError(error)) {
return;
}

const { status, data } = error.response || {};

const errorMessage = data?.message || '잘못된 요청입니다. 입력 값을 확인해주세요.';

// NOTE: swagger 문서에서 닉네임 관련은 400 에러로 응답 옴.
if (status === 400) {
if (errorMessage.includes('Validation Failed')) {
toast({
description: '닉네임 입력은 필수입니다.',
className: 'bg-state-error text-white font-semibold',
});
} else {
toast({
description: errorMessage,
className: 'bg-state-error text-white font-semibold',
});
}

onRegisterError();
return;
}

toast({
description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
className: 'bg-state-error text-white font-semibold',
});
},
});
};
Expand All @@ -27,7 +63,15 @@ export const useCreatePresignedUrl = (options?: MutationOptions<PostPresignedUrl
useMutation({
mutationFn: (request: PostPresignedUrlRequestType) => createPresignedUrl(request),
...options,
onSuccess: (data: PostPresignedUrlResponseType) =>
// 이미지 URL 반환
data.url,
onSuccess: (data: PostPresignedUrlResponseType) => data.url, // 이미지 URL 반환
onError: (error) => {
if (!isAxiosError(error)) {
return;
}

toast({
description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
className: 'bg-state-error text-white font-semibold',
});
},
});
4 changes: 2 additions & 2 deletions src/pageLayout/MypageLayout/EmotionMonthlyLogs.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useMonthlyEmotionLogs } from '@/hooks/useGetEmotion';
import { Emotion } from '@/types/emotion';
import { useEffect, useState } from 'react';
import Calendar from '../../user/ui-calendar/Calendar';
import Chart from '../../user/ui-chart/Chart';
import Calendar from '../../components/mypage/Calendar';
import Chart from '../../components/mypage/Chart';

interface EmotionMonthlyLogsProps {
userId: number;
Expand Down
4 changes: 2 additions & 2 deletions src/pageLayout/MypageLayout/MyContent.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import useGetEpigrams from '@/hooks/useGetEpigrams';
import MyEpigrams from '@/user/ui-content/MyEpigrams';
import MyEpigrams from '@/components/mypage/MyEpigrams';
import Image from 'next/image';
import { useToast } from '@/components/ui/use-toast';
import { EpigramsResponse } from '@/types/epigram.types';
import { CommentResponseType } from '@/schema/comment';
import useCommentsHook from '@/hooks/useCommentsHook';
import useGetMyContentHook from '@/hooks/useGetMyContentHook';
import MyComment from '@/user/ui-content/MyComment';
import MyComment from '@/components/mypage/MyComment';
import UserInfo from '@/types/user';
import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook';
import { Button } from '@/components/ui/button';
Expand Down
2 changes: 1 addition & 1 deletion src/pageLayout/MypageLayout/MyPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import NewHeader from '@/components/Header/NewHeader';
import { useMeQuery } from '@/hooks/userQueryHooks';
import UserInfo from '@/types/user';
import EmotionMonthlyLogs from '@/pageLayout/MypageLayout/EmotionMonthlyLogs';
import Profile from '@/user/ui-profile/Profile';
import Profile from '@/components/mypage/Profile';
import { useRouter } from 'next/navigation';
import TodayEmotion from '@/components/main/TodayEmotion';
import MyContent from './MyContent';
Expand Down
2 changes: 1 addition & 1 deletion src/schema/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { MAX_FILE_SIZE, ACCEPTED_IMAGE_TYPES } from '@/user/utill/constants';

export const PatchMeRequest = z.object({
image: z.string().url(),
nickname: z.string(),
nickname: z.string().min(1, { message: '닉네임은 필수 입력입니다.' }).max(20, { message: '닉네임은 최대 20자까지 가능합니다.' }),
});

export const GetUserRequest = z.object({
Expand Down
Loading
Loading