티스토리 뷰
개발냥이/타입스크립트(Typescript)
[리액트+타입스크립트] 이미지 업로드 구현 & 이미지와 콘텐츠 하나의 객체로 관리하기
데브캣_DevCat 2023. 7. 12. 09:31- 우선 피그마로 그려본 페이지 프로토타입 입니다.
- 해당 페이지에서는 사용자가 수거 신청을 할 의류의 이미지를 등록하고 물품의 내용을 적어서 보낼 수 있는데 상품 추가를 통해 한 번에 여러 개를 묶어서 보낼 수도 있습니다.
요구 사항
- 하나의 물품당 하나의 이미지만 등록이 가능합니다.
- 이미지가 등록이 되어있지 않거나 상세 내용 중 하나라도 빠진 곳이 있으면 에러가 나야합니다.
- 상품 추가 버튼을 누르면 새로운 등록 폼이 등장하고 추가로 입력해서 한 화면에서 여러 개의 물품을 한 번에 보낼 수 있어야 합니다.
- 입력하던 폼의 삭제 버튼을 누르면 해당 폼은 사라지고 요청에서 제외됩니다.
- 요청은 formData 형식으로 보내며 productlist 라는 key로 물품의 상세 내용 배열을 문자열로, 각 이미지 파일은 files 라는 key로 보냅니다.
구현
초기 작성 로직
CollectionPage.tsx
export interface ContentsProps {
name: string;
content: string;
category: string;
}
const CollectionPage = () => {
const initialValue = {
name: "",
content: "",
category: "",
};
const [formCount, setFormCount] = useState(1);
const [images, setImages] = useState<File[]>([]);
const [contents, setContents] = useState<ContentsProps[]>([initialValue]);
- 처음에 작성한 로직은 상세 내용을 저장할 contents 와 이미지를 저장할 images 두 가지 상태가 존재합니다.
CollectionPage.tsx
return (
<section>
<div>
<h1>수거 신청하기</h1>
<h4>의류를 보내서 포인트도 얻고 친환경도 실천해보세요!</h4>
</div>
{Array(formCount)
.fill(0)
.map((_, index) => (
<div key={index}>
<button onClick={() => setFormCount(formCount - 1)}>삭제</button>
<CollectionForm
images={images}
setImages={setImages}
contents={contents}
setContents={setContents}
index={index++}
/>
</div>
))}
<div>
<button
onClick={() => {
setFormCount(formCount + 1);
setContents([...contents, initialValue]);
}}
>
상품 추가
</button>
</div>
<div>
<button onClick={() => submitHandler()}>보내기</button>
</div>
</section>
);
};
- 그리고 작성 폼은 상품 추가 버튼을 클릭하면 formCount 가 1 증가하고 formCount만큼 CollectionForm 컴포넌트를 가져옵니다.
- 초기값은 1이므로 처음에는 하나의 폼만 그려내고 상품 추가 버튼을 누르면 formCount = 2가 되므로 두 개의 폼이 생기는 방식입니다.
- 이 때 index를 넘겨주어서 폼을 구분하였습니다.
CollectionForm.tsx
interface Props {
images: File[];
setImages: React.Dispatch<React.SetStateAction<File[]>>;
contents: ContentsProps[];
setContents: React.Dispatch<React.SetStateAction<ContentsProps[]>>;
index: number;
}
const CollectionForm = ({
images,
setImages,
contents,
setContents,
index,
}: Props) => {
const [preview, setPreview] = useState<string>();
const [titleValue, titleHandler, titleReset] = useInput("");
const [contentValue, contentHandler, contentReset] = useInput("");
const [categoryValue, setCategoryValue] = useState("");
- CollectionForm 은 이미지 미리보기를 위한 preview와 콘텐츠의 각 내용들을 저장할 value 들이 있습니다.
CollectionForm.tsx
useEffect(() => {
setContents(
contents.map((item, idx) =>
index === idx
? {
...item,
name: titleValue,
content: contentValue,
category: categoryValue,
}
: item
)
);
}, [titleValue, contentValue, categoryValue]);
- value값이 바뀌면 props로 전달받은 index와 같은 컴포넌트의 값을 변경합니다. (setContents)
CollectionForm.tsx
const uploadImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const imageFile = e.target.files;
if (imageFile && imageFile[0]) {
const url = URL.createObjectURL(imageFile[0]);
setPreview(url);
}
imageFile && setImages([...images, imageFile[0]]);
};
- 이미지는 따로 setImages에 저장을 합니다.
문제점
- 여기까지 구현을 했을 때 등록하는 부분에 한해서는 서버와 통신이 가능했습니다.
- 그러나 서버에 전달을 할 때 images 배열과 contents 배열을 따로 전달하는데 두 배열의 순서가 일치하지 않으면 잘못된 요청이 전달되었습니다.
- 예를 들어서 상품 추가 버튼을 눌러서 2개의 폼이 있는 상태에서 두 번째 폼에 이미지를 먼저 등록하고 그 다음으로 첫 번째 폼에 이미지를 등록한 후 상세 내용을 입력하면 서버에서는 두 번째 폼에 등록한 이미지를 1번 폼의 상세내용과 연결하게 되므로 원하는 결과를 얻을 수 없었습니다.
- 또한 여러 개의 폼 중 중간 혹은 맨 앞에 있는 폼을 삭제하게 되면 인덱스가 하나씩 변하게 되므로 이 또한 원하는 대로 요청을 보낼 수가 없었습니다.
고민한 방법
- 인덱스로 물품을 안정적으로 관리하는 것은 위험한 방법이라고 판단해서 각각 물품마다 itemId 를 부여하기로 하였습니다.
- 또한 formData로 전송을 할 때 contents와 image를 각각 다른 key값으로 분리해서 보내긴 하지만 관리할 때는 하나의 객체로 관리하는 것이 안정적이고 직관적이라고 생각하였습니다.
문제를 해결한 로직
CollectionPage.tsx
export interface ItemProps {
name: string;
content: string;
category: string;
}
export interface ContentsProps {
itemId: number;
itemInfo: ItemProps;
itemImage: File | any;
}
const CollectionPage = () => {
const initialValue = {
itemId: 0,
itemInfo: { name: "", content: "", category: "" },
itemImage: undefined,
};
const [contents, setContents] = useState<ContentsProps[]>([initialValue]);
const [itemNumber, setItemNumber] = useState(1);
- 우선 images와 contents로 분리되어 있던 상태를 하나로 합쳤습니다.
- 하나의 contents에는 itemId와 상세 내용을 저장하는 itemInfo 그리고 이미지를 저장하는 itemImage가 존재합니다.
<S.AddBtnBox>
<S.AddFormBtn
onClick={() => {
setContents([
...contents,
{
itemId: itemNumber,
itemInfo: { name: "", content: "", category: "" },
itemImage: undefined,
},
]);
setItemNumber(itemNumber + 1);
}}
>
상품 추가
</S.AddFormBtn>
</S.AddBtnBox>
- 또한 itemId는 itemNumber라는 상태를 이용해서 상품 추가 버튼을 누를 때마다 itemNumber가 1씩 증가하여 itemId에 부여되도록 하였습니다.
- setContents 부분은 initialValue와 itemNumber를 적절하게 변경하면 코드량을 줄일 수 있을 것 같아서 리팩토링을 고민중입니다 ㅠㅠ
CollectionForm.tsx
interface Props {
contents: ContentsProps[];
setContents: React.Dispatch<React.SetStateAction<ContentsProps[]>>;
itemNumber: number;
}
const CollectionForm = ({ contents, setContents, itemNumber }: Props) => {
const [preview, setPreview] = useState<string>();
const [titleValue, titleHandler] = useInput("");
const [contentValue, contentHandler] = useInput("");
const [categoryValue, setCategoryValue] = useState("");
const [imageFile, setImageFile] = useState<File>();
- CollectionForm 컴포넌트는 이제 콘텐츠와 이미지를 하나의 props로 받게 됩니다.
useInput.tsx
import { ChangeEvent, useCallback, useState } from "react";
type UseInputProps<T> = [
value: T,
changeHandler: (
e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>
) => void,
reset?: () => void
];
const useInput = <T>(initialValue: T): UseInputProps<T> => {
const [value, setValue] = useState<T>(initialValue);
const changeHandler = (e: ChangeEvent<HTMLInputElement> | any) => {
if (e.target) setValue(e.target.value);
};
const reset = useCallback(() => setValue(initialValue), [initialValue]);
return [value, changeHandler, reset];
};
export default useInput;
- 그리고 CollectionForm 컴포넌트에서 사용하지 않는 useInput의 reset 함수를 optional로 변경하고 import시에 빼주었습니다.
CollectionForm.tsx
useEffect(() => {
setContents(
contents.map(item =>
item.itemId === itemNumber
? {
...item,
itemId: itemNumber,
itemInfo: {
name: titleValue,
content: contentValue,
category: categoryValue,
},
itemImage: imageFile,
}
: item
)
);
}, [titleValue, contentValue, categoryValue]);
- 기존에 props로 넘겨받은 index가 아니라 itemNumber 와 itemId 를 비교해서 값을 변경합니다. 인덱스가 변화해도 안정적으로 원하는 컴포넌트의 값만 변경할 수 있게 되었네요!
반응형 적용하여 완성된 페이지
전체 코드
CollectionPage.tsx
import CollectionForm from "../../components/Collection_form/CollectionForm";
import { useState } from "react";
import axios from "axios";
import * as S from "./style";
import { BASE_URL } from "../../constants/constants";
import { useNavigate } from "react-router-dom";
export interface ItemProps {
name: string;
content: string;
category: string;
}
export interface ContentsProps {
itemId: number;
itemInfo: ItemProps;
itemImage: File | any;
}
const CollectionPage = () => {
const initialValue = {
itemId: 0,
itemInfo: { name: "", content: "", category: "" },
itemImage: undefined,
};
const [contents, setContents] = useState<ContentsProps[]>([initialValue]);
const [itemNumber, setItemNumber] = useState(1);
const navigate = useNavigate();
const deleteHandler = (id: number) => {
confirm("정말 삭제하시겠습니까?");
setContents(contents.filter(item => item.itemId !== id));
};
const submitHandler = async () => {
/* 예외 처리 */
for (let i = 0; i < contents.length; i++) {
if (
contents[i].itemInfo.name === "" ||
contents[i].itemInfo.content === "" ||
contents[i].itemInfo.category === ""
) {
alert("필수 항목을 모두 작성해주세요.");
return;
} else {
if (contents[i].itemImage === undefined) {
alert("이미지를 등록해주세요.");
return;
}
}
}
/* 요청 보낼 데이터 처리 */
const formData = new FormData();
const contentList = [];
for (let i = 0; i < contents.length; i++) {
contentList.push(contents[i].itemInfo);
contents[i].itemImage && formData.append("files", contents[i].itemImage);
}
formData.append("productlist", JSON.stringify(contentList));
// API 요청 로직
if (res.status === 200) {
alert("정상적으로 요청되었습니다.");
navigate("/");
}
};
return (
<S.Section>
<S.PageTitle>
<h1>수거 신청하기</h1>
<h4>의류를 보내서 포인트도 얻고 친환경도 실천해보세요!</h4>
</S.PageTitle>
<S.ContentsContainer>
{contents.map((item, index) => (
<div key={item.itemId}>
<S.ContentHeader>
<div className="product_no">상품 번호 : {index + 1}</div>
<S.DeleteBtn
onClick={() => {
deleteHandler(item.itemId);
}}
>
삭제
</S.DeleteBtn>
</S.ContentHeader>
<CollectionForm
contents={contents}
setContents={setContents}
itemNumber={item.itemId}
/>
</div>
))}
</S.ContentsContainer>
<S.AddBtnBox>
<S.AddFormBtn
onClick={() => {
setContents([
...contents,
{
itemId: itemNumber,
itemInfo: { name: "", content: "", category: "" },
itemImage: undefined,
},
]);
setItemNumber(itemNumber + 1);
}}
>
상품 추가
</S.AddFormBtn>
</S.AddBtnBox>
<S.SubmitBox>
<div className="total_product">TOTAL : {contents.length}개의 물품</div>
<S.SubmitBtn onClick={() => submitHandler()}>보내기</S.SubmitBtn>
</S.SubmitBox>
</S.Section>
);
};
export default CollectionPage;
CollectionForm.tsx
import { useEffect, useState, useRef } from "react";
import ImageIcon from "../../assets/icons/ImageIcon";
import * as S from "./style";
import { ContentsProps } from "../../pages/collectionPage/collectionPage";
import useInput from "../../hooks/useInput";
import SelectBox from "../SelectBox/SelectBox";
interface Props {
contents: ContentsProps[];
setContents: React.Dispatch<React.SetStateAction<ContentsProps[]>>;
itemNumber: number;
}
const CollectionForm = ({ contents, setContents, itemNumber }: Props) => {
const [preview, setPreview] = useState<string>();
const [titleValue, titleHandler] = useInput("");
const [contentValue, contentHandler] = useInput("");
const [categoryValue, setCategoryValue] = useState("");
const [imageFile, setImageFile] = useState<File>();
const categoryOptions = ["상의", "하의", "아우터", "기타"];
const imgInput = useRef<HTMLInputElement>(null);
useEffect(() => {
setContents(
contents.map(item =>
item.itemId === itemNumber
? {
...item,
itemId: itemNumber,
itemInfo: {
name: titleValue,
content: contentValue,
category: categoryValue,
},
itemImage: imageFile,
}
: item
)
);
}, [titleValue, contentValue, categoryValue]);
/**
* [이미지 업로드]
* setPreview : 미리보기 이미지 저장
* setImageFile : 객체에 이미지 파일 저장
*/
const uploadImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const imageFile = e.target.files;
if (imageFile && imageFile[0]) {
const url = URL.createObjectURL(imageFile[0]);
setPreview(url);
}
imageFile && setImageFile(imageFile[0]);
};
const uploadBtnClickHandler = () => {
imgInput.current && imgInput.current.click();
};
return (
<S.FormContainer>
<S.ContentContainer>
<S.Imagebox>
<div className="image_background">
{preview ? <img src={preview} /> : <ImageIcon />}
</div>
<input
type="file"
accept="image/*"
onChange={uploadImage}
ref={imgInput}
style={{ display: "none" }}
/>
<button className="upload_btn" onClick={uploadBtnClickHandler}>
이미지 등록
</button>
</S.Imagebox>
<S.ContentBox>
<SelectBox
usage={"카테고리"}
options={categoryOptions}
setOption={setCategoryValue}
/>
<input
type="text"
placeholder="상품명을 입력해주세요."
value={titleValue}
onChange={titleHandler}
required
/>
<textarea
placeholder="1. 브랜드 2. 컬러 3. 사이즈 등을 상세하게 입력해주세요."
value={contentValue}
onChange={contentHandler}
required
></textarea>
</S.ContentBox>
</S.ContentContainer>
</S.FormContainer>
);
};
export default CollectionForm;
반응형
'개발냥이 > 타입스크립트(Typescript)' 카테고리의 다른 글
[리액트+타입스크립트] 프로젝트 리팩토링(refactoring)_사이드바(sidebar) (0) | 2023.07.31 |
---|---|
[Next.js + 타입스크립트] 무한 스크롤(Infinite scroll) 구현해보기(feat. Intersection observer) (0) | 2023.07.15 |
[Next.js + typescript] 페이지네이션(Pagination) 구현해보기 (0) | 2023.07.10 |
[리액트+타입스크립트] 경과 시간(날짜) 표시하기 & Intl API 알아보기 (0) | 2023.07.07 |
[리액트+타입스크립트] 커스텀 셀렉트 박스(custom select box) 만들어보기)_리팩토링 추가(2023-07-29) (0) | 2023.07.06 |
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- SQL
- 해시맵
- 자바bfs
- 자바dp
- 프로그래머스
- 스프링부트
- 스프링
- Nest
- BFS
- 리액트
- Queue
- dfs
- 알고리즘
- 백준
- 형변환
- Comparator
- Algorithm
- java
- 자바스크립트
- 이분탐색
- SQLD
- Spring
- JPA
- 자바트리
- DP
- CS
- 자바
- 정렬
- 타입스크립트
- JavaScript
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
글 보관함