어드민 상품 등록 페이지 with Recoil

김기영·2022년 3월 2일
0

원티드프리온보딩

목록 보기
2/4
post-thumbnail

🎉어드민 상품 등록 페이지

회의

두 번째 과제로 어드민 상품 등록 페이지를 구현 하게 됐다. 팀원들과 회의 결과, 공통 컴포넌트를 가장 먼저 구현하는 것으로 결정되었다. 그 중에서 나는 이미지 첨부 부분을 맡았다.

✨이미지 첨부

첨부 시, 이미지의 타이틀만 보여주는 컴포넌트와, 이미지를 미리보기 할 수 있는 컴포넌트 2종류를 구현해야 했다. 그리고 타이틀만 보여주는 컴포넌트는 다중 선택이 되는 것과, 안되는 것을 구분해주어야 했다.

완성 후의 사진을 가져와 보았다.

이미지 타이틀 컴포넌트

  1. props
// src/common/ImageTitleButton.js
export const ImageNameButton = ({ inputId, isMultiple }) => {
  const inputValue = useRef(null);
  ...
  return (
    <Container>
      <ImageAttachButton htmlFor={inputId}>+ 이미지 첨부</ImageAttachButton>
      <Input
        ref={inputValue}
        type="file"
        id={inputId}
        onChange={onLoadImage}
        multiple={isMultiple}
      />
    </Container>
	...
  );
};

label과 input의 for(htmlFor), id 값이 같아야 label 클릭 시 input이 클릭 되는 것과 같은 효과를 줄 수 있다. input을 display:none 하고, label을 스타일링할 것이므로 필수 과정이라 할 수 있다. 사용하는 곳이 많기 때문에 inputId props로 아무 string이나 받아서 같은 값을 주는 것으로 처리했다. 다중 선택 여부는 isMultiple props로 boolean 값을 받는다.

  1. 이미지 첨부 함수
const onLoadImage = useCallback(
  (e) => {
    // console.log(e.target.files);
    const file = e.target.files[0];
    if (!file.type.match("image.*")) {
      alert("이미지 파일이 아닙니다.");
      return;
    }
    if (isMultiple) {
      setImageFileNames([...imageFileNames, file.name]);
    } else {
      setImageFileNames(file.name);
    }
  },
  [imageFileNames, isMultiple]
);

input의 타입을 file로 설정하면 input 클릭 시 파일 첨부 모달창이 나온다. 파일 첨부시 e.target.files 를 console에 찍어보면 다음과 같다.

여기서는 이미지만 첨부 가능해야 하기 때문에, if (!file.type.match("image.*")) 블록을 추가하였다. 이후 file.name을 state에 저장하고, 렌더시킨다.


  1. 첨부 파일 삭제
const onClickRemoveButton = (e) => {
  inputValue.current.value = "";
  if (isMultiple) {
    setImageFileNames(
      imageFileNames.filter((name) => name !== e.target.value)
    );
  } else {
    setImageFileNames("");
  }
};

useRef 로 선택한 input의 value를 초기화 시켜주었다. 이 코드가 없으면, 삭제 버튼을 클릭 한 후, 같은 이름의 파일을 첨부하려 할 때, 제대로 동작하지 않는다. input의 display: none을 빼보면 왜 그런지 알 수 있다.

여기서 X 버튼을 클릭하여도, 해당 코드가 없으면 input의 value가 초기화 되지 않는다.
이 상태에서 같은 파일을 첨부하려 하면, onChange 된것이 아니기 때문에 정상적으로 동작하지 않는 것이다.

이미지 미리보기 컴포넌트

  1. props
// src/common/ImagePreivewButton.js
export default function ImagePreviewButton({ id }) {
  ...
  return (
    <Container>
      {loadedImageSrc ? (
        <>
          <ImagePreview src={loadedImageSrc} alt="preview" />
          <RemoveButton onClick={onClickRemoveButton}>X</RemoveButton>
        </>
      ) : (
        <ImageAttachButton htmlFor={id}>+ 이미지 첨부</ImageAttachButton>
      )}
      <Input ref={inputValue} type="file" id={id} onChange={onLoadImage} />
    </Container>
  );
}

마찬가지로 id값을 입력 받아 htmlFor, id 에 주었다.

  1. 이미지 첨부 함수
const onLoadImage = (e) => {
  const file = e.target.files[0];
  if (!file.type.match("image.*")) {
    alert("이미지 파일이 아닙니다.");
    return;
  }
  setLoadedImageSrc(URL.createObjectURL(file));
};

URL.createObjectURL(file) 은 URL API method의 종류로 주어진 객체를 가리키는 URL을 DOMString으로 반환한다. 객체로는 File, Blob, MediaSource를 입력받을 수 있다. 반환값을 console로 찍어보면 다음과 같다.

