Spring Security + JWT + React - 07. 프론트엔드 - 게시판 제작: 컴포넌트 생성

june·2022년 9월 1일
0
post-thumbnail

Context 적용

이전의 Context는 인증과 인가에 대한 Context였기 때문에 전방위로 적용해야 했으므로, index.ts에 덮어 씌우면서 전체 적용해줬다.

하지만 이번의 Context는 게시판 기능에만 한정되기 때문에, 그에 맞는 Contextpages에 있는 페이지 컴포넌트에만 적용하기로 한다.

React Router 설정

App.tsx

import React, { useContext } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';

import Layout from './components/Layout/Layout';
import ArticleListPage from './pages/ArticleListPage';
import ArticleOnePage from './pages/ArticleOnePage';
import AuthPage from './pages/AuthPage';
import CreateAccountPage from './pages/CreateAccountPage';
import CreateArticlePage from './pages/CreateArticlePage';
import HomePage from './pages/HomePage';
import ProfilePage from './pages/ProfilePage';
import UpdateArticlePage from './pages/UpdateArticlePage';
import AuthContext from './store/auth-context';


function App() {

  const authCtx = useContext(AuthContext);

  return (
    <Layout>
      <Routes>
        <Route path="/" element={<HomePage />} />

        
        <Route path="/page/:pageId" element={<ArticleListPage />} />
        <Route path="/create" element={authCtx.isLoggedIn ? <CreateArticlePage /> : <Navigate to='/' />} />
        <Route path="/update/:articleId" element={authCtx.isLoggedIn ? <UpdateArticlePage /> : <Navigate to='/' />} />
        <Route path="/article/:articleId" element={<ArticleOnePage />} />

        <Route path="/signup/" element={authCtx.isLoggedIn ? <Navigate to='/' /> : <CreateAccountPage />} />
        <Route path="/login/*" 
          element={authCtx.isLoggedIn ? <Navigate to='/' /> : <AuthPage />}
        />
        <Route path="/profile/" element={!authCtx.isLoggedIn ? <Navigate to='/' /> : <ProfilePage />} />
      </Routes>
    </Layout>
  );
}

export default App;

전체적인 구조는 이전과 같으나, 로직에 맞는Route컴포넌트를 더 추가했다.

먼저 게시판 리스트는 /page/뒤에 pageId부분에 파라미터를 매치해서, 올바른 url을 만들어 준다. 이는 이후에 작성할 useParams()라는 훅을 통해 파라미터를 받을 수 있다.

게시판 작성은 로그인 체크를 해서, 만약 로그인이 되지 않았을 경우에는 메인 페이지로 보내게 한다.

게시판 수정도 마찬가지로 되어있고, 이 또한 특정 게시판의 주소를 수정해야하므로, 파라미터를 매치해준다.

게시물의 경우도 마찬가지로, 파라미터가 매치되어 있다.

Pages

ArticleListPage

/pages/ArticleListPage.tsx

import { Fragment } from "react";
import { useParams } from "react-router-dom";
import ArticleList from "../components/Article/ArticleList";
import SearchForm from "../components/Article/SearchForm";
import { ArticleContextProvider } from "../store/article-context";

const ArticleListPage = () => {
  let { pageId } = useParams();
  return (
    <ArticleContextProvider>
      <Fragment>
        <ArticleList item={pageId}/>
        <SearchForm />
      </Fragment>
  </ArticleContextProvider>
  );
}

export default ArticleListPage;

페이지로 구성된 게시판 리스트에 관한 컴포넌트다.

전체를 ArticleContextProvider로 감싸서, ArticleContext를 사용할 수 있게했으며, 앞서 말한 pageIduseParams()훅으로 설정하면서, 쿼리 파라미터에 대한 설정도 마쳤다.

따라서 /page/ 뒤에 파라미터가 붙게 된다면, 그 파라미터를 게시판 리스트 컴포넌트인 ArticleList 컴포넌트의 item에 적용을 시켜, 컴포넌트의 훅과 함수들이 알맞은 로직을 실행하게 된다.

ArticleOnePage

/pages/ArticleOnePage.tsx

import { Fragment } from "react";
import { useParams } from "react-router-dom";
import ArticleOne from "../components/Article/ArticleOne";
import Comment from "../components/Article/CommentList";
import Recommend from "../components/Article/Recommend";
import { ArticleContextProvider } from "../store/article-context";
import { CommentContextProvider } from "../store/comment-context";
import { RecommendContextProvider } from "../store/recommend-context";

const ArticleOnePage = () => {
  let { articleId } = useParams();

  return (
    <Fragment>
      <ArticleContextProvider>
          <ArticleOne item={articleId}/>
      </ArticleContextProvider>
      <RecommendContextProvider>
        <Recommend item={articleId}/>
      </RecommendContextProvider>
      <CommentContextProvider>
        <Comment item={articleId}/>
      </CommentContextProvider>
    </Fragment>
  )
};

export default ArticleOnePage;

게시물 하나를 담당하는 컴포넌트인 ArticleOnePage또한 비슷한 구성으로 되어있다.

그러나, 게시물 하나에는 단순히 게시물 뿐만 아니라, 추천과 댓글 기능도 들어가야 하므로,

ArticleContextProvider 뿐만 아니라 RecommendContextProvider, CommentContextProvider 또한 적용이 되어있으며, 그에 맞는 Recommend, Comment 컴포넌트도 적용을 시켜준다.

CreateArticlePage

/pages/CreateArticlePage.tsx

import CreateArticleForm from "../components/Article/CreateArticleForm";

import { ArticleContextProvider } from "../store/article-context";

const CreateArticlePage = () => {
  return (
    <ArticleContextProvider>
      <CreateArticleForm item={undefined}/>
    </ArticleContextProvider>
  )
}

export default CreateArticlePage;

게시물 생성을 담당하는 page 컴포넌트다.

