로그인 한 번 하고나니까 그래도 좀 수월했는데
input 코드가 너무 반복되어서 컴포넌트로 빼서 했더니 하나가 동작하면 다른게 안되고 그거 고치면 또 다른 문제가 생기고 너무 복잡해져서 그냥 로그인과 비슷하게 구현하려고 수정했다.
강사 회원가입에서는 자식 컴포넌트에서 useForm의 메서드들을 사용하려고 FormProvider로 넘겨주는 방식을 사용했다.
useForm을 내려주고싶은 자식 컴포넌트를 <FormProvider {...methods}>
같은 형식으로 감싸주고, 자식 컴포넌트에서는 useFormContext
에서 메서드를 뽑아 쓰면 된다.
(아래의 전체 코드에서 확인 가능)
학생 회원가입 구현은 여차저차 구현 했는데, 강사 회원가입 구현하려다가 학생 회원가입까지 동작을 안하게 됐다 ㅋ
그래서 반쪽짜리지만 학생 회원가입 동작하게 해서 깃헙에 올리고,
첨부파일을 넣는 input 태그 하나 추가했다고 이렇게 고전할 줄 몰랐다...
처음에 입력받은 파일이 하나여서 as File로 타입을 지정했는데 .length 메서드를 사용하거나 [0]번째에 접근하려고 했는데 계속 오류가 발생했다.
log를 찍어보니 FileList 형태로 값이 들어와서
interface.ts에 지정한 File을 FileList로 수정하고, FileList의 [0]번째 접근하는 방식으로 수정했더니 해결됐다.
const onSubmit = (data: SignupData) => { const { id, password, nickname, email, authDocument } = data; // TODO: tutor의 경우 authDocument가 없을 때 alert 띄우기 if (role === "tutor" && authDocument) { const formData = new FormData(); formData.append("id", id); formData.append("password", password); formData.append("nickname", nickname); formData.append("email", email); // formData.append("authDocument", authDocument as File) (X) formData.append("authDocument", authDocument[0]) signup(role, formData); } else { const newData = { id, password, nickname, email }; signup(role, newData); } };
export interface SignupData { id: string; password: string; nickname: string; email: string; // authDocument?: File | null; (X) -> List가 아니기 때문에 .length or [0]번째 접근 불가 authDocument?: FileList | null; certification?: number; }
pages/Signup.tsx
import { RoleProps, SignupData } from "../../types/interface"; import "../../styles/pages/account/signup.scss"; import axios from "axios"; import { Link, useNavigate } from "react-router-dom"; import { FormProvider, set, useForm } from "react-hook-form"; import { useEffect, useState } from "react"; import usePrevious from "../../hooks/usePrevious"; import SignupForm from "../../components/form/SignupForm"; export default function StudentSignup({ role }: RoleProps) { const navigate = useNavigate(); // 부모 컴포넌트에서 FormProvider로 useForm 함수 넘겨주기 위해 methods에 담음 const methods = useForm<SignupData>({ mode: "onSubmit", defaultValues: { id: "", password: "", nickname: "", email: "", authDocument: null, certification: undefined, }, }); const { watch, reset } = methods; const [isIdChecked, setIsIdChecked] = useState(false); const [isNicknameChecked, setIsNicknameChecked] = useState(false); const [isCertified, setIsCertified] = useState(false); const [randomNum, setRandomNum] = useState(0); const navigateAndReset = (path: string) => { reset(); navigate(path); setIsIdChecked(false); setIsNicknameChecked(false); setIsCertified(false); }; const idValue = watch("id"); const nicknameValue = watch("nickname"); const emailValue = watch("email"); const preIdValue = usePrevious(idValue); const preNicknameValue = usePrevious(nicknameValue); const preEmailValue = usePrevious(emailValue); useEffect(() => { if (idValue !== preIdValue) setIsIdChecked(false); if (nicknameValue !== preNicknameValue) setIsNicknameChecked(false); if (emailValue !== preEmailValue) setIsCertified(false); }, [idValue, nicknameValue, emailValue, preIdValue, preNicknameValue, preEmailValue]); /* axios */ // 중복 체크(아이디, 닉네임) const checkDuplicate = async (keyword: string, value: string): Promise<void> => { if (!value) return alert(`${keyword === "id" ? "아이디를" : "닉네임을"} 입력해주세요.`); try { const url = `${process.env.REACT_APP_API_SERVER}/api/check${ role === "student" ? "Student" : "Tutor" }${keyword === "id" ? "Id" : "Nickname"}?${keyword}=${value}`; const res = await axios.get(url); if (res.data.available === false) { alert(`이미 사용중인 ${keyword === "id" ? "아이디" : "닉네임"}입니다.`); } else { alert(`사용 가능한 ${keyword === "id" ? "아이디" : "닉네임"}입니다.`); keyword === "id" ? setIsIdChecked(true) : setIsNicknameChecked(true); } } catch (error) { console.error("아이디 중복검사 오류", error); } }; // 회원가입 const signup = async (role: string, data: SignupData | FormData) => { if (!isIdChecked || !isNicknameChecked) return alert("아이디와 닉네임 모두 중복 확인을 해주세요."); if (!isCertified) return alert("이메일 인증을 해주세요."); if (data instanceof FormData) { const newFormData = new FormData(); data.forEach((value, key) => { const newKey = key === "authDocument" ? "auth" : key; newFormData.append(newKey, value); }); const nickname = newFormData.get("nickname") as string; try { const res = await axios({ method: "post", url: `${process.env.REACT_APP_API_SERVER}/api/${role}`, data: newFormData, headers: { "Content-Type": "multipart/form-data" }, }); alert(`${res.data}! ${nickname}님 환영합니다🎉\n로그인 페이지로 이동합니다.`); navigate("/login"); } catch (error) { console.error("회원가입 오류", error); } } else { try { const res = await axios.post( `${process.env.REACT_APP_API_SERVER}/api/${role}`, data ); alert(`${res.data}! ${data.nickname}님 환영합니다🎉\n로그인 페이지로 이동합니다.`); navigate("/login"); } catch (error) { console.error("회원가입 오류", error); } } }; // 이메일 인증 const sendEmail = async (email: string) => { const res = await axios.post(`${process.env.REACT_APP_API_SERVER}/api/sendEmail`, { email, }); setRandomNum(res.data.randomNum); }; // 인증번호 확인 const checkCertification = (certification: number) => { if (!certification) return alert("인증번호를 입력해주세요."); if (certification === randomNum) { alert("인증되었습니다."); setIsCertified(true); } else { alert("인증번호가 일치하지 않습니다."); } }; return ( <section> <div className="signup_container"> <h2>{role === "student" ? "학생 " : "강사 "} 회원가입</h2> <div className="go_to_other_sign_up"> {role === "student" ? ( <button type="button" onClick={() => navigateAndReset("/signup/tutor")}> 강사로 가입하기 </button> ) : ( <button type="button" onClick={() => navigateAndReset("/signup/student")}> 학생으로 가입하기 </button> )} </div> <span>이미 계정이 있으신가요?</span> <Link to="/login">로그인</Link> <FormProvider {...methods}> <SignupForm role={role} signup={signup} checkDuplicate={checkDuplicate} sendEmail={sendEmail} checkCertification={checkCertification} /> </FormProvider> </div> </section> ); }
components/form/SignupForm.tsx
import { useFormContext } from "react-hook-form"; import { RoleProps, SignupData } from "../../types/interface"; import PasswordInput from "../input/PasswordInput"; import "../../styles/components/form/signupForm.scss"; // signup, checkDuplicate 함수를 props로 받아옴 interface SignupFormProps { checkDuplicate: (keyword: string, value: string) => Promise<void>; signup: ( role: string, data: FormData | { id: string; password: string; nickname: string; email: string } ) => Promise<void>; sendEmail: (email: string) => Promise<void>; checkCertification: (certification: number) => void; } export default function SignupForm({ role, signup, checkDuplicate, sendEmail, checkCertification, }: SignupFormProps & RoleProps) { const { register, handleSubmit, watch, formState: { errors }, } = useFormContext<SignupData>(); const onSubmit = (data: SignupData) => { const { id, password, nickname, email, authDocument } = data; // TODO: tutor의 경우 authDocument가 없을 때 alert 띄우기 if (role === "tutor" && authDocument) { const formData = new FormData(); formData.append("id", id); formData.append("password", password); formData.append("nickname", nickname); formData.append("email", email); formData.append("authDocument", authDocument[0]); signup(role, formData); } else { const newData = { id, password, nickname, email }; signup(role, newData); } }; return ( <> <div className="signup_wrapper"> <div className="basic_signup"> <form name="signup_form" onSubmit={handleSubmit(onSubmit)}> <div className="signup_id"> <label htmlFor="id">ID</label> <input type="text" {...register("id", { required: "아이디를 입력해주세요." })} id="id" autoComplete="username" /> <button type="button" onClick={() => checkDuplicate("id", watch("id"))}> 중복 확인 </button> </div> <div className="signup_pw"> <label htmlFor="pw">비밀번호</label> <PasswordInput type="password" {...register("password", { required: true })} id="pw" /> </div> <div className="signup_nickname"> <label htmlFor="nickname">닉네임</label> <input type="text" {...register("nickname", { required: true })} id="nickname" /> <button type="button" onClick={() => checkDuplicate("nickname", watch("nickname"))} 중복 확인 </button> </div> <div className="signup_email"> <label htmlFor="email">이메일</label> <input type="email" {...register("email", { required: true })} id="email" /> <button type="button" onClick={() => sendEmail(watch("email"))}> 인증번호 발송 </button> </div> <div className="signup_certification"> <label htmlFor="certification">인증번호</label> <input type="number" placeholder="숫자만 입력해주세요." {...register("certification", { required: true })} id="certification" /> <button type="button" onClick={() => checkCertification(Number(watch("certification")))} 인증 확인 </button> </div> {role !== "student" && ( <div className="sign_up_auth_document"> <label htmlFor="auth_document">증빙 자료</label> <input type="file" id="auth_document" accept=".jpg, .jpeg, .png, .pdf" {...register("authDocument", { required: true })} /> </div> )} <button type="submit">회원가입</button> </form> </div> </div> </> ); }
types/interface.ts
export interface RoleProps { role: "student" | "tutor"; } export interface SignupData { id: string; password: string; nickname: string; email: string; authDocument?: FileList | null; certification?: number; }
components/input/PasswordInput.tsx
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faEye, faEyeSlash } from "@fortawesome/free-regular-svg-icons"; import React, { forwardRef, useState } from "react"; import "../../styles/components/input/password.scss"; interface PasswordProps extends React.HTMLProps<HTMLInputElement> { type: string; } const PasswordInput = forwardRef<HTMLInputElement, PasswordProps>(({ type, ...rest }, ref) => { const [isShow, setIsShow] = useState(false); const handleClick = () => { setIsShow(!isShow); }; return ( <> <div className="pw_input_wrapper"> <input ref={ref} type={isShow ? "text" : type} autoComplete="current-password" {...rest} /> <div className="eye_icon" onClick={handleClick}> <FontAwesomeIcon icon={isShow ? faEyeSlash : faEye} /> </div> </div> </> ); }); export default PasswordInput;
참고 자료
[React Hook Form] props drilling 줄이기 | useFormContext, FormProvider