From 9a5bad7296fc4e7a9f796e3aa505bd326058ba6d Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Tue, 8 Aug 2023 16:08:39 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EC=9E=85=EB=A0=A5=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=EB=82=A0=EC=A7=9C=EA=B0=80=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=EB=A5=BC=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=ED=95=9C=EC=A7=80=20=ED=8C=90=EB=B3=84=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tests/utilTests/isCurrentOrFutureDate.test.ts | 7 +++++++ client/src/utils/_common/isCurrentOrFutureDate.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) create mode 100644 client/src/tests/utilTests/isCurrentOrFutureDate.test.ts create mode 100644 client/src/utils/_common/isCurrentOrFutureDate.ts diff --git a/client/src/tests/utilTests/isCurrentOrFutureDate.test.ts b/client/src/tests/utilTests/isCurrentOrFutureDate.test.ts new file mode 100644 index 000000000..119786f02 --- /dev/null +++ b/client/src/tests/utilTests/isCurrentOrFutureDate.test.ts @@ -0,0 +1,7 @@ +import { isCurrentOrFutureDate } from '@utils/_common/isCurrentOrFutureDate'; + +it('과거의 날짜를 입력하면 false를 반환한다', () => { + const isValidateDate = isCurrentOrFutureDate('2022-03-14'); + + expect(isValidateDate).toBe(false); +}); diff --git a/client/src/utils/_common/isCurrentOrFutureDate.ts b/client/src/utils/_common/isCurrentOrFutureDate.ts new file mode 100644 index 000000000..a9594e00f --- /dev/null +++ b/client/src/utils/_common/isCurrentOrFutureDate.ts @@ -0,0 +1,10 @@ +export const isCurrentOrFutureDate = (dateStr: string) => { + const inputDate = new Date(dateStr); + const today = new Date(); + + // 시간, 분, 초, 밀리초를 무시하고 날짜만 비교하기 위해 설정 + inputDate.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); + + return inputDate.getTime() >= today.getTime(); +}; From f9cf4025be587953e7cd808552d15a77fba518c5 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Thu, 10 Aug 2023 12:49:31 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20onChange=EA=B0=80=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=ED=96=88=EC=9D=84=20=EB=95=8C,=20onSubmit=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=96=88=EC=9D=84=20=EB=95=8C=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20Hook=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/_common/useFormInput.ts | 101 +++++++++++++++++++++-- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/client/src/hooks/_common/useFormInput.ts b/client/src/hooks/_common/useFormInput.ts index c3720010d..b6a7308a0 100644 --- a/client/src/hooks/_common/useFormInput.ts +++ b/client/src/hooks/_common/useFormInput.ts @@ -1,19 +1,105 @@ -import { useState } from 'react'; +import { FormEvent, useState } from 'react'; -const useFormInput = (initialState: T) => { +type FormErrorType = { + [key: string]: string; +}; + +type ValidationType = { + validate: (inputValue: string) => boolean; + message: string; + updateOnFail: boolean; +}; + +type ValidationsType = { + [key: string]: ValidationType[]; +}; + +const useFormInput = ( + initialState: T, + validations?: ValidationsType +) => { const [formState, setFormState] = useState(initialState); + const [error, setError] = useState(); + + const validateInputValue = (name: string, inputValue: string) => { + if (!validations || !validations?.[name]) return true; + + const shouldUpdateValue = validations[name].every( + ({ validate, message, updateOnFail }) => { + if (!validate(inputValue)) { + setError((prev) => ({ + ...prev, + [name]: message, + })); + + return updateOnFail; + } + + return true; + } + ); + + return shouldUpdateValue; + }; + + const cleanError = (name: string) => { + if (!error || !error[name]) return; + + setError((prev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [name]: _, ...rest } = prev as FormErrorType; + + return rest; + }); + }; + + const handleSubmit = (callback: () => void) => (e: FormEvent) => { + e.preventDefault(); + if (!validations) { + callback(); + return; + } + + const isFormValid = Object.entries(validations).every(([key, fieldValidations]) => + fieldValidations.every(({ validate, message }) => { + const parts = key.split('[').map((part) => part.replace(']', '')); + + const fieldValue = parts.reduce((currentValue, part) => { + return (currentValue as any)[part]; + }, formState); + + const isValid = validate(String(fieldValue)); + + if (!isValid) { + setError((prev) => ({ + ...prev, + [key]: message, + })); + } + + return isValid; + }) + ); + + if (isFormValid) { + callback(); + } + }; const handleInputChange = ({ target: { name, value }, }: React.ChangeEvent) => { - // 이름을 '['를 기준으로 분리하고, ']'를 제거 + cleanError(name); + + const shouldUpdateValue = validateInputValue(name, value); + if (!shouldUpdateValue) return; + const parts = name.split('[').map((part) => part.replace(']', '')); - // 배열 요소인지 확인하기 위해 첫 번째 요소가 'goalRoomRoadmapNodeRequests'인지 확인 const isArray = parts.length > 2; - // 배열 요소일 때. 즉, NodeList 내부의 값이 변했을 때 if (isArray) { const [baseName, arrayIndex, arrayPropName] = parts; + setFormState((prevState: any) => { if (Array.isArray(prevState[baseName])) { return { @@ -32,10 +118,8 @@ const useFormInput = (initialState: T) => { return prevState; }); } else { - // 배열 요소가 아닐 때 const [propName, nestedPropName] = parts; setFormState((prevState: any) => { - // 객체 내부의 객체를 업데이트 하기 위함 (2 Depth) if (nestedPropName) { return { ...prevState, @@ -45,7 +129,6 @@ const useFormInput = (initialState: T) => { }, }; } - // 속성을 업데이트 (1 Depth) return { ...prevState, [propName]: value, @@ -62,6 +145,8 @@ const useFormInput = (initialState: T) => { formState, handleInputChange, resetFormState, + error, + handleSubmit, }; }; From 175df49222aac01c05c34799956314ed7efb1cf9 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Thu, 10 Aug 2023 13:14:34 +0900 Subject: [PATCH 03/19] =?UTF-8?q?chore:=20handleSubmit=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/_common/useFormInput.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/src/hooks/_common/useFormInput.ts b/client/src/hooks/_common/useFormInput.ts index b6a7308a0..5c0b9e366 100644 --- a/client/src/hooks/_common/useFormInput.ts +++ b/client/src/hooks/_common/useFormInput.ts @@ -62,8 +62,12 @@ const useFormInput = ( const isFormValid = Object.entries(validations).every(([key, fieldValidations]) => fieldValidations.every(({ validate, message }) => { + // key 문자열을 구문 분석하여 'parts' 배열 생성 + // ex) "user[details][name]" => ["user", "details", "name"] const parts = key.split('[').map((part) => part.replace(']', '')); + // 중첩된 객체에서 필드 값을 검색 + // "user[details][name]" ex에서 fieldValue는 "name" const fieldValue = parts.reduce((currentValue, part) => { return (currentValue as any)[part]; }, formState); From ca66909b7d6449b40f62d50450181325c6f05519 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Thu, 10 Aug 2023 17:35:48 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EC=BD=94=EB=81=BC=EB=A6=AC?= =?UTF-8?q?=EB=81=BC=EB=A6=AC=20InputField=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/InputField/InputField.styles.ts | 39 ++++++++++++++++ .../_common/InputField/InputField.tsx | 44 +++++++++++++++++++ .../inputField/InputField.styles.ts | 28 ------------ .../inputField/InputField.tsx | 40 ----------------- 4 files changed, 83 insertions(+), 68 deletions(-) create mode 100644 client/src/components/_common/InputField/InputField.styles.ts create mode 100644 client/src/components/_common/InputField/InputField.tsx delete mode 100644 client/src/components/goalRoomCreatePage/inputField/InputField.styles.ts delete mode 100644 client/src/components/goalRoomCreatePage/inputField/InputField.tsx diff --git a/client/src/components/_common/InputField/InputField.styles.ts b/client/src/components/_common/InputField/InputField.styles.ts new file mode 100644 index 000000000..cd39eb4ec --- /dev/null +++ b/client/src/components/_common/InputField/InputField.styles.ts @@ -0,0 +1,39 @@ +import styled from 'styled-components'; + +export const InputField = styled.div``; + +export const FieldHeader = styled.div<{ size?: 'small' | 'normal' }>` + margin-bottom: ${({ size }) => (size === 'small' ? '0.8rem' : '1.8rem')}; +`; + +export const Label = styled.label<{ size?: 'small' | 'normal' }>` + ${({ theme, size }) => + size === 'small' ? theme.fonts.nav_text : theme.fonts.nav_title}; + & > span { + color: ${({ theme }) => theme.colors.red}; + } +`; + +export const Description = styled.p` + ${({ theme }) => theme.fonts.h2}; + color: ${({ theme }) => theme.colors.gray300}; +`; + +export const InputBox = styled.div<{ size?: 'small' | 'normal' }>` + position: relative; + + & > input { + ${({ theme, size }) => (size === 'small' ? theme.fonts.button1 : theme.fonts.h2)}; + width: ${({ size }) => (size === 'small' ? '' : '100%')}; + padding: ${({ size }) => (size === 'small' ? '0.1rem' : '0.4rem')}; + text-align: ${({ size }) => (size === 'small' ? 'center' : '')}; + border-bottom: ${({ theme }) => `0.1rem solid ${theme.colors.black}`}; + } +`; + +export const ErrorMessage = styled.p` + ${({ theme }) => theme.fonts.button1}; + position: absolute; + top: 2.8rem; + color: ${({ theme }) => theme.colors.red}; +`; diff --git a/client/src/components/_common/InputField/InputField.tsx b/client/src/components/_common/InputField/InputField.tsx new file mode 100644 index 000000000..fcc8f676a --- /dev/null +++ b/client/src/components/_common/InputField/InputField.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import * as S from './InputField.styles'; + +type InputFieldProps = { + name: string; + value: string; + onChange: ( + e: React.ChangeEvent + ) => void; + label?: string; + type?: 'text' | 'date' | 'textarea'; + size?: 'small' | 'normal'; + isRequired?: boolean; + description?: string; + placeholder?: string; + errorMessage?: string; +}; + +const InputField = ({ ...props }: InputFieldProps) => { + return ( + + + + {props.label} + {props.isRequired && *} + + {props.description && {props.description}} + + + + {props.errorMessage && {props.errorMessage}} + + + ); +}; + +export default InputField; diff --git a/client/src/components/goalRoomCreatePage/inputField/InputField.styles.ts b/client/src/components/goalRoomCreatePage/inputField/InputField.styles.ts deleted file mode 100644 index 4866b7d3b..000000000 --- a/client/src/components/goalRoomCreatePage/inputField/InputField.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { styled } from 'styled-components'; -import { InputType } from './InputField'; - -export const InputField = styled.label` - display: block; - margin-bottom: 6rem; -`; - -export const Label = styled.div<{ isRequired: boolean; type: InputType }>` - ${({ theme, type }) => - type === 'normal' ? theme.fonts.nav_title : theme.fonts.description2}; - - ${({ isRequired }) => - isRequired && - `&::after { - content: '*'; - color: red; -}`}; -`; - -export const Description = styled.div` - ${({ theme }) => theme.fonts.nav_text}; - color: ${({ theme }) => theme.colors.gray300}; -`; - -export const ChildrenWrapper = styled.div<{ type: InputType }>` - margin-top: ${({ type }) => (type === 'normal' ? '1.8rem' : '1.2rem')}; -`; diff --git a/client/src/components/goalRoomCreatePage/inputField/InputField.tsx b/client/src/components/goalRoomCreatePage/inputField/InputField.tsx deleted file mode 100644 index d6b1902c5..000000000 --- a/client/src/components/goalRoomCreatePage/inputField/InputField.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { PropsWithChildren } from 'react'; -import * as S from './InputField.styles'; - -export type InputType = 'normal' | 'small'; - -type InputFieldProps = { - label: string; - description?: string; - isRequired?: boolean; - errorMessage?: any; - type?: InputType; -} & PropsWithChildren; - -const InputField = (props: InputFieldProps) => { - const { - label, - description, - isRequired, - errorMessage, - children, - type = 'normal', - } = props; - - return ( - -
- {label && ( - - {label} - - )} - {description && {description}} -
- {children} - {errorMessage &&

에러가 들어갈 예정

} -
- ); -}; - -export default InputField; From dd05190d5777c646fc769ad4632941034060a8e7 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Thu, 10 Aug 2023 18:51:35 +0900 Subject: [PATCH 05/19] =?UTF-8?q?design:=20number=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=EC=9D=BC=20=EB=96=84=EC=9D=98=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/InputField/InputField.styles.ts | 18 ++++++++++++++---- .../_common/InputField/InputField.tsx | 12 +++++------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/client/src/components/_common/InputField/InputField.styles.ts b/client/src/components/_common/InputField/InputField.styles.ts index cd39eb4ec..d343fb055 100644 --- a/client/src/components/_common/InputField/InputField.styles.ts +++ b/client/src/components/_common/InputField/InputField.styles.ts @@ -15,19 +15,29 @@ export const Label = styled.label<{ size?: 'small' | 'normal' }>` `; export const Description = styled.p` - ${({ theme }) => theme.fonts.h2}; + ${({ theme }) => theme.fonts.nav_text}; color: ${({ theme }) => theme.colors.gray300}; `; -export const InputBox = styled.div<{ size?: 'small' | 'normal' }>` +export const InputBox = styled.div<{ + size?: 'small' | 'normal'; + type?: 'text' | 'date' | 'textarea' | 'number'; +}>` position: relative; & > input { ${({ theme, size }) => (size === 'small' ? theme.fonts.button1 : theme.fonts.h2)}; - width: ${({ size }) => (size === 'small' ? '' : '100%')}; - padding: ${({ size }) => (size === 'small' ? '0.1rem' : '0.4rem')}; + width: ${({ size, type }) => + type === 'number' ? '7rem' : size === 'small' ? '' : '100%'}; + padding: ${({ size, type }) => + type === 'number' ? '0.4rem' : size === 'small' ? '0.1rem' : '0.4rem'}; + text-align: ${({ size }) => (size === 'small' ? 'center' : '')}; + + border: ${({ theme, type }) => + type === 'number' ? `0.1rem solid ${theme.colors.black}` : 'none'}; border-bottom: ${({ theme }) => `0.1rem solid ${theme.colors.black}`}; + border-radius: ${({ type }) => (type === 'number' ? '4px' : '')}; } `; diff --git a/client/src/components/_common/InputField/InputField.tsx b/client/src/components/_common/InputField/InputField.tsx index fcc8f676a..ec7120bf7 100644 --- a/client/src/components/_common/InputField/InputField.tsx +++ b/client/src/components/_common/InputField/InputField.tsx @@ -1,14 +1,12 @@ -import React from 'react'; +import { HandleInputChangeType } from '@hooks/_common/useFormInput'; import * as S from './InputField.styles'; type InputFieldProps = { name: string; value: string; - onChange: ( - e: React.ChangeEvent - ) => void; + onChange: HandleInputChangeType; label?: string; - type?: 'text' | 'date' | 'textarea'; + type?: 'text' | 'date' | 'textarea' | 'number'; size?: 'small' | 'normal'; isRequired?: boolean; description?: string; @@ -26,13 +24,13 @@ const InputField = ({ ...props }: InputFieldProps) => { {props.description && {props.description}} - + {props.errorMessage && {props.errorMessage}} From 6d27e7611dbaeb67661cf06680f0fe8b0a4c48bf Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Thu, 10 Aug 2023 18:52:26 +0900 Subject: [PATCH 06/19] =?UTF-8?q?design:=20form=EC=9D=84=20section=20?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=9E=AC=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateGoalRoomForm.styles.ts | 92 +++---------------- .../nodeSection/NodeSection.styles.ts | 38 ++++++++ .../nodeSection/NodeSection.tsx | 62 +++++++++++++ .../pageSection/PageSection.styles.ts | 2 +- .../todoListSection/TodoListSection.styles.ts | 9 ++ .../todoListSection/TodoListSection.tsx | 53 +++++++++++ 6 files changed, 174 insertions(+), 82 deletions(-) create mode 100644 client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts create mode 100644 client/src/components/goalRoomCreatePage/nodeSection/NodeSection.tsx create mode 100644 client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.styles.ts create mode 100644 client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.tsx diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.styles.ts b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.styles.ts index 0f60754e6..18614f184 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.styles.ts +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.styles.ts @@ -1,88 +1,18 @@ -import media from '@/styles/media'; import { styled } from 'styled-components'; -export const Form = styled.form``; - -export const RoadmapInfo = styled.div` - ${({ theme }) => theme.fonts.description4} -`; - -export const InputField = styled.label` - width: 80%; - height: 80%; - color: ${({ theme }) => theme.colors.gray300}; - &::placeholder { - ${({ theme }) => theme.fonts.description4}; - color: ${({ theme }) => theme.colors.gray200}; - } -`; - -export const Input = styled.input` - ${({ theme }) => theme.fonts.nav_title} - width: 70%; - padding: 1rem 0.5rem; - color: ${({ theme }) => theme.colors.gray300}; - border-bottom: ${({ theme }) => `0.1rem solid ${theme.colors.black}`}; - &::placeholder { - ${({ theme }) => theme.fonts.description4}; - color: ${({ theme }) => theme.colors.gray200}; - } - - ${media.mobile` - width:100%; - `} -`; - -export const NodeSectionWrapper = styled.div` - display: flex; - column-gap: 2rem; -`; - -export const NodeList = styled.form<{ nodeCount: number }>` - display: grid; - grid-template-rows: ${({ nodeCount }) => `repeat(${nodeCount}, 1fr)`}; - row-gap: 2.4rem; +export const Form = styled.form` + margin: 6rem 0; `; -export const NodeWrapper = styled.div` +export const SubmitButtonWrapper = styled.div` display: flex; - align-items: center; -`; - -export const NodeInfo = styled.div` - width: 18rem; - height: 18rem; - margin-right: 2rem; - padding: 2rem; - - background-color: ${({ theme }) => theme.colors.gray100}; - border-radius: 8px; -`; - -export const NodeConfigs = styled.div` - display: flex; - - & > *:not(:last-child) { - margin-right: 2rem; + justify-content: end; + margin-top: 3rem; + + & > button { + padding: 1.8rem 3rem; + color: ${({ theme }) => theme.colors.white}; + background-color: ${({ theme }) => theme.colors.main_dark}; + border-radius: 8px; } `; - -export const DateInput = styled.input` - padding: 0.7rem 0; - text-align: center; - background-color: ${({ theme }) => theme.colors.gray100}; - border-radius: 4px; -`; - -export const Textarea = styled.textarea` - width: 70%; - height: 16rem; - padding: 1rem 0.5rem; - - border: ${({ theme }) => `0.1rem solid ${theme.colors.black}`}; - border-radius: 8px; - - ${media.mobile` - width:100%; - `} -`; diff --git a/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts new file mode 100644 index 000000000..8e7cac4c6 --- /dev/null +++ b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +export const NodeList = styled.form` + display: flex; + flex-direction: column; + row-gap: 2rem; +`; + +export const Node = styled.div` + display: flex; + align-items: center; + border: ${({ theme }) => `0.2rem solid ${theme.colors.gray200}`}; + border-radius: 8px; +`; + +export const NodeInfo = styled.div` + width: 18rem; + height: 18rem; + margin-right: 2rem; + padding: 2rem; + + background-color: ${({ theme }) => theme.colors.gray100}; + border-radius: 8px; +`; + +export const NodeConfigs = styled.div` + display: flex; + + & > *:not(:last-child) { + margin-right: 2rem; + } +`; + +export const DateConfig = styled.div` + & > div:not(:last-child) { + margin-bottom: 4rem; + } +`; diff --git a/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.tsx b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.tsx new file mode 100644 index 000000000..24ab524e5 --- /dev/null +++ b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.tsx @@ -0,0 +1,62 @@ +import { CreateGoalRoomRequest } from '@myTypes/goalRoom/remote'; +import InputField from '@components/_common/InputField/InputField'; +import { HandleInputChangeType, FormErrorType } from '@hooks/_common/useFormInput'; +import { NodeType } from '@myTypes/roadmap/internal'; +import * as S from './NodeSection.styles'; + +type NodeSectionProps = { + nodes: NodeType[]; + formState: CreateGoalRoomRequest; + handleInputChange: HandleInputChangeType; + error: FormErrorType; +}; + +const NodeSection = (props: NodeSectionProps) => { + const { nodes, formState, handleInputChange, error } = props; + + return ( + + {nodes.map(({ id, title }, index) => ( + + {title} + + + + + + + + + ))} + + ); +}; + +export default NodeSection; diff --git a/client/src/components/goalRoomCreatePage/pageSection/PageSection.styles.ts b/client/src/components/goalRoomCreatePage/pageSection/PageSection.styles.ts index 89ee03ad8..00d046b53 100644 --- a/client/src/components/goalRoomCreatePage/pageSection/PageSection.styles.ts +++ b/client/src/components/goalRoomCreatePage/pageSection/PageSection.styles.ts @@ -1,7 +1,7 @@ import styled from 'styled-components'; export const PageSection = styled.div` - margin-bottom: 6rem; + margin-top: 6rem; `; export const SectionTitle = styled.div<{ isRequired: boolean }>` diff --git a/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.styles.ts b/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.styles.ts new file mode 100644 index 000000000..7c63e3a7f --- /dev/null +++ b/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.styles.ts @@ -0,0 +1,9 @@ +import { styled } from 'styled-components'; + +export const DateConfig = styled.div` + display: flex; + margin-bottom: 4rem; + & > div:not(:last-child) { + margin-right: 4rem; + } +`; diff --git a/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.tsx b/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.tsx new file mode 100644 index 000000000..0532c6673 --- /dev/null +++ b/client/src/components/goalRoomCreatePage/todoListSection/TodoListSection.tsx @@ -0,0 +1,53 @@ +import { CreateGoalRoomRequest } from '@myTypes/goalRoom/remote'; +import InputField from '@components/_common/InputField/InputField'; +import { HandleInputChangeType, FormErrorType } from '@hooks/_common/useFormInput'; +import * as S from './TodoListSection.styles'; + +type TodoListSectionProps = { + formState: CreateGoalRoomRequest; + handleInputChange: HandleInputChangeType; + error: FormErrorType; +}; + +const TodoListSection = ({ + formState, + handleInputChange, + error, +}: TodoListSectionProps) => { + return ( +
+ + + + + +
+ ); +}; + +export default TodoListSection; From 84cb9371c65fbe79b748b1de6dd0dcd9949a1a13 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Fri, 11 Aug 2023 12:15:13 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=EA=B0=9D=EC=B2=B4=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EC=9D=98=20yyyy-mm-dd=20=ED=98=95=EC=8B=9D=EC=9D=98?= =?UTF-8?q?=20=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=84=20yyyymmdd=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B3=80=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utilTests/transformDateStringsIn.test.ts | 66 +++++++++++++++++++ .../utils/_common/transformDateStringsIn.ts | 23 +++++++ 2 files changed, 89 insertions(+) create mode 100644 client/src/tests/utilTests/transformDateStringsIn.test.ts create mode 100644 client/src/utils/_common/transformDateStringsIn.ts diff --git a/client/src/tests/utilTests/transformDateStringsIn.test.ts b/client/src/tests/utilTests/transformDateStringsIn.test.ts new file mode 100644 index 000000000..d1ee88e70 --- /dev/null +++ b/client/src/tests/utilTests/transformDateStringsIn.test.ts @@ -0,0 +1,66 @@ +import { transformDateStringsIn } from '@utils/_common/transformDateStringsIn'; + +describe('transformDateStringsIn 테스트', () => { + it('객체 내부에 yyyy-mm-dd 형식의 string이 존재한다면 yyyymmdd 형식으로 변환한다', () => { + const obj = { + date: '2023-08-11', + }; + + const result = transformDateStringsIn(obj); + + expect(result.date).toBe('20230811'); + }); + + it('중첩된 객체와 배열을 처리할 수 있어야 한다', () => { + const obj = { + nestedObj: { + date: '2023-08-11', + }, + nestedArray: [{ date: '2023-08-11' }, 'notADate', 456], + }; + + const result = transformDateStringsIn(obj); + + expect(result.nestedObj.date).toBe('20230811'); + expect(result.nestedArray[0]?.date).toBe('20230811'); + expect(result.nestedArray[1]).toBe('notADate'); + expect(result.nestedArray[2]).toBe(456); + }); + + it('깊게 중첩된 구조도 처리할 수 있어야 한다', () => { + const obj = { + deep: { + deeper: { + date: '2023-08-11', + array: [{ evenDeeper: { date: '2023-08-11' } }], + }, + }, + }; + + const result = transformDateStringsIn(obj); + + expect(result.deep.deeper.date).toBe('20230811'); + expect(result.deep.deeper.array[0].evenDeeper.date).toBe('20230811'); + }); + + it('빈 객체에 대해서는 빈 객체를 반환한다', () => { + const obj = {}; + + const result = transformDateStringsIn(obj); + + expect(result).toEqual({}); + }); + + it('날짜가 아닌 문자열, 숫자, 불리언, null 등의 값은 수정되지 않아야 한다', () => { + const obj = { + str: 'hello', + num: 123, + bool: true, + nil: null, + }; + + const result = transformDateStringsIn(obj); + + expect(result).toEqual(obj); + }); +}); diff --git a/client/src/utils/_common/transformDateStringsIn.ts b/client/src/utils/_common/transformDateStringsIn.ts new file mode 100644 index 000000000..10930fa00 --- /dev/null +++ b/client/src/utils/_common/transformDateStringsIn.ts @@ -0,0 +1,23 @@ +import removeDashesFromStr from './removeDashesFromStr'; + +export type RecursiveObjectType = { + // 객체 key의 value로 무엇이든 올 수 있기 때문에 any 지정 + [key: string]: any; +}; + +export const transformDateStringsIn = (obj: RecursiveObjectType): RecursiveObjectType => { + if (typeof obj !== 'object' || obj == null) return obj; + + return Object.entries(obj).reduce((acc, [key, value]) => { + if (typeof value === 'string' && /^(\d{4}-\d{2}-\d{2})$/.test(value)) { + acc[key] = removeDashesFromStr(value); + } else if (Array.isArray(value)) { + acc[key] = value.map((item) => transformDateStringsIn(item as RecursiveObjectType)); + } else if (typeof value === 'object') { + acc[key] = transformDateStringsIn(value as RecursiveObjectType); + } else { + acc[key] = value; + } + return acc; + }, {} as RecursiveObjectType); +}; From 3678e9b606ad0aff7a7c23d17fb801544446c7fa Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Fri, 11 Aug 2023 12:19:31 +0900 Subject: [PATCH 08/19] =?UTF-8?q?refactor:=20=EC=99=B8=EB=B6=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20handleInputChange=20=ED=95=A8=EC=88=98=EC=9D=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EA=B3=BC=20=EC=97=90=EB=9F=AC=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EC=9D=98=20=ED=83=80=EC=9E=85=EC=97=90=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createGoalRoomForm/CreateGoalRoomForm.tsx | 169 +++++++++--------- 1 file changed, 82 insertions(+), 87 deletions(-) diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx index 480402460..64360d549 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx @@ -1,120 +1,115 @@ -import { FormEvent } from 'react'; import { useCreateGoalRoom } from '@hooks/queries/goalRoom'; import useFormInput from '@hooks/_common/useFormInput'; import { CreateGoalRoomRequest } from '@myTypes/goalRoom/remote'; -import InputField from '../inputField/InputField'; import PageSection from '../pageSection/PageSection'; import * as S from './CreateGoalRoomForm.styles'; import { convertFieldsToNumber } from '@utils/_common/convertFieldsToNumber'; import { NodeType } from '@myTypes/roadmap/internal'; +import InputField from '@components/_common/InputField/InputField'; +import TodoListSection from '../todoListSection/TodoListSection'; +import NodeSection from '../nodeSection/NodeSection'; +import { transformDateStringsIn } from '@utils/_common/transformDateStringsIn'; type CreateGoalRoomFormProps = { roadmapContentId: number; nodes: NodeType[]; }; -const CreateGoalRoomForm = ({ roadmapContentId, nodes }: CreateGoalRoomFormProps) => { - const { createGoalRoom } = useCreateGoalRoom(roadmapContentId); - const { formState, handleInputChange } = useFormInput({ - roadmapContentId: Number(roadmapContentId), - name: '', - limitedMemberCount: 10, - goalRoomTodo: { - content: '', - startDate: '', - endDate: '', +const createGoalRoomValidation = { + name: [ + { + validate: (inputValue: string) => inputValue.length > 0, + message: '이름은 필수 항목입니다', + updateOnFail: true, + }, + ], + limitedMemberCount: [ + { + validate: (inputValue: string) => inputValue.length > 0, + message: '최대 인원수는 필수 항목입니다', + updateOnFail: true, }, - goalRoomRoadmapNodeRequests: nodes.map(({ id }) => ({ - roadmapNodeId: id, - checkCount: 5, - startDate: '', - endDate: '', - })), - }); + ], + 'goalRoomTodo[content]': [ + { + validate: (inputValue: string) => inputValue.length > 0, + message: '투두 리스트는 필수 항목입니다', + updateOnFail: true, + }, + { + validate: (inputValue: string) => inputValue.length <= 10, + message: '최대 250글자까지 작성할 수 있습니다', + updateOnFail: false, + }, + ], +}; - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); +const CreateGoalRoomForm = ({ roadmapContentId, nodes }: CreateGoalRoomFormProps) => { + const { createGoalRoom } = useCreateGoalRoom(roadmapContentId); + const { formState, handleInputChange, handleSubmit, error } = + useFormInput( + { + roadmapContentId: Number(roadmapContentId), + name: '', + limitedMemberCount: 10, + goalRoomTodo: { + content: '', + startDate: '', + endDate: '', + }, + goalRoomRoadmapNodeRequests: nodes.map(({ id }) => ({ + roadmapNodeId: id, + checkCount: 1, + startDate: '', + endDate: '', + })), + }, + createGoalRoomValidation + ); - const transformedFormState = convertFieldsToNumber(formState, [ + const onSubmit = () => { + const numericalFormState = convertFieldsToNumber(formState, [ 'limitedMemberCount', 'checkCount', ]); + const dateFormattedFormState = transformDateStringsIn(numericalFormState); - createGoalRoom(transformedFormState as CreateGoalRoomRequest); + createGoalRoom(dateFormattedFormState as CreateGoalRoomRequest); }; return ( - - - - + + - - - {nodes.map(({ id, title }, index) => ( - - {title} - - <> - - - - - - - - - - - - - ))} - - + - -
- - - - - - -
- + -
- + + + +
); }; From 8c02fa9616d4a911631f84a22c5e0122bd6b1fcb Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Fri, 11 Aug 2023 17:35:53 +0900 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20=EA=B0=81=20Input=EC=9D=98=20?= =?UTF-8?q?validation=EC=9D=84=20=ED=95=98=EB=82=98=EC=9D=98=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createGoalRoomForm/CreateGoalRoomForm.tsx | 32 +----- .../createGoalRoomValidations.ts | 106 ++++++++++++++++++ client/src/hooks/_common/useFormInput.ts | 99 ++++++++-------- 3 files changed, 158 insertions(+), 79 deletions(-) create mode 100644 client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx index 64360d549..a9b25aabd 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx @@ -9,41 +9,13 @@ import InputField from '@components/_common/InputField/InputField'; import TodoListSection from '../todoListSection/TodoListSection'; import NodeSection from '../nodeSection/NodeSection'; import { transformDateStringsIn } from '@utils/_common/transformDateStringsIn'; +import { generateNodesValidations, staticValidations } from './createGoalRoomValidations'; type CreateGoalRoomFormProps = { roadmapContentId: number; nodes: NodeType[]; }; -const createGoalRoomValidation = { - name: [ - { - validate: (inputValue: string) => inputValue.length > 0, - message: '이름은 필수 항목입니다', - updateOnFail: true, - }, - ], - limitedMemberCount: [ - { - validate: (inputValue: string) => inputValue.length > 0, - message: '최대 인원수는 필수 항목입니다', - updateOnFail: true, - }, - ], - 'goalRoomTodo[content]': [ - { - validate: (inputValue: string) => inputValue.length > 0, - message: '투두 리스트는 필수 항목입니다', - updateOnFail: true, - }, - { - validate: (inputValue: string) => inputValue.length <= 10, - message: '최대 250글자까지 작성할 수 있습니다', - updateOnFail: false, - }, - ], -}; - const CreateGoalRoomForm = ({ roadmapContentId, nodes }: CreateGoalRoomFormProps) => { const { createGoalRoom } = useCreateGoalRoom(roadmapContentId); const { formState, handleInputChange, handleSubmit, error } = @@ -64,7 +36,7 @@ const CreateGoalRoomForm = ({ roadmapContentId, nodes }: CreateGoalRoomFormProps endDate: '', })), }, - createGoalRoomValidation + { ...generateNodesValidations(nodes), ...staticValidations } ); const onSubmit = () => { diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts new file mode 100644 index 000000000..851e007f4 --- /dev/null +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts @@ -0,0 +1,106 @@ +import { ValidationsType } from '@hooks/_common/useFormInput'; +import { NodeType } from '@myTypes/roadmap/internal'; + +export const staticValidations = { + name: (inputValue: string) => { + if (inputValue.length === 0) { + return { ok: false, message: '이름은 필수 항목입니다', updateOnFail: true }; + } + + return { ok: true }; + }, + + limitedMemberCount: (inputValue: string) => { + if (inputValue.length === 0) { + return { ok: false, message: '최대 인원수는 필수 항목입니다', updateOnFail: true }; + } + + if (!/^[1-9]+\d$/.test(inputValue)) { + return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; + } + + return { ok: true }; + }, + + 'goalRoomTodo[content]': (inputValue: string) => { + if (inputValue.length === 0) { + return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + } + + if (inputValue.length > 250) { + return { + ok: false, + message: '최대 250글자까지 작성할 수 있습니다', + updateOnFail: false, + }; + } + + return { ok: true }; + }, + + 'goalRoomTodo[startDate]': (inputValue: string) => { + if (inputValue.length === 0) { + return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + } + + return { ok: true }; + }, + + 'goalRoomTodo[endDate]': (inputValue: string) => { + if (inputValue.length === 0) { + return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + } + + if (inputValue.length > 250) { + return { + ok: false, + message: '최대 250글자까지 작성할 수 있습니다', + updateOnFail: false, + }; + } + + return { ok: true }; + }, +}; + +export const generateNodesValidations = (nodes: NodeType[]) => { + const validations: ValidationsType = {}; + + nodes.forEach((_, index) => { + validations[`goalRoomRoadmapNodeRequests[${index}][checkCount]`] = ( + inputValue: string + ) => { + if (inputValue.length === 0) { + return { ok: false, message: '체크 횟수는 필수 항목입니다', updateOnFail: true }; + } + + if (!/^[1-9]\d*$/.test(inputValue)) { + return { ok: false, message: '숫자 형식을 입력해주세요', updateOnFail: false }; + } + + return { ok: true }; + }; + + validations[`goalRoomRoadmapNodeRequests[${index}][startDate]`] = ( + inputValue: string + ) => { + if (inputValue.length === 0) { + return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; + } + + return { ok: true }; + }; + + validations[`goalRoomRoadmapNodeRequests[${index}][endDate]`] = ( + inputValue: string + ) => { + if (inputValue.length === 0) { + return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; + } + + return { ok: true }; + }; + }); + + return validations; +}; diff --git a/client/src/hooks/_common/useFormInput.ts b/client/src/hooks/_common/useFormInput.ts index 5c0b9e366..e938ac60b 100644 --- a/client/src/hooks/_common/useFormInput.ts +++ b/client/src/hooks/_common/useFormInput.ts @@ -1,17 +1,32 @@ import { FormEvent, useState } from 'react'; -type FormErrorType = { +export type FormErrorType = { [key: string]: string; }; -type ValidationType = { - validate: (inputValue: string) => boolean; - message: string; - updateOnFail: boolean; +export type ValidationReturnType = { + ok: boolean; + message?: string; + updateOnFail?: boolean; }; -type ValidationsType = { - [key: string]: ValidationType[]; +export type ValidationFunctionType = (inputValue: string) => ValidationReturnType; + +export type ValidationsType = { + [key: string]: ValidationFunctionType; +}; + +export type HandleInputChangeType = ( + e: React.ChangeEvent +) => void; + +const getParts = (path: string) => { + return path.split('[').map((part) => part.replace(']', '')); +}; + +const getNestedValue = (obj: any, path: string) => { + const parts = getParts(path); + return parts.reduce((curr, part) => curr[part], obj); }; const useFormInput = ( @@ -19,35 +34,29 @@ const useFormInput = ( validations?: ValidationsType ) => { const [formState, setFormState] = useState(initialState); - const [error, setError] = useState(); + const [error, setError] = useState({}); const validateInputValue = (name: string, inputValue: string) => { - if (!validations || !validations?.[name]) return true; + if (typeof validations?.[name] !== 'function') return true; - const shouldUpdateValue = validations[name].every( - ({ validate, message, updateOnFail }) => { - if (!validate(inputValue)) { - setError((prev) => ({ - ...prev, - [name]: message, - })); - - return updateOnFail; - } + const result = validations[name](inputValue); - return true; - } - ); + if (!result.ok) { + setError((prev) => ({ + ...prev, + [name]: result.message || '', + })); + } - return shouldUpdateValue; + return result.ok || result.updateOnFail; }; const cleanError = (name: string) => { - if (!error || !error[name]) return; + if (!error[name]) return; setError((prev) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [name]: _, ...rest } = prev as FormErrorType; + const { [name]: _, ...rest } = prev; return rest; }); @@ -60,30 +69,22 @@ const useFormInput = ( return; } - const isFormValid = Object.entries(validations).every(([key, fieldValidations]) => - fieldValidations.every(({ validate, message }) => { - // key 문자열을 구문 분석하여 'parts' 배열 생성 - // ex) "user[details][name]" => ["user", "details", "name"] - const parts = key.split('[').map((part) => part.replace(']', '')); - - // 중첩된 객체에서 필드 값을 검색 - // "user[details][name]" ex에서 fieldValue는 "name" - const fieldValue = parts.reduce((currentValue, part) => { - return (currentValue as any)[part]; - }, formState); - - const isValid = validate(String(fieldValue)); - - if (!isValid) { - setError((prev) => ({ - ...prev, - [key]: message, - })); - } + let isFormValid = true; + + Object.entries(validations).forEach(([key, fieldValidation]) => { + const fieldValue = getNestedValue(formState, key); - return isValid; - }) - ); + const result = fieldValidation(String(fieldValue)); + + if (!result.ok) { + setError((prev) => ({ + ...prev, + [key]: result.message || '', + })); + + isFormValid = false; + } + }); if (isFormValid) { callback(); @@ -98,7 +99,7 @@ const useFormInput = ( const shouldUpdateValue = validateInputValue(name, value); if (!shouldUpdateValue) return; - const parts = name.split('[').map((part) => part.replace(']', '')); + const parts = getParts(name); const isArray = parts.length > 2; if (isArray) { From 06a12b8bf3682f731735a6799f67c3c0c8136f51 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Fri, 11 Aug 2023 18:40:37 +0900 Subject: [PATCH 10/19] =?UTF-8?q?refactor:=20useFormInput=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/_common/useFormInput.ts | 104 +++++++++-------------- 1 file changed, 39 insertions(+), 65 deletions(-) diff --git a/client/src/hooks/_common/useFormInput.ts b/client/src/hooks/_common/useFormInput.ts index e938ac60b..1227829f6 100644 --- a/client/src/hooks/_common/useFormInput.ts +++ b/client/src/hooks/_common/useFormInput.ts @@ -1,4 +1,4 @@ -import { FormEvent, useState } from 'react'; +import { ChangeEvent, FormEvent, useState } from 'react'; export type FormErrorType = { [key: string]: string; @@ -25,10 +25,26 @@ const getParts = (path: string) => { }; const getNestedValue = (obj: any, path: string) => { + if (obj == null || typeof obj !== 'object') return obj; + const parts = getParts(path); return parts.reduce((curr, part) => curr[part], obj); }; +const setNestedValue = (obj: any, path: string, value: any) => { + const parts = getParts(path); + const lastKey = parts.pop(); + + if (!lastKey) { + return obj; + } + + const lastObj = parts.reduce((curr, part) => curr[part], obj); + lastObj[lastKey] = value; + + return obj; +}; + const useFormInput = ( initialState: T, validations?: ValidationsType @@ -57,89 +73,47 @@ const useFormInput = ( setError((prev) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [name]: _, ...rest } = prev; - return rest; }); }; - const handleSubmit = (callback: () => void) => (e: FormEvent) => { - e.preventDefault(); - if (!validations) { - callback(); - return; - } + const updateFormState = (name: string, value: any) => { + setFormState((prev) => setNestedValue({ ...prev }, name, value)); + }; + + const handleInputChange = ({ + target: { name, value }, + }: ChangeEvent) => { + cleanError(name); - let isFormValid = true; + if (validateInputValue(name, value)) { + updateFormState(name, value); + } + }; - Object.entries(validations).forEach(([key, fieldValidation]) => { - const fieldValue = getNestedValue(formState, key); + const isFormValid = () => { + let isValid = true; + if (!validations) return isValid; - const result = fieldValidation(String(fieldValue)); + Object.keys(validations).forEach((key) => { + const result = validations[key](String(getNestedValue(formState, key))); if (!result.ok) { setError((prev) => ({ ...prev, [key]: result.message || '', })); - - isFormValid = false; + isValid = false; } }); - if (isFormValid) { - callback(); - } + return isValid; }; - const handleInputChange = ({ - target: { name, value }, - }: React.ChangeEvent) => { - cleanError(name); + const handleSubmit = (callback: () => void) => (e: FormEvent) => { + e.preventDefault(); - const shouldUpdateValue = validateInputValue(name, value); - if (!shouldUpdateValue) return; - - const parts = getParts(name); - const isArray = parts.length > 2; - - if (isArray) { - const [baseName, arrayIndex, arrayPropName] = parts; - - setFormState((prevState: any) => { - if (Array.isArray(prevState[baseName])) { - return { - ...prevState, - [baseName]: prevState[baseName].map((item: any, index: number) => { - if (index === Number(arrayIndex)) { - return { - ...item, - [arrayPropName]: value, - }; - } - return item; - }), - }; - } - return prevState; - }); - } else { - const [propName, nestedPropName] = parts; - setFormState((prevState: any) => { - if (nestedPropName) { - return { - ...prevState, - [propName]: { - ...prevState[propName], - [nestedPropName]: value, - }, - }; - } - return { - ...prevState, - [propName]: value, - }; - }); - } + if (isFormValid()) callback(); }; const resetFormState = () => { From fffff00bfba2d6ca1e9300bf0a0726ff766afc23 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 01:35:02 +0900 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20validation=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EC=97=90=EC=84=9C=20=EB=AA=A8=EB=93=A0=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20=EC=88=98=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createGoalRoomValidations.ts | 102 +++++++++++------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts index 851e007f4..f302f38fa 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts @@ -1,36 +1,40 @@ import { ValidationsType } from '@hooks/_common/useFormInput'; -import { NodeType } from '@myTypes/roadmap/internal'; +import { isCurrentOrFutureDate } from '@utils/_common/isCurrentOrFutureDate'; -export const staticValidations = { - name: (inputValue: string) => { - if (inputValue.length === 0) { - return { ok: false, message: '이름은 필수 항목입니다', updateOnFail: true }; +const isEmpty = (value: string) => value.length === 0; +const isNumber = (value: string) => /^[1-9]+\d*$/.test(value); +const isMaxLength = (value: string, length: number) => value.length <= length; + +export const staticValidations: ValidationsType = { + name: (inputValue) => { + if (isEmpty(inputValue)) { + return { ok: false, message: '골룸 이름은 필수 항목입니다', updateOnFail: true }; } return { ok: true }; }, - limitedMemberCount: (inputValue: string) => { - if (inputValue.length === 0) { + limitedMemberCount: (inputValue) => { + if (isEmpty(inputValue)) { return { ok: false, message: '최대 인원수는 필수 항목입니다', updateOnFail: true }; } - if (!/^[1-9]+\d$/.test(inputValue)) { + if (!isNumber(inputValue)) { return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; } return { ok: true }; }, - 'goalRoomTodo[content]': (inputValue: string) => { - if (inputValue.length === 0) { + 'goalRoomTodo[content]': (inputValue) => { + if (isEmpty(inputValue)) { return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; } - if (inputValue.length > 250) { + if (!isMaxLength(inputValue, 10)) { return { ok: false, - message: '최대 250글자까지 작성할 수 있습니다', + message: '최대 10글자까지 작성할 수 있습니다', updateOnFail: false, }; } @@ -38,23 +42,31 @@ export const staticValidations = { return { ok: true }; }, - 'goalRoomTodo[startDate]': (inputValue: string) => { - if (inputValue.length === 0) { - return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + 'goalRoomTodo[startDate]': (inputValue) => { + if (isEmpty(inputValue)) { + return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; + } + + if (!isCurrentOrFutureDate(inputValue)) { + return { + ok: false, + message: '이전 날짜를 입력할 수 없습니다', + updateOnFail: false, + }; } return { ok: true }; }, - 'goalRoomTodo[endDate]': (inputValue: string) => { - if (inputValue.length === 0) { - return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + 'goalRoomTodo[endDate]': (inputValue) => { + if (isEmpty(inputValue)) { + return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; } - if (inputValue.length > 250) { + if (!isCurrentOrFutureDate(inputValue)) { return { ok: false, - message: '최대 250글자까지 작성할 수 있습니다', + message: '이전 날짜를 입력할 수 없습니다', updateOnFail: false, }; } @@ -63,41 +75,59 @@ export const staticValidations = { }, }; -export const generateNodesValidations = (nodes: NodeType[]) => { +export const generateNodesValidations = (nodes: any[]) => { const validations: ValidationsType = {}; nodes.forEach((_, index) => { - validations[`goalRoomRoadmapNodeRequests[${index}][checkCount]`] = ( - inputValue: string - ) => { - if (inputValue.length === 0) { - return { ok: false, message: '체크 횟수는 필수 항목입니다', updateOnFail: true }; + const checkCountKey = `goalRoomRoadmapNodeRequests[${index}][checkCount]`; + const startDateKey = `goalRoomRoadmapNodeRequests[${index}][startDate]`; + const endDateKey = `goalRoomRoadmapNodeRequests[${index}][endDate]`; + + validations[checkCountKey] = (inputValue) => { + if (isEmpty(inputValue)) { + return { + ok: false, + message: '인증 횟수는 필수 항목입니다', + updateOnFail: true, + }; } - if (!/^[1-9]\d*$/.test(inputValue)) { - return { ok: false, message: '숫자 형식을 입력해주세요', updateOnFail: false }; + if (!isNumber(inputValue)) { + return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; } return { ok: true }; }; - validations[`goalRoomRoadmapNodeRequests[${index}][startDate]`] = ( - inputValue: string - ) => { - if (inputValue.length === 0) { + validations[startDateKey] = (inputValue) => { + if (isEmpty(inputValue)) { return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; } + if (!isCurrentOrFutureDate(inputValue)) { + return { + ok: false, + message: '이전 날짜를 입력할 수 없습니다', + updateOnFail: false, + }; + } + return { ok: true }; }; - validations[`goalRoomRoadmapNodeRequests[${index}][endDate]`] = ( - inputValue: string - ) => { - if (inputValue.length === 0) { + validations[endDateKey] = (inputValue) => { + if (isEmpty(inputValue)) { return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; } + if (!isCurrentOrFutureDate(inputValue)) { + return { + ok: false, + message: '이전 날짜를 입력할 수 없습니다', + updateOnFail: false, + }; + } + return { ok: true }; }; }); From 36e7bfced9fe275c5a63aa04a100a82bdf2c8bba Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 01:41:38 +0900 Subject: [PATCH 12/19] =?UTF-8?q?design:=20=EC=97=90=EB=9F=AC=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EA=B0=80=20=EC=9E=98=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/_common/InputField/InputField.styles.ts | 4 ++-- client/src/components/_common/InputField/InputField.tsx | 4 +++- .../goalRoomCreatePage/nodeSection/NodeSection.styles.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/_common/InputField/InputField.styles.ts b/client/src/components/_common/InputField/InputField.styles.ts index d343fb055..3ac40a1df 100644 --- a/client/src/components/_common/InputField/InputField.styles.ts +++ b/client/src/components/_common/InputField/InputField.styles.ts @@ -41,9 +41,9 @@ export const InputBox = styled.div<{ } `; -export const ErrorMessage = styled.p` +export const ErrorMessage = styled.p<{ size?: 'small' | 'normal' }>` ${({ theme }) => theme.fonts.button1}; position: absolute; - top: 2.8rem; + top: ${({ size }) => (size === 'small' ? '2.3rem' : '2.55rem')}; color: ${({ theme }) => theme.colors.red}; `; diff --git a/client/src/components/_common/InputField/InputField.tsx b/client/src/components/_common/InputField/InputField.tsx index ec7120bf7..b623926f8 100644 --- a/client/src/components/_common/InputField/InputField.tsx +++ b/client/src/components/_common/InputField/InputField.tsx @@ -33,7 +33,9 @@ const InputField = ({ ...props }: InputFieldProps) => { type={props.type === 'number' ? 'text' : props.type || 'text'} onChange={props.onChange} /> - {props.errorMessage && {props.errorMessage}} + {props.errorMessage && ( + {props.errorMessage} + )}
); diff --git a/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts index 8e7cac4c6..612144c5a 100644 --- a/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts +++ b/client/src/components/goalRoomCreatePage/nodeSection/NodeSection.styles.ts @@ -14,8 +14,8 @@ export const Node = styled.div` `; export const NodeInfo = styled.div` - width: 18rem; - height: 18rem; + width: 20rem; + height: 20rem; margin-right: 2rem; padding: 2rem; From a765fb199da96e5256cc4bd698cbe96729ddbeb4 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 14:15:54 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20any=20=ED=83=80=EC=9E=85=EC=9D=84?= =?UTF-8?q?=20=EC=B5=9C=EC=86=8C=ED=99=94=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/_common/useFormInput.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/client/src/hooks/_common/useFormInput.ts b/client/src/hooks/_common/useFormInput.ts index 1227829f6..a7e2f69d1 100644 --- a/client/src/hooks/_common/useFormInput.ts +++ b/client/src/hooks/_common/useFormInput.ts @@ -17,21 +17,25 @@ export type ValidationsType = { }; export type HandleInputChangeType = ( - e: React.ChangeEvent + e: ChangeEvent ) => void; +export type ObjectType = Record; + const getParts = (path: string) => { return path.split('[').map((part) => part.replace(']', '')); }; -const getNestedValue = (obj: any, path: string) => { - if (obj == null || typeof obj !== 'object') return obj; +const getNestedValue = (obj: ObjectType, path: string) => { + if (obj === null) return obj; const parts = getParts(path); return parts.reduce((curr, part) => curr[part], obj); }; -const setNestedValue = (obj: any, path: string, value: any) => { +const setNestedValue = (obj: T, path: string, value: unknown) => { + if (obj === null) return obj; + const parts = getParts(path); const lastKey = parts.pop(); @@ -40,7 +44,7 @@ const setNestedValue = (obj: any, path: string, value: any) => { } const lastObj = parts.reduce((curr, part) => curr[part], obj); - lastObj[lastKey] = value; + (lastObj as ObjectType)[lastKey] = value; return obj; }; @@ -77,7 +81,7 @@ const useFormInput = ( }); }; - const updateFormState = (name: string, value: any) => { + const updateFormState = (name: string, value: unknown) => { setFormState((prev) => setNestedValue({ ...prev }, name, value)); }; From 419ffde61c08851bebc4433da9bbeaa512cd9be7 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 14:53:36 +0900 Subject: [PATCH 14/19] =?UTF-8?q?refactor:=20validation=20utils=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tests/utilTests/isEmptyString.test.ts | 13 +++++++ client/src/tests/utilTests/isNumeric.test.ts | 35 +++++++++++++++++++ .../tests/utilTests/isValidMaxLength.test.ts | 18 ++++++++++ client/src/utils/_common/isEmptyString.ts | 1 + client/src/utils/_common/isNumeric.ts | 1 + client/src/utils/_common/isValidMaxLength.ts | 3 ++ 6 files changed, 71 insertions(+) create mode 100644 client/src/tests/utilTests/isEmptyString.test.ts create mode 100644 client/src/tests/utilTests/isNumeric.test.ts create mode 100644 client/src/tests/utilTests/isValidMaxLength.test.ts create mode 100644 client/src/utils/_common/isEmptyString.ts create mode 100644 client/src/utils/_common/isNumeric.ts create mode 100644 client/src/utils/_common/isValidMaxLength.ts diff --git a/client/src/tests/utilTests/isEmptyString.test.ts b/client/src/tests/utilTests/isEmptyString.test.ts new file mode 100644 index 000000000..d84fc2b78 --- /dev/null +++ b/client/src/tests/utilTests/isEmptyString.test.ts @@ -0,0 +1,13 @@ +import { isEmptyString } from '@utils/_common/isEmptyString'; + +describe('isEmptyString 함수 테스트', () => { + it.each([ + ['', true], + ['a', false], + [' ', false], + ['123', false], + ['\t\n', false], + ])('입력값 "%s"에 대한 반환값은 %s이어야 한다', (input, expected) => { + expect(isEmptyString(input)).toBe(expected); + }); +}); diff --git a/client/src/tests/utilTests/isNumeric.test.ts b/client/src/tests/utilTests/isNumeric.test.ts new file mode 100644 index 000000000..379f91eaa --- /dev/null +++ b/client/src/tests/utilTests/isNumeric.test.ts @@ -0,0 +1,35 @@ +import { isNumeric } from '@utils/_common/isNumeric'; + +describe('isNumeric 함수 테스트', () => { + test.each([ + ['123', true], + ['45678', true], + ])('숫자로만 구성된 문자열을 올바르게 인식한다: %s', (input, expected) => { + expect(isNumeric(input)).toBe(expected); + }); + + test.each([['0123', false]])( + '0으로 시작하는 숫자는 올바른 숫자로 인식하지 않는다: %s', + (input, expected) => { + expect(isNumeric(input)).toBe(expected); + } + ); + + test.each([ + ['123a', false], + ['12.3', false], + ['123-', false], + ])( + '문자나 특수문자가 포함된 문자열은 올바른 숫자로 인식하지 않는다: %s', + (input, expected) => { + expect(isNumeric(input)).toBe(expected); + } + ); + + test.each([['', false]])( + '빈 문자열은 올바른 숫자로 인식하지 않는다: %s', + (input, expected) => { + expect(isNumeric(input)).toBe(expected); + } + ); +}); diff --git a/client/src/tests/utilTests/isValidMaxLength.test.ts b/client/src/tests/utilTests/isValidMaxLength.test.ts new file mode 100644 index 000000000..e6531d6f3 --- /dev/null +++ b/client/src/tests/utilTests/isValidMaxLength.test.ts @@ -0,0 +1,18 @@ +import { isValidMaxLength } from '@utils/_common/isValidMaxLength'; + +describe('isValidMaxLength 함수 테스트', () => { + it.each([ + ['test', 4, true], + ['test', 5, true], + ['test', 3, false], + ['', 0, true], + ['', 1, true], + ['hello world', 11, true], + ['hello world', 10, false], + ])( + '문자열 "%s"의 길이가 %d 이하인지 검사하면 결과는 %s이어야 한다', + (input, maxLength, expected) => { + expect(isValidMaxLength(input, maxLength)).toBe(expected); + } + ); +}); diff --git a/client/src/utils/_common/isEmptyString.ts b/client/src/utils/_common/isEmptyString.ts new file mode 100644 index 000000000..7302b6e1d --- /dev/null +++ b/client/src/utils/_common/isEmptyString.ts @@ -0,0 +1 @@ +export const isEmptyString = (value: string) => value.length === 0; diff --git a/client/src/utils/_common/isNumeric.ts b/client/src/utils/_common/isNumeric.ts new file mode 100644 index 000000000..6123368b0 --- /dev/null +++ b/client/src/utils/_common/isNumeric.ts @@ -0,0 +1 @@ +export const isNumeric = (value: string) => /^[1-9]+\d*$/.test(value); diff --git a/client/src/utils/_common/isValidMaxLength.ts b/client/src/utils/_common/isValidMaxLength.ts new file mode 100644 index 000000000..e1b84d4bf --- /dev/null +++ b/client/src/utils/_common/isValidMaxLength.ts @@ -0,0 +1,3 @@ +export const isValidMaxLength = (value: string, max: number) => { + return value.length <= max; +}; From 7759c7899d8de14d1cefeef9bd072636986e95ea Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 15:06:21 +0900 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20isValidMaxValue=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createGoalRoomValidations.ts | 46 ++++++++++++------- .../tests/utilTests/isValidMaxValue.test.ts | 18 ++++++++ client/src/utils/_common/isValidMaxValue.ts | 2 + 3 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 client/src/tests/utilTests/isValidMaxValue.test.ts create mode 100644 client/src/utils/_common/isValidMaxValue.ts diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts index f302f38fa..b65be3db5 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts @@ -1,40 +1,52 @@ -import { ValidationsType } from '@hooks/_common/useFormInput'; +import { isEmptyString } from '@utils/_common/isEmptyString'; +import { isValidMaxLength } from '@utils/_common/isValidMaxLength'; import { isCurrentOrFutureDate } from '@utils/_common/isCurrentOrFutureDate'; - -const isEmpty = (value: string) => value.length === 0; -const isNumber = (value: string) => /^[1-9]+\d*$/.test(value); -const isMaxLength = (value: string, length: number) => value.length <= length; +import { isNumeric } from '@utils/_common/isNumeric'; +import { isValidMaxValue } from '@utils/_common/isValidMaxValue'; +import { ValidationsType } from '@hooks/_common/useFormInput'; export const staticValidations: ValidationsType = { name: (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '골룸 이름은 필수 항목입니다', updateOnFail: true }; } + if (!isValidMaxLength(inputValue, 40)) { + return { + ok: false, + message: `최대 ${40}글자까지 작성할 수 있습니다`, + updateOnFail: false, + }; + } + return { ok: true }; }, limitedMemberCount: (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '최대 인원수는 필수 항목입니다', updateOnFail: true }; } - if (!isNumber(inputValue)) { + if (!isNumeric(inputValue)) { return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; } + if (!isValidMaxValue(inputValue, 20)) { + return { ok: false, message: '최대 인원수는 20입니다', updateOnFail: false }; + } + return { ok: true }; }, 'goalRoomTodo[content]': (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; } - if (!isMaxLength(inputValue, 10)) { + if (!isValidMaxLength(inputValue, 40)) { return { ok: false, - message: '최대 10글자까지 작성할 수 있습니다', + message: `최대 ${40}글자까지 작성할 수 있습니다`, updateOnFail: false, }; } @@ -43,7 +55,7 @@ export const staticValidations: ValidationsType = { }, 'goalRoomTodo[startDate]': (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; } @@ -59,7 +71,7 @@ export const staticValidations: ValidationsType = { }, 'goalRoomTodo[endDate]': (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; } @@ -84,7 +96,7 @@ export const generateNodesValidations = (nodes: any[]) => { const endDateKey = `goalRoomRoadmapNodeRequests[${index}][endDate]`; validations[checkCountKey] = (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '인증 횟수는 필수 항목입니다', @@ -92,7 +104,7 @@ export const generateNodesValidations = (nodes: any[]) => { }; } - if (!isNumber(inputValue)) { + if (!isNumeric(inputValue)) { return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; } @@ -100,7 +112,7 @@ export const generateNodesValidations = (nodes: any[]) => { }; validations[startDateKey] = (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; } @@ -116,7 +128,7 @@ export const generateNodesValidations = (nodes: any[]) => { }; validations[endDateKey] = (inputValue) => { - if (isEmpty(inputValue)) { + if (isEmptyString(inputValue)) { return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; } diff --git a/client/src/tests/utilTests/isValidMaxValue.test.ts b/client/src/tests/utilTests/isValidMaxValue.test.ts new file mode 100644 index 000000000..857214847 --- /dev/null +++ b/client/src/tests/utilTests/isValidMaxValue.test.ts @@ -0,0 +1,18 @@ +import { isValidMaxValue } from '@utils/_common/isValidMaxValue'; + +describe('isValidMaxValue 함수 테스트', () => { + it.each([ + ['10', 10, true], + ['10', 11, true], + ['10', 9, false], + ['-1', 0, true], + ['10.5', 10.5, true], + ['10.5', 10, false], + ['10', 10.5, true], + ])( + '문자열 "%s"를 숫자로 변환한 값이 %d 이하인지 검사하면 결과는 %s이어야 한다', + (inputValue, max, expected) => { + expect(isValidMaxValue(inputValue, max)).toBe(expected); + } + ); +}); diff --git a/client/src/utils/_common/isValidMaxValue.ts b/client/src/utils/_common/isValidMaxValue.ts new file mode 100644 index 000000000..ef234a487 --- /dev/null +++ b/client/src/utils/_common/isValidMaxValue.ts @@ -0,0 +1,2 @@ +export const isValidMaxValue = (inputValue: string, max: number) => + Number(inputValue) <= max; From d5728d487b2bef1b1ee717488907e6b1761466e1 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 15:14:25 +0900 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20=EC=9D=B8=EC=9B=90=EC=88=98?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20Input=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/InputField/InputField.styles.ts | 4 +++- .../src/components/_common/InputField/InputField.tsx | 3 ++- .../createGoalRoomForm/CreateGoalRoomForm.tsx | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/client/src/components/_common/InputField/InputField.styles.ts b/client/src/components/_common/InputField/InputField.styles.ts index 3ac40a1df..202b540f2 100644 --- a/client/src/components/_common/InputField/InputField.styles.ts +++ b/client/src/components/_common/InputField/InputField.styles.ts @@ -1,6 +1,8 @@ import styled from 'styled-components'; -export const InputField = styled.div``; +export const InputField = styled.div` + width: 100%; +`; export const FieldHeader = styled.div<{ size?: 'small' | 'normal' }>` margin-bottom: ${({ size }) => (size === 'small' ? '0.8rem' : '1.8rem')}; diff --git a/client/src/components/_common/InputField/InputField.tsx b/client/src/components/_common/InputField/InputField.tsx index b623926f8..f6a8430b9 100644 --- a/client/src/components/_common/InputField/InputField.tsx +++ b/client/src/components/_common/InputField/InputField.tsx @@ -12,11 +12,12 @@ type InputFieldProps = { description?: string; placeholder?: string; errorMessage?: string; + style?: { [key: string]: string }; }; const InputField = ({ ...props }: InputFieldProps) => { return ( - + {props.label} diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx index a9b25aabd..4b223033c 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/CreateGoalRoomForm.tsx @@ -51,6 +51,18 @@ const CreateGoalRoomForm = ({ roadmapContentId, nodes }: CreateGoalRoomFormProps return ( + Date: Mon, 14 Aug 2023 15:51:32 +0900 Subject: [PATCH 17/19] =?UTF-8?q?refactor:=20validation=EC=9D=98=20?= =?UTF-8?q?=EB=A7=A4=EC=A7=81=EB=84=98=EB=B2=84=EC=99=80=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EC=83=81?= =?UTF-8?q?=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createGoalRoomValidations.ts | 66 +++++++++++++------ .../constants/goalRoom/goalRoomValidation.ts | 26 ++++++++ 2 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 client/src/constants/goalRoom/goalRoomValidation.ts diff --git a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts index b65be3db5..b52220d75 100644 --- a/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts +++ b/client/src/components/goalRoomCreatePage/createGoalRoomForm/createGoalRoomValidations.ts @@ -5,16 +5,18 @@ import { isNumeric } from '@utils/_common/isNumeric'; import { isValidMaxValue } from '@utils/_common/isValidMaxValue'; import { ValidationsType } from '@hooks/_common/useFormInput'; +import { GOALROOM, ERROR_MESSAGE } from '@constants/goalRoom/goalRoomValidation'; + export const staticValidations: ValidationsType = { name: (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '골룸 이름은 필수 항목입니다', updateOnFail: true }; + return { ok: false, message: ERROR_MESSAGE.NAME_REQUIRED, updateOnFail: true }; } - if (!isValidMaxLength(inputValue, 40)) { + if (!isValidMaxLength(inputValue, GOALROOM.MAX_NAME_LENGTH)) { return { ok: false, - message: `최대 ${40}글자까지 작성할 수 있습니다`, + message: ERROR_MESSAGE.NAME_MAX_LENGTH, updateOnFail: false, }; } @@ -24,15 +26,23 @@ export const staticValidations: ValidationsType = { limitedMemberCount: (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '최대 인원수는 필수 항목입니다', updateOnFail: true }; + return { + ok: false, + message: ERROR_MESSAGE.MEMBER_COUNT_REQUIRED, + updateOnFail: true, + }; } if (!isNumeric(inputValue)) { - return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; + return { ok: false, message: ERROR_MESSAGE.NUMERIC, updateOnFail: false }; } - if (!isValidMaxValue(inputValue, 20)) { - return { ok: false, message: '최대 인원수는 20입니다', updateOnFail: false }; + if (!isValidMaxValue(inputValue, GOALROOM.MEMBER_COUNT_MAX_VALUE)) { + return { + ok: false, + message: ERROR_MESSAGE.MEMBER_COUNT_MAX_VALUE, + updateOnFail: false, + }; } return { ok: true }; @@ -40,13 +50,17 @@ export const staticValidations: ValidationsType = { 'goalRoomTodo[content]': (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '투두 리스트는 필수 항목입니다', updateOnFail: true }; + return { + ok: false, + message: ERROR_MESSAGE.TODO_CONTENT_REQUIRED, + updateOnFail: true, + }; } - if (!isValidMaxLength(inputValue, 40)) { + if (!isValidMaxLength(inputValue, GOALROOM.TODO_CONTENT_MAX_LENGTH)) { return { ok: false, - message: `최대 ${40}글자까지 작성할 수 있습니다`, + message: ERROR_MESSAGE.TODO_CONTENT_MAX_LENGTH, updateOnFail: false, }; } @@ -56,13 +70,17 @@ export const staticValidations: ValidationsType = { 'goalRoomTodo[startDate]': (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; + return { + ok: false, + message: ERROR_MESSAGE.START_DATE_REQUIRED, + updateOnFail: true, + }; } if (!isCurrentOrFutureDate(inputValue)) { return { ok: false, - message: '이전 날짜를 입력할 수 없습니다', + message: ERROR_MESSAGE.INVALID_DATE, updateOnFail: false, }; } @@ -72,13 +90,13 @@ export const staticValidations: ValidationsType = { 'goalRoomTodo[endDate]': (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; + return { ok: false, message: ERROR_MESSAGE.END_DATE_REQUIRED, updateOnFail: true }; } if (!isCurrentOrFutureDate(inputValue)) { return { ok: false, - message: '이전 날짜를 입력할 수 없습니다', + message: ERROR_MESSAGE.INVALID_DATE, updateOnFail: false, }; } @@ -99,13 +117,13 @@ export const generateNodesValidations = (nodes: any[]) => { if (isEmptyString(inputValue)) { return { ok: false, - message: '인증 횟수는 필수 항목입니다', + message: ERROR_MESSAGE.CHECK_COUNT_REQUIRED, updateOnFail: true, }; } if (!isNumeric(inputValue)) { - return { ok: false, message: '숫자를 입력해주세요', updateOnFail: false }; + return { ok: false, message: ERROR_MESSAGE.NUMERIC, updateOnFail: false }; } return { ok: true }; @@ -113,13 +131,17 @@ export const generateNodesValidations = (nodes: any[]) => { validations[startDateKey] = (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '시작일은 필수 항목입니다', updateOnFail: true }; + return { + ok: false, + message: ERROR_MESSAGE.START_DATE_REQUIRED, + updateOnFail: true, + }; } if (!isCurrentOrFutureDate(inputValue)) { return { ok: false, - message: '이전 날짜를 입력할 수 없습니다', + message: ERROR_MESSAGE.INVALID_DATE, updateOnFail: false, }; } @@ -129,13 +151,17 @@ export const generateNodesValidations = (nodes: any[]) => { validations[endDateKey] = (inputValue) => { if (isEmptyString(inputValue)) { - return { ok: false, message: '종료일은 필수 항목입니다', updateOnFail: true }; + return { + ok: false, + message: ERROR_MESSAGE.END_DATE_REQUIRED, + updateOnFail: true, + }; } if (!isCurrentOrFutureDate(inputValue)) { return { ok: false, - message: '이전 날짜를 입력할 수 없습니다', + message: ERROR_MESSAGE.INVALID_DATE, updateOnFail: false, }; } diff --git a/client/src/constants/goalRoom/goalRoomValidation.ts b/client/src/constants/goalRoom/goalRoomValidation.ts new file mode 100644 index 000000000..9f97627ab --- /dev/null +++ b/client/src/constants/goalRoom/goalRoomValidation.ts @@ -0,0 +1,26 @@ +export const GOALROOM = { + MAX_NAME_LENGTH: 40, + + MEMBER_COUNT_MAX_VALUE: 20, + + TODO_CONTENT_MAX_LENGTH: 250, +}; + +export const ERROR_MESSAGE = { + NAME_REQUIRED: '골룸 이름은 필수 항목입니다', + NAME_MAX_LENGTH: `최대 ${GOALROOM.MAX_NAME_LENGTH}글자까지 입력할 수 있습니다`, + + MEMBER_COUNT_REQUIRED: '최대 인원수는 필수 항목입니다', + MEMBER_COUNT_MAX_VALUE: `최대 인원수는 ${GOALROOM.MEMBER_COUNT_MAX_VALUE}입니다`, + + TODO_CONTENT_REQUIRED: '투두 리스트는 필수 항목입니다', + TODO_CONTENT_MAX_LENGTH: `최대 ${GOALROOM.TODO_CONTENT_MAX_LENGTH}글자까지 입력할 수 있습니다`, + + START_DATE_REQUIRED: '시작일은 필수 항목입니다', + END_DATE_REQUIRED: '종료일은 필수 항목입니다', + INVALID_DATE: '이전 날짜를 입력할 수 없습니다', + + CHECK_COUNT_REQUIRED: '인증 횟수는 필수 항목입니다', + + NUMERIC: '숫자를 입력해주세요', +}; From cfec4a6c318af71dbf40a8ddc0fb380814c5f657 Mon Sep 17 00:00:00 2001 From: Jungwoo Date: Mon, 14 Aug 2023 15:52:11 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat:=20InputField=EC=97=90=20textarea=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=EB=A5=BC=20=EC=A1=B0=EA=B1=B4=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/InputField/InputField.styles.ts | 13 +++++++--- .../_common/InputField/InputField.tsx | 26 +++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/client/src/components/_common/InputField/InputField.styles.ts b/client/src/components/_common/InputField/InputField.styles.ts index 202b540f2..4eebea675 100644 --- a/client/src/components/_common/InputField/InputField.styles.ts +++ b/client/src/components/_common/InputField/InputField.styles.ts @@ -25,8 +25,6 @@ export const InputBox = styled.div<{ size?: 'small' | 'normal'; type?: 'text' | 'date' | 'textarea' | 'number'; }>` - position: relative; - & > input { ${({ theme, size }) => (size === 'small' ? theme.fonts.button1 : theme.fonts.h2)}; width: ${({ size, type }) => @@ -41,11 +39,20 @@ export const InputBox = styled.div<{ border-bottom: ${({ theme }) => `0.1rem solid ${theme.colors.black}`}; border-radius: ${({ type }) => (type === 'number' ? '4px' : '')}; } + + & > textarea { + ${({ theme, size }) => (size === 'small' ? theme.fonts.button1 : theme.fonts.h2)}; + width: 100%; + height: 15rem; + padding: 1.5rem 1rem; + + border: ${({ theme }) => `3px solid ${theme.colors.main_dark}`}; + border-radius: 8px; + } `; export const ErrorMessage = styled.p<{ size?: 'small' | 'normal' }>` ${({ theme }) => theme.fonts.button1}; position: absolute; - top: ${({ size }) => (size === 'small' ? '2.3rem' : '2.55rem')}; color: ${({ theme }) => theme.colors.red}; `; diff --git a/client/src/components/_common/InputField/InputField.tsx b/client/src/components/_common/InputField/InputField.tsx index f6a8430b9..dc2eaae11 100644 --- a/client/src/components/_common/InputField/InputField.tsx +++ b/client/src/components/_common/InputField/InputField.tsx @@ -26,14 +26,24 @@ const InputField = ({ ...props }: InputFieldProps) => { {props.description && {props.description}} - + {props.type === 'textarea' ? ( +