여기서는 CreateArticleForm 컴포넌트에 undefined를 넣고 있는데, 왜냐하면 이 컴포넌트는 뒤에도 설명하겠지만, 생성과 수정을 동시에 담당하는 컴포넌트기 때문에, 특정 값을 넣으면 값을 받아 수정하는 컴포넌트로 사용되기 때문이다.

따라서 아무것도 없는 빈 화면을 보여주는 로직에서는 item의 값이 존재하지 않아야 하기 때문에 undefined를 넣게 되었다.

UpdateArticlePage

/pages/UpdateArticlePage.tsx

import { useParams } from "react-router-dom";
import CreateArticleForm from "../components/Article/CreateArticleForm";
import { ArticleContextProvider } from "../store/article-context";


const UpdateArticlePage = () => {

  let { articleId } = useParams();

  return ( 
    <ArticleContextProvider>
      <CreateArticleForm item={articleId} />
    </ArticleContextProvider>
  );
}

export default UpdateArticlePage;

게시물 수정 페이지를 담당하는 컴포넌트다.

앞서 설명했듯이 CreateArticleForm의 item값에, 파라미터를 넣음으로써, 안의 함수와 훅이 실행되어 로직이 실행되게 된다.

Components

ArticleList

/components/Article/ArticleList.tsx

import BootStrapTable from 'react-bootstrap-table-next';
import { Button } from 'react-bootstrap';
import { useCallback, useContext, useEffect, useState } from 'react';
import AuthContext from '../../store/auth-context';
import { Link, useNavigate } from 'react-router-dom';
import 'react-bootstrap-table-next/dist/react-bootstrap-table2.min.css';
import classes from './ArticleList.module.css';
import ArticleContext from '../../store/article-context';
import Paging from './Paging';

type Props = { item:string | undefined}

type ArticleInfo = {
  articleId: number,
  memberNickname: string,
  articleTitle: string,
  articleBody?: string,
  cratedAt: string,
  updatedAt?: string,
  isWritten?: boolean
};


const ArticleList:React.FC<Props> = (props) => {

  let navigate = useNavigate();
  const pageId = String(props.item);

  const columns = [{
    dataField: 'articleId',
    text: '#',
    headerStyle: () => {
      return { width: "8%" };
    }
  },{
    dataField: 'articleTitle',
    text: '제목',
    headerStyle: () => {
      return { width: "65%" };
    },
    events: {
      onClick: (e:any, column:any, columnIndex:any, row:any, rowIndex:any) => {
        const articleIdNum:string = row.articleId;
        navigate(`../article/${articleIdNum}`);
      }
    }
  },{
    dataField: 'memberNickname',
    text: '닉네임'
  },{
    dataField: 'createdAt',
    text: '작성일'
  },]


  const authCtx = useContext(AuthContext);
  const articleCtx = useContext(ArticleContext);
  
  const [AList, setAList] = useState<ArticleInfo[]>([]);
  const [maxNum, setMaxNum] = useState<number>(1);

  let isLogin = authCtx.isLoggedIn;

  const fetchListHandler = useCallback(() => {
    articleCtx.getPageList(pageId);
  }, []);


  useEffect(() => {
    fetchListHandler();
  }, [fetchListHandler]);


  useEffect(() => {
    if (articleCtx.isSuccess) {
      setAList(articleCtx.page);
      console.log(AList);
      setMaxNum(articleCtx.totalPages);
    }
  }, [articleCtx])

  return (
    <div className={classes.list}>
      <BootStrapTable keyField='id' data = { AList } columns={ columns } />
      <div>{isLogin &&
        <Link to="/create">
          <Button>글 작성</Button>
        </Link>
      }
      </div>
      <Paging currentPage={Number(pageId)} maxPage={maxNum}/>
    </div>
  );
}
export default ArticleList;

기능들을 살펴보자

type Props = { item:string | undefined}

type ArticleInfo = {
  articleId: number,
  memberNickname: string,
  articleTitle: string,
  articleBody?: string,
  cratedAt: string,
  updatedAt?: string,
  isWritten?: boolean
};

첫번째인 Props 타입은, pages 컴포넌트로부터 값을 전달받기 위해 설정했다. 여기서 단순히 string이 아니라 undefined로 설정한 이유는, react router v6가 useParams()훅을 반환할때, 선택적 매개변수를 지원하지 않기 때문에, 이렇게 설정했다.

두번째는 Context에서 미리 언급한 ArticleInfo로, 게시물을 정렬하기 위해 쓰인다.

