관리 메뉴

거니의 velog

(26) 리액트와 상품 API 서버 연동 5 본문

SpringBoot_React 풀스택 프로젝트

(26) 리액트와 상품 API 서버 연동 5

Unlimited00 2024. 3. 5. 16:33

5. 수정/삭제 페이지와 컴포넌트 처리

* 수정/삭제를 위한 페이지는 pages/products/ModifyPage.js 파일로 작성하고, router/productsRouter.js 파일을 수정해서 라우팅 설정을 추가한다.

import React from "react";

const ModifyPage = () => {
  return (
    <div className="p-4 w-full bg-white">
      <div className="text-3xl font-extrabold">Products Modify Page</div>
    </div>
  );
};

export default ModifyPage;

* router/productsRouter.js 내 ModifyPage 설정을 추가한다.

import React, { Suspense, lazy } from "react";
import { Navigate } from "react-router-dom";

const productsRouter = () => {
  const Loading = <div>Loading....</div>;
  const ProductsList = lazy(() => import("../pages/products/ListPage"));
  const ProductsAdd = lazy(() => import("../pages/products/AddPage"));
  const ProductRead = lazy(() => import("../pages/products/ReadPage"));
  const ProductModify = lazy(() => import("../pages/products/ModifyPage"));

  return [
    {
      path: "list",
      element: (
        <Suspense fallback={Loading}>
          <ProductsList />
        </Suspense>
      ),
    },
    {
      path: "",
      element: <Navigate replace to="/products/list" />,
    },
    {
      path: "add",
      element: (
        <Suspense fallback={Loading}>
          <ProductsAdd />
        </Suspense>
      ),
    },
    {
      path: "read/:pno",
      element: (
        <Suspense fallback={Loading}>
          <ProductRead />
        </Suspense>
      ),
    },
    {
      path: "modify/:pno",
      element: (
        <Suspense fallback={Loading}>
          <ProductModify />
        </Suspense>
      ),
    },
  ];
};

export default productsRouter;

* 상품의 조회 화면에서 수정/삭제 화면으로 정상적으로 이동되는지 확인한다.


(1) ModifyComponent 처리

* 수정 작업은 등록과 마찬가지로 첨부파일이 존재하기 때문에 multipart/form-data 헤더를 설정해서 전송 처리해야 하고 삭제 작업은 해당 상품의 번호만을 전달해서 처리한다.

* api/productsApi.js 파일에 수정/삭제를 위한 함수를 추가한다.

import axios from "axios";
import { API_SERVER_HOST } from "./todoApi";

const host = `${API_SERVER_HOST}/api/products`;

export const postAdd = async (product) => {
  const header = { headers: { "Content-Type": "multipart/form-data" } };
  // 경로 뒤 '/' 주의
  const res = await axios.post(`${host}/`, product, header);
  return res.data;
};

export const getList = async (pageParam) => {
  const { page, size } = pageParam;
  const res = await axios.get(`${host}/list`, {
    params: { page: page, size: size },
  });
  return res.data;
};

export const getOne = async (tno) => {
  const res = await axios.get(`${host}/${tno}`);
  return res.data;
};

export const putOne = async (pno, product) => {
  const header = { headers: { "Content-Type": "multipart/form-data" } };
  const res = await axios.put(`${host}/${pno}`, product, header);
  return res.data;
};

export const deleteOne = async (pno) => {
  const res = await axios.delete(`${host}/${pno}`);
  return res.data;
};

* components/products 폴더에는 ModifyComponent를 추가한다.

import React from "react";

const ModifyComponent = ({ pno }) => {
  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      Product Modify Component
    </div>
  );
};

export default ModifyComponent;

* ModifyComponent는 ModifyPage에서 import 하도록 추가한다. 이때 현재 상품번호를 전달하도록 구성한다.

import React from "react";
import { useParams } from "react-router-dom";
import ModifyComponent from "../../components/products/ModifyComponent";

const ModifyPage = () => {
  const { pno } = useParams();

  return (
    <div className="p-4 w-full bg-white">
      <div className="text-3xl font-extrabold">Products Modify Page</div>
      <ModifyComponent pno={pno} />
    </div>
  );
};

export default ModifyPage;

