Skip to content
Merged
110 changes: 78 additions & 32 deletions src/app/meeting/_features/form/MeetingForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ import {
import { useToast } from '@/components/common/ToastContext';
import useMeetingFormMutation from '@/hooks/mutations/useMeetingFormMutation';
import { convertImageToBase64 } from '@/util/base64';
import { AxiosError } from 'axios';
import { MEETING_TYPES } from 'constants/category/category';
import { useRouter } from 'next/navigation';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { CreateMeetingPayload } from 'types/meetingForm';
import { CreateMeetingPayload, UpdateMeetingPayload } from 'types/meetingForm';

interface MeetingFormProps {
mode: 'create' | 'edit';
initialData?: Partial<CreateMeetingPayload>;
meetingId?: string;
initialData?: Partial<CreateMeetingPayload> & { imageUrl?: string };
meetingId?: number;
}

export default function MeetingForm({
Expand All @@ -47,25 +48,34 @@ export default function MeetingForm({
return category ? category.id : '';
};

const { createMeeting, isLoading } = useMeetingFormMutation({
const { createMeeting, updateMeeting, isLoading } = useMeetingFormMutation({
onSuccessCallback: (response, formData) => {
if (mode === 'create') {
showToast('모임이 성공적으로 생성되었습니다', 'success', {
duration: 3000,
});
const categoryId = getCategoryId(formData.categoryTitle);
router.push(`/meeting/${categoryId}/${response.data.meetingId}`);
} else if (mode === 'edit') {
showToast('모임이 성공적으로 수정되었습니다', 'success');
const categoryId = getCategoryId(formData.categoryTitle);
router.push(`/meeting/${categoryId}/${meetingId}`);
}
const isCreateMode = mode === 'create';
const message = isCreateMode
? '모임이 성공적으로 생성되었습니다'
: '모임이 성공적으로 수정되었습니다';

showToast(message, 'success', { duration: 3000 });

const categoryId = getCategoryId(formData.categoryTitle);
router.push(
`/meeting/${categoryId}/${response.data.meetingId || meetingId}`,
);
},
onErrorCallback: () => {
// 실패 시 토스트 표시
showToast('모임 생성에 실패했습니다', 'error', {
duration: 3000,
});

onErrorCallback: (error: AxiosError) => {
let message;

if (mode !== 'create' && error?.response?.status === 403) {
message = '모임 수정 권한이 없습니다';
} else {
message =
mode === 'create'
? '모임 생성에 실패했습니다'
: '모임 수정에 실패했습니다';
}

showToast(message, 'error', { duration: 3000 });
},
});

Expand All @@ -84,30 +94,63 @@ export default function MeetingForm({
...initialData,
};

const defaultValuesRef = useRef(defaultValues);

const methods = useForm<CreateMeetingPayload>({
defaultValues,
});

const { handleSubmit } = methods;
const { handleSubmit, reset } = methods;

// 초기 데이터가 변경되면 폼 재설정
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
if (initialData && Object.keys(initialData).length > 0) {
reset({
...defaultValuesRef,
...initialData,
});
}
}, [initialData, reset]);

const onSubmit = async (formData: CreateMeetingPayload) => {
try {
// 이미지 처리
const fileInput = document.getElementById('image') as HTMLInputElement;
if (fileInput?.files && fileInput.files.length > 0) {
const imageData = await convertImageToBase64(fileInput.files[0]);
formData.imageName = imageData.name;
formData.imageEncodedBase64 = imageData.base64;
}

if (mode === 'create') {
// 생성 모드에서는 모든 필드 포함
if (fileInput?.files && fileInput.files.length > 0) {
const imageData = await convertImageToBase64(fileInput.files[0]);
formData.imageName = imageData.name;
formData.imageEncodedBase64 = imageData.base64;
}

await createMeeting.mutateAsync(formData);
} else if (mode === 'edit' && meetingId) {
// 모임 수정 (TODO: API 구현 시 수정)
// await updateMeeting.mutateAsync({ id: meetingId, data: formData });
// API가 구현되지 않았으므로 수정 성공으로 처리
// 수정 모드용 데이터 생성
// imageUrl 덮어 없애기 위한 구조분해
/* eslint-disable @typescript-eslint/no-explicit-any */
const { imageUrl: _, ...formDataWithoutUrl } = formData as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
const updateData: UpdateMeetingPayload = {
...formDataWithoutUrl,
imageName: null,
imageEncodedBase64: null,
};

// 이미지가 변경된 경우에만 이미지 데이터 설정
if (fileInput?.files && fileInput.files.length > 0) {
const imageData = await convertImageToBase64(fileInput.files[0]);
updateData.imageName = imageData.name;
updateData.imageEncodedBase64 = imageData.base64;
}

await updateMeeting.mutateAsync({ meetingId, formData: updateData });
}
} catch (error) {}
} catch (error) {
console.error('폼 제출 오류:', error);
}
};

return (
Expand All @@ -124,7 +167,10 @@ export default function MeetingForm({
<DateField required />
<MemberLimitField required />
<TechStackField maxSelections={5} />
<ImageField required={true} />
<ImageField
required={mode === 'create'}
imageUrl={initialData.imageUrl}
/>
<DescriptionField required />
<RequireApprovalField />
<PrivacyField />
Expand Down
32 changes: 27 additions & 5 deletions src/app/meeting/_features/form/form-filed/ImageField.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
'use client';

import { Button } from '@/components/ui/Button';
import { validateImageSize, validateImageType } from '@/util/base64';
import { IMAGE_CONFIG } from 'constants/meeting-form/meetingConstants';
Expand All @@ -12,9 +14,14 @@ import { imageValidationMessages } from '../validation';
interface ImageFieldProps {
required?: boolean;
maxSizeMB?: number;
imageUrl?: string; // 추가: 이미지 URL을 위한 프로퍼티
}

const ImageField = ({ required = true, maxSizeMB = 5 }: ImageFieldProps) => {
const ImageField = ({
required = true,
maxSizeMB = 5,
imageUrl: initialImageUrl,
}: ImageFieldProps) => {
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [showError, setShowError] = useState(false);

Expand All @@ -25,24 +32,31 @@ const ImageField = ({ required = true, maxSizeMB = 5 }: ImageFieldProps) => {
formState: { errors, isSubmitted },
getValues,
watch,
} = useFormContext<CreateMeetingPayload>();
} = useFormContext<CreateMeetingPayload & { imageUrl?: string }>();

const imageName = watch('imageName');

// 컴포넌트 초기 렌더링 시 기존 이미지가 있으면 미리보기 설정
useEffect(() => {
// 기존 이미지 URL이 있는 경우 (수정 모드)
if (initialImageUrl) {
setImagePreview(initialImageUrl);
return;
}

// 기존 base64 이미지가 있는 경우
const existingImageName = getValues('imageName');
const imageBase64 = getValues('imageEncodedBase64');

if (existingImageName && imageBase64) {
setImagePreview(`data:image/jpeg;base64,${imageBase64}`);
}
}, [getValues]);
}, [initialImageUrl, getValues]);

// 사용자가 상호작용한 경우에만 에러 메시지 표시
useEffect(() => {
if (isSubmitted || showError) {
if (required && !imageName) {
if (required && !imageName && !imagePreview) {
setError('imageName', {
type: 'manual',
message: imageValidationMessages.required,
Expand All @@ -51,7 +65,15 @@ const ImageField = ({ required = true, maxSizeMB = 5 }: ImageFieldProps) => {
clearErrors('imageName');
}
}
}, [imageName, isSubmitted, required, setError, clearErrors, showError]);
}, [
imageName,
imagePreview,
isSubmitted,
required,
setError,
clearErrors,
showError,
]);

const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
Expand Down
13 changes: 9 additions & 4 deletions src/app/meeting/_features/form/form-filed/TechStackField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ const TechStackField = ({
control,
setValue,
formState: { errors },
} = useFormContext<CreateMeetingPayload>();
} = useFormContext<CreateMeetingPayload & { imageUrl?: string }>();

const handleTechStackChange = (selection: string[]) => {
setValue('skillArray', selection);
};

// Controller를 사용하여 값 변경 시에만 컴포넌트 업데이트
return (
<div className="space-y-2">
<label
Expand All @@ -36,13 +37,17 @@ const TechStackField = ({
name="skillArray"
control={control}
rules={required ? { required: techStackValidation.required } : {}}
render={({ field }) => (
render={({ field: { value, onChange } }) => (
<TechSelector
id="skillArray"
maxSelections={maxSelections}
initialSelection={value || []}
onSelectionChange={(selection) => {
field.onChange(selection);
handleTechStackChange(selection);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(selection) !== JSON.stringify(value)) {
onChange(selection);
handleTechStackChange(selection);
}
}}
/>
)}
Expand Down
98 changes: 95 additions & 3 deletions src/app/meeting/edit-meeting/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,97 @@
import React from 'react';
'use client';

export default function EditPage() {
return <div>page</div>;
import { meetingKeys } from '@/hooks/queries/useMeetingQueries';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { MeetingDetail } from 'service/api/meeting';
import { getMeetingDetail } from 'service/api/meeting';
import { CreateMeetingPayload } from 'types/meetingForm';

import MeetingForm from '../../_features/form/MeetingForm';

export default function EditPage({ params }: { params: { id: string } }) {
const queryClient = useQueryClient();
const meetingId = Number(params.id);
const [initialData, setInitialData] = useState<
Partial<CreateMeetingPayload> & { imageUrl: string }
>({
imageUrl: '',
});
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const cachedData: MeetingDetail | undefined = queryClient.getQueryData(
meetingKeys.detailInfo(meetingId),
);

if (cachedData) {
// MeetingDetail 타입에서 CreateMeetingPayload 형식으로 변환
const formattedData: Partial<CreateMeetingPayload> & {
imageUrl: string;
} = {
meetingTitle: cachedData.title,
categoryTitle: cachedData.categoryTitle,
content: cachedData.content,
location: cachedData.location,
maxMember: cachedData.maxMember,
startDate: cachedData.startdate,
isPublic: cachedData.isPublic,
requireApproval: cachedData.requireApproval,
skillArray: cachedData.meetingSkillArray || [],
imageName: '',
imageEncodedBase64: '',
imageUrl: cachedData.thumbnail,
};

setInitialData(formattedData);
setIsLoading(false);
} else {
const fetchData = async () => {
try {
const response = await getMeetingDetail(meetingId);

const formattedData: Partial<CreateMeetingPayload> & {
imageUrl: string;
} = {
meetingTitle: response.title,
categoryTitle: response.categoryTitle,
content: response.content,
location: response.location,
maxMember: response.maxMember,
startDate: response.startdate,
isPublic: response.isPublic,
requireApproval: response.requireApproval,
skillArray: response.meetingSkillArray || [],
imageName: '',
imageEncodedBase64: '',
imageUrl: response.thumbnail,
};

setInitialData(formattedData);
} catch (error) {
console.error('모임 데이터 가져오기 실패:', error);
} finally {
setIsLoading(false);
}
};
fetchData();
setIsLoading(false);
}
}, [queryClient, meetingId]);

if (isLoading) {
return (
<div className="flex h-[60vh] items-center justify-center">
로딩 중...
</div>
);
}

return (
<MeetingForm
mode="edit"
initialData={initialData}
meetingId={Number(params.id)}
/>
);
}
Loading
Loading