const ArticleList:React.FC<Props> = (props) => {

  let navigate = useNavigate();
  const pageId = String(props.item);

  const columns = [{
    dataField: 'articleId',
    text: '#',
    headerStyle: () => {
      return { width: "8%" };
    }
  },{
    dataField: 'articleTitle',
    text: '제목',
    headerStyle: () => {
      return { width: "65%" };
    },
    events: {
      onClick: (e:any, column:any, columnIndex:any, row:any, rowIndex:any) => {
        const articleIdNum:string = row.articleId;
        navigate(`../article/${articleIdNum}`);
      }
    }
  },{
    dataField: 'memberNickname',
    text: '닉네임'
  },{
    dataField: 'createdAt',
    text: '작성일'
  },]

맨 위는 일단 useNavigate()훅과, props를 통해 받은 pageId를 설정해놨다.

게시물 리스트를 간단하게 구현하기 위해, react-bootstrap-table-next의 기능을 사용했다.

기본적으로 테이블에 넣을 columns 객체를 만들고, 그 안에 게시판의 형태를 잡는 방식이다.

여기서 제목 탭에는 events를 추가하여, 제목을 클릭할 시, useNavigate()훅을 사용하여 그에 맞는 게시물로 이동하는 로직을 짰다.

  const authCtx = useContext(AuthContext);
  const articleCtx = useContext(ArticleContext);
  
  const [AList, setAList] = useState<ArticleInfo[]>([]);
  const [maxNum, setMaxNum] = useState<number>(1);

사용하는 Context들이다. 글 작성여부를 가려야 하기 때문에 AuthContext를 가져왔고, 게시물에 관한 정보를 사용해야하기 때문에 ArticleContext를 가져왔다

상태에는 페이지 게시물 리스트를 저장하는 ArticleInfo형태의 리스트 state와, 페이지 이동 버튼에 필요한, 전체 페이지 숫자를 담는 masNum state로 구성되어있다.

  let isLogin = authCtx.isLoggedIn;

  const fetchListHandler = useCallback(() => {
    articleCtx.getPageList(pageId);
  }, []);


  useEffect(() => {
    fetchListHandler();
  }, [fetchListHandler]);


  useEffect(() => {
    if (articleCtx.isSuccess) {
      setAList(articleCtx.page);
      setMaxNum(articleCtx.totalPages);
    }
  }, [articleCtx])

형태는 간단하다 먼저 useEffect훅을 통해, 사이트가 실행되면 fetchListHandler함수를 실행한다.

그러면 컴포넌트 전체 재평가로 인한 무한루프를 방지하기 위해, useCallback훅으로 감싼 fetchListHandler함수가 실행되며, 이는 ArticleContext를 통해, 원하는 페이지의 List를 저장하게 된다.

그리고 다음 useEffect훅을 통해, Context내부에 있는 isSuccess가 성공으로 나오게 된다면, 로딩이 성공되었다는 뜻이므로, 상태 내에 게시물 리스트와 페이지 전체 숫자를 저장한다.

  return (
    <div className={classes.list}>
      <BootStrapTable keyField='id' data = { AList } columns={ columns } />
      <div>{isLogin &&
        <Link to="/create">
          <Button>글 작성</Button>
        </Link>
      }
      </div>
      <Paging currentPage={Number(pageId)} maxPage={maxNum}/>
    </div>
  );
}
export default ArticleList;

그리고 이는 컴포넌트의 반환에서 BootStrapTable의 data를 state의 AList로 하고, 전체적인 형태는 앞서 말한 columns객체로 실행하면서 구현한다.

또한, 앞서 설정한 isLogin boolean값을 통해 만약 로그인 되었을 경우, 글 작성 버튼을 구현하게 한다.

그리고 맨 밑에는 나중에 서술할 Paging객체에 현재 페이지 값인 pageId와 전체 페이지 값인 maxNum을 넘기게 된다.

Paging

페이징, 즉 Pagination을 맡는 컴포넌트다.

ArticleList로 부터 현재 페이지 값, 최대 페이지 값을 받아, 그에 맞게 로직을 구현한다.

이 컴포넌트도 react-bootstrapPagination기능을 사용했다.

/components/Article/Paging.tsx

import React from "react";
import { Pagination } from "react-bootstrap";
import { useNavigate } from 'react-router-dom';
import classes from './Paging.module.css';

type Props = { currentPage:number, maxPage:number }

