공통 컴포넌트 작업을 마무리하고, 오늘부터는 페이지 작업 시작입니다.
팀 생성 페이지를 먼저 작업해 보겠습니다❗
작업 목표
작업에 앞서 피그마 디자인을 먼저 확인해 볼게요👀
팀 이름 Input이 팀원분이 공통으로 작업해 주신 Input 컴포넌트와 유사하게 생겼으니 공통 Input을 사용하면 되겠네요👍
공통 Input 코드
import { MouseEvent, ReactNode, useState } from 'react';
import { useFormContext, RegisterOptions } from 'react-hook-form';
import IconVisibility from '@/app/components/icons/IconVisibility';
import IconInVisibility from '@/app/components/icons/IconInVisibility';
type AuthInputProps = {
name: string; // 필드 이름 (폼 데이터의 키)
title: string; // 라벨 제목
type: string; // input 타입 (예: text, password 등)
placeholder: string; // 플레이스홀더
autoComplete: string; // 자동 완성 옵션
validationRules?: RegisterOptions; // react-hook-form 유효성 검증 규칙
backgroundColor?: string; // 입력 필드 배경색
customButton?: ReactNode; // 추가 버튼 컴포넌트
};
function AuthInput({
name,
title,
type = 'text',
placeholder,
autoComplete,
validationRules,
backgroundColor = 'bg-background-secondary',
customButton,
}: AuthInputProps) {
const [isVisibleToggle, setIsVisibleToggle] = useState(false);
const {
register,
formState: { errors },
} = useFormContext();
const isPassword = type === 'password';
const inputType = isPassword ? (isVisibleToggle ? 'text' : 'password') : type;
const handleToggleClick = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsVisibleToggle(!isVisibleToggle);
};
const inputBorderClass = errors[name]
? 'border-status-danger' // 에러시 border 색상
: 'border-[#F8FAFC1A]'; // 기본 border 색상
return (
<div className="flex flex-col gap-3">
<label className="text-text-primary text-base font-medium" htmlFor={name}>
{title}
</label>
<div className="relative">
<input
className={`focus:border-interaction-focus placeholder:text-text-danger text-text-primary h-full w-full rounded-xl border px-4 py-[0.85rem] placeholder:text-lg focus:outline-none ${backgroundColor} ${inputBorderClass}`}
{...register(name, validationRules)}
type={inputType}
id={name}
placeholder={placeholder}
autoComplete={autoComplete}
/>
{isPassword && customButton && (
<div className="absolute right-4 top-3 z-20">{customButton}</div>
)}
{isPassword && !customButton && (
<button
className="absolute right-4 top-3 z-10"
type="button"
onClick={handleToggleClick}
>
{isVisibleToggle ? <IconVisibility /> : <IconInVisibility />}
</button>
)}
</div>
{errors[name] && (
<span className="text-status-danger text-sm">
{errors[name]?.message as string}
</span>
)}
</div>
);
}
export default AuthInput;
컴포넌트 이름을 보니 로그인, 회원가입에 관련된 Input만 고려하신 것 같네요🤔
공통 컴포넌트를 사용하지 말아야 하나 고민했지만, React Hook Form을 사용해 작업해 주셔서 공통 Input을 사용하는 게 효율적일 것 같아 사용해야겠습니다.
저번 코드 리뷰 때 배운 점을 바탕으로 팀 회의 때 명확하고 부드러운 소통으로 컴포넌트와 변수의 이름을 바꾸는 내용을 건의해 보겠습니다✨
axios를 추가하고 instance를 세팅해 보겠습니다.
Axios Instance 생성
import axios from 'axios';
const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
headers: {
'Content-Type': 'application/json',
// 로그인 기능이 구현되면 TOKEN 추가하겠습니다.
// Authorization: `Bearer ${}`
},
});
export default instance;
이제 axios도 설정했으니, api 작업을 시작해 볼게요.
팀 생성 API는 body에 image와 name을 필요로 하는데요, image는 필수는 아니지만 http:// 또는 https://로 시작하는 문자열을 받네요🤔
이미지를 첨부했을 때 정해진 패턴으로 변경해 줄 수 있게 Image 업로드 API를 먼저 작업해 볼게요.
팀 생성 페이지 구조 및 Image 업로드 API
<FormProvider {...method}>
<form className="flex flex-col gap-6" onSubmit={handleSubmit(onSubmit)}>
<div>
<span className="mb-3 inline-block">팀 프로필</span>
<label
htmlFor="profile"
className="relative block h-16 w-16 cursor-pointer"
>
<input
id="profile"
{/* 화면에 표시되지 않지만 파일 선택을 위해 sr-only 추가 */}
className="sr-only"
type="file"
accept="image/*"
{...register('profile')}
onChange={handleFileChange}
/>
{profileImage ? (
<Image src={profileImage} fill alt="profile image" />
) : (
<IconProfile />
)}
</label>
</div>
<AuthInput
name="name"
title="팀 이름"
type="text"
placeholder="팀 이름을 입력해주세요."
autoComplete="off"
/>
</form>
</FormProvider>
공통 Input을 useFormContext로 작업해 주셔서 form 상태를 관리하기 위해 FormProvider를 사용했어요❗
import axios from '@/app/lib/instance';
const uploadImage = (imageData: FormData) => {
const res = axios.post('images/upload', imageData, {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
},
});
return res;
};
export { uploadImage };
- Content-Type : React Hook Form의 Form 데이터를 사용하기 위해 multipart/form-data로 설정
- Authorization : 로그인 기능이 아직 구현 전이라 .env에 인증 토큰을 임의로 저장하고 사용
// 파일 처리하는 함수
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file); // 미리보기 URL 생성
setProfileImage(url); // 미리보기 이미지 업데이트
setValue('profile', file); // form data 설정
}
};
const onSubmit = async (data: PostGroupData) => {
try {
const formData = new FormData();
formData.append('image', data.profile);
const img = await uploadImage(formData);
} catch (error) {
alert(`팀 생성에 실패했습니다`);
}
return null;
};
API를 완성했으니 결과를 확인해 봐야겠죠👀
무슨 에러인지 응답과 페이로드로 서버에 전달한 데이터를 확인해 볼게요.
image에 File이 아니라 FileList가 보내지고 있네요 😭
확인해 보니 아래 두 경우에 data.profile의 값이 FileList입니다.
1️⃣ 이미지를 선택하지 않은 경우
2️⃣ 이미지를 선택한 여러 번 선택한 경우(이미 선택한 상태에서 변경하는 경우)
1️⃣번 문제는 간단한 조건문을 통해 해결할 수 있을 것 같아요.
if (data.profile && data.profile instanceof File) {
const formData = new FormData();
formData.append('image', data.profile);
const img = await uploadImage(formData);
}
- data.profile : formData의 profile 데이터가 있는지 확인
- data.profile instanceof File : formData의 profile 데이터가 File 객체인지 확인
이미지를 선택하지 않은 경우 API 요청을 안 하도록 잘 적용되었네요👍
2️⃣번 문제를 해결하려고 이미지를 추가하던 중 디버깅을 해보니 해결된 게 아니었네요.
profile가 FileList여서 이미지 업로드 API가 동작을 안 하고 있었어요😭
코드를 살펴보다가 의문이 드는 부분이 생겼습니다.
// handleFileChange
setValue('profile', file);
// 파일 업로드 input
<input
id="profile"
className="sr-only"
type="file"
accept="image/*"
{...register('profile')}
onChange={handleFileChange}
/>;
이미 파일 업로드 input에 register로 Form State를 연결했는데, 파일 change 핸들러에 setValue로 register filed의 profile 값을 다시 변경해주고 있네요...🤔
아마 register를 등록하기 전에 setValue로 관리하던 로직을 삭제하지 않은 것 같아요.
삭제하고 다시 테스트해 보겠습니다.
로직을 꼼꼼하게 확인하지 못해 발생한 문제로 너무 많은 시간을 투자한 것 같아 아쉬운 마음이 드네요😅
이번 경험을 통해 React Hook Form의 공식 문서를 깊이 이해하는 계기가 되었고, 앞으로는 더 꼼꼼하게 코드를 점검할 자신감이 생긴 것 같습니다👍
'프로젝트 > Next+TypeScript' 카테고리의 다른 글
[맛길] API Routes를 이용한 API 구축 (2) | 2025.01.24 |
---|---|
[Coworkers] 팀 생성 페이지 작업 (feat. 빌드 타임 에러 수정) (0) | 2025.01.23 |
[Coworkers] Trouble Shooting - 코드 리뷰 (0) | 2025.01.21 |
[Coworkers] 공통 컴포넌트 Modal (0) | 2025.01.20 |
[맛길] 데이터 채우기 - Youtube Data API (2) | 2025.01.18 |