From ead1ed6f93db0a67128833a772e5a05982c770a1 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Tue, 21 Jan 2025 01:16:07 +0900 Subject: [PATCH 01/32] =?UTF-8?q?[YS-172]=20chore:=20zod=20@hookform/resol?= =?UTF-8?q?vers=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +++- pnpm-lock.yaml | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f5ce81..8e07b87 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@emotion/react": "^11.14.0", + "@hookform/resolvers": "^3.10.0", "@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-popover": "^1.1.4", "@radix-ui/react-select": "^2.1.4", @@ -24,9 +25,10 @@ "jira-prepare-commit-msg": "^1.7.2", "next": "14.2.22", "react": "^18", + "react-day-picker": "^9.5.0", "react-dom": "^18", "react-hook-form": "^7.54.2", - "react-day-picker": "^9.5.0" + "zod": "^3.24.1" }, "devDependencies": { "@mswjs/http-middleware": "^0.10.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d94c520..f23459a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@18.3.18)(react@18.3.1) + '@hookform/resolvers': + specifier: ^3.10.0 + version: 3.10.0(react-hook-form@7.54.2(react@18.3.1)) '@radix-ui/react-dialog': specifier: ^1.1.4 version: 1.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -53,6 +56,9 @@ importers: react-hook-form: specifier: ^7.54.2 version: 7.54.2(react@18.3.1) + zod: + specifier: ^3.24.1 + version: 3.24.1 devDependencies: '@mswjs/http-middleware': specifier: ^0.10.2 @@ -220,6 +226,11 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -2439,6 +2450,9 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + snapshots: '@babel/code-frame@7.26.2': @@ -2616,6 +2630,10 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@hookform/resolvers@3.10.0(react-hook-form@7.54.2(react@18.3.1))': + dependencies: + react-hook-form: 7.54.2(react@18.3.1) + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -5099,3 +5117,5 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + + zod@3.24.1: {} From dcfee313d7892662685865ba4976981d1e70943b Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 22:23:32 +0900 Subject: [PATCH 02/32] =?UTF-8?q?[YS-172]=20feat:=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20zod=20schema=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OutlineSection/OutlineSection.tsx | 2 +- .../UploadContainer/UploadContainer.tsx | 58 ++++++------ .../upload/hooks/useUploadExperiemntPost.tsx | 36 ++++++++ .../upload/uploadExperimentPostSchema.ts | 89 +++++++++++++++++++ 4 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 src/app/upload/hooks/useUploadExperiemntPost.tsx create mode 100644 src/schema/upload/uploadExperimentPostSchema.ts diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index 048ae5f..b98c516 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -13,7 +13,7 @@ import { headingIcon, input, label } from '../UploadContainer/UploadContainer'; import DatePickerField from '@/app/upload/components/DatePickerField/DatePickerField'; import { colors } from '@/styles/colors'; -enum MatchType { +export enum MatchType { OFFLINE = 'OFFLINE', ONLINE = 'ONLINE', HYBRID = 'HYBRID', diff --git a/src/app/upload/components/UploadContainer/UploadContainer.tsx b/src/app/upload/components/UploadContainer/UploadContainer.tsx index 671b775..035abec 100644 --- a/src/app/upload/components/UploadContainer/UploadContainer.tsx +++ b/src/app/upload/components/UploadContainer/UploadContainer.tsx @@ -4,40 +4,46 @@ import { Theme } from '@emotion/react'; import { css } from '@emotion/react'; import Link from 'next/link'; import React from 'react'; +import { FormProvider } from 'react-hook-form'; +import useUploadExperimentPost from '../../hooks/useUploadExperiemntPost'; import ApplyMethodSection from '../ApplyMethodSection/ApplyMethodSection'; import DescriptionSection from '../DescriptionSection/DescriptionSection'; import OutlineSection from '../OutlineSection/OutlineSection'; const UploadContainer = () => { - return ( -
-
-

실험에 대한 정보를 입력해 주세요

-

구체적일수록 참여자 매칭 확률이 높아져요