const Paging:React.FC<Props> = (props) => {
  let navigate = useNavigate();

  const maxNum = props.maxPage;
  const currentNum = props.currentPage;

  const navigateToPage = (page:number) => (event:React.MouseEvent<HTMLElement>) => {
    event.preventDefault();
    console.log(page);
    if (props.currentPage !== page) {
      const pageNumb = String(page);
      navigate(`../page/${pageNumb}`);
    }
  };

  const definePage = () => {
    let pageProp: JSX.Element[] = []
    if (maxNum < 6) {
      for (let num = 1; num <= maxNum; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      return pageProp;
    } 

    if (currentNum < 5) {
      for (let num = 1; num <= 4; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      pageProp.push(<Pagination.Ellipsis />);
      pageProp.push(<Pagination.Item>{maxNum}</Pagination.Item>);
      pageProp.push(<Pagination.Next />)
      return pageProp;
    }

    if (maxNum - currentNum < 4) {
      pageProp.push(<Pagination.First />)
      pageProp.push(<Pagination.Item>{1}</Pagination.Item>);
      pageProp.push(<Pagination.Ellipsis />);
      for (let num = maxNum-3; num <= maxNum; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      return pageProp;
    } 

    pageProp.push(<Pagination.First />)
    pageProp.push(<Pagination.Item>{1}</Pagination.Item>);
    pageProp.push(<Pagination.Ellipsis />);
    for (let num = currentNum-2; num <= currentNum + 2; num++) {
      <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
        {num}
      </Pagination.Item>
    }
    return pageProp;
  }

  

  return (
    <Pagination className={classes.page}>
    {definePage()}
    </Pagination>
  );
}

export default Paging;

로직을 살펴보자.

type Props = { currentPage:number, maxPage:number }

const Paging:React.FC<Props> = (props) => {
  let navigate = useNavigate();

  const maxNum = props.maxPage;
  const currentNum = props.currentPage;

먼저 props로 받는 두 데이터를 type으로 지정해주고 이를 통해 함수형 컴포넌트를 구현한다.

이후 페이지 이동을 위해 useNavigate()훅을 불러오고, props에서 현재 페이지와 최대페이지의 값을 불러온다.

  const navigateToPage = (page:number) => (event:React.MouseEvent<HTMLElement>) => {
    event.preventDefault();
    if (props.currentPage !== page) {
      const pageNumb = String(page);
      navigate(`../page/${pageNumb}`);
    }
  };

이 로직은, 해당하는 숫자와 evnet를 매개변수로 넣으면, 그에 맞게 이동해주는 로직이다.

원래는 아래 함수의 각각의 if값마다 이 로직이 있었으나, 리팩토링을 통해 함수를 따로 분리해주었다.

만약 현재 페이지를 클릭한다면, 이동할 수 없게 if값으로 설정을 해주었다.

  const definePage = () => {
    let pageProp: JSX.Element[] = []
    if (maxNum < 6) {
      for (let num = 1; num <= maxNum; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      return pageProp;
    } 

    if (currentNum < 5) {
      for (let num = 1; num <= 4; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      pageProp.push(<Pagination.Ellipsis />);
      pageProp.push(<Pagination.Item>{maxNum}</Pagination.Item>);
      pageProp.push(<Pagination.Next />)
      return pageProp;
    }

    if (maxNum - currentNum < 4) {
      pageProp.push(<Pagination.First />)
      pageProp.push(<Pagination.Item>{1}</Pagination.Item>);
      pageProp.push(<Pagination.Ellipsis />);
      for (let num = maxNum-3; num <= maxNum; num ++) {
        pageProp.push(
          <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
            {num}
          </Pagination.Item>
        )
      }
      return pageProp;
    } 

    pageProp.push(<Pagination.First />)
    pageProp.push(<Pagination.Item>{1}</Pagination.Item>);
    pageProp.push(<Pagination.Ellipsis />);
    for (let num = currentNum-2; num <= currentNum + 2; num++) {
      <Pagination.Item key={num} active={num === currentNum} onClick={navigateToPage(num)}>
        {num}
      </Pagination.Item>
    }
    return pageProp;
  }

props의 주어진 값을 토대로 if를 통해, Pagination을 만드는 함수다.

좀더 자세히 보자면 일단 Pagination컴포넌트를 넣을 JSX.Element 리스트를 만들고, 이후maxNumcurrentNum에 따라 Pagination 구조를 정한 다음, 리스트 안에 넣어 return하는 것이다.

그리고 안에 있는 if 로직들은 그림으로 만들면 이러한 형식으로 요약될 수 있다.

  return (
    <Pagination className={classes.page}>
    {definePage()}
    </Pagination>
  );
}

export default Paging;

이후 해당하는 함수를 Pagination 컴포넌트 안에 넣는 것으로 마무리한다.

ArticleOne

순수한 게시물 하나만을 나타내는 컴포넌트다

/components/Article/ArticleOne.tsx

import { useState, useContext, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';

import ArticleContext from '../../store/article-context';
import AuthContext from '../../store/auth-context';
import Article from './Article';
import classses from './ArticleOne.module.css';

type Props = { item:string | undefined }

type ArticleInfo = {
  articleId: number,
  memberNickname: string,
  articleTitle: string,
  articleBody?: string,
  cratedAt: string,
  updatedAt?: string,
  written?: boolean
};

const ArticleOne:React.FC<Props> = (props) => {

  let navigate = useNavigate();

  const [article, setArticle] = useState<ArticleInfo>();
  const [isLoading, setIsLoading ] = useState<boolean>(false);

  const authCtx = useContext(AuthContext);
  const articleCtx = useContext(ArticleContext);
  let isLogin = authCtx.isLoggedIn;
  const id = String(props.item);


  const deleteHandler = (id:string) => {
    articleCtx.deleteArticle(authCtx.token, id);
    alert("삭제되었습니다.");
    navigate("/page/1")
  }

  const getContext = useCallback(() => {
    setIsLoading(false);
    (isLogin ? articleCtx.getArticle(id, authCtx.token) : articleCtx.getArticle(id));
  }, [isLogin])
  
  useEffect(() => {
    getContext();
  }, [getContext])

  useEffect(() => {
    if (articleCtx.isSuccess) {
      setArticle(articleCtx.article);
      console.log(article);
      console.log(article?.cratedAt);
      setIsLoading(true);
    }
  }, [articleCtx, article]);

  let content = <p>Loading</p>

  if (isLoading && article) {
    content = <Article item={article} onDelete={deleteHandler} />
  }

  return ( 
    <div className={classses.article}>
      {content}
    </div>
    
  );
}

export default ArticleOne;

type부분은 앞서 말했으니 생략하고, state에는 전체 ArticleInfo타입 객체로 이루어져 있는, article과, 로딩 여부를 알려주는 isLoading으로 이루어져 있다.

  const deleteHandler = (id:string) => {
    articleCtx.deleteArticle(authCtx.token, id);
    alert("삭제되었습니다.");
    navigate("/page/1")
  }

게시물을 삭제해주는 함수다. 삭제되었으면 alert을 해준다음 자동으로 첫번째 페이지로 이동하게 한다.

 const getContext = useCallback(() => {
   setIsLoading(false);
   (isLogin ? articleCtx.getArticle(id, authCtx.token) : articleCtx.getArticle(id));
 }, [isLogin])
 
 useEffect(() => {
   getContext();
 }, [getContext])

 useEffect(() => {
   if (articleCtx.isSuccess) {
     setArticle(articleCtx.article);
     setIsLoading(true);
   }
 }, [articleCtx, article]);

게시물을 불러오는 구조는 리스트와 거의 동일하나, 로그인 여부를 알려줘야 하기때문에, 로그인 여부에 따라 ArticleContext의 함수에 보내는 매개변수가 다르다.

  let content = <p>Loading</p>

  if (isLoading && article) {
    content = <Article item={article} onDelete={deleteHandler} />
  }

  return ( 
    <div className={classses.article}>
      {content}
    </div>
    
  );
}

export default ArticleOne;

이후 앞서 말한 useEffect훅에 의해, 로딩 상태면 로딩을 내보내고, 성공적으로 되었다면, Article 컴포넌트에 값을 집어넣은 콘텐츠를 불러온다.

Article

import { useNavigate } from "react-router-dom";

type Props = { item:ArticleInfo, onDelete: (id:string) => void }

type ArticleInfo = {
  articleId: number,
  memberNickname: string,
  articleTitle: string,
  articleBody?: string,
  cratedAt: string,
  updatedAt?: string,
  written?: boolean
};

const Article:React.FC<Props> = (props) => {

  let navigate = useNavigate();

  const id = props.item!.articleId.toString();

  const backHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    navigate("/page/1");
  }
  
  const updateHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    navigate("../update/" + id);
  }

  const deleteHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    if (window.confirm("삭제하시겠습니까?")){
      props.onDelete(id);
    }
  }

  return (
    <div>
      <header>
          <h4>{props.item!.articleTitle}</h4>
          <div>
            <span>이름: {props.item!.memberNickname}</span><br />
            <span>날짜 : {props.item!.updatedAt}</span>
          </div>
        </header>
        <div>
          <div>{props.item!.articleBody}</div>
        </div>
        <button onClick={backHandler}>뒤로</button>
        {props.item!.written && 
          <div>
            <button onClick={updateHandler}>수정</button><br />
            <button onClick={deleteHandler}>삭제</button>
          </div>
        }
    </div>
  );
}

export default Article;

Props에서 받는 값은 전체 게시판 작성을 위한 ArticleinfoonDelete함수다. 나머지 type부분은 생략하자.

const Article:React.FC<Props> = (props) => {

  let navigate = useNavigate();

  const id = props.item!.articleId.toString();

  const backHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    navigate("/page/1");
  }
  
  const updateHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    navigate("../update/" + id);
  }

  const deleteHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault();
    if (window.confirm("삭제하시겠습니까?")){
      props.onDelete(id);
    }
  }

