Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Feat/#238] HostApplyPage 폼 react-hook-form 도입 #240

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"react": "^18.3.1",
"react-datepicker": "^7.2.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.1",
"react-router-dom": "^6.24.0",
"react-spinners": "^0.14.1",
"swiper": "^11.1.4"
Expand Down
10 changes: 5 additions & 5 deletions src/apis/domains/host/usePostHostApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEasyNavigate } from '@hooks';
import { components } from '@schema';
import { useQueryClient, useMutation } from '@tanstack/react-query';
import { ErrorResponse, ErrorType, MutateResponseType } from '@types';
import { Dispatch, RefObject, SetStateAction } from 'react';
import { Dispatch, SetStateAction } from 'react';

type HostApplyRequest = components['schemas']['SubmitterCreateRequest'];

Expand All @@ -21,10 +21,10 @@ const postHostApply = async (hostApplyState: HostApplyRequest): Promise<MutateRe
};

export const usePostHostApply = (
resetHostApplyState: () => void,
// resetHostApplyState: () => void,
onNext: () => void,
setIsNicknameDuplicate: Dispatch<SetStateAction<boolean>>,
nicknameRef: RefObject<HTMLInputElement>
// nicknameRef: RefObject<HTMLInputElement>
) => {
const queryClient = useQueryClient();
const { goGuestMyPage } = useEasyNavigate();
Expand All @@ -33,13 +33,13 @@ export const usePostHostApply = (
mutationFn: (hostApplyState: HostApplyRequest) => postHostApply(hostApplyState),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY.HOST_APPLY] });
resetHostApplyState();
// resetHostApplyState();
onNext();
},
onError: (error: ErrorType) => {
if (error.status === 40008) {
setIsNicknameDuplicate(true);
nicknameRef.current?.focus();
// nicknameRef.current?.focus();
} else {
alert(error.message);
goGuestMyPage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ interface Category {
}

interface CategorySelectBoxProps {
selectedCategories: Category;
selectedCategories: Category | undefined;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

undefined를 옵셔널로 주신 이유가 있으실까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분에 undefined을 주지 않으면 CategorySelectBox 컴포넌트를 호출하는 부분에서 selectedCategories을 prop으로 넘겨줄 때 category1만 string값이 부여되어 있고, category2,3은 string | undefined 타입이라 타입에러가 떠서 이런식으로 수정했습니다!

onUpdateCategories: (newCategories: Category) => void;
}

Expand Down
85 changes: 40 additions & 45 deletions src/components/common/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TextareaHTMLAttributes, useState } from 'react';
import { forwardRef, TextareaHTMLAttributes, useState } from 'react';

import {
textAreaStyle,
Expand All @@ -17,54 +17,49 @@ export interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
maxLength: number;
size?: 'small' | 'medium';
}
const TextArea = ({
value,
onChange,
isValid,
size = 'small',
placeholder,
errorMessage,
maxLength,
}: TextAreaProps) => {
const [maxLengthError, setMaxLengthError] = useState(false);
const [isFocused, setIsFocused] = useState(false);
// 글자 수 세서 바로 화면에 반영하는 onChange 함수 (default)
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (e.target.value.length <= maxLength) {
onChange(e);
setMaxLengthError(false);
} else {
setMaxLengthError(true);
}
};
const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
({ value, onChange, isValid, size = 'small', placeholder, errorMessage, maxLength }, ref) => {
const [maxLengthError, setMaxLengthError] = useState(false);
const [isFocused, setIsFocused] = useState(false);
// 글자 수 세서 바로 화면에 반영하는 onChange 함수 (default)
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (e.target.value.length <= maxLength) {
onChange(e);
setMaxLengthError(false);
} else {
setMaxLengthError(true);
}
};

// TODO: constants 파일에 분리하기
// 글자 수 에러 메시지
const textLengthErrorMessage = `* 글자 수 ${maxLength}자 이하로 입력해주세요.`;
// TODO: constants 파일에 분리하기
// 글자 수 에러 메시지
const textLengthErrorMessage = `* 글자 수 ${maxLength}자 이하로 입력해주세요.`;

const isError = maxLengthError || !isValid;
const isError = maxLengthError || !isValid;

return (
<div css={textAreaContainerStyle}>
<div css={[textAreaWrapperStyle(isError, isFocused), textAreaWrapperSize[size]]}>
<textarea
css={textAreaStyle}
value={value}
onChange={handleTextAreaChange}
placeholder={placeholder}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span css={textLengthStyle(isError, isFocused)}>
{value.length}/{maxLength}
</span>
</div>
return (
<div css={textAreaContainerStyle}>
<div css={[textAreaWrapperStyle(isError, isFocused), textAreaWrapperSize[size]]}>
<textarea
ref={ref}
css={textAreaStyle}
value={value}
onChange={handleTextAreaChange}
placeholder={placeholder}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span css={textLengthStyle(isError, isFocused)}>
{value.length}/{maxLength}
</span>
</div>

{maxLengthError && <span css={errorMessageStyle}>{textLengthErrorMessage}</span>}
{maxLengthError && <span css={errorMessageStyle}>{textLengthErrorMessage}</span>}

{isFocused && !isValid && <span css={errorMessageStyle}>{errorMessage}</span>}
</div>
);
};
{!isValid && <span css={errorMessageStyle}>{errorMessage}</span>}
</div>
);
}
);

