본문 바로가기

프로젝트 개발일지/플케어

[기능 구현] 프로젝트 목록 페이지를 개발해보자.

아래 사진은 구현된 프로젝트 목록 페이지 예시이다.

왼쪽 하단 꽃 모양은 리액트 쿼리 데브 툴이니까 신경 쓰지 말자.

세부적인 기능 설명에 앞서, 동작 방식부터 알아보자.

 

동작 방식

먼저, 사용자가 프로젝트 생성 버튼을 누르게 되면 아래와 같이 프로젝트 생성 모달창이 뜬다.

여기서 프로젝트 이름, 진행 기간, 설명, 이미지를 추가/제거할 수 있다.

작성 완료를 누르게 되면 프로젝트 목록에 추가된다.

 

그리고 특정 프로젝트를 클릭하게 되면 해당 프로젝트 관리 상세 페이지로 넘어가게 된다.

해당 페이지에는 오버뷰, 회의록, 일정, 평가, 팀, 관리 탭이 있다.

 

아래 사진이 특정 프로젝트의 관리 상세 페이지이다.

접속하게 되면 바로 전체 일정 오버뷰가 보이게 된다.

 

프로젝트 관리 메인 페이지를 보게 되면 전체, 진행중 버튼이 보이는데, 이는 프로젝트 목록을 필터링하기 위한 버튼으로, 전체 버튼을 누르면 완료된 프로젝트와 진행 중인 프로젝트 전체를 보여주고, 진행 중 버튼을 누르면 진행 중인 프로젝트만 보여주도록 하였다.

 

프로젝트 목록을 보여주는 방식으로는 페이지네이션을 택했다.

아래와 같이 페이지당 프로젝트 4개씩을 보여주도록 설정하였고, 그 이상의 프로젝트를 생성한 경우 페이지 별로 나누어서 보여주도록 했다.

 

팀 나가기 버튼의 경우, 모든 멤버들에게 공통적으로 보이는 버튼인데, 리더가 팀 나가기 버튼을 누른 경우에는 리더 위임을 한 후 프로젝트를 나가야 하기 때문에 팀원 관리 탭으로 리다이렉트시키고, 일반 멤버는 팀 나가기 버튼을 누르면 경고성 모달창을 띄운 후 나가기에 동의를 하면 프로젝트 목록에서 해당 프로젝트를 삭제시키도록 하였다.

 

세부 기능 구현

아래 코드는 방금 알아본 페이지에 해당하는 Management.js 코드이다.

// Management.js
import Button from "../components/common/Button";
import ProjectList from "../components/Management/ProjectList";
import Pagination from "../components/common/Pagination";
import NewProject from "../components/Management/NewProject";

import { useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useNavigate } from "react-router-dom";

import { history } from "../utils/history";

import { getProjectList } from "../lib/apis/managementApi";