* 프로젝트를 실행하고 브라우저에서 목록 -> 조회 -> 수정의 단계로 결과를 확인한다. 현재 ModifyComponent의 디자인이 없기 때문에 화면은 아래와 같이 단순 텍스트만 출력된다.


[데이터의 출력]

* ModifyComponent는 전달되는 pno를 속성으로 처리하고 서버와 통신 처리 후 결과를 출력한다. FetchingModal을 이용해서 서버와의 통신 상태를 알 수 있게 처리하고 이미지들을 출력한다.

import React, { useEffect, useState } from "react";
import { getOne } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";

const initState = {
  pno: 0,
  pname: "",
  pdesc: "",
  price: 0,
  delFlag: false,
  uploadFileNames: [],
};

const ModifyComponent = ({ pno }) => {
  const [product, setProduct] = useState(initState);
  const [fetching, setFetching] = useState(false);

  useEffect(() => {
    setFetching(true);

    getOne(pno).then((data) => {
      setProduct(data);
      setFetching(false);
    });
  }, [pno]);

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      Product Modify Component
      {fetching ? <FetchingModal /> : <></>}
    </div>
  );
};

export default ModifyComponent;

* 브라우저에서는 FetchingModal 창을 통해서 서버와 통신이 이루어졌음을 확인할 수 있다(아래 화면은 API 서버의 응답 시간을 조정해서 FetchingModal 이 보일 수 있는 시간을 준 상태이다).

* 서버에서 가져온 상품정보를 화면에 출력하고 변경이 가능한 속성들을 <input>으로 작성하고 onChange()를 이용해서 변경 가능하도록 구성한다.

import React, { useEffect, useState, useRef } from "react";
import { getOne } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";
import { API_SERVER_HOST } from "../../api/todoApi";

const initState = {
  pno: 0,
  pname: "",
  pdesc: "",
  price: 0,
  delFlag: false,
  uploadFileNames: [],
};

const host = API_SERVER_HOST;