useNaviagate훅을 사용하며, id는 props에서 받은 값을 뽑아낸다.

backHandlerupdateHandler는 둘다 useNavigate훅을 통해 각각 페이지와 수정페이지로 이동시키며,

deleteHandler는 삭제 여부를 알려주는 alert를 해준다음 만약 허용된다면, 상위 컴포넌트의 props에 onDelete값을 보낸다. 이후 상위 컴포넌트인 ArticleOnedeleteHandler 함수가 이 값을 받아서 처리하게 된다.

  return (
    <div>
      <header>
          <h4>{props.item!.articleTitle}</h4>
          <div>
            <span>이름: {props.item!.memberNickname}</span><br />
            <span>날짜 : {props.item!.updatedAt}</span>
          </div>
        </header>
        <div>
          <div>{props.item!.articleBody}</div>
        </div>
        <button onClick={backHandler}>뒤로</button>
        {props.item!.written && 
          <div>
            <button onClick={updateHandler}>수정</button><br />
            <button onClick={deleteHandler}>삭제</button>
          </div>
        }
    </div>
  );
}

export default Article;

이후 해당 로직에 맞게 jsx부분을 작성하면 된다.

수정과 삭제 버튼은 모두 propswritten이 true일 경우에만 표시가 된다는 점을 잊지 말자.

CreateArticleForm

게시물의 생성과 수정을 담당하는 컴포넌트다. 마찬가지로 react-bootstrap의 여러부분을 사용했다.

/components/Article/CreateArticleForm.tsx

import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Button, Form } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import ArticleContext from "../../store/article-context";
import AuthContext from "../../store/auth-context";

type Props = { item:string | undefined }

interface PostArticle {
  id?: string
  title: string,
  body: string
} 


const CreateArticleForm:React.FC<Props> = (props) => {

  let navigate = useNavigate();


  const [updateArticle, setUpdateArticle] = useState<PostArticle>({
    title: '',
    body: ''
  });

  const articleCtx = useContext(ArticleContext);
  const authCtx = useContext(AuthContext);

  const titleRef = useRef<HTMLInputElement>(null);
  const mainRef = useRef<HTMLTextAreaElement>(null);

  const submitHandler = (event: React.FormEvent) => {
    event.preventDefault();

    let postArticle:PostArticle = {
      title: titleRef.current!.value,
      body: mainRef.current!.value
    }

    if (props.item) {
      console.log('update!');
      postArticle = { ...postArticle, id:props.item }
    }

    props.item 
    ? articleCtx.updateArticle(authCtx.token, postArticle) : articleCtx.createArticle(postArticle, authCtx.token);
  }

  const setUpdateArticleHandler = useCallback(() => {
    if (articleCtx.isGetUpdateSuccess) {
      setUpdateArticle({
        title: articleCtx.article!.articleTitle,
        body: articleCtx.article!.articleBody
      })
    }
  }, [articleCtx.isGetUpdateSuccess])

  useEffect(() => {
    if (props.item) {
      articleCtx.getUpdateArticle(authCtx.token, props.item);
    }
  }, [props.item])

  useEffect(() => {
    console.log('update effect')
    setUpdateArticleHandler();
  }, [setUpdateArticleHandler])

  useEffect(() => {
    if (articleCtx.isSuccess) {
      console.log("wrting success");
      navigate("/page/1", { replace: true })
    }
  }, [articleCtx.isSuccess])

  return (
    <div>
        <Form onSubmit={submitHandler}>
          <Form.Group>
            <Form.Label>제목</Form.Label>
            <Form.Control 
              type="text" 
              placeholder="제목을 입력하세요"
              required
              ref={titleRef}
              defaultValue={updateArticle.title}
            />
          </Form.Group>
          <br />
          <Form.Group>
            <Form.Label>본문</Form.Label>
            <Form.Control 
              as="textarea" 
              rows={20}
              required
              ref={mainRef}
              defaultValue={updateArticle.body} 
            /> 
          </Form.Group>
          <br />
          <Button variant="primary">
            취소
          </Button>
          <Button variant="primary" type="submit">
            작성
          </Button>
        </Form>
    </div>
  );
}

export default CreateArticleForm;

타입부분은 생략하고, 인터페이스에서는 id가 선택적 프로퍼티로 되어있어서 의무적으로 객체에 들어가지 않아도 된다는 점을 기억하자.