const Management = () => {
  const [currentPage, setCurrentPage] = useState(1);
  const [ongoingCurrentPage, setOngoingCurrentPage] = useState(1);
  const [allProjectListVisible, setAllProjectListVisible] = useState(false);
  const [isModalVisible, setIsModalVisible] = useState(false);

  const authState = useSelector((state) => state.auth.isLoggedIn);

  const navigate = useNavigate();

  const [recordDatasPerPage, setRecordDatasPerPage] = useState(4);

  const { data = { projectList: [] } } = useQuery(
    [
      allProjectListVisible
        ? "managementAllProjectList"
        : "managementOngoingProjectList",
      allProjectListVisible ? currentPage : ongoingCurrentPage,
      allProjectListVisible,
    ],
    () =>
      getProjectList(
        allProjectListVisible ? currentPage : ongoingCurrentPage,
        allProjectListVisible ? "ALL" : "ONGOING"
      ),
    { keepPreviousData: true }
  );

  const projectList = data.projectList;

  const totalElements = data.totalElements;

  useEffect(() => {
    if (allProjectListVisible) {
      setCurrentPage(1);
    } else {
      setOngoingCurrentPage(1);
    }
  }, [allProjectListVisible]);

  useEffect(() => {
    const listenBackEvent = () => {
      navigate("/management");
    };

    const historyEvent = history.listen(({ action }) => {
      if (action === "POP") {
        listenBackEvent();
      }
    });

    return historyEvent;
  }, [navigate]);

  const handleClickAllProjectList = () => {
    setAllProjectListVisible((prevData) => true);
  };

  const handleClickOngoingProjectList = () => {
    setAllProjectListVisible((prevData) => false);
  };

  const handleModalVisible = () => {
    setIsModalVisible(true);
  };

  return (
    <div>
      <div className="management">
        <header className="management-main-header">
          <div className="management-main-header-left-col">
            <h1>참여 프로젝트</h1>
            <Button onClick={handleModalVisible} text={"프로젝트 생성"} />
          </div>
          <div className="management-main-header-right-col">
            <Button
              className="all-projects-button"
              text={"전체"}
              type={allProjectListVisible && "positive_dark"}
              onClick={handleClickAllProjectList}
            />
            <Button
              text={"진행중"}
              type={!allProjectListVisible && "positive_dark"}
              onClick={handleClickOngoingProjectList}
            />
          </div>
        </header>
        <main className="project-list-wrapper">
          <div>
            <ProjectList
              type={allProjectListVisible ? "all" : "ongoing"}
              totalElements={totalElements}
              projectList={projectList}
            />
            <Pagination
              currentPage={
                allProjectListVisible ? currentPage : ongoingCurrentPage
              }
              setCurrentPage={
                allProjectListVisible ? setCurrentPage : setOngoingCurrentPage
              }
              recordDatasPerPage={recordDatasPerPage}
              totalData={data.totalElements}
            />
          </div>
          {isModalVisible ? (
            <NewProject
              isModalVisible={isModalVisible}
              setIsModalVisible={setIsModalVisible}
            />
          ) : null}
        </main>
      </div>
    </div>
  );
};

export default Management;

자세히 살펴보자.

 

먼저, useQuery 문이 눈에 띈다.

해당 useQuery 훅을 사용해서 프로젝트 목록에 관한 데이터들을 불러온다.

 

프로젝트 목록을 불러올 때 신경써야할 항목은 두 가지이다.

바로, 페이지네이션전체 프로젝트인지 진행중 프로젝트인지 여부이다.

전체 프로젝트인지 진행중 프로젝트인지 여부는 allProjectListVisible이라는 state로 관리하도록 하였고, 페이지네이션의 경우, 전체 프로젝트에 관한 현재 페이지인 currentPage, 진행 중 프로젝트에 관한 현재 페이지인 ongoingCurrentPage state로 관리하도록 하였다.

 

그리고 useQuery 문에는 쿼리 키가 필요한데, 전체인 경우와 진행중인 경우를 구분해 서로 다른 키 값을 갖도록 하였고, 이때, 페이지별로 다른 데이터를 불러와야 하기 때문에 현재 페이지 값도 쿼리 키로 추가해주었다.

  const [currentPage, setCurrentPage] = useState(1);
  const [ongoingCurrentPage, setOngoingCurrentPage] = useState(1);
  const [allProjectListVisible, setAllProjectListVisible] = useState(false);

  const { data = { projectList: [] } } = useQuery(
    [
      allProjectListVisible
        ? "managementAllProjectList"
        : "managementOngoingProjectList",
      allProjectListVisible ? currentPage : ongoingCurrentPage,
      allProjectListVisible,
    ],
    () =>
      getProjectList(
        allProjectListVisible ? currentPage : ongoingCurrentPage,
        allProjectListVisible ? "ALL" : "ONGOING"
      ),
    { keepPreviousData: true }
  );

 

getProjectList라는 함수는 서버로부터 프로젝트 데이터를 받아오는 함수이다.

페이지 값, 불러올 데이터 수, 진행 중/완료 프로젝트 여부 등을 인수로 해서 함수를 호출하도록 하였다.

export const getProjectList = async (pageNum = 1, state) => {
  let response = null;

  if (state === "ALL") {
    response = await customAxios.get(
      `/auth/project/list?page=${pageNum}&size=4&state=ONGOING&state=COMPLETE`
    );
  } else {
    response = await customAxios.get(
      `/auth/project/list?page=${pageNum}&size=4&state=${state}`
    );
  }

  return {
    projectList: response.data.content,
    totalElements: response.data.totalElements,
    totalPages: response.data.totalPages,
  };
};

 

이제 프로젝트 생성 로직을 조금 더 자세히 살펴보자.

프로젝트 생성 버튼을 클릭하게 되면 모달 창을 띄우게 된다.

 