https://developer.mozilla.org/ko/docs/Web/API/URL/createObjectURL

  1. 첨부 파일 삭제
const onClickRemoveButton = () => {
  inputValue.current.value = "";
  setLoadedImageSrc("");
  URL.revokeObjectURL(loadedImageSrc);
};

createObjectURL()은 사용 후 revokeObjectURL()로 객체 URL을 해제해주어야 한다.

이로써 이미지 첨부 컴포넌트 끝.

✨노출 & 판매기간 설정 페이지

공통 컴포넌트 작업이 끝나고 다음 회의에서 페이지 역할을 분배했다.

완성 후의 사진은 다음과 같다.

날짜 선택은 datepicker 라이브러리를 사용했고, 나머지는 마크업, 스타일링만 하면 되는 부분이어서 따로 포스팅 하지 않겠다. 이로써 페이지 구현 완료.

✨저장하기


상품 등록 페이지에서 Form을 입력하고 최상단의 저장하기를 누르면, 필수 값이 입력 되었는지 확인하고, 전부 입력되었다면 저장, 아닐 경우 따로 문구를 표시 해주어야 한다. 이를 위해서 전역상태관리가 필요했고, recoil을 사용하기로 했다.

atoms

form에 입력된 전체 정보를 가지고 있는 productRegisterFormState, 필수 값이 입력 되었는지 확인하는 productRequiredInfoState 두 개를 구현했다.

  1. productRegisterFormState
// src/atoms/productRegisterForm.js
import { atom } from "recoil";

export const productRegisterFormState = atom({
  key: "productRegisterFormState",
  default: {
    상품노출기한: "제한 없음",
    상품판매기한: "제한 없음",
    카테고리: [],
    필터태그: [],
    상품명: "",
    상품구성소개정보: "",
    상품썸네일: "",
    상품대표이미지: "",
    상품소개이미지: [],
    구매자추천이미지: [],
    상품배송설정: {
      사용자배송일출발일지정: false,
      방문수령: false,
      선주문예약배송: false,
    },
    마일리지적립: false,
    감사카드제공: false,
  },
});

// src/components/period/Period.js
export const ProductPeriod = () => {
  const setProductForm = useSetRecoilState(productRegisterFormState);
  
  const onChangeExposureOption = (value) => {
    setSelectedExposureOption(value);
    setProductForm((prev) => ({ ...prev, 상품노출기한: value }));
  };

  const onChnageSalesOption = (value) => {
    setSelectedSalesOption(value);
    setProductForm((prev) => ({ ...prev, 상품판매기한: value }));
  };
  ...
}

default 값을 위와 같이 지정해주고, 각 페이지에서 useSetRecoilState 로 불러와서 해당 값이 변경되었을 때 적용 시켜준다. 노출 & 판매기간 설정 페이지에서는 상품노출기간, 상품판매기간을 변경한다. 같은 방식으로 다른 페이지에도 추가하면 끝. 이 값은 모든 필수 값이 입력 된 상태에서 저장하기를 누르면, console에 출력되도록 해두었다.


  1. productRequiredInfoState
// src/atoms/productRequiredInfo.js
import { atom } from "recoil";

export const productRequiredInfoState = atom({
  key: "productRequiredInfoState",
  default: {
    productInfo: false,
    productOption: false,
    productCategory: false,
  },
});

// src/components/category/Category.js
export const Category = () => {
  const setProductRequried = useSetRecoilState(productRequiredInfoState);
	
  useEffect(() => {
    setProductRequried((prev) => ({
      ...prev,
      productCategory: categoryList.some((category) => category.checked),
    }));
  }, [categoryList, setProductRequried]);
  ...

필수 입력값으로는 회색의 Category, 붉은색의 Info, 푸른색의 Option 으로 3개의 페이지에서 각각 설정한다.

Category는 id, name, checked key를 가지고 있으며 하나라도 checked=true인 경우, 필수 값이 입력 되었다고 판단하게 된다.

나머지 Info, Option 페이지에서도 같은 방식으로 설정하면 전역상태관리 끝. 과제도 끝.

📝회고

이미지 미리보기할때, 기존에 알고있던 방식은 FileReader 객체로 readAsDataURL을 사용해서 Base64로 인코딩하는 것이었는데 createObjectURL 라는 URL API method를 새로 알게되어 쉽게 처리할 수 있었다. 또, recoil을 처음 사용해 보았는데, 리액트의 useState 와 비슷해서 사용하기 너무 편했다. Redux 딱 대.

profile
FE Developer

0개의 댓글