const CreateArticleForm:React.FC<Props> = (props) => {

  let navigate = useNavigate();

  const [updateArticle, setUpdateArticle] = useState<PostArticle>({
    title: '',
    body: ''
  });

  const articleCtx = useContext(ArticleContext);
  const authCtx = useContext(AuthContext);

  const titleRef = useRef<HTMLInputElement>(null);
  const mainRef = useRef<HTMLTextAreaElement>(null);

state로는 제목과 본문으로 이루어진 PostArticle 타입을 사용하고, 그에 맞는 값을 가져오기 위해 useRef훅을 사용한다. 만약 게시물이 수정이 아니라 생성일 경우 빈 값을 넣는다.

제목은 HTMLInputElement으로 되어있지만, 본문은 HTMLTextAreaElement타입으로 되어있다.

  const submitHandler = (event: React.FormEvent) => {
    event.preventDefault();

    let postArticle:PostArticle = {
      title: titleRef.current!.value,
      body: mainRef.current!.value
    }

    if (props.item) {
      console.log('update!');
      postArticle = { ...postArticle, id:props.item }
    }

    props.item 
    ? articleCtx.updateArticle(authCtx.token, postArticle) : articleCtx.createArticle(postArticle, authCtx.token);
  }

  ...
  useEffect(() => {
    if (articleCtx.isSuccess) {
      console.log("wrting success");
      navigate("/page/1", { replace: true })
    }
  }, [articleCtx.isSuccess])

생성/수정한 게시물을 등록하는 로직이다.

useRef훅을 통해 가져온 데이터를 PostArticle타입 객체에 넣는다.

만약 수정인 경우, 그러니까 props에 데이터가 있는 경우에는 id를 추가해야하므로 props에서 id를 뽑아온다.

이후 props의 데이터 여부에 따라 수정인가 생성인가를 정해서 데이터를 보낸다.

그 다음 useEffect훅을 통해, Context의 state에서 isSuccess가 true일 경우 useNavigate훅을 통해, 첫 페이지로 이동시킨다.

  const setUpdateArticleHandler = useCallback(() => {
    if (articleCtx.isGetUpdateSuccess) {
      setUpdateArticle({
        title: articleCtx.article!.articleTitle,
        body: articleCtx.article!.articleBody
      })
    }
  }, [articleCtx.isGetUpdateSuccess])

  useEffect(() => {
    if (props.item) {
      articleCtx.getUpdateArticle(authCtx.token, props.item);
    }
  }, [props.item])

  useEffect(() => {
    console.log('update effect')
    setUpdateArticleHandler();
  }, [setUpdateArticleHandler])

게시물 수정일 경우, 게시물에 관한 데이터를 불러오는 기능이다.

마찬가지로 props.item으로 판단하며, 이후의 과정은 useEffect훅과 useCallback훅을 쓴 함수의 이용으로, 위와 로직이 비슷하므로 생략하겠다.

  return (
    <div>
        <Form onSubmit={submitHandler}>
          <Form.Group>
            <Form.Label>제목</Form.Label>
            <Form.Control 
              type="text" 
              placeholder="제목을 입력하세요"
              required
              ref={titleRef}
              defaultValue={updateArticle.title}
            />
          </Form.Group>
          <br />
          <Form.Group>
            <Form.Label>본문</Form.Label>
            <Form.Control 
              as="textarea" 
              rows={20}
              required
              ref={mainRef}
              defaultValue={updateArticle.body} 
            /> 
          </Form.Group>
          <br />
          <Button variant="primary">
            취소
          </Button>
          <Button variant="primary" type="submit">
            작성
          </Button>
        </Form>
    </div>
  );
}

export default CreateArticleForm;

앞서 말한 react-bootstrapFormButton을 써서 구현했다.

defaultValueupdateArticle state를 써서 표현해준다.

즉 수정의 경우라면, state에 그에 알맞은 데이터가 있을 것이며, 생성인 경우에는 빈값을 넣게 된다는 것이다.

또한 ref를 통해 useRef훅과 연결시켜준다.

Recommend

/components/Article/Recommend.tsx

import React, { useCallback, useContext, useEffect, useState } from 'react';

import EmptyHeart from '../../images/empty-heart.png';
import Heart from '../../images/heart.png';
import AuthContext from '../../store/auth-context';
import RecommendContext from '../../store/recommend-context';
import classes from './Recommend.module.css';

type Props = { item:string | undefined }

type Recommends = {
  recommendNum: number
  recommended: boolean
}

const Recommend:React.FC<Props> = (props) => {
  
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const [recommends, setRecommends] = useState<Recommends>();
  
  const authCtx = useContext(AuthContext);
  const recommendCtx = useContext(RecommendContext);


  let isLogin = authCtx.isLoggedIn;
  const id = String(props.item);

  const getContext = useCallback(() => {
    setIsLoading(false);
    (isLogin ? recommendCtx.getRecommends(id, authCtx.token) : recommendCtx.getRecommends(id));
  }, [isLogin])

  useEffect(() => {
    getContext();
  }, [getContext]);

  useEffect(() => {
    if (recommendCtx.isSuccess) {
      setRecommends(recommendCtx.recommends);
      console.log(recommends);
      console.log("set");
      setIsLoading(true);
    }
  }, [recommendCtx, recommends])

  useEffect(() => {
    if (recommendCtx.isChangeSuccess) {
      setRecommends(recommendCtx.recommends);
      console.log(recommends);
      console.log("change set");
      setIsLoading(true);
    }
  }, [recommendCtx.isChangeSuccess])
  

  const changeRecommend = () =>{
    if (!isLogin) {
      return alert("로그인 하세요");
    } else {
      (recommends!.recommended ? recommendCtx.deleteRecommend(id, authCtx.token) : recommendCtx.postRecommend(id, authCtx.token));
    }

  }

  const heartImage = (heart:string) => {
    return (
      <img alt="heart" className={classes.heart} src={heart} onClick={changeRecommend}/>
    )
  }

  let media = <h3>is Loading...</h3>

  if (isLoading && recommends) {
    media = (
      <div>
        {recommends.recommended ? heartImage(Heart) : heartImage(EmptyHeart)}
        <h4>좋아요 숫자 {recommends.recommendNum}</h4>
      </div>
    )
  }

  return ( 
    <div className={classes.recommend}>
      {media}
    </div>
  );
}

export default Recommend;