-
- -
- {/* 실험 개요 */} - - - {/* 실험 설명 */} - + const { form, handleSubmit } = useUploadExperimentPost(); - {/* 실험 참여 방법 */} - -
- - {/* 버튼 */} -
- - - - - - + return ( + +
+
+

실험에 대한 정보를 입력해 주세요

+

구체적일수록 참여자 매칭 확률이 높아져요

+
+ +
+ {/* 실험 개요 */} + + + {/* 실험 설명 */} + + + {/* 실험 참여 방법 */} + +
+ + {/* 버튼 */} +
+ + + + +
-
+ ); }; diff --git a/src/app/upload/hooks/useUploadExperiemntPost.tsx b/src/app/upload/hooks/useUploadExperiemntPost.tsx new file mode 100644 index 0000000..0a46b0e --- /dev/null +++ b/src/app/upload/hooks/useUploadExperiemntPost.tsx @@ -0,0 +1,36 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import UploadExperimentPostSchema, { + UploadExperimentPostSchemaType, +} from '@/schema/upload/uploadExperimentPostSchema'; + +const useUploadExperimentPost = () => { + const form = useForm({ + mode: 'onBlur', + reValidateMode: 'onBlur', + resolver: zodResolver(UploadExperimentPostSchema()), + defaultValues: { + leadResearcher: '', + startDate: undefined, + endDate: undefined, + }, + }); + + const handleSubmit = async (data: UploadExperimentPostSchemaType) => { + try { + // console.log('공고 등록 form >> ', data); + + await form.reset(); + } catch (error) { + // console.error('공고 등록 form 저장 중 오류 발생', error); + } + }; + + return { + form, + handleSubmit: form.handleSubmit(handleSubmit), + }; +}; + +export default useUploadExperimentPost; diff --git a/src/schema/upload/uploadExperimentPostSchema.ts b/src/schema/upload/uploadExperimentPostSchema.ts new file mode 100644 index 0000000..483735d --- /dev/null +++ b/src/schema/upload/uploadExperimentPostSchema.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +import { MatchType } from '@/app/upload/components/OutlineSection/OutlineSection'; + +export type UploadExperimentPostSchemaType = z.infer>; + +const UploadExperimentPostSchema = () => { + return z.object({ + // targetGroupInfo: z.object({ + // startAge: z.number().min(0, '0세 이상'), // 참여 가능 나이 (이상) + // endAge: z.number().min(0, '0세 이상'), // 참여 가능 나이 (이하) + // genderType: z.nativeEnum(GenderType), // 성별 + // otherCondition: z.string().optional(), // 기타조건 + // }), + // applyMethodInfo: z.object({ + // content: z.string().nonempty('필수 값'), // 참여 방법 + // formUrl: z.string().url().optional(), // 링크 + // phoneNum: z.string().optional(), // 연락처 + // }), + // imageListInfo: z.object({ + // images: z.array(z.string()).optional(), // 이미지 목록 (최대 3장) + // }), + + // 실험 시작 날짜 + startDate: z.union( + [ + z.date({ errorMap: () => ({ message: '' }) }), + z.null({ errorMap: () => ({ message: '' }) }), + ], + { + required_error: '', + invalid_type_error: '', + }, + ), + + // 실험 종료 날짜 + endDate: z.union( + [ + z.date({ errorMap: () => ({ message: '' }) }), + z.null({ errorMap: () => ({ message: '' }) }), + ], + { + required_error: '', + invalid_type_error: '', + }, + ), + + // 진행 방식 + matchType: z.nativeEnum(MatchType), + // 실험 횟수 + count: z.enum(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']).transform(Number), // 참여 횟수 + // 소요 시간 + timeRequired: z.union([ + z.enum([ + 'LESS_30M', + 'ABOUT_30M', + 'ABOUT_1H', + 'ABOUT_1H30M', + 'ABOUT_2H', + 'ABOUT_2H30M', + 'ABOUT_3H', + 'ABOUT_3H30M', + 'ABOUT_4H', + ]), + z.null(), + ]), + // 연구 책임자 + leadResearcher: z + .string() + .min(10, { message: '최소 10자 이상으로 입력해 주세요' }) + .max(150, { message: '최대 150자 이하로 입력해 주세요' }), + // 대학교 + univName: z.string().nonempty('대학교 이름 필수'), + // 지역 + region: z.string().nonempty('지역 필수'), + // 지역구 + area: z.string().nonempty('지역구 필수'), + // 상세 주소 + detailedAddress: z.string().optional(), + // 보상 + reward: z.string().nonempty('보상 필수'), + + // title: z.string().nonempty('실험 제목 필수'), // 실험 제목 + // content: z.string().nonempty('실험 본문 필수'), // 실험 본문 + // alarmAgree: z.boolean().default(false), // 알람 동의 + }); +}; + +export default UploadExperimentPostSchema; From 942d466825b04a82480d38765ba540ca592b7da7 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 22:27:00 +0900 Subject: [PATCH 03/32] =?UTF-8?q?[YS-172]=20refactor:=20useUploadExperimen?= =?UTF-8?q?tPost=20=EC=98=A4=ED=83=88=EC=9E=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/upload/components/UploadContainer/UploadContainer.tsx | 2 +- ...{useUploadExperiemntPost.tsx => useUploadExperimentPost.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/app/upload/hooks/{useUploadExperiemntPost.tsx => useUploadExperimentPost.tsx} (100%) diff --git a/src/app/upload/components/UploadContainer/UploadContainer.tsx b/src/app/upload/components/UploadContainer/UploadContainer.tsx index 035abec..2d24dfa 100644 --- a/src/app/upload/components/UploadContainer/UploadContainer.tsx +++ b/src/app/upload/components/UploadContainer/UploadContainer.tsx @@ -6,7 +6,7 @@ import Link from 'next/link'; import React from 'react'; import { FormProvider } from 'react-hook-form'; -import useUploadExperimentPost from '../../hooks/useUploadExperiemntPost'; +import useUploadExperimentPost from '../../hooks/useUploadExperimentPost'; import ApplyMethodSection from '../ApplyMethodSection/ApplyMethodSection'; import DescriptionSection from '../DescriptionSection/DescriptionSection'; import OutlineSection from '../OutlineSection/OutlineSection'; diff --git a/src/app/upload/hooks/useUploadExperiemntPost.tsx b/src/app/upload/hooks/useUploadExperimentPost.tsx similarity index 100% rename from src/app/upload/hooks/useUploadExperiemntPost.tsx rename to src/app/upload/hooks/useUploadExperimentPost.tsx From 7b2b3b02b73f5ccdf5f6b924c5347d1ee83f9967 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:16:19 +0900 Subject: [PATCH 04/32] =?UTF-8?q?[YS-172]=20chore:=20DatePickerForm=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20react-hook-f?= =?UTF-8?q?orm=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DatePickerField/DatePickerField.tsx | 302 ------------------ .../DatePickerForm/DatePickerForm.styles.ts | 222 +++++++++++++ .../DatePickerForm/DatePickerForm.tsx | 120 +++++++ src/app/upload/upload.utils.ts | 10 + 4 files changed, 352 insertions(+), 302 deletions(-) delete mode 100644 src/app/upload/components/DatePickerField/DatePickerField.tsx create mode 100644 src/app/upload/components/DatePickerForm/DatePickerForm.styles.ts create mode 100644 src/app/upload/components/DatePickerForm/DatePickerForm.tsx create mode 100644 src/app/upload/upload.utils.ts diff --git a/src/app/upload/components/DatePickerField/DatePickerField.tsx b/src/app/upload/components/DatePickerField/DatePickerField.tsx deleted file mode 100644 index acc1a7a..0000000 --- a/src/app/upload/components/DatePickerField/DatePickerField.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { css, Theme } from '@emotion/react'; -import * as Popover from '@radix-ui/react-popover'; -import { ko } from 'date-fns/locale'; -import React, { useState } from 'react'; -import { DayPicker, DateRange } from 'react-day-picker'; - -import 'react-day-picker/dist/style.css'; - -import Icon from '@/components/Icon'; -import { colors } from '@/styles/colors'; - -interface DatePickerFieldProps { - placeholder: string; - onDateChange: (dates: DateRange) => void; - experimentDateChecked?: boolean; -} - -export type NullableDate = Date | null; - -const DatePickerField = ({ - placeholder, - onDateChange, - experimentDateChecked = false, -}: DatePickerFieldProps) => { - const [isOpen, setIsOpen] = useState(false); - - const [selectedDates, setSelectedDates] = useState({ - from: undefined, - to: undefined, - }); - - const handleDateChange = (range: DateRange) => { - setSelectedDates(range); - onDateChange(range); - }; - - return ( -
- - -
datePickerField(theme, experimentDateChecked, isOpen)} - role="button" - tabIndex={0} - > - placeholderText(theme, !!selectedDates.from, experimentDateChecked)} - > - {!experimentDateChecked - ? selectedDates.from - ? selectedDates.from?.toLocaleDateString() === - selectedDates.to?.toLocaleDateString() - ? `${selectedDates.from?.toLocaleDateString()}` - : `${selectedDates.from?.toLocaleDateString()} ~ ${selectedDates.to?.toLocaleDateString()}` - : placeholder - : '본문 참고'} - - - - -
-
- - - - - -
-
- ); -}; - -export default DatePickerField; - -const datePickerFieldContainer = css` - position: relative; -`; - -const datePickerField = (theme: Theme, experimentDateChecked: boolean, isOpen: boolean) => css` - width: 100%; - height: 4.8rem; - - display: flex; - align-items: center; - justify-content: space-between; - - padding: 1.3rem 1.6rem; - - border: 0.1rem solid - ${experimentDateChecked - ? theme.colors.line01 - : isOpen - ? theme.colors.lineTinted - : theme.colors.line01}; - border-radius: 1.2rem; - - background-color: ${experimentDateChecked ? theme.colors.field02 : colors.field01}; - - cursor: ${experimentDateChecked ? 'not-allowed' : 'pointer'}; -`; - -const placeholderText = ( - theme: Theme, - bothDatesSelected: boolean, - experimentDateChecked: boolean, -) => css` - ${theme.fonts.label.large.R14}; - color: ${experimentDateChecked - ? theme.colors.text02 - : bothDatesSelected - ? theme.colors.text06 - : theme.colors.text02}; - - flex: 1; - - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const iconStyle = css` - margin-left: 1rem; -`; - -const popoverLayout = (theme: Theme) => css` - background-color: ${colors.field01}; - border: 0.1rem solid ${colors.line01}; - border-radius: 8px; - padding: 1rem; - z-index: ${theme.zIndex.datePickerPopup}; - - width: 45.2rem; - - box-shadow: 0rem 0.4rem 1rem rgba(0, 0, 0, 0.1); -`; - -const datepickerCustom = (theme: Theme) => css` - .rdp-months { - width: 40rem; - position: relative; - padding-top: 1.2rem; - } - - .rdp-month { - display: flex; - flex-flow: column nowrap; - } - - .rdp-nav { - position: absolute; - top: 1.2rem; - right: 50%; - transform: translate(50%); - - display: flex; - flex-flow: row nowrap; - justify-content: center; - gap: 24.8rem; - } - - .rdp-chevron { - fill: ${theme.colors.icon03}; - } - - .rdp-month_caption { - display: flex; - flex-flow: row-reverse nowrap; - justify-content: center; - } - - .rdp-dropdowns { - margin-top: 2.4rem 3rem 0 3rem; - width: 21.6rem; - - display: flex; - flex-flow: row-reverse nowrap; - } - - .rdp-dropdown:focus { - outline: none; - border: none; - } - - .rdp-years_dropdown { - width: 11.4rem; - } - - .rdp-months_dropdown { - width: 9.4rem; - cursor: not-allowed; - pointer-events: none; - } - - .rdp-caption_label { - width: 9.4rem; - } - .rdp-caption_label .rdp-chevron { - visibility: hidden; - } - - .rdp-dropdowns span[role='status']::after { - content: '년'; - } - - .rdp-month_grid { - border-collapse: separate !important; - - border-spacing: 0 1.2rem; - margin-top: 0.8rem; - - width: 43rem; - padding: 0 1.6rem; - } - - .rdp-weekdays { - color: ${theme.colors.text03}; - height: 3.2rem; - - background-color: ${theme.colors.field02}; - border-radius: 1.2rem; - - th { - ${theme.fonts.label.medium.M13}; - } - - th:first-of-type { - border-top-left-radius: 1.2rem; - border-bottom-left-radius: 1.2rem; - } - - th:last-of-type { - border-top-right-radius: 1.2rem; - border-bottom-right-radius: 1.2rem; - } - } - - .rdp-day_button { - width: 4rem; - height: 4rem; - - border-radius: 1.2rem; - border: none; - - margin: 0 auto; - } - - .rdp-selected .rdp-range_middle { - width: 4rem; - height: 4rem; - } - - .rdp-today .rdp-day_button { - color: ${theme.colors.text06}; - - width: 4rem; - height: 4rem; - - border-radius: 1.2rem; - border: none; - - background-color: ${theme.colors.field02}; - } - - .rdp-range_start .rdp-day_button { - background-color: ${theme.colors.primaryMint}; - color: ${theme.colors.text01}; - } - - .rdp-day_button { - ${theme.fonts.body.normal.M16}; - } - .rdp-selected { - ${theme.fonts.body.normal.M16}; - } -`; diff --git a/src/app/upload/components/DatePickerForm/DatePickerForm.styles.ts b/src/app/upload/components/DatePickerForm/DatePickerForm.styles.ts new file mode 100644 index 0000000..21cdc70 --- /dev/null +++ b/src/app/upload/components/DatePickerForm/DatePickerForm.styles.ts @@ -0,0 +1,222 @@ +import { css, Theme } from '@emotion/react'; + +export const datePickerFieldContainer = (theme: Theme) => css` + position: relative; + outline: none; + + :focus { + outline: none; + outline: 0.1rem solid ${theme.colors.primaryMint}; + border-radius: 1.2em; + + .date-picker-field { + border: none; + } + } +`; + +export const datePickerField = ( + theme: Theme, + experimentDateChecked: boolean, + isOpen: boolean, + isError: boolean, +) => css` + width: 100%; + height: 4.8rem; + + display: flex; + align-items: center; + justify-content: space-between; + + padding: 1.3rem 1.6rem; + + border: 0.1rem solid + ${isError + ? theme.colors.textAlert + : experimentDateChecked + ? theme.colors.line01 + : isOpen + ? theme.colors.lineTinted + : theme.colors.line01}; + + border-radius: 1.2rem; + + background-color: ${experimentDateChecked ? theme.colors.field02 : theme.colors.field01}; + + cursor: ${experimentDateChecked ? 'not-allowed' : 'pointer'}; +`; + +export const placeholderText = ( + theme: Theme, + bothDatesSelected: boolean, + experimentDateChecked: boolean, +) => css` + ${theme.fonts.label.large.R14}; + color: ${experimentDateChecked + ? theme.colors.text02 + : bothDatesSelected + ? theme.colors.text06 + : theme.colors.text02}; + + flex: 1; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const iconStyle = css` + margin-left: 1rem; +`; + +export const popoverLayout = (theme: Theme) => css` + background-color: ${theme.colors.field01}; + border: 0.1rem solid ${theme.colors.line01}; + border-radius: 8px; + padding: 1rem; + z-index: ${theme.zIndex.datePickerPopup}; + + width: 45.2rem; + + box-shadow: 0rem 0.4rem 1rem rgba(0, 0, 0, 0.1); +`; + +export const datepickerCustom = (theme: Theme) => css` + .rdp-months { + width: 40rem; + position: relative; + padding-top: 1.2rem; + } + + .rdp-month { + display: flex; + flex-flow: column nowrap; + } + + .rdp-nav { + position: absolute; + top: 1.2rem; + right: 50%; + transform: translate(50%); + + display: flex; + flex-flow: row nowrap; + justify-content: center; + gap: 24.8rem; + } + + .rdp-chevron { + fill: ${theme.colors.icon03}; + } + + .rdp-month_caption { + display: flex; + flex-flow: row-reverse nowrap; + justify-content: center; + } + + .rdp-dropdowns { + margin-top: 2.4rem 3rem 0 3rem; + width: 21.6rem; + + display: flex; + flex-flow: row-reverse nowrap; + } + + .rdp-dropdown:focus { + outline: none; + border: none; + } + + .rdp-years_dropdown { + width: 11.4rem; + } + + .rdp-months_dropdown { + width: 9.4rem; + cursor: not-allowed; + pointer-events: none; + } + + .rdp-caption_label { + width: 9.4rem; + } + .rdp-caption_label .rdp-chevron { + visibility: hidden; + } + + .rdp-dropdowns span[role='status']::after { + content: '년'; + } + + .rdp-month_grid { + border-collapse: separate !important; + + border-spacing: 0 1.2rem; + margin-top: 0.8rem; + + width: 43rem; + padding: 0 1.6rem; + } + + .rdp-weekdays { + color: ${theme.colors.text03}; + height: 3.2rem; + + background-color: ${theme.colors.field02}; + border-radius: 1.2rem; + + th { + ${theme.fonts.label.medium.M13}; + } + + th:first-of-type { + border-top-left-radius: 1.2rem; + border-bottom-left-radius: 1.2rem; + } + + th:last-of-type { + border-top-right-radius: 1.2rem; + border-bottom-right-radius: 1.2rem; + } + } + + .rdp-day_button { + width: 4rem; + height: 4rem; + + border-radius: 1.2rem; + border: none; + + margin: 0 auto; + } + + .rdp-selected .rdp-range_middle { + width: 4rem; + height: 4rem; + } + + .rdp-today .rdp-day_button { + color: ${theme.colors.text06}; + + width: 4rem; + height: 4rem; + + border-radius: 1.2rem; + border: none; + + background-color: ${theme.colors.field02}; + } + + .rdp-range_start .rdp-day_button { + background-color: ${theme.colors.primaryMint}; + color: ${theme.colors.text01}; + } + + .rdp-day_button { + ${theme.fonts.body.normal.M16}; + } + .rdp-selected { + ${theme.fonts.body.normal.M16}; + } +`; diff --git a/src/app/upload/components/DatePickerForm/DatePickerForm.tsx b/src/app/upload/components/DatePickerForm/DatePickerForm.tsx new file mode 100644 index 0000000..c9869be --- /dev/null +++ b/src/app/upload/components/DatePickerForm/DatePickerForm.tsx @@ -0,0 +1,120 @@ +import * as Popover from '@radix-ui/react-popover'; +import { ko } from 'date-fns/locale'; +import React, { useState } from 'react'; +import { DayPicker, DateRange } from 'react-day-picker'; +import { FieldError } from 'react-hook-form'; + +import 'react-day-picker/dist/style.css'; + +import { + datepickerCustom, + datePickerField, + datePickerFieldContainer, + iconStyle, + placeholderText, + popoverLayout, +} from './DatePickerForm.styles'; +import { formatRange } from '../../upload.utils'; + +import Icon from '@/components/Icon'; +import { colors } from '@/styles/colors'; + +interface DatePickerFormProps { + placeholder: string; + onDateChange: (dates: { from: string | null; to: string | null }) => void; + experimentDateChecked?: boolean; + error?: FieldError; +} + +const DatePickerField = ({ + placeholder, + onDateChange, + experimentDateChecked = false, + error, +}: DatePickerFormProps) => { + const [isOpen, setIsOpen] = useState(false); + + const [selectedDates, setSelectedDates] = useState({ + from: undefined, + to: undefined, + }); + + const handleDateChange = (range: DateRange) => { + const formattedRange = formatRange(range); + setSelectedDates(range); + onDateChange(formattedRange); + }; + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsOpen((prev) => !prev); + } + }} + > + + +
datePickerField(theme, experimentDateChecked, isOpen, !!error)} + aria-label="실험일시 선택" + className="date-picker-field" + > + placeholderText(theme, !!selectedDates.from, experimentDateChecked)} + > + {!experimentDateChecked + ? selectedDates.from + ? selectedDates.from?.toLocaleDateString() === + selectedDates.to?.toLocaleDateString() + ? `${selectedDates.from?.toLocaleDateString()}` + : `${selectedDates.from?.toLocaleDateString()} ~ ${selectedDates.to?.toLocaleDateString()}` + : placeholder + : '본문 참고'} + + + + +
+
+ + + + + +
+
+ ); +}; + +export default DatePickerField; diff --git a/src/app/upload/upload.utils.ts b/src/app/upload/upload.utils.ts new file mode 100644 index 0000000..3f50031 --- /dev/null +++ b/src/app/upload/upload.utils.ts @@ -0,0 +1,10 @@ +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { DateRange } from 'react-day-picker'; + +export const formatRange = (range: DateRange) => { + return { + from: range.from ? format(range.from, 'yyyy.MM.dd', { locale: ko }) : null, + to: range.to ? format(range.to, 'yyyy.MM.dd', { locale: ko }) : null, + }; +}; From bbc5dbbf2539a1da98a27cda988f321cd6a7a196 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:18:02 +0900 Subject: [PATCH 05/32] =?UTF-8?q?[YS-172]=20feat:=20InputForm=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CheckboxWithIcon/CheckboxWithIcon.tsx | 2 +- .../upload/components/InputForm/InputForm.tsx | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/app/upload/components/InputForm/InputForm.tsx diff --git a/src/app/upload/components/CheckboxWithIcon/CheckboxWithIcon.tsx b/src/app/upload/components/CheckboxWithIcon/CheckboxWithIcon.tsx index 001ebca..1a25718 100644 --- a/src/app/upload/components/CheckboxWithIcon/CheckboxWithIcon.tsx +++ b/src/app/upload/components/CheckboxWithIcon/CheckboxWithIcon.tsx @@ -40,7 +40,7 @@ const CheckboxWithIcon = ({ cursor="pointer" /> )} - +