이때 아래 두 컴포넌트가 작동하게 된다.

NewProject.js 컴포넌트는 새 프로젝트 작성을 위해서 생성하였고 ProjectEditor.js 컴포넌트는 프로젝트 수정을 위해서 작성하였는데, 기존 컴포넌트를 재사용하면 좋을 것 같아 아래와 같이 작성하게 되었다.

// NewProject.js
import ProjectEditor from "./ProjectEditor";

const NewProject = ({ isModalVisible, setIsModalVisible }) => {
  return (
    <ProjectEditor
      isModalVisible={isModalVisible}
      setIsModalVisible={setIsModalVisible}
    />
  );
};

export default NewProject;
// ProjectEditor.js
import { useEffect, useRef, useState } from "react";

import { getStringDate } from "../../utils/date";

import projectDefaultImg from "../../assets/project-default-img.jpg";

import { deleteImage } from "../../lib/apis/managementApi";
import ModalContainer from "../common/ModalContainer";
import useManagementMutation from "../../hooks/useManagementMutation";

import { toast } from "react-toastify";
import Button from "../common/Button";
import { handleImageUploader } from "../../utils/handleImageUploader";

const ProjectEditor = ({
  isModalVisible,
  setIsModalVisible,
  isEdit = false,
  editData = null,
}) => {
  const { createMutate, editMutate } = useManagementMutation();

  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [startDate, setStartDate] = useState(getStringDate(new Date()));
  const [endDate, setEndDate] = useState(getStringDate(new Date()));
  const [imgUrl, setImgUrl] = useState("");
  const [responseImgUrl, setResponseImgUrl] = useState("");

  const descriptionRef = useRef(null);

  const inputRef = useRef(null);

  const handleModalClose = () => {
    setIsModalVisible(false);
  };

  const handleChangeTitle = (e) => {
    setTitle(e.target.value);
  };

  const handleChangeStartDate = (e) => {
    setStartDate(e.target.value);
  };

  const handleChangeEndDate = (e) => {
    setEndDate(e.target.value);
  };

  const handleChangeContent = (e) => {
    setDescription(e.target.value);
  };

  const handleChangeImage = async (e) => {
    const imgUrl = await handleImageUploader(e.target.files);

    setImgUrl(imgUrl);
    setResponseImgUrl(imgUrl);
  };

  const handleSubmitNewProject = async () => {
    if (title.length < 2) {
      toast.error("프로젝트 이름은 두 글자 이상 작성해주세요.");
      return;
    }

    if (description.length < 5) {
      toast.error("프로젝트 설명은 다섯 글자 이상 작성해주세요.");
      descriptionRef.current.focus();
      return;
    }

    if (new Date(startDate).getTime() > new Date(endDate).getTime()) {
      toast.error("진행 기간이 잘못 설정되었습니다. 다시 설정해주세요.");
      return;
    }

    if (isEdit) {
      editMutate({
        projectId: editData.projectId,
        title,
        description,
        state: "ONGOING",
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    } else {
      createMutate({
        title: title,
        description: description,
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    }

    setIsModalVisible(false);
  };

  const handleUploadImageClick = () => {
    if (!inputRef.current) {
      toast.error("잘못된 접근입니다. 다시 이미지를 업로드해주세요.");
      return;
    }

    inputRef.current.click();
  };

  const handleRemoveImageClick = () => {
    deleteImage("");

    setImgUrl(null);
    setResponseImgUrl(null);
  };

  useEffect(() => {
    if (isEdit) {
      setTitle(editData.title);
      setStartDate(editData.startDate);
      setEndDate(editData.endDate);
      setImgUrl(editData.imageUrl);
      setDescription(editData.description);
    }
  }, [editData, isEdit]);

  return (
    <ModalContainer
      open={isModalVisible}
      onClose={handleModalClose}
      width={"60%"}
      height={"75%"}
    >
      <div className="project-editor">
        <div className="project-editor-heading">
          <input
            className="project-editor-heading-input"
            type="text"
            required
            value={title}
            onChange={handleChangeTitle}
            placeholder="프로젝트 이름을 입력하세요"
          />
        </div>
        <div className="project-editor-body">
          <div className="project-editor-left-col">
            <div className="project-editor-img-wrapper">
              <figure
                style={{
                  backgroundImage: `url(${
                    imgUrl ? imgUrl : projectDefaultImg
                  })`,
                }}
              />
              <input
                className="image-input"
                type="file"
                accept="image/*"
                ref={inputRef}
                onChange={handleChangeImage}
              />
              <Button
                onClick={handleUploadImageClick}
                text={"이미지 업로드"}
                size={"small"}
              />
              <Button
                onClick={handleRemoveImageClick}
                text={"이미지 제거"}
                size={"small"}
              />
            </div>
          </div>
          <div className="project-editor-right-col">
            <div className="project-editor-period-first-row">
              <h1>진행 기간:</h1>
              <div>
                <input
                  className="project-editor-period-start-date"
                  type="date"
                  required
                  value={startDate}
                  onChange={handleChangeStartDate}
                  data-placeholder="시작 일자"
                />
                -
                <input
                  className="project-editor-period-end-date"
                  type="date"
                  required
                  value={endDate}
                  onChange={handleChangeEndDate}
                  data-placeholder="종료 일자"
                />
              </div>
            </div>
            <div className="project-editor-period-second-row">
              <textarea
                value={description}
                onChange={handleChangeContent}
                ref={descriptionRef}
                placeholder="프로젝트 설명을 작성하세요"
                required
              />
            </div>
          </div>
        </div>
        <div className="button-wrapper">
          <Button
            text="작성 완료"
            size={"small"}
            type={"positive"}
            onClick={handleSubmitNewProject}
          />
        </div>
      </div>
    </ModalContainer>
  );
};

export default ProjectEditor;

일단, 이미지 업로드 부분프로젝트 생성 과정을 자세히 살펴볼 것이다.

 

이미지 업로드 부분 코드는 다음과 같다.

보면, 코드가 너무 간단하다.

 

이는 handleImageUploader라는 함수를 모듈화해서 그런 것이다.

재사용을 위해서 utils 디렉토리 내부 함수로 따로 빼놨다.

handleImageUploader 함수 내부에서 서버에 이미지를 업로드하는 로직을 작성하였고, ProjectEditor 컴포넌트에서는 이를 그냥 갖다 쓰기만 한 것이다.

서버에 이미지를 업로드하는 로직을 조금 더 자세히 살펴보면, 사용자가 올린 파일을 formData 내부에 담아 서버에 업로드한다.

이 업로드는 uploadImage라는 함수를 통해서 이루어지는데, 해당 함수도 아래에서 볼 수 있다.

서버에 해당 이미지를 업로드한 후 해당 이미지 url을 받아 클라이언트 상에서 화면에 보여준다.

const handleChangeImage = async (e) => {
    const imgUrl = await handleImageUploader(e.target.files);

    setImgUrl(imgUrl);
    setResponseImgUrl(imgUrl);
 };
 
 const handleUploadImageClick = () => {
    if (!inputRef.current) {
      toast.error("잘못된 접근입니다. 다시 이미지를 업로드해주세요.");
      return;
    }

    inputRef.current.click();
 };

  const handleRemoveImageClick = () => {
    deleteImage("");

    setImgUrl(null);
    setResponseImgUrl(null);
 };
import { toast } from "react-toastify";
import { uploadImage } from "../lib/apis/managementApi";

export const handleImageUploader = async (files) => {
  if (!files) {
    toast.error("잘못된 접근입니다. 다시 이미지를 업로드해주세요.");
    return;
  }

  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.readAsDataURL(files[0]);

    reader.onloadend = async () => {
      const formData = new FormData();
      formData.append("file", files[0]);

      const imgUrl = await uploadImage({
        dir: "project",
        formData: formData.get("file"),
      });

      resolve(imgUrl);
    };
  });
};
export const uploadImage = async (imgData) => {
  const response = await customAxios.post(
    `/auth/upload/image?dir=${imgData.dir}`,
    {
      file: imgData.formData,
    },
    {
      headers: { "Content-Type": "multipart/form-data" },
    }
  );

  return response.data.imageUrl;
};

 

이제 프로젝트 생성 로직을 한 번 살펴보자.

작성 완료 버튼을 누르게 되면 아래 함수가 작동한다.

기본적인 예외 처리 로직이 작동하고, 수정하는 경우에는 editMutate 함수가 호출되고, 프로젝트 생성의 경우 createMutate 함수가 호출된다.

 

이번에는 createMutate 함수 위주로 알아보자.

createMutate 함수는 useMutation 훅을 사용한다.

 

조회성 api를 호출하는 함수가 아닌, 생성, 삭제, 수정 등의 동작을 하는 경우 useQuery가 아니라 useMutation 훅을 사용한다.

다음 createMutate 함수에서는 createProject 함수를 호출하는데 여기서 서버에 새로운 프로젝트 정보를 등록한다고 보면 된다.

 

createMutate 함수 내부에는 onSuccess, onError 문이 적혀있는데 mutate가 성공한 경우와 에러 난 경우를 처리하기 위한 로직이다.

onSuccess 문 내부에는 queryClient.invalidateQueries라는 문장이 있다.

이는 이전에 useQuery 훅의 키로 작성해 둔 쿼리를 무효화하는 것인데, 이는 프로젝트 생성 시 진행 중인 프로젝트 리스트와 전체 프로젝트 리스트를 다시 불러와서 사용자에게 보여줘야 한다.

그래서 이전에 불러온 쿼리를 무효화하고 다시 쿼리를 불러오도록 설정하는 것이다.

이전에는 useQuery 문에 쿼리 키로 의존성 배열로 작성해줬는데 invalidateQueries 문장을 작성할 때는 쿼리 키의 prefix만 맞춰주면 해당 prefix 값을 가진 쿼리들은 다 무효화된다.

const { createMutate, editMutate } = useManagementMutation();

const handleSubmitNewProject = async () => {
    if (title.length < 2) {
      toast.error("프로젝트 이름은 두 글자 이상 작성해주세요.");
      return;
    }

    if (description.length < 5) {
      toast.error("프로젝트 설명은 다섯 글자 이상 작성해주세요.");
      descriptionRef.current.focus();
      return;
    }

    if (new Date(startDate).getTime() > new Date(endDate).getTime()) {
      toast.error("진행 기간이 잘못 설정되었습니다. 다시 설정해주세요.");
      return;
    }

    if (isEdit) {
      editMutate({
        projectId: editData.projectId,
        title,
        description,
        state: "ONGOING",
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    } else {
      createMutate({
        title: title,
        description: description,
        startDate: getStringDate(new Date(startDate)),
        endDate: getStringDate(new Date(endDate)),
        imageUrl: responseImgUrl,
      });
    }

    setIsModalVisible(false);
  };
import { useNavigate } from "react-router-dom";
import { useQueryClient, useMutation } from "react-query";

import { toast } from "react-toastify";

import {
  completeProject,
  createProject,
  deleteProject,
  editProject,
  leaveProject,
} from "../lib/apis/managementApi";

const useManagementMutation = () => {
  const queryClient = useQueryClient();
  const navigate = useNavigate();

  const { mutate: createMutate } = useMutation(createProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);
      toast.success("생성되었습니다!");
    },
    onError: () => {
      toast.error("생성 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
  });

  const { mutate: deleteMutate } = useMutation(deleteProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);

      navigate("/management");
      toast.success("삭제되었습니다!");
    },
    onError: () => {
      toast.error("삭제 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
  });

  const { mutate: completeMutate } = useMutation(completeProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);
      queryClient.invalidateQueries(["completeProjectData"]);

      navigate("/management");
      toast.success("완료 처리되었습니다!");
    },
    onError: () => {
      toast.error("완료 처리 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
  });

  const { mutate: editMutate } = useMutation(editProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);

      navigate("/management");
      toast.success("수정되었습니다!");
    },
    onError: () => {
      toast.error("수정 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
  });

  const { mutate: leaveMutate } = useMutation(leaveProject, {
    onSuccess: () => {
      queryClient.invalidateQueries(["managementOngoingProjectList"]);
      queryClient.invalidateQueries(["managementAllProjectList"]);
      toast.success("팀에서 탈퇴되었습니다!");
    },
    onError: () => {
      toast.error("팀 탈퇴를 실패하였습니다. 잠시 후 다시 시도해주세요.");
    },
  });

  return {
    createMutate,
    deleteMutate,
    completeMutate,
    editMutate,
    leaveMutate,
  };
};

export default useManagementMutation;
export const createProject = async (newProjectObj) => {
  const response = await customAxios.post("/auth/project", newProjectObj);

  return response.data.content;
};