기본적으로 type에 관한부분이나, state, context는 이전과 유사하니 생략하자.

  const getContext = useCallback(() => {
    setIsLoading(false);
    (isLogin ? recommendCtx.getRecommends(id, authCtx.token) : recommendCtx.getRecommends(id));
  }, [isLogin])

  useEffect(() => {
    getContext();
  }, [getContext]);

  useEffect(() => {
    if (recommendCtx.isSuccess) {
      setRecommends(recommendCtx.recommends);
      console.log(recommends);
      console.log("set");
      setIsLoading(true);
    }
  }, [recommendCtx, recommends])

  useEffect(() => {
    if (recommendCtx.isChangeSuccess) {
      setRecommends(recommendCtx.recommends);
      console.log(recommends);
      console.log("change set");
      setIsLoading(true);
    }
  }, [recommendCtx.isChangeSuccess])
  

이것도 위와 구조가 유사하나, RecommendContext는 생성과 삭제 같이, 변화에 대해서 판단하는 state가 있다.

따라서 isChangeSuccess의 값이 true일 경우 다시 Contextrecommends state를 불러와서 컴포넌트의 state에 적용시킨다.

  const changeRecommend = () =>{
    if (!isLogin) {
      return alert("로그인 하세요");
    } else {
      (recommends!.recommended ? recommendCtx.deleteRecommend(id, authCtx.token) : recommendCtx.postRecommend(id, authCtx.token));
    }
  }

추천 생성, 삭제에 관한 함수다.

로그인이 되어있지 않은경우, alert으로 거절하고, 만약 로그인이 되어있다면, recommends상태의 recommended값을 통해, 추천을 생성하려는 것인지, 삭제하려는 것인지 파악하고 각각 맞는 함수를 날린다.


  const heartImage = (heart:string) => {
    return (
      <img alt="heart" className={classes.heart} src={heart} onClick={changeRecommend}/>
    )
  }

  let media = <h3>is Loading...</h3>

  if (isLoading && recommends) {
    media = (
      <div>
        {recommends.recommended ? heartImage(Heart) : heartImage(EmptyHeart)}
        <h4>좋아요 숫자 {recommends.recommendNum}</h4>
      </div>
    )
  }

  return ( 
    <div className={classes.recommend}>
      {media}
    </div>
  );
}

export default Recommend;

추천 이미지는 구분이 쉽게 하기 위해서 2가지의 값(Heart와 EmptyHeart)을 가져왔고, recommended의 여부에 따라 Heart냐 EmptyHeart가 나뉘게 된다.

이후는 해당로직에 맞게 전개한다. 앞선 부분과 유사해서 생략한다.

CommentList

한 글에 여러개의 댓글들이 있을 수 있기 때문에, 리스트형식으로 구현해야했고, 따라서 CommentList안에, Comment 컴포넌트를 리스트 형식으로 넣는 방안으로 로직을 짰다.

import React, { useCallback, useContext, useEffect, useState, useRef } from 'react';
import AuthContext from '../../store/auth-context';
import CommentContext from '../../store/comment-context';
import Comment from './Comment';
import classes from './CommentList.module.css';

type Props = { item:string | undefined }

type CommentInfo = {
  commentId: number,
  memberNickname: string,
  commentBody: string,
  createdAt: Date,
  written: boolean
  onDelete?: (id:string) => void;
}

type PostComment = {
  articleId: string,
  body: string
}



const CommentList:React.FC<Props> = (props) => {

  const [comments, setComments] = useState<CommentInfo[]>();
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const commentRef = useRef<HTMLTextAreaElement>(null);

  const authCtx = useContext(AuthContext);
  const commentCtx = useContext(CommentContext);

  let isLogin = authCtx.isLoggedIn;
  let isSuccess = commentCtx.isSuccess;
  const token = authCtx.token;
  const articleId = String(props.item);

  const getContext = useCallback(() => {
    setIsLoading(false);
    (isLogin ? commentCtx.getComments(articleId, authCtx.token) : commentCtx.getComments(articleId));
    console.log("get comment");
  }, [isLogin]);

  useEffect(() => {
    getContext();
  }, [getContext]);

  useEffect(() => {
    if (isSuccess) {
      setComments(commentCtx.commentList);
      console.log(comments);
      setIsLoading(true);
    }
  }, [isSuccess]);

  const createComment = (event:React.FormEvent) => {
    event.preventDefault();
    const comment:PostComment = 
    { 
      articleId: articleId,
      body:commentRef.current!.value 
    } 
    
    commentCtx.createComment(comment, token);
  };

  const deleteComment = (commentId:string) => {
    commentCtx.deleteComment(commentId, articleId, token);
  }

  let media = <h3>is Loading...</h3>

  if (isLoading && comments) {
    if (comments!.length > 0) {
      console.log("if start")
      console.log(comments);
      media = (<ul>
        {
        comments.map((comment) => {
          return <Comment
            key={comment.commentId}
            commentId={comment.commentId}
            memberNickname={comment.memberNickname}
            commentBody={comment.commentBody}
            createdAt={comment.createdAt.toString()}
            written={comment.written}
            onDelete={deleteComment}
          />}
        )
      }
    </ul>)
    } else {
      media = <div></div>
    }
    
      
  }
  
  return ( 
    <div className={classes.list}>
      {media}
      {isLogin && 
      <form onSubmit={createComment} className={classes.box}>
        <label htmlFor="inputName" className={classes.name}>{authCtx.userObj.nickname}</label>
        <textarea 
          name="comment"
          className={classes.text} 
          cols={100} 
          rows={3} 
          ref={commentRef}/>
        <input type="submit" className={classes.btn}/>
      </form>}
    </div>
  );
}

export default CommentList;

하나하나씩 살펴보자.


type Props = { item:string | undefined }

type CommentInfo = {
  commentId: number,
  memberNickname: string,
  commentBody: string,
  createdAt: Date,
  written: boolean
  onDelete?: (id:string) => void;
}

type PostComment = {
  articleId: string,
  body: string
}

type에서는 똑같으나, Comment에서 삭제를 구현할 때 함수를 값으로 주고받아야 하기 때문에, onDelete라는 함수를 선택적 프로퍼티로 넣어줬다.

또한, 댓글을 작성하기 위한 PostComment 타입도 구현했다.