{label}

); diff --git a/src/app/upload/components/InputForm/InputForm.tsx b/src/app/upload/components/InputForm/InputForm.tsx new file mode 100644 index 0000000..a052030 --- /dev/null +++ b/src/app/upload/components/InputForm/InputForm.tsx @@ -0,0 +1,38 @@ +import { input } from '@/app/upload/components/UploadContainer/UploadContainer'; + +interface InputFormProps { + id: string; + field: { + name: string; + value: string; + onChange: (event: React.ChangeEvent) => void; + onBlur: VoidFunction; + }; + fieldState: { + error?: { + message?: string; + }; + }; + placeholder?: string; + type?: string; +} + +const InputForm = ({ field, fieldState, placeholder, type = 'text', id }: InputFormProps) => { + return ( + <> + + {fieldState.error && ( +

{fieldState.error.message}

+ )} + + ); +}; + +export default InputForm; From 0e84ec873ef8f59a85717d65fcc2ed924f375ec6 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:18:24 +0900 Subject: [PATCH 06/32] =?UTF-8?q?[YS-172]=20refactor:=20=EC=97=B0=EA=B5=AC?= =?UTF-8?q?=20=EC=B1=85=EC=9E=84=EC=9E=90,=20=EC=8B=A4=ED=97=98=EC=9D=BC?= =?UTF-8?q?=EC=8B=9C=20form=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OutlineSection/OutlineSection.tsx | 105 ++++++++++++------ .../upload/hooks/useUploadExperimentPost.tsx | 2 +- .../upload/uploadExperimentPostSchema.ts | 24 +--- src/types/uploadExperimentPost.ts | 5 + 4 files changed, 77 insertions(+), 59 deletions(-) create mode 100644 src/types/uploadExperimentPost.ts diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index b98c516..d8684d4 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -1,25 +1,27 @@ import { css, Theme } from '@emotion/react'; import { useState } from 'react'; -import { DateRange } from 'react-day-picker'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import CheckboxWithIcon from '../CheckboxWithIcon/CheckboxWithIcon'; import CountSelect from '../CountSelect/CountSelect'; import DurationSelect from '../DurationSelect/DurationSelect'; +import InputForm from '../InputForm/InputForm'; import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'; import RegionPopover from '../RegionPopover/RegionPopover'; import { TextInput } from '../TextInput/TextInput'; import { headingIcon, input, label } from '../UploadContainer/UploadContainer'; -import DatePickerField from '@/app/upload/components/DatePickerField/DatePickerField'; +import DatePickerForm from '@/app/upload/components/DatePickerForm/DatePickerForm'; import { colors } from '@/styles/colors'; - -export enum MatchType { - OFFLINE = 'OFFLINE', - ONLINE = 'ONLINE', - HYBRID = 'HYBRID', -} +import { MatchType } from '@/types/uploadExperimentPost'; const OutlineSection = () => { + const { control, setValue, formState } = useFormContext(); + const formData = useWatch({ control }); + console.log('formData >> ', formData); + console.log('errors>> ', formState.errors); + + // todo useReducer로 리팩토링 const [experimentDateChecked, setExperimentDateChecked] = useState(false); const [durationChecked, setDurationChecked] = useState(false); @@ -29,17 +31,6 @@ const OutlineSection = () => { setSelectedMatchType(method); }; - // todo react-hook-form 연결 시 controller로 관리할 값 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [selectedDates, setSelectedDates] = useState({ - from: undefined, - to: undefined, - }); - - const handleDateChange = (dates: DateRange) => { - setSelectedDates(dates); - }; - // 실험 장소 지역구 선택 const [selectedRegion, setSelectedRegion] = useState(null); const [selectedSubRegion, setSelectedSubRegion] = useState(null); @@ -74,44 +65,84 @@ const OutlineSection = () => { 1실험의 개요를 알려주세요 -
+
{/* 연구 책임자 */}
-
{/* 실험 일시 */}
-
- {/* 진행 방식 */}
-
-
+ ); }; diff --git a/src/app/upload/hooks/useUploadExperimentPost.tsx b/src/app/upload/hooks/useUploadExperimentPost.tsx index 0a46b0e..f66ca2c 100644 --- a/src/app/upload/hooks/useUploadExperimentPost.tsx +++ b/src/app/upload/hooks/useUploadExperimentPost.tsx @@ -8,7 +8,7 @@ import UploadExperimentPostSchema, { const useUploadExperimentPost = () => { const form = useForm({ mode: 'onBlur', - reValidateMode: 'onBlur', + reValidateMode: 'onChange', resolver: zodResolver(UploadExperimentPostSchema()), defaultValues: { leadResearcher: '', diff --git a/src/schema/upload/uploadExperimentPostSchema.ts b/src/schema/upload/uploadExperimentPostSchema.ts index 483735d..11479b5 100644 --- a/src/schema/upload/uploadExperimentPostSchema.ts +++ b/src/schema/upload/uploadExperimentPostSchema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { MatchType } from '@/app/upload/components/OutlineSection/OutlineSection'; +import { MatchType } from '@/types/uploadExperimentPost'; export type UploadExperimentPostSchemaType = z.infer>; @@ -22,28 +22,10 @@ const UploadExperimentPostSchema = () => { // }), // 실험 시작 날짜 - startDate: z.union( - [ - z.date({ errorMap: () => ({ message: '' }) }), - z.null({ errorMap: () => ({ message: '' }) }), - ], - { - required_error: '', - invalid_type_error: '', - }, - ), + startDate: z.union([z.string(), z.null()]), // 실험 종료 날짜 - endDate: z.union( - [ - z.date({ errorMap: () => ({ message: '' }) }), - z.null({ errorMap: () => ({ message: '' }) }), - ], - { - required_error: '', - invalid_type_error: '', - }, - ), + endDate: z.union([z.string(), z.null()]), // 진행 방식 matchType: z.nativeEnum(MatchType), diff --git a/src/types/uploadExperimentPost.ts b/src/types/uploadExperimentPost.ts new file mode 100644 index 0000000..f4f65ef --- /dev/null +++ b/src/types/uploadExperimentPost.ts @@ -0,0 +1,5 @@ +export enum MatchType { + OFFLINE = 'OFFLINE', + ONLINE = 'ONLINE', + HYBRID = 'HYBRID', +} From eea47531f10a6b0a80ff52af72d481e700bf1208 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:27:53 +0900 Subject: [PATCH 07/32] =?UTF-8?q?[YS-172]=20feat:=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20RadioButtonGroup=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20form=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OutlineSection/OutlineSection.tsx | 31 +++++++----- .../RadioButtonGroup.styles.ts | 44 +++++++++++++++++ .../RadioButtonGroup/RadioButtonGroup.tsx | 49 ++++--------------- 3 files changed, 71 insertions(+), 53 deletions(-) create mode 100644 src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index d8684d4..bf2ca4e 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -25,11 +25,8 @@ const OutlineSection = () => { const [experimentDateChecked, setExperimentDateChecked] = useState(false); const [durationChecked, setDurationChecked] = useState(false); - const [selectedMatchType, setSelectedMatchType] = useState(null); - - const handleMatchTypeChange = (method: MatchType) => { - setSelectedMatchType(method); - }; + // 대면 방식 + const selectedMatchType = useWatch({ control, name: 'matchType' }); // 실험 장소 지역구 선택 const [selectedRegion, setSelectedRegion] = useState(null); @@ -144,14 +141,22 @@ const OutlineSection = () => { 진행 방식 *

- - options={[ - { value: MatchType.OFFLINE, label: '대면' }, - { value: MatchType.ONLINE, label: '비대면' }, - { value: MatchType.HYBRID, label: '대면+비대면' }, - ]} - selectedValue={selectedMatchType} - onChange={handleMatchTypeChange} + ( + + options={[ + { value: MatchType.OFFLINE, label: '대면' }, + { value: MatchType.ONLINE, label: '비대면' }, + { value: MatchType.HYBRID, label: '대면+비대면' }, + ]} + selectedValue={field.value} + onChange={(value) => field.onChange(value)} + isError={!!fieldState.error} // 에러 상태 전달 + /> + )} /> diff --git a/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts new file mode 100644 index 0000000..0a6cce7 --- /dev/null +++ b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts @@ -0,0 +1,44 @@ +import { css, Theme } from '@emotion/react'; + +export const customRadioGroup = css` + display: flex; + flex-flow: row nowrap; + gap: 0.8rem; +`; + +export const customRadioButton = (theme: Theme) => css` + ${theme.fonts.label.large.M14}; + + width: 14.533rem; + height: 4.8rem; + + padding: 1rem 2rem; + + border: 0.1rem solid ${theme.colors.line01}; + border-radius: 1.2rem; + + background-color: ${theme.colors.field01}; + + cursor: pointer; + + transition: all 0.2s ease; + + &:hover { + background-color: ${theme.colors.field02}; + } +`; + +export const activeRadioButton = (theme: Theme) => css` + border: 0.1rem solid ${theme.colors.lineTinted}; + + background-color: ${theme.colors.primaryTinted}; + color: ${theme.colors.textPrimary}; + + &:hover { + background-color: ${theme.colors.primaryTinted}; + } +`; + +export const errorRadioButton = (theme: Theme) => css` + border-color: ${theme.colors.textAlert} !important; +`; diff --git a/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.tsx b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.tsx index 1d16b6e..1c5dd02 100644 --- a/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.tsx +++ b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.tsx @@ -1,15 +1,22 @@ -import { css, Theme } from '@emotion/react'; +import { + customRadioGroup, + customRadioButton, + activeRadioButton, + errorRadioButton, +} from './RadioButtonGroup.styles'; interface RadioButtonGroupProps { options: { value: T; label: string }[]; selectedValue: T | null; onChange: (value: T) => void; + isError?: boolean; } const RadioButtonGroup = ({ options, selectedValue, onChange, + isError = false, }: RadioButtonGroupProps) => { return (
@@ -20,6 +27,7 @@ const RadioButtonGroup = ({ css={(theme) => [ customRadioButton(theme), selectedValue === option.value && activeRadioButton(theme), + isError && errorRadioButton(theme), ]} onClick={() => onChange(option.value)} > @@ -31,42 +39,3 @@ const RadioButtonGroup = ({ }; export default RadioButtonGroup; - -const customRadioGroup = css` - display: flex; - flex-flow: row nowrap; - gap: 0.8rem; -`; - -const customRadioButton = (theme: Theme) => css` - ${theme.fonts.label.large.M14}; - - width: 14.533rem; - height: 4.8rem; - - padding: 1rem 2rem; - - border: 0.1rem solid ${theme.colors.line01}; - border-radius: 1.2rem; - - background-color: ${theme.colors.field01}; - - cursor: pointer; - - transition: all 0.2s ease; - - &:hover { - background-color: ${theme.colors.field02}; - } -`; - -const activeRadioButton = (theme: Theme) => css` - border: 0.1rem solid ${theme.colors.lineTinted}; - - background-color: ${theme.colors.primaryTinted}; - color: ${theme.colors.textPrimary}; - - &:hover { - background-color: ${theme.colors.primaryTinted}; - } -`; From 3f96b8201996db16ef13f860d105a25c75df0910 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:36:50 +0900 Subject: [PATCH 08/32] =?UTF-8?q?[YS-172]=20feat:=20=EB=B3=B4=EC=83=81=20f?= =?UTF-8?q?orm=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20InputForm=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20border-color=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/components/InputForm/InputForm.tsx | 2 +- .../OutlineSection/OutlineSection.tsx | 17 +++++++++++++++-- .../UploadContainer/UploadContainer.tsx | 4 ++-- src/schema/upload/uploadExperimentPostSchema.ts | 6 +++++- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/upload/components/InputForm/InputForm.tsx b/src/app/upload/components/InputForm/InputForm.tsx index a052030..cff8ba5 100644 --- a/src/app/upload/components/InputForm/InputForm.tsx +++ b/src/app/upload/components/InputForm/InputForm.tsx @@ -23,7 +23,7 @@ const InputForm = ({ field, fieldState, placeholder, type = 'text', id }: InputF input(theme, !!fieldState.error)} type={type} placeholder={placeholder} value={field.value || ''} diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index bf2ca4e..3727d0d 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -135,6 +135,7 @@ const OutlineSection = () => { label="본문 참고" />
+ {/* 진행 방식 */}

@@ -154,7 +155,7 @@ const OutlineSection = () => { ]} selectedValue={field.value} onChange={(value) => field.onChange(value)} - isError={!!fieldState.error} // 에러 상태 전달 + isError={!!fieldState.error} /> )} /> @@ -165,7 +166,19 @@ const OutlineSection = () => { - + ( + + )} + />

{/* 실험 장소 */} diff --git a/src/app/upload/components/UploadContainer/UploadContainer.tsx b/src/app/upload/components/UploadContainer/UploadContainer.tsx index 2d24dfa..4c8b29d 100644 --- a/src/app/upload/components/UploadContainer/UploadContainer.tsx +++ b/src/app/upload/components/UploadContainer/UploadContainer.tsx @@ -144,7 +144,7 @@ export const outlineFormLayout = css` margin: 0 auto; `; -export const input = (theme: Theme) => css` +export const input = (theme: Theme, isError?: boolean) => css` ${theme.fonts.label.large.R14}; width: 100%; @@ -152,7 +152,7 @@ export const input = (theme: Theme) => css` height: 4.8rem; padding: 10px; - border: 0.1rem solid ${theme.colors.line01}; + border: 0.1rem solid ${isError ? theme.colors.textAlert : theme.colors.line01}; border-radius: 1.2rem; outline: none; diff --git a/src/schema/upload/uploadExperimentPostSchema.ts b/src/schema/upload/uploadExperimentPostSchema.ts index 11479b5..1017cc1 100644 --- a/src/schema/upload/uploadExperimentPostSchema.ts +++ b/src/schema/upload/uploadExperimentPostSchema.ts @@ -29,8 +29,10 @@ const UploadExperimentPostSchema = () => { // 진행 방식 matchType: z.nativeEnum(MatchType), + // 실험 횟수 count: z.enum(['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']).transform(Number), // 참여 횟수 + // 소요 시간 timeRequired: z.union([ z.enum([ @@ -60,7 +62,9 @@ const UploadExperimentPostSchema = () => { // 상세 주소 detailedAddress: z.string().optional(), // 보상 - reward: z.string().nonempty('보상 필수'), + reward: z + .string({ message: '최소 3자 이상으로 입력해 주세요' }) + .min(3, { message: '최소 3자 이상으로 입력해 주세요' }), // title: z.string().nonempty('실험 제목 필수'), // 실험 제목 // content: z.string().nonempty('실험 본문 필수'), // 실험 본문 From 115a1385593610b0c97da34dc644a574471ff5ed Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Wed, 22 Jan 2025 23:50:40 +0900 Subject: [PATCH 09/32] =?UTF-8?q?[YS-172]=20design:=20SelectInput=20focus?= =?UTF-8?q?=20=EC=8B=9C=20border-color=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DatePickerForm/DatePickerForm.tsx | 2 +- .../RadioButtonGroup.styles.ts | 4 ++++ .../RegionPopover/RegionPopover.styles.ts | 10 ++++++++++ .../RegionPopover/RegionPopover.tsx | 20 +++++++++++++++---- .../components/SelectInput/SelectInput.tsx | 4 ++++ 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/app/upload/components/DatePickerForm/DatePickerForm.tsx b/src/app/upload/components/DatePickerForm/DatePickerForm.tsx index c9869be..3e969d2 100644 --- a/src/app/upload/components/DatePickerForm/DatePickerForm.tsx +++ b/src/app/upload/components/DatePickerForm/DatePickerForm.tsx @@ -50,7 +50,7 @@ const DatePickerField = ({ css={datePickerFieldContainer} tabIndex={0} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { + if (e.key === ' ') { e.preventDefault(); setIsOpen((prev) => !prev); } diff --git a/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts index 0a6cce7..084048d 100644 --- a/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts +++ b/src/app/upload/components/RadioButtonGroup/RadioButtonGroup.styles.ts @@ -26,6 +26,10 @@ export const customRadioButton = (theme: Theme) => css` &:hover { background-color: ${theme.colors.field02}; } + + :focus { + border: 0.1rem solid ${theme.colors.primaryMint}; + } `; export const activeRadioButton = (theme: Theme) => css` diff --git a/src/app/upload/components/RegionPopover/RegionPopover.styles.ts b/src/app/upload/components/RegionPopover/RegionPopover.styles.ts index 29102ff..73e9ea0 100644 --- a/src/app/upload/components/RegionPopover/RegionPopover.styles.ts +++ b/src/app/upload/components/RegionPopover/RegionPopover.styles.ts @@ -1,5 +1,15 @@ import { css, Theme } from '@emotion/react'; +export const regionPopoverContainer = (theme: Theme) => css` + width: 45.2rem; + + :focus { + outline: none; + border: 0.1rem solid ${theme.colors.primaryMint}; + border-radius: 1.2rem; + } +`; + export const regionField = (theme: Theme) => css` display: flex; flex-flow: row nowrap; diff --git a/src/app/upload/components/RegionPopover/RegionPopover.tsx b/src/app/upload/components/RegionPopover/RegionPopover.tsx index a8bb469..9b0cf41 100644 --- a/src/app/upload/components/RegionPopover/RegionPopover.tsx +++ b/src/app/upload/components/RegionPopover/RegionPopover.tsx @@ -1,7 +1,6 @@ +import { Theme } from '@emotion/react'; import * as Popover from '@radix-ui/react-popover'; -import { css, Theme } from '@emotion/react'; -import { UPLOAD_REGION } from '@/constants/uploadRegion'; -import { input } from '../UploadContainer/UploadContainer'; + import { regionField, popoverContent, @@ -12,8 +11,12 @@ import { subRegionList, subRegionButton, placeholderText, + regionPopoverContainer, } from './RegionPopover.styles'; +import { input } from '../UploadContainer/UploadContainer'; + import Icon from '@/components/Icon'; +import { UPLOAD_REGION } from '@/constants/uploadRegion'; interface RegionPopoverProps { regionPopoverProps: { @@ -43,7 +46,16 @@ const RegionPopover = ({ regionPopoverProps }: RegionPopoverProps) => { return ( -
+
{ + if (e.key === ' ') { + e.preventDefault(); + onOpenRegionPopover(!isOpenRegionPopover); + } + }} + >
diff --git a/src/app/upload/components/SelectInput/SelectInput.tsx b/src/app/upload/components/SelectInput/SelectInput.tsx index f8a0ce4..bee502c 100644 --- a/src/app/upload/components/SelectInput/SelectInput.tsx +++ b/src/app/upload/components/SelectInput/SelectInput.tsx @@ -89,6 +89,10 @@ const selectTrigger = (theme: Theme) => css` &[data-state='open'] { border: 0.1rem solid ${theme.colors.primaryMint}; } + + :focus { + border: 0.1rem solid ${theme.colors.primaryMint}; + } `; const selectDisabled = (theme: Theme) => css` From e2c355a9a09150ad95df62a2e128dd84cae98b6d Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Thu, 23 Jan 2025 23:20:21 +0900 Subject: [PATCH 10/32] =?UTF-8?q?[YS-172]=20feat:=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20form=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/components/InputForm/InputForm.tsx | 74 ++++++++++++++++-- .../OutlineSection/OutlineSection.tsx | 77 +++++++++++++++---- .../RegionPopover/RegionPopover.styles.ts | 5 +- .../RegionPopover/RegionPopover.tsx | 5 +- .../upload/components/TextInput/TextInput.tsx | 25 ++++-- .../UploadContainer/UploadContainer.tsx | 2 +- .../upload/hooks/useUploadExperimentPost.tsx | 15 +++- .../upload/uploadExperimentPostSchema.ts | 14 ++-- 8 files changed, 180 insertions(+), 37 deletions(-) diff --git a/src/app/upload/components/InputForm/InputForm.tsx b/src/app/upload/components/InputForm/InputForm.tsx index cff8ba5..11b568f 100644 --- a/src/app/upload/components/InputForm/InputForm.tsx +++ b/src/app/upload/components/InputForm/InputForm.tsx @@ -1,3 +1,6 @@ +import { css, Theme } from '@emotion/react'; +import { useState } from 'react'; + import { input } from '@/app/upload/components/UploadContainer/UploadContainer'; interface InputFormProps { @@ -15,11 +18,30 @@ interface InputFormProps { }; placeholder?: string; type?: string; + showErrorMessage?: boolean; + size?: 'half' | 'full'; + maxLength?: number; } -const InputForm = ({ field, fieldState, placeholder, type = 'text', id }: InputFormProps) => { +const InputForm = ({ + field, + fieldState, + placeholder, + type = 'text', + id, + showErrorMessage = true, + size = 'half', + maxLength, +}: InputFormProps) => { + const [textLength, setTextLength] = useState(field.value?.length || 0); + + const handleChange = (e: React.ChangeEvent) => { + field.onChange(e); + setTextLength(e.target.value.length); + }; + return ( - <> +
- {fieldState.error && ( -

{fieldState.error.message}

- )} - + +
+ {maxLength && ( +
+ {textLength}/{maxLength} +
+ )} + {fieldState?.error && showErrorMessage && ( +

{fieldState.error.message}

+ )} +
+
); }; export default InputForm; + +const textInputContainer = (size: 'half' | 'full') => css` + display: flex; + flex-direction: column; + gap: 0.4rem; + position: relative; + + width: 100%; + max-width: ${size === 'half' ? '45.2rem' : '93.6rem'}; +`; + +const textCounter = (theme: Theme) => css` + ${theme.fonts.label.small.M12}; + color: ${theme.colors.text02}; + + text-align: right; +`; + +const textSubMessageLayout = css` + display: flex; + flex-flow: row-reverse nowrap; + justify-content: space-between; + align-items: center; +`; + +const formMessage = (theme: Theme) => css` + ${theme.fonts.label.small.M12}; + color: ${theme.colors.textAlert}; + margin: 0; +`; diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index 3727d0d..2b8fa61 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -8,7 +8,6 @@ import DurationSelect from '../DurationSelect/DurationSelect'; import InputForm from '../InputForm/InputForm'; import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'; import RegionPopover from '../RegionPopover/RegionPopover'; -import { TextInput } from '../TextInput/TextInput'; import { headingIcon, input, label } from '../UploadContainer/UploadContainer'; import DatePickerForm from '@/app/upload/components/DatePickerForm/DatePickerForm'; @@ -36,11 +35,16 @@ const OutlineSection = () => { const handleRegionSelect = (region: string) => { setSelectedRegion(region); setSelectedSubRegion(null); + + setValue('region', region, { shouldValidate: true }); + setValue('area', '', { shouldValidate: true }); }; const handleSubRegionSelect = (subRegion: string) => { setSelectedSubRegion(subRegion); setIsOpenRegionPopover(false); + + setValue('area', subRegion, { shouldValidate: true }); }; const regionPopoverProps = { @@ -71,18 +75,15 @@ const OutlineSection = () => { ( - <> - - + )} />
@@ -145,7 +146,6 @@ const OutlineSection = () => { ( options={[ @@ -190,11 +190,56 @@ const OutlineSection = () => {
비대면
) : (
- + ( + + )} + /> {/* 지역구 선택 */} - + ( + ( + + )} + /> + )} + /> - + {/* 상세 주소 입력 */} + ( + + )} + />
)}
diff --git a/src/app/upload/components/RegionPopover/RegionPopover.styles.ts b/src/app/upload/components/RegionPopover/RegionPopover.styles.ts index 73e9ea0..f7dcf82 100644 --- a/src/app/upload/components/RegionPopover/RegionPopover.styles.ts +++ b/src/app/upload/components/RegionPopover/RegionPopover.styles.ts @@ -1,8 +1,11 @@ import { css, Theme } from '@emotion/react'; -export const regionPopoverContainer = (theme: Theme) => css` +export const regionPopoverContainer = (theme: Theme, isError?: boolean) => css` width: 45.2rem; + border: 0.1rem solid ${isError ? theme.colors.textAlert : 'none'}; + border-radius: 1.2rem; + :focus { outline: none; border: 0.1rem solid ${theme.colors.primaryMint}; diff --git a/src/app/upload/components/RegionPopover/RegionPopover.tsx b/src/app/upload/components/RegionPopover/RegionPopover.tsx index 9b0cf41..5c98246 100644 --- a/src/app/upload/components/RegionPopover/RegionPopover.tsx +++ b/src/app/upload/components/RegionPopover/RegionPopover.tsx @@ -1,5 +1,6 @@ import { Theme } from '@emotion/react'; import * as Popover from '@radix-ui/react-popover'; +import { FieldError } from 'react-hook-form'; import { regionField, @@ -26,6 +27,7 @@ interface RegionPopoverProps { onOpenRegionPopover: (open: boolean) => void; onRegionSelect: (region: string) => void; onSubRegionSelect: (subRegion: string) => void; + error?: FieldError; }; } @@ -37,6 +39,7 @@ const RegionPopover = ({ regionPopoverProps }: RegionPopoverProps) => { onSubRegionSelect, selectedRegion, selectedSubRegion, + error, } = regionPopoverProps; const regionData = selectedRegion @@ -47,7 +50,7 @@ const RegionPopover = ({ regionPopoverProps }: RegionPopoverProps) => {
regionPopoverContainer(theme, !!error)} tabIndex={0} onKeyDown={(e) => { if (e.key === ' ') { diff --git a/src/app/upload/components/TextInput/TextInput.tsx b/src/app/upload/components/TextInput/TextInput.tsx index 536081d..3c16d04 100644 --- a/src/app/upload/components/TextInput/TextInput.tsx +++ b/src/app/upload/components/TextInput/TextInput.tsx @@ -5,29 +5,44 @@ interface TextInputProps { id: string; placeholder: string; maxLength?: number; - message?: string; - status?: 'error' | ''; size?: 'half' | 'full'; + field?: { + name: string; + value: string; + onChange: (event: React.ChangeEvent) => void; + onBlur: VoidFunction; + }; + fieldState?: { + error?: { + message?: string; + }; + }; } export const TextInput = forwardRef( - ({ id, placeholder, maxLength, message, status = '', size = 'half' }, ref) => { + ({ id, placeholder, maxLength, size = 'half', field, fieldState }, ref) => { const [textLength, setTextLength] = useState(0); const handleChange = (e: React.ChangeEvent) => { + if (field?.onChange) { + field.onChange(e); + } + setTextLength(e.target.value.length); }; return (
textInput(theme, status)} + value={field?.value || ''} + css={(theme) => textInput(theme, fieldState?.error ? 'error' : '')} />
{maxLength && ( @@ -35,7 +50,7 @@ export const TextInput = forwardRef( {textLength}/{maxLength}
)} - {status === 'error' && message &&

{message}

} + {fieldState?.error &&

{fieldState.error.message}

}
); diff --git a/src/app/upload/components/UploadContainer/UploadContainer.tsx b/src/app/upload/components/UploadContainer/UploadContainer.tsx index 4c8b29d..dd8b445 100644 --- a/src/app/upload/components/UploadContainer/UploadContainer.tsx +++ b/src/app/upload/components/UploadContainer/UploadContainer.tsx @@ -38,7 +38,7 @@ const UploadContainer = () => { -
diff --git a/src/app/upload/hooks/useUploadExperimentPost.tsx b/src/app/upload/hooks/useUploadExperimentPost.tsx index f66ca2c..770e97c 100644 --- a/src/app/upload/hooks/useUploadExperimentPost.tsx +++ b/src/app/upload/hooks/useUploadExperimentPost.tsx @@ -9,17 +9,28 @@ const useUploadExperimentPost = () => { const form = useForm({ mode: 'onBlur', reValidateMode: 'onChange', - resolver: zodResolver(UploadExperimentPostSchema()), + resolver: async (data, context, options) => { + const matchType = data.matchType; + const schema = UploadExperimentPostSchema({ matchType }); + return zodResolver(schema)(data, context, options); + }, defaultValues: { leadResearcher: '', startDate: undefined, endDate: undefined, + matchType: undefined, + reward: '', + univName: undefined, + detailedAddress: '', + region: undefined, + area: undefined, }, }); const handleSubmit = async (data: UploadExperimentPostSchemaType) => { try { - // console.log('공고 등록 form >> ', data); + console.log('공고 등록 form >> ', data); + // todo region label이 아닌 value로 변경 필요 await form.reset(); } catch (error) { diff --git a/src/schema/upload/uploadExperimentPostSchema.ts b/src/schema/upload/uploadExperimentPostSchema.ts index 1017cc1..46612af 100644 --- a/src/schema/upload/uploadExperimentPostSchema.ts +++ b/src/schema/upload/uploadExperimentPostSchema.ts @@ -4,7 +4,10 @@ import { MatchType } from '@/types/uploadExperimentPost'; export type UploadExperimentPostSchemaType = z.infer>; -const UploadExperimentPostSchema = () => { +interface UploadExperimentPostSchemaProps { + matchType: MatchType; +} +const UploadExperimentPostSchema = ({ matchType }: UploadExperimentPostSchemaProps) => { return z.object({ // targetGroupInfo: z.object({ // startAge: z.number().min(0, '0세 이상'), // 참여 가능 나이 (이상) @@ -54,13 +57,14 @@ const UploadExperimentPostSchema = () => { .min(10, { message: '최소 10자 이상으로 입력해 주세요' }) .max(150, { message: '최대 150자 이하로 입력해 주세요' }), // 대학교 - univName: z.string().nonempty('대학교 이름 필수'), + univName: z.string({ required_error: '' }), // 지역 - region: z.string().nonempty('지역 필수'), + region: + matchType === MatchType.ONLINE ? z.string().optional() : z.string({ required_error: '' }), // 지역구 - area: z.string().nonempty('지역구 필수'), + area: matchType === MatchType.ONLINE ? z.string().optional() : z.string({ required_error: '' }), // 상세 주소 - detailedAddress: z.string().optional(), + detailedAddress: z.string().max(70, { message: '최대 70자 이하로 입력해 주세요' }), // 보상 reward: z .string({ message: '최소 3자 이상으로 입력해 주세요' }) From b067b5bf44b11bc1f43d1d15ae327008a6ac6b68 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Thu, 23 Jan 2025 23:45:27 +0900 Subject: [PATCH 11/32] =?UTF-8?q?[YS-172]=20feat:=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=EA=B0=9C=EC=9A=94=20form=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/CountSelect/CountSelect.tsx | 6 ++- .../DurationSelect/DurationSelect.tsx | 21 ++++++--- .../upload/components/InputForm/InputForm.tsx | 12 +++--- .../OutlineSection/OutlineSection.tsx | 43 ++++++++++++++----- .../components/SelectInput/SelectInput.tsx | 8 +++- .../upload/hooks/useUploadExperimentPost.tsx | 16 ++++++- .../upload/uploadExperimentPostSchema.ts | 1 + 7 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/app/upload/components/CountSelect/CountSelect.tsx b/src/app/upload/components/CountSelect/CountSelect.tsx index 38941ad..ffb89a3 100644 --- a/src/app/upload/components/CountSelect/CountSelect.tsx +++ b/src/app/upload/components/CountSelect/CountSelect.tsx @@ -45,16 +45,18 @@ const countSelectOptions = [ interface CountSelectProps { value: string | undefined; - onChange: (value: string) => void; + onChange: (value: string | undefined) => void; + error: boolean; } -const CountSelect = ({ value, onChange }: CountSelectProps) => { +const CountSelect = ({ value, onChange, error }: CountSelectProps) => { return ( ); }; diff --git a/src/app/upload/components/DurationSelect/DurationSelect.tsx b/src/app/upload/components/DurationSelect/DurationSelect.tsx index ac1ef5b..ade78f5 100644 --- a/src/app/upload/components/DurationSelect/DurationSelect.tsx +++ b/src/app/upload/components/DurationSelect/DurationSelect.tsx @@ -1,11 +1,5 @@ import SelectInput from '../SelectInput/SelectInput'; -interface DurationSelectProps { - value: string | undefined; - onChange: (value: string | undefined) => void; - referToDetailsChecked?: boolean; -} - export const durationMinutesOptions = [ { label: '30분 미만', value: 'LESS_30M' }, { label: '약 30분', value: 'ABOUT_30M' }, @@ -18,7 +12,19 @@ export const durationMinutesOptions = [ { label: '약 4시간', value: 'ABOUT_4H' }, ]; -const DurationSelect = ({ value, onChange, referToDetailsChecked }: DurationSelectProps) => { +interface DurationSelectProps { + value: string | undefined; + onChange: (value: string | undefined) => void; + referToDetailsChecked?: boolean; + error: boolean; +} + +const DurationSelect = ({ + value, + onChange, + referToDetailsChecked = false, + error, +}: DurationSelectProps) => { return ( ); }; diff --git a/src/app/upload/components/InputForm/InputForm.tsx b/src/app/upload/components/InputForm/InputForm.tsx index 11b568f..0f74002 100644 --- a/src/app/upload/components/InputForm/InputForm.tsx +++ b/src/app/upload/components/InputForm/InputForm.tsx @@ -53,15 +53,15 @@ const InputForm = ({ maxLength={maxLength} /> -
+
+ {fieldState?.error && showErrorMessage && ( +

{fieldState.error.message}

+ )} {maxLength && (
{textLength}/{maxLength}
)} - {fieldState?.error && showErrorMessage && ( -

{fieldState.error.message}

- )}
); @@ -86,9 +86,9 @@ const textCounter = (theme: Theme) => css` text-align: right; `; -const textSubMessageLayout = css` +const textSubMessageLayout = (showTextCounter: boolean) => css` display: flex; - flex-flow: row-reverse nowrap; + flex-flow: ${showTextCounter ? 'row-reverse nowrap' : 'row nowrap'}; justify-content: space-between; align-items: center; `; diff --git a/src/app/upload/components/OutlineSection/OutlineSection.tsx b/src/app/upload/components/OutlineSection/OutlineSection.tsx index 2b8fa61..1c91732 100644 --- a/src/app/upload/components/OutlineSection/OutlineSection.tsx +++ b/src/app/upload/components/OutlineSection/OutlineSection.tsx @@ -56,10 +56,6 @@ const OutlineSection = () => { onSubRegionSelect: handleSubRegionSelect, }; - // 소요 시간 - const [countValue, setCountValue] = useState(undefined); - const [durationValue, setDurationValue] = useState(undefined); - return (

@@ -251,12 +247,39 @@ const OutlineSection = () => {

- - + {/* 실험 횟수 */} +
+ ( + field.onChange(value)} + error={!!fieldState.error} + /> + )} + /> +
+ + {/* 소요 시간 */} +
+ ( + field.onChange(value || null)} + referToDetailsChecked={durationChecked} + error={!!fieldState.error} + /> + )} + /> +
+ + {/* 본문 참고 체크박스 */} setDurationChecked((prev) => !prev)} diff --git a/src/app/upload/components/SelectInput/SelectInput.tsx b/src/app/upload/components/SelectInput/SelectInput.tsx index bee502c..c6466f9 100644 --- a/src/app/upload/components/SelectInput/SelectInput.tsx +++ b/src/app/upload/components/SelectInput/SelectInput.tsx @@ -11,6 +11,7 @@ interface SelectInputProps { options: { label: string; value: string }[]; placeholder?: string; referToDetailsChecked?: boolean; + error?: boolean; } const SelectInput = ({ @@ -19,6 +20,7 @@ const SelectInput = ({ options, placeholder = '선택', referToDetailsChecked = false, + error = false, }: SelectInputProps) => { const [isOpen, setIsOpen] = useState(false); @@ -30,7 +32,7 @@ const SelectInput = ({ disabled={referToDetailsChecked} > @@ -141,3 +143,7 @@ const selectItem = (theme: Theme) => css` color: ${theme.colors.textPrimary}; } `; + +const selectError = (theme: Theme) => css` + border: 0.1rem solid ${theme.colors.textAlert}; +`; diff --git a/src/app/upload/hooks/useUploadExperimentPost.tsx b/src/app/upload/hooks/useUploadExperimentPost.tsx index 770e97c..da722f8 100644 --- a/src/app/upload/hooks/useUploadExperimentPost.tsx +++ b/src/app/upload/hooks/useUploadExperimentPost.tsx @@ -24,6 +24,8 @@ const useUploadExperimentPost = () => { detailedAddress: '', region: undefined, area: undefined, + count: undefined, + timeRequired: undefined, }, }); @@ -32,7 +34,19 @@ const useUploadExperimentPost = () => { console.log('공고 등록 form >> ', data); // todo region label이 아닌 value로 변경 필요 - await form.reset(); + await form.reset({ + leadResearcher: '', + startDate: undefined, + endDate: undefined, + matchType: undefined, + reward: '', + univName: undefined, + detailedAddress: '', + region: undefined, + area: undefined, + count: undefined, + timeRequired: undefined, + }); } catch (error) { // console.error('공고 등록 form 저장 중 오류 발생', error); } diff --git a/src/schema/upload/uploadExperimentPostSchema.ts b/src/schema/upload/uploadExperimentPostSchema.ts index 46612af..d6cefaf 100644 --- a/src/schema/upload/uploadExperimentPostSchema.ts +++ b/src/schema/upload/uploadExperimentPostSchema.ts @@ -51,6 +51,7 @@ const UploadExperimentPostSchema = ({ matchType }: UploadExperimentPostSchemaPro ]), z.null(), ]), + // 연구 책임자 leadResearcher: z .string() From ba59fcc53b71eee062d4c7696c1d839bdfed348d Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Fri, 24 Jan 2025 00:00:08 +0900 Subject: [PATCH 12/32] =?UTF-8?q?[YS-172]=20refactor:=20InputForm=EC=97=90?= =?UTF-8?q?=20ref=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/components/InputForm/InputForm.tsx | 115 +++++++++++------- 1 file changed, 71 insertions(+), 44 deletions(-) diff --git a/src/app/upload/components/InputForm/InputForm.tsx b/src/app/upload/components/InputForm/InputForm.tsx index 0f74002..a4979b2 100644 --- a/src/app/upload/components/InputForm/InputForm.tsx +++ b/src/app/upload/components/InputForm/InputForm.tsx @@ -1,7 +1,5 @@ import { css, Theme } from '@emotion/react'; -import { useState } from 'react'; - -import { input } from '@/app/upload/components/UploadContainer/UploadContainer'; +import React, { forwardRef, useState } from 'react'; interface InputFormProps { id: string; @@ -23,49 +21,57 @@ interface InputFormProps { maxLength?: number; } -const InputForm = ({ - field, - fieldState, - placeholder, - type = 'text', - id, - showErrorMessage = true, - size = 'half', - maxLength, -}: InputFormProps) => { - const [textLength, setTextLength] = useState(field.value?.length || 0); - - const handleChange = (e: React.ChangeEvent) => { - field.onChange(e); - setTextLength(e.target.value.length); - }; +const InputForm = forwardRef( + ( + { + field, + fieldState, + placeholder, + type = 'text', + id, + showErrorMessage = true, + size = 'half', + maxLength, + }, + ref, + ) => { + const [textLength, setTextLength] = useState(field.value?.length || 0); + + const handleChange = (e: React.ChangeEvent) => { + field.onChange(e); + setTextLength(e.target.value.length); + }; + + return ( +
+ textInput(theme, fieldState?.error ? 'error' : '')} + type={type} + placeholder={placeholder} + value={field.value || ''} + onChange={handleChange} + maxLength={maxLength} + /> - return ( -
- input(theme, !!fieldState.error)} - type={type} - placeholder={placeholder} - value={field.value || ''} - onChange={handleChange} - maxLength={maxLength} - /> - -
- {fieldState?.error && showErrorMessage && ( -

{fieldState.error.message}

- )} - {maxLength && ( -
- {textLength}/{maxLength} -
- )} +
+ {fieldState?.error && showErrorMessage && ( +

{fieldState.error.message}

+ )} + {maxLength && ( +
+ {textLength}/{maxLength} +
+ )} +
-
- ); -}; + ); + }, +); + +InputForm.displayName = 'InputForm'; // 컴포넌트 이름 설정 export default InputForm; @@ -98,3 +104,24 @@ const formMessage = (theme: Theme) => css` color: ${theme.colors.textAlert}; margin: 0; `; + +const textInput = (theme: Theme, status: string) => css` + ${theme.fonts.label.large.R14}; + + width: 100%; + height: 4.8rem; + padding: 0.8rem 1.2rem; + border: 0.1rem solid ${status === 'error' ? theme.colors.textAlert : theme.colors.line01}; + border-radius: 1.2rem; + color: ${theme.colors.text06}; + + &:focus { + border-color: ${status === 'error' ? theme.colors.textAlert : theme.colors.lineTinted}; + outline: none; + } + + &::placeholder { + color: ${theme.colors.text02}; + ${theme.fonts.label.large.R14}; + } +`; From 62458627ea5c0e6483b0b291140de33be2294966 Mon Sep 17 00:00:00 2001 From: eeeyooon Date: Fri, 24 Jan 2025 00:16:02 +0900 Subject: [PATCH 13/32] =?UTF-8?q?[YS-172]=20feat:=20=EC=8B=A4=ED=97=98=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EB=B0=8F=20=EB=B3=B8=EB=AC=B8=20form=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20/=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DescriptionSection/DescriptionSection.tsx | 77 +++++++++++++------ .../OutlineSection/OutlineSection.tsx | 7 +- .../upload/hooks/useUploadExperimentPost.tsx | 2 + .../upload/uploadExperimentPostSchema.ts | 12 ++- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/src/app/upload/components/DescriptionSection/DescriptionSection.tsx b/src/app/upload/components/DescriptionSection/DescriptionSection.tsx index 1a52db8..d2b1a65 100644 --- a/src/app/upload/components/DescriptionSection/DescriptionSection.tsx +++ b/src/app/upload/components/DescriptionSection/DescriptionSection.tsx @@ -1,10 +1,13 @@ import { css, Theme } from '@emotion/react'; import Image from 'next/image'; import { useState, ChangeEvent } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import InputForm from '../InputForm/InputForm'; import { headingIcon, input } from '../UploadContainer/UploadContainer'; import Icon from '@/components/Icon'; +import { UploadExperimentPostSchemaType } from '@/schema/upload/uploadExperimentPostSchema'; import { colors } from '@/styles/colors'; type Photo = { @@ -14,6 +17,9 @@ type Photo = { }; const DescriptionSection = () => { + const { control, formState } = useFormContext(); + const contentError = formState.errors.content; + const [photos, setPhotos] = useState([]); const MAX_PHOTOS = 3; @@ -68,19 +74,38 @@ const DescriptionSection = () => {

- ( + + )} /> -
-