Skip to content

Commit 3934066

Browse files
youngminssclaude
andauthored
fix: 모임 생성 퍼널 필드 상태 초기화 버그 수정 (#64)
* fix: CreateMeetingForm 타입을 optional 에서 nullable 로 변경 react-hook-form의 field.onChange(undefined) 호출 시 이전 값으로 롤백되는 문제를 해결하기 위해 undefined 대신 null을 사용하도록 타입을 변경하고 defaultValues를 설정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: 모임 생성 퍼널 Step 컴포넌트 toggle 버그 수정 - undefined 대신 null을 사용하여 필드 초기화가 정상 동작하도록 수정 - Footer 컴포넌트에서 useController 대신 useWatch 사용 - RegionChip에 toggle 로직 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 모임 생성 폼을 zod 기반 validation으로 리팩토링 (#65) * feat: CreateMeetingForm zod 스키마 추가 모임 생성 폼의 validation을 zod 기반으로 관리하기 위한 스키마 정의 - peopleCount, scheduledDate, timeSlot, region 필드 정의 - 기존 validateDateInput 유틸 함수 재사용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: 모임 생성 폼을 zod 기반 validation으로 리팩토링 - useCreateMeetingForm에 zodResolver 적용 및 submit 로직 통합 - Step 컴포넌트에서 rules 객체 제거 - page.tsx에서 submit 로직 제거하여 간소화 - CreateMeetingForm 인터페이스를 스키마 타입으로 대체 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: DATE_ERROR_MESSAGES 중복 제거 및 formState 활용 - DATE_ERROR_MESSAGES를 유틸로 추출하여 공유 - DateStep에서 fieldState.error 사용으로 변경 - DateStepFooter에서 formState.errors 기반으로 버튼 활성화 판단 - 스키마에서 10자리 미만일 때는 validation 건너뛰도록 수정 (이전 동작 유지) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style: useCreateMeetingForm 조건문 포맷팅 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> * refactor: useWatch 사용 패턴 개선 - DateStep: 두 개의 useWatch를 배열 형태로 축약 - PeopleStep: useWatch + compute 조합으로 isValid 계산 - RegionStep: useWatch + compute 조합으로 isValid 계산 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: DateStep isValid 검증 로직 개선 - isValidDateFormat을 사용하여 날짜 포맷 검증 복구 - useWatch + compute 조합으로 isValid 계산 통합 - formState.errors 의존성 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3070aae commit 3934066

File tree

13 files changed

+163
-147
lines changed

13 files changed

+163
-147
lines changed

app/gathering/create/page.tsx

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,14 @@ import {
1818
useCreateMeetingForm,
1919
useCreateMeetingFunnel,
2020
} from "#/hooks/gathering";
21-
import { useCreateGathering } from "#/hooks/apis/gathering";
2221
import { Toaster } from "#/components/toast";
23-
import { isApiError } from "#/utils/api";
24-
import { toast } from "#/utils/toast";
25-
import type { CreateMeetingForm } from "#/types/gathering";
2622

2723
export default function GatheringCreatePage() {
2824
const router = useRouter();
29-
const form = useCreateMeetingForm();
25+
const { methods, onSubmit, isPending } = useCreateMeetingForm();
3026
const { step, direction, next, back, isFirstStep } =
3127
useCreateMeetingFunnel();
3228

33-
const { mutate: createGathering, isPending } = useCreateGathering();
34-
3529
const handleBackward = () => {
3630
if (isFirstStep) {
3731
router.push("/");
@@ -40,40 +34,6 @@ export default function GatheringCreatePage() {
4034
}
4135
};
4236

43-
const handleComplete = (accessKey: string) => {
44-
router.push(`/gathering/create/complete/${accessKey}`);
45-
};
46-
47-
const onSubmit = (formData: CreateMeetingForm) => {
48-
if (
49-
!formData.peopleCount ||
50-
!formData.region ||
51-
!formData.scheduledDate ||
52-
!formData.timeSlot
53-
) {
54-
return;
55-
}
56-
57-
createGathering(
58-
{
59-
peopleCount: formData.peopleCount,
60-
region: formData.region,
61-
scheduledDate: formData.scheduledDate.replace(/\./g, "-"),
62-
timeSlot: formData.timeSlot,
63-
},
64-
{
65-
onSuccess: (response) => {
66-
handleComplete(response.data.accessKey);
67-
},
68-
onError: (error) => {
69-
if (isApiError(error)) {
70-
toast.warning(error.message);
71-
}
72-
},
73-
},
74-
);
75-
};
76-
7737
const renderContent = () => {
7838
switch (step) {
7939
case "people":
@@ -101,9 +61,9 @@ export default function GatheringCreatePage() {
10161
};
10262

10363
return (
104-
<FormProvider {...form}>
64+
<FormProvider {...methods}>
10565
<Layout.Root>
106-
<form onSubmit={form.handleSubmit(onSubmit)}>
66+
<form onSubmit={onSubmit}>
10767
<Layout.Header>
10868
{step !== "people" && (
10969
<BackwardButton onClick={handleBackward} />
Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,66 @@
11
"use client";
22

33
import { useForm } from "react-hook-form";
4-
import type { CreateMeetingForm } from "#/types/gathering";
4+
import { zodResolver } from "@hookform/resolvers/zod";
5+
import { useRouter } from "next/navigation";
6+
import {
7+
createMeetingFormSchema,
8+
type CreateMeetingFormSchema,
9+
} from "#/schemas/gathering";
10+
import { useCreateGathering } from "#/hooks/apis/gathering";
11+
import { isApiError } from "#/utils/api";
12+
import { toast } from "#/utils/toast";
513

6-
export const useCreateMeetingForm = () => {
7-
const form = useForm<CreateMeetingForm>({
14+
export function useCreateMeetingForm() {
15+
const router = useRouter();
16+
const { mutate: createGathering, isPending } = useCreateGathering();
17+
18+
const methods = useForm<CreateMeetingFormSchema>({
819
mode: "onChange",
20+
resolver: zodResolver(createMeetingFormSchema),
21+
defaultValues: {
22+
peopleCount: null,
23+
scheduledDate: "",
24+
timeSlot: null,
25+
region: null,
26+
},
27+
});
28+
29+
const handleSubmit = methods.handleSubmit((data) => {
30+
if (
31+
!data.peopleCount ||
32+
!data.timeSlot ||
33+
!data.scheduledDate ||
34+
!data.region
35+
) {
36+
return;
37+
}
38+
39+
createGathering(
40+
{
41+
peopleCount: data.peopleCount,
42+
region: data.region,
43+
scheduledDate: data.scheduledDate.replace(/\./g, "-"),
44+
timeSlot: data.timeSlot,
45+
},
46+
{
47+
onSuccess: (response) => {
48+
router.push(
49+
`/gathering/create/complete/${response.data.accessKey}`,
50+
);
51+
},
52+
onError: (error) => {
53+
if (isApiError(error)) {
54+
toast.warning(error.message);
55+
}
56+
},
57+
},
58+
);
959
});
1060

11-
return form;
12-
};
61+
return {
62+
methods,
63+
onSubmit: handleSubmit,
64+
isPending,
65+
};
66+
}

src/pageComponents/gathering/create/DateStep.tsx

Lines changed: 18 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,33 @@
11
"use client";
22

3-
import { useFormContext, useController } from "react-hook-form";
3+
import { useFormContext, useController, useWatch } from "react-hook-form";
44
import { isNil } from "es-toolkit";
55

66
import { Layout } from "#/components/layout";
77
import { StepIndicator } from "#/components/stepIndicator";
88
import { Button } from "#/components/button";
99
import { InputField } from "#/components/inputField";
1010
import { Chip } from "#/components/chip";
11-
import {
12-
formatDateInput,
13-
isValidDateFormat,
14-
validateDateInput,
15-
type DateValidationError,
16-
} from "#/utils/gathering/create";
17-
import type { CreateMeetingForm, TimeSlot } from "#/types/gathering";
18-
19-
const scheduledDateRules = {
20-
validate: (value: string | undefined) =>
21-
!isNil(value) && isValidDateFormat(value),
22-
};
23-
24-
const timeSlotRules = {
25-
validate: (value: TimeSlot | undefined) => !isNil(value),
26-
};
27-
28-
const DATE_ERROR_MESSAGES: Record<
29-
Exclude<DateValidationError, null>,
30-
string
31-
> = {
32-
INVALID_FORMAT: "날짜 형식을 확인해주세요 (예: 2026.01.31)",
33-
INVALID_DATE: "존재하지 않는 날짜예요",
34-
PAST_DATE: "이미 지난 날짜예요",
35-
};
11+
import { formatDateInput, isValidDateFormat } from "#/utils/gathering/create";
12+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
13+
import type { TimeSlot } from "#/types/gathering";
3614

3715
export const DateStepContent = () => {
38-
const { control } = useFormContext<CreateMeetingForm>();
16+
const { control } = useFormContext<CreateMeetingFormSchema>();
3917

40-
const { field: scheduledDateField } = useController({
18+
const {
19+
field: scheduledDateField,
20+
fieldState: { error: scheduledDateError },
21+
} = useController({
4122
control,
4223
name: "scheduledDate",
43-
rules: scheduledDateRules,
4424
});
4525

4626
const { field: timeSlotField } = useController({
4727
control,
4828
name: "timeSlot",
49-
rules: timeSlotRules,
5029
});
5130

52-
const dateError =
53-
scheduledDateField.value?.length === 10
54-
? validateDateInput(scheduledDateField.value)
55-
: null;
56-
5731
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
5832
const formatted = formatDateInput(e.target.value);
5933
scheduledDateField.onChange(formatted);
@@ -64,7 +38,7 @@ export const DateStepContent = () => {
6438
};
6539

6640
const handleTimeSlotChange = (slot: TimeSlot) => {
67-
timeSlotField.onChange(slot === timeSlotField.value ? undefined : slot);
41+
timeSlotField.onChange(slot === timeSlotField.value ? null : slot);
6842
};
6943

7044
return (
@@ -78,11 +52,7 @@ export const DateStepContent = () => {
7852
<InputField
7953
placeholder="날짜를 입력해주세요"
8054
helperText="예) 2026.01.28"
81-
errorText={
82-
dateError
83-
? DATE_ERROR_MESSAGES[dateError]
84-
: undefined
85-
}
55+
errorText={scheduledDateError?.message}
8656
inputMode="numeric"
8757
showClearButton
8858
value={scheduledDateField.value || ""}
@@ -120,25 +90,16 @@ interface DateStepFooterProps {
12090
}
12191

12292
export const DateStepFooter = ({ onNext }: DateStepFooterProps) => {
123-
const { control } = useFormContext<CreateMeetingForm>();
124-
125-
const { field: scheduledDateField } = useController({
93+
const { control } = useFormContext<CreateMeetingFormSchema>();
94+
const isValid = useWatch({
12695
control,
127-
name: "scheduledDate",
128-
rules: scheduledDateRules,
96+
name: ["scheduledDate", "timeSlot"],
97+
compute: ([scheduledDate, timeSlot]) =>
98+
!isNil(scheduledDate) &&
99+
!isNil(timeSlot) &&
100+
isValidDateFormat(scheduledDate),
129101
});
130102

131-
const { field: timeSlotField } = useController({
132-
control,
133-
name: "timeSlot",
134-
rules: timeSlotRules,
135-
});
136-
137-
const isValid =
138-
!isNil(scheduledDateField.value) &&
139-
isValidDateFormat(scheduledDateField.value) &&
140-
!isNil(timeSlotField.value);
141-
142103
return (
143104
<Layout.Footer>
144105
<div className="ygi:px-6">

src/pageComponents/gathering/create/PeopleCountGrid.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import { twJoin } from "tailwind-merge";
44

55
interface PeopleCountGridProps {
6-
value: number | undefined;
7-
onChange: (count: number | undefined) => void;
6+
value: number | null;
7+
onChange: (count: number | null) => void;
88
}
99

1010
const PEOPLE_COUNTS = [
@@ -24,7 +24,7 @@ export const PeopleCountGrid = ({ value, onChange }: PeopleCountGridProps) => {
2424
key={count}
2525
type="button"
2626
onClick={() =>
27-
onChange(isSelected ? undefined : count)
27+
onChange(isSelected ? null : count)
2828
}
2929
className={twJoin(
3030
"ygi:flex ygi:aspect-square ygi:flex-1 ygi:cursor-pointer ygi:items-center ygi:justify-center",

src/pageComponents/gathering/create/PeopleStep.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,22 @@
11
"use client";
22

3-
import { useFormContext, useController } from "react-hook-form";
3+
import { useFormContext, useController, useWatch } from "react-hook-form";
44
import { isNil } from "es-toolkit";
55

66
import { Layout } from "#/components/layout";
77
import { StepIndicator } from "#/components/stepIndicator";
88
import { Button } from "#/components/button";
99
import { PeopleCountGrid } from "./PeopleCountGrid";
10-
import type { CreateMeetingForm } from "#/types/gathering";
11-
12-
const rules = {
13-
validate: (value: number | undefined) => !isNil(value),
14-
};
10+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
1511

1612
export const PeopleStepContent = () => {
17-
const { control } = useFormContext<CreateMeetingForm>();
13+
const { control } = useFormContext<CreateMeetingFormSchema>();
1814
const { field } = useController({
1915
control,
2016
name: "peopleCount",
21-
rules,
2217
});
2318

24-
const handleChange = (count?: number) => {
19+
const handleChange = (count: number | null) => {
2520
field.onChange(count);
2621
};
2722

@@ -43,15 +38,13 @@ interface PeopleStepFooterProps {
4338
}
4439

4540
export const PeopleStepFooter = ({ onNext }: PeopleStepFooterProps) => {
46-
const { control } = useFormContext<CreateMeetingForm>();
47-
const { field } = useController({
41+
const { control } = useFormContext<CreateMeetingFormSchema>();
42+
const isValid = useWatch({
4843
control,
4944
name: "peopleCount",
50-
rules,
45+
compute: (peopleCount) => !isNil(peopleCount),
5146
});
5247

53-
const isValid = !isNil(field.value);
54-
5548
return (
5649
<Layout.Footer>
5750
<div className="ygi:px-6">

src/pageComponents/gathering/create/RegionChip.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33
import { useFormContext, useController } from "react-hook-form";
44

55
import { Chip } from "#/components/chip";
6-
import type { CreateMeetingForm, Region } from "#/types/gathering";
6+
import type { CreateMeetingFormSchema } from "#/schemas/gathering";
7+
import type { Region } from "#/types/gathering";
78

89
interface RegionChipProps {
910
value: Region;
1011
label: string;
1112
}
1213

1314
export const RegionChip = ({ value, label }: RegionChipProps) => {
14-
const { control } = useFormContext<CreateMeetingForm>();
15+
const { control } = useFormContext<CreateMeetingFormSchema>();
1516
const { field } = useController({
1617
name: "region",
1718
control,
1819
});
1920

21+
const handleClick = () => {
22+
field.onChange(field.value === value ? null : value);
23+
};
24+
2025
return (
21-
<Chip
22-
selected={field.value === value}
23-
onClick={() => field.onChange(value)}
24-
>
26+
<Chip selected={field.value === value} onClick={handleClick}>
2527
{label}
2628
</Chip>
2729
);

0 commit comments

Comments
 (0)