const ModifyComponent = ({ pno }) => {
  const [product, setProduct] = useState(initState);
  const [fetching, setFetching] = useState(false);
  const uploadRef = useRef();

  useEffect(() => {
    setFetching(true);

    getOne(pno).then((data) => {
      setProduct(data);
      setFetching(false);
    });
  }, [pno]);

  const handleChangeProduct = (e) => {
    product[e.target.name] = e.target.value;
    setProduct({ ...product });
  };

  const deleteOldImages = (imageName) => {};

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      {fetching ? <FetchingModal /> : <></>}

      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Product Name</div>
          <input
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            name="pname"
            type={"text"}
            value={product.pname}
            onChange={handleChangeProduct}
          ></input>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Desc</div>
          <textarea
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md resize-y"
            name="pdesc"
            rows="4"
            onChange={handleChangeProduct}
            value={product.pdesc}
          >
            {product.pdesc}
          </textarea>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Price</div>
          <input
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            name="price"
            type={"number"}
            value={product.price}
            onChange={handleChangeProduct}
          ></input>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">DELETE</div>
          <select
            name="delFlag"
            value={product.delFlag}
            onChange={handleChangeProduct}
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
          >
            <option value={false}>사용</option>
            <option value={true}>삭제</option>
          </select>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Files</div>
          <input
            ref={uploadRef}
            className="w-4/5 p-6 rounded-r border border-solid border-neutral-300 shadow-md"
            type={"file"}
            multiple={true}
          ></input>
        </div>
      </div>
      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Images</div>
          <div className="w-4/5 justify-center flex flex-wrap items-start">
            {product.uploadFileNames.map((imgFile, i) => (
              <div className="flex justify-center flex-col w-1/3" key={i}>
                <button
                  className="bg-blue-500 text-3xl text-white"
                  onClick={() => deleteOldImages(imgFile)}
                >
                  DELETE
                </button>
                <img alt="img" src={`${host}/api/products/view/s_${imgFile}`} />
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
};

export default ModifyComponent;

* 브라우저에서는 해당 상품의 이미지들이 출력되고 입력창은 수정이 가능한 상태가 된다.


[기존 이미지 삭제]

* ModifyComponent에서 기존 이미지의 삭제는 이미지 상단에 있는 DELETE 버튼을 클릭했을 때 상품 데이터가 가지고 있는 uploadFileNames 배열에서 해당 이미지를 삭제한다. 배열에서는 filter()를 사용해서 해당 이미지가 아닌 이미지들만 유지하도록 한다.

  const deleteOldImages = (imageName) => {
    const resultFileNames = product.uploadFileNames.filter(
      (fileName) => fileName !== imageName
    );
    product.uploadFileNames = resultFileNames;
    setProduct({ ...product });
  };

* 상품의 이미지에 있는 버튼을 클릭해서 해당 이미지를 배열에서 제거할 수 있도록 이벤트 처리를 추가한다.

      <div className="flex justify-center">
        <div className="relative mb-4 flex w-full flex-wrap items-stretch">
          <div className="w-1/5 p-6 text-right font-bold">Images</div>
          <div className="w-4/5 justify-center flex flex-wrap items-start">
            {product.uploadFileNames.map((imgFile, i) => (
              <div className="flex justify-center flex-col w-1/3" key={i}>
                <button
                  className="bg-blue-500 text-3xl text-white"
                  onClick={() => deleteOldImages(imgFile)}
                >
                  DELETE
                </button>
                <img alt="img" src={`${host}/api/products/view/s_${imgFile}`} />
              </div>
            ))}
          </div>
        </div>
      </div>

* 브라우저 화면에서는 기존 상품 이미지의 삭제를 확인할 수 있다. 이미지의 삭제는 화면 상에서만 반영되기 때문에 다시 조회해 보면 원래의 모든 이미지를 볼 수 있다.


[새로운 이미지 파일의 추가와 수정]

* 새로운 이미지 파일을 추가하는 부분은 기존의 신규 상품등록과 동일하므로 화면에 수정 버튼을 작성하고 이를 클릭할 때 파일 데이터를 추가하도록 구성한다.

* ModifyComponent 내에 이벤트 처리를 위한 handleClickModify() 함수를 추가하고 가장 아래쪽에 Modify 버튼을 추가해서 이벤트 처리를 완성한다.

import { getOne, putOne } from "../../api/productsApi";

(...)

const ModifyComponent = ({ pno }) => {
  (...)

  const handleClickModify = () => {
    const files = uploadRef.current.files;
    const formData = new FormData();
    for (let i = 0; i < files.length; i++) {
      formData.append("files", files[i]);
    }

    //other data
    formData.append("pname", product.pname);
    formData.append("pdesc", product.pdesc);
    formData.append("price", product.price);
    formData.append("delFlag", product.delFlag);

    for (let i = 0; i < product.uploadFileNames.length; i++) {
      formData.append("uploadFileNames", product.uploadFileNames[i]);
    }
    putOne(pno, formData);
  };

  (...)
}

* 화면에서는 마지막 부분에 버튼들을 추가하고 버튼 중에서 Modify에 대해서 이벤트 처리를 추가한다.

(...)

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      (...)
      <div className="flex justify-end p-4">
        <button
          type="button"
          className="rounded p-4 m-2 text-xl w-32 text-white bg-red-500"
        >
          Delete
        </button>
        <button
          type="button"
          className="inline-block rounded p-4 m-2 text-xl w-32  text-white bg-orange-500"
          onClick={handleClickModify}
        >
          Modify
        </button>
        <button
          type="button"
          className="rounded p-4 m-2 text-xl w-32 text-white bg-blue-500"
        >
          List
        </button>
      </div>
    </div>
  );
};

export default ModifyComponent;

* 화면 상에 새로운 이미지를 추가하고 Modify 버튼을 클릭해서 서버의 동작 여부를 먼저 확인한다(모달창의 처리가 없으므로 아직 화면에 변화는 없다). 기존의 이미지를 삭제하지 않았다면 브라우저에서 '새로고침'을 통해서 추가된 이미지를 확인할 수 있다.


[수정 작업 후 모달창]

* Axios를 이용한 상품의 수정 처리가 완료되면 서버에서는 아래 화면과 같은 JSON 결과를 전송한다.

* ModifyComponent에서는 모달창을 이용해서 결과를 보여주도록 수정한다. 우선 모달창에서 필요한 상태와 모달창이 사라진 후 동작해야 하는 함수를 미리 정의한다. useCustomMove를 이용해서 수정은 상품의 조회 화면으로 이동하고, 상품의 삭제 후에는 목록 화면으로 이동하도록 구성한다.

import React, { useEffect, useState, useRef } from "react";
import { getOne, putOne } from "../../api/productsApi";
import FetchingModal from "../common/FetchingModal";
import { API_SERVER_HOST } from "../../api/todoApi";
import useCustomMove from "../../hooks/useCustomMove";
import ResultModal from "../common/ResultModal";

const initState = {
  (...)
};

const host = API_SERVER_HOST;

const ModifyComponent = ({ pno }) => {
  const [product, setProduct] = useState(initState);
  const [fetching, setFetching] = useState(false);
  const uploadRef = useRef();
  //결과 모달
  const [result, setResult] = useState(null);
  //이동용 함수
  const { moveToRead, moveToList } = useCustomMove();

  (...)

  const handleClickModify = () => {
    const files = uploadRef.current.files;
    const formData = new FormData();
    for (let i = 0; i < files.length; i++) {
      formData.append("files", files[i]);
    }

    //other data
    formData.append("pname", product.pname);
    formData.append("pdesc", product.pdesc);
    formData.append("price", product.price);
    formData.append("delFlag", product.delFlag);

    for (let i = 0; i < product.uploadFileNames.length; i++) {
      formData.append("uploadFileNames", product.uploadFileNames[i]);
    }

    // fetching
    setFetching(true);

    putOne(pno, formData).then((data) => {
      // 수정 처리
      setResult("Modified");
      setFetching(false);
    });
  };

  const closeModal = () => {
    if (result === "Modified") {
      moveToRead(pno); // 조회 화면으로 이동
    }
    setResult(null);
  };

  return (
    <div className="border-2 border-sky-200 mt-10 m-2 p-4">
      {fetching ? <FetchingModal /> : <></>}
      {result ? (
        <ResultModal
          title={`${result}`}
          content={"정상적으로 처리되었습니다."} // 결과 모달창
          callbackFn={closeModal}
        />
      ) : (
        <></>
      )}

      (...)
    </div>
  );
};

export default ModifyComponent;

* 상품을 수정하면 ResultModal을 보여주고 조회 화면으로 이동하는 것을 확인할 수 있다.


(2) 삭제 버튼의 동작 처리

* 상품의 삭제는 화면 아래쪽의 Delete 버튼을 클릭하면 동작하는 함수를 작성하고 삭제 후 모달창을 띄우도록 작성한다.

import React, { useEffect, useState, useRef } from "react";
import { getOne, putOne, deleteOne } from "../../api/productsApi";
(...)

const initState = {
  (...)
};

const host = API_SERVER_HOST;

const ModifyComponent = ({ pno }) => {
  (...)

  const handleClickDelete = () => {
    setFetching(true);
    deleteOne(pno).then((data) => {
      setResult("Deleted");
      setFetching(false);
    });
  };

  const closeModal = () => {
    if (result === "Modified") {
      moveToRead(pno); // 조회 화면으로 이동
    } else if (result === "Deleted") {
      moveToList({ page: 1 }); // 리스트 화면으로 이동
    }
    setResult(null);
  };

  (...)
};

export default ModifyComponent;

* 화면 아래쪽의 버튼에는 이벤트를 추가하고 삭제 처리 후 목록 화면으로 이동하는지 확인한다.

      <div className="flex justify-end p-4">
        <button
          type="button"
          className="rounded p-4 m-2 text-xl w-32 text-white bg-red-500"
          onClick={handleClickDelete}
        >
          Delete
        </button>
        (...)
      </div>

120번 게시물이 사라졌다.


[목록 화면 이동]

* 목록 화면의 이동은 useCustomMove의 moveToList를 이용해서 버튼에 이벤트를 추가한다.

      <div className="flex justify-end p-4">
        (...)
        <button
          type="button"
          className="rounded p-4 m-2 text-xl w-32 text-white bg-blue-500"
          onClick={moveToList}
        >
          List
        </button>
      </div>

* 화면 상에서는 쿼리스트링이 유지된 상태로 목록 화면으로 이동하게 된다.