export default TextArea;
6 changes: 2 additions & 4 deletions src/components/common/inputs/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InputHTMLAttributes, forwardRef, useState } from 'react';
import { forwardRef, InputHTMLAttributes, useState } from 'react';
import {
inputContainerStyle,
inputLabelStyle,
Expand Down Expand Up @@ -83,9 +83,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
''
)}
</div>
{isFocused && displayErrorMessage && (
<span css={errorMessageStyle}>{displayErrorMessage}</span>
)}
{!isValid && <span css={errorMessageStyle}>{displayErrorMessage}</span>}
</div>
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/host/components/StepOne/StepOne.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const subTitleStyle = (theme: Theme) => css`

export const mainStyle = css`
${flexGenerator('column')};
gap: 2rem;
gap: 3rem;

margin-top: 3.8rem;
margin-bottom: 11.9rem;
Expand All @@ -37,4 +37,5 @@ export const sectionStyle = css`

export const footerStyle = css`
width: 100%;
margin-top: 2rem;
`;
144 changes: 91 additions & 53 deletions src/pages/host/components/StepOne/StepOne.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ import {
titleStyle,
} from './StepOne.style';
import { smoothScroll } from '@utils';
import { useHostApplyInputChange, useHostApplyInputValidation } from '@pages/host/hooks';
import { useAtom } from 'jotai';
import { hostApplyAtom } from '@stores';
import { Controller, useForm } from 'react-hook-form';
import { components } from '@schema';

const StepOne = ({ onNext }: StepProps) => {
const { hostApplyState, handleInputChange } = useHostApplyInputChange();
const { validateStepOne } = useHostApplyInputValidation();
const { isIntroValid, isGoalValid, isLinkValid, isAllValid } = validateStepOne(hostApplyState);
const [hostApplyState, setHostApplyState] = useAtom(hostApplyAtom);

const handleNextClick = () => {
if (isAllValid) {
onNext();
smoothScroll(0);
}
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
defaultValues: hostApplyState,
mode: 'onBlur',
});

const onSubmit = (data: components['schemas']['SubmitterCreateRequest']) => {
setHostApplyState((prev) => ({ ...prev, ...data }));
onNext();
smoothScroll(0);
};

return (
Expand All @@ -32,50 +41,79 @@ const StepOne = ({ onNext }: StepProps) => {
<h4 css={titleStyle}>호스트 신청</h4>
<h1 css={subTitleStyle}>호스트님에 대해 알려주세요!</h1>
</header>
<main css={mainStyle}>
<section css={sectionStyle}>
<QuestionText numberLabel="Q1">호스트님은 어떤 분이신가요?</QuestionText>
<TextArea
size="medium"
maxLength={300}
placeholder={`호스트님에 대해 자유롭게 소개해 주세요! \n모임 참여 및 개최 경험 등도 좋아요.`}
value={hostApplyState.intro}
onChange={(e) => handleInputChange(e, 'intro')}
isValid={isIntroValid}
errorMessage="빈칸을 입력해주세요."
/>
</section>
<section css={sectionStyle}>
<QuestionText numberLabel="Q2">호스트로 이루고 싶은 목표를 알려주세요!</QuestionText>
<TextArea
size="medium"
maxLength={300}
placeholder={`모임에서 어떤 가치를 공유하고 싶으신가요? \n그 이유는 무엇인가요?`}
value={hostApplyState.goal}
onChange={(e) => handleInputChange(e, 'goal')}
isValid={isGoalValid}
errorMessage="빈칸을 입력해주세요."
/>
</section>
<section css={sectionStyle}>
<QuestionText numberLabel="Q3">
호스트님을 잘 알 수 있는 SNS 혹은 홈페이지 링크를 첨부해 주세요!
</QuestionText>
<Input
value={hostApplyState.link}
onChange={(e) => handleInputChange(e, 'link')}
placeholder="URL을 첨부해주세요."
isValid={isLinkValid}
errorMessage="올바른 URL 형식을 입력해주세요"
isCountValue={false}
/>
</section>
</main>
<footer css={footerStyle}>
<Button variant="large" onClick={handleNextClick} disabled={!isAllValid}>
다음
</Button>
</footer>

<form onSubmit={handleSubmit(onSubmit)} >
<main css={mainStyle}>
<section css={sectionStyle}>
<QuestionText numberLabel="Q1">호스트님은 어떤 분이신가요?</QuestionText>
<Controller
name="intro"
control={control}
rules={{ required: '빈칸을 입력해주세요.' }}
render={({ field }) => (
<TextArea
{...field}
size="medium"
maxLength={300}
placeholder={`호스트님에 대해 자유롭게 소개해 주세요! \n모임 참여 및 개최 경험 등도 좋아요.`}
aria-invalid={!!errors.intro}
isValid={!errors.intro}
errorMessage={errors.intro?.message}
/>
)}
/>
</section>
<section css={sectionStyle}>
<QuestionText numberLabel="Q2">호스트로 이루고 싶은 목표를 알려주세요!</QuestionText>
<Controller
name="goal"
control={control}
rules={{ required: '빈칸을 입력해주세요.' }}
render={({ field }) => (
<TextArea
{...field}
size="medium"
maxLength={300}
placeholder={`모임에서 어떤 가치를 공유하고 싶으신가요? \n그 이유는 무엇인가요?`}
aria-invalid={!!errors.goal}
isValid={!errors.goal}
errorMessage={errors.goal?.message}
/>
)}
/>
</section>
<section css={sectionStyle}>
<QuestionText numberLabel="Q3">
호스트님을 잘 알 수 있는 SNS 혹은 홈페이지 링크를 첨부해 주세요!
</QuestionText>
<Controller
name="link"
control={control}
rules={{
required: '올바른 URL 형식을 입력해주세요.',
pattern: {
value: /^(https?:\/\/)?([\w-]+(\.[\w-]+)+\/?)([^\s]*)?$/i,
message: '올바른 URL 형식을 입력해주세요.',
},
}}
render={({ field }) => (
<Input
{...field}
placeholder="URL을 첨부해주세요."
isValid={!errors.link}
errorMessage={errors.link?.message}
isCountValue={false}
/>
)}
/>
Comment on lines +89 to +108
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프로젝트에서 사용되고 있는 Input들이 모두 form에서 사용된다면 Controller를 Input안으로 옮겨서 Input컴포넌트 자체를 react-hook-form 을 사용하는 Input으로 만드는 것은 어떤가요?

그냥 Input도 필요하다면 Controller를 포함하는 Input을 FormInput이라고 한다던지 해서, Controller로 감싸는 부분을 공통 컴포넌트 안으로 넣어버리는게 좋지 않을까..? 라는 생각인데 어떠신가요?

저도 react-hook-form을 안써봐서 여러 의견 부탁드립니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 그런 방식도 생각을 해봤는데 안해봐서 되는지는 확인을 해봐야 할 것 같습니다.
그리고 지금처럼 Controller 내부에 Input을 render할 때나 Controller를 Input 안에 넣을 때나 prop의 개수는 거의 비슷할 것 같긴 합니다.
추후에 리뷰기능이나 공지사항 등 모든 Input이 사용되는 곳을 form으로 본다면 괜찮은 방식인것 같습니다. 하지만 그게 아니라면 공통 컴포넌트인 Input은 지금처럼 유지하는게 좋을 것 같습니다.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 찾아봤을 때는 (레퍼는 기억이 안나서,, 찾으면 댓글로 추가하겠습니다 ㅠ) 그렇게 컴포넌트 안에 Controller를 함께 포함하는 것이 가능할 뿐 아니라, 일반적으로 이렇게 많이 사용하는 것 같았습니다.

제 생각에도 Input 컴포넌트 자체를 수정하는 것은 좀 아닌것 같고, Input컴포넌트를 Controller로 감싸놓은 FormInput이라는 새로운 컴포넌트를 만드는게 어떨까라고 생각합니다.

</section>
</main>
<footer css={footerStyle}>
<Button variant="large" type="submit" disabled={isSubmitting}>
다음
</Button>
</footer>
</form>
</div>
</>
);
Expand Down
Loading