const CommentList:React.FC<Props> = (props) => {

  const [comments, setComments] = useState<CommentInfo[]>();
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const commentRef = useRef<HTMLTextAreaElement>(null);

  const authCtx = useContext(AuthContext);
  const commentCtx = useContext(CommentContext);

  let isLogin = authCtx.isLoggedIn;
  let isSuccess = commentCtx.isSuccess;
  const token = authCtx.token;
  const articleId = String(props.item);

Context에서 값을 바져와서 저장할 state로 CommentInfotype 객체 리스트인 comments를 설정해줬다.

이후 ContextuseRef와 같은 훅들은 앞서 설명했으므로 생략한다.

  const getContext = useCallback(() => {
    setIsLoading(false);
    (isLogin ? commentCtx.getComments(articleId, authCtx.token) : commentCtx.getComments(articleId));
    console.log("get comment");
  }, [isLogin]);

  useEffect(() => {
    getContext();
  }, [getContext]);

  useEffect(() => {
    if (isSuccess) {
      setComments(commentCtx.commentList);
      console.log(comments);
      setIsLoading(true);
    }
  }, [isSuccess]);

댓글을 불러오는 로직이다. 앞서 설명한 useEffectuseCallback훅을 이용한 함수로 구현한 로직이므로 생략한다.

  const createComment = (event:React.FormEvent) => {
    event.preventDefault();
    const comment:PostComment = 
    { 
      articleId: articleId,
      body:commentRef.current!.value 
    } 
    
    commentCtx.createComment(comment, token);
  };

  const deleteComment = (commentId:string) => {
    commentCtx.deleteComment(commentId, articleId, token);
  }

댓글 생성과 삭제로직이다.

생성은 useRef훅을 통해 얻어온 본문과, 해당 CommentList에서 받은 props에 있는 게시물 id값을 추출해온다.

삭제는 comment의 Id로 실행하는데, 이는 뒤에 언급할 Comment에서 매개변수 값을 받으며 실행이 된다.

  let media = <h3>is Loading...</h3>

  if (isLoading && comments) {
    if (comments!.length > 0) {
      console.log("if start")
      console.log(comments);
      media = (<ul>
        {
        comments.map((comment) => {
          return <Comment
            key={comment.commentId}
            commentId={comment.commentId}
            memberNickname={comment.memberNickname}
            commentBody={comment.commentBody}
            createdAt={comment.createdAt.toString()}
            written={comment.written}
            onDelete={deleteComment}
          />}
        )
      }
    </ul>)
    } else {
      media = <div></div>
    }
  }

만약 로딩상태가 아니고, comments 상태가 존재하며, 빈값이 아닐 경우 (0보다 클 경우)

media라는 JSX데이터는 comments상태로 부터 있는 모든 값이, Comment객체에 map을 통해 들어가게 된값이 된다.

만약 댓글이 없다면, media는 div 태그 안에 있는 빈값이 된다.


  return ( 
    <div className={classes.list}>
      {media}
      {isLogin && 
      <form onSubmit={createComment} className={classes.box}>
        <label htmlFor="inputName" className={classes.name}>{authCtx.userObj.nickname}</label>
        <textarea 
          name="comment"
          className={classes.text} 
          cols={100} 
          rows={3} 
          ref={commentRef}/>
        <input type="submit" className={classes.btn}/>
      </form>}
    </div>
  );
}

export default CommentList;

앞서 말했던 로직을 기반으로 JSX 부분을 구현한다.

댓글 작성 부분에서는 로그인이 되었을 때만 구현되게 하고, authContext를 통해 닉네임을 끌어와, label을 통해 닉네임을 나타나게 한다.

Comment

import React, { useRef } from "react";
import classes from "./Comment.module.css";

type Props = {
  commentId: number,
  memberNickname: string,
  commentBody: string,
  createdAt: string,
  written: boolean,
  onDelete: (id:string) => void;
}

const Comment:React.FC<Props> = (props) => {
  
  const deleteIdRef = useRef<HTMLInputElement>(null);

  const submitDeleteHandler = (event:React.FormEvent) => {
    event.preventDefault();
    const deleteId = deleteIdRef.current!.value;
    props.onDelete(deleteId);
  };

  return (
    <li className={classes.list}>
      <h4 className={classes.name}>{props.memberNickname}</h4>
      <p className={classes.body}>{props.commentBody}</p>
      <p className={classes.date}>{props.createdAt}</p>
      <form onSubmit={submitDeleteHandler}>
        <input 
          type="hidden" 
          name="commentId"
          value={props.commentId}
          ref={deleteIdRef} 
        />
        {props.written && <button type="submit">삭제</button>}
      </form>
    </li>
  )
}

export default Comment;

많은 부분이 비슷하므로, 함수와 jsx부분만 보고가도록 하자.

const Comment:React.FC<Props> = (props) => {

  const deleteIdRef = useRef<HTMLInputElement>(null);

  const submitDeleteHandler = (event:React.FormEvent) => {
    event.preventDefault();
    const deleteId = deleteIdRef.current!.value;
    props.onDelete(deleteId);
  };

useRef를 통해 삭제하려는 댓글의 Id를 알아내고, 그 id를 상위 컴포넌트에 보내게 한다.

  return (
    <li className={classes.list}>
      <h4 className={classes.name}>{props.memberNickname}</h4>
      <p className={classes.body}>{props.commentBody}</p>
      <p className={classes.date}>{props.createdAt}</p>
      <form onSubmit={submitDeleteHandler}>
        <input 
          type="hidden" 
          name="commentId"
          value={props.commentId}
          ref={deleteIdRef} 
        />
        {props.written && <button type="submit">삭제</button>}
      </form>
    </li>
  )
}

export default Comment;

props를 통해 구현하는건 이전의 Article과 비슷하고, 여기서는 written 정보를 통해, 삭제 버튼을 표시할지 안할지를 정하게 된다.


이제 거의 모든 과정이 끝났다. 다음에는 리팩토링 후 서버에 올리는 과정을 실행해 보기로 한다.

profile
초보 개발자

0개의 댓글