티스토리 뷰
# 시작
지난 포스팅에 게시판의 BE 설정을 했습니다. 이번 포스팅에서는 FE 설정을 진행합니다.
개인 공부를 기록하는 게 주목적이다 보니 기본적인 부분들의 설명은 생략했습니다.
# 준비
위와 같이 파일들을 생성합니다. 각 파일의 역할은 다음과 같습니다.
- index: 게시판 목록 최상위 컴포넌트
- Board: 게시판 목록의 각 로우를 생성하는 컴포넌트
- BoardList: 게시판 목록을 생성하는 컴포넌트
- BoardDetail: 게시글 상세보기 컴포넌트
- BoardWriter: 게시글을 등록, 수정하는 컴포넌트
- Queries: 게시판과 관련된 GraphQL 쿼리를 관리하는 컴포넌트
- Type: TypeScript의 interface 및 type을 관리하는 컴포넌트
먼저 Route설정을 진행합니다.
RouteComponent파일을 다음과 같이 수정합니다.
import React from 'react'
import { Route } from 'react-router-dom';
import Home from './Home';
import Sample from '../sample';
import Board from '../board/index';
import BoardWriter from '../board/BoardWriter';
import BoardDetail from '../board/BoardDetail';
const RouteComponent = () => {
return (
<div id="wrapper">
<Route exact={true} path="/" component={Home} />
<Route path="/Sample" component={Sample} />
// (1)
<Route path="/Board/:pageIndex" component={Board} />
<Route path="/BoardWriter/:id" component={BoardWriter} />
<Route path="/BoardDetail/:pageIndex/:id" component={BoardDetail} />
</div>
)
}
export default RouteComponent;
@(1)
:pageIndex는 파라미터입니다. 예를 들면 `/Board/2`로 라우팅을 했을 때 라우트 된 컴포넌트에서 `match.params.pageIndex`로 2라는 파라미터 값을 가져올 수 있습니다.
Sidebar파일도 다음과 같이 수정합니다.
import React from 'react';
import { NavLink } from 'react-router-dom';
const Sidebar = () => {
return (
<section id="sidebar">
<div className="inner">
<nav>
<ul>
<li><NavLink exact to="/" activeClassName="active">Home</NavLink></li>
<li><NavLink to="/Board/0" activeClassName="active">Board</NavLink></li>
<li><NavLink to="/Sample" activeClassName="active">Sample</NavLink></li>
</ul>
</nav>
</div>
</section>
)
}
export default Sidebar;
# Front End
다음 파일들을 순서대로 작성하겠습니다.
Type.ts
// 페이지 이동시 사용할 Reducer의 타입 정의입니다.
export type TChangePageAction = { type: 'INCREMENT', length: number } | { type: 'DECREMENT', length: number };
export interface IPageIndexState {
index: number;
}
export interface IBoardProps {
pageIndex: string
}
export interface IBoardDetailProps {
id: string
pageIndex: string
}
export interface IBoardWriterProps {
id: string
}
export interface IBoard {
id: number
title: string
content: string
visitCount: number
postDate: string
pageIndex: number
}
export interface IBoardList {
boardList: [IBoard]
pageIndex: number
}
export interface IPutBoard {
[key: number]: IPutBoard
id: number
title: string
content: string
isPublic: boolean
}
// Board 컴포넌트의 td style에 적용할 타입 정의입니다.
export interface ITd {
width: string,
}
Queries.tsx
import gql from 'graphql-tag';
export default {
// 게시글 목록을 가져옵니다. 페이지 번호를 인자로 넘깁니다.
GET_BOARD_LIST: gql`
query GetBoardList($index: Int!) {
getBoardList(index: $index) {
id
title
visitCount
postDate
}
}
`,
// 게시글 번호를 인자로 해서 게시글 하나의 데이터를 가져옵니다.
GET_BOARD: gql`
query GetBoard($id: Int!) {
getBoard(id: $id) {
id
title
content
isPublic
visitCount
postDate
updateDate
}
}
`,
// 게시글을 신규 저장합니다. 제목, 내용, 공개 여부는 필수값입니다.
POST_BOARD: gql`
mutation PostBoard($title: String!, $content: String!, $isPublic: Boolean!) {
postBoard(title: $title, content: $content, isPublic: $isPublic) {
id
title
content
postDate
}
}
`,
// 게시글을 수정합니다. 제목, 내용, 공개 여부는 필수값입니다.
PATCH_BOARD: gql`
mutation PatchBoard($id: Int!, $title: String!, $content: String!, $isPublic: Boolean!) {
patchBoard(id: $id, title: $title, content: $content, isPublic: $isPublic) {
id
title
content
updateDate
}
}
`
};
게시판에서 사용할 쿼리들을 정의합니다.
Board.tsx
import React, { FunctionComponent } from 'react';
import { Link } from 'react-router-dom';
import { IBoard } from './Type';
import styled from 'styled-components';
import { ITd } from './Type';
const Td = styled.td`
width: ${(props: ITd) => props.width || '100%'};
`;
const Board: FunctionComponent<IBoard> = ({ id, title, visitCount, postDate, pageIndex }) => {
return (
<tr>
<Td width="15%">{id}</Td>
<Td width="30%"><Link to={`/BoardDetail/${pageIndex}/${id}`}>{title}</Link></Td>
<Td width="15%">{postDate}</Td>
<Td width="15%">{visitCount}</Td>
</tr>
)
}
export default Board;
props로 단건 데이터를 받아서 로우를 구성하는 컴포넌트입니다.
@styled
Td 컴포넌트의 width속성 값을 파라미터로 받아 각각 다르게 적용합니다.
BoardList.tsx
import React, { useEffect, FunctionComponent } from 'react';
import { IBoardList, IBoard } from './Type';
import Board from './Board';
const BoardList: FunctionComponent<IBoardList> = ({ boardList, pageIndex }) => {
const makeList = () => {
return boardList.map((board: IBoard) => {
return <Board
key={board.id}
id={board.id}
title={board.title}
content={board.content}
visitCount={board.visitCount}
postDate={board.postDate}
pageIndex={pageIndex}
/>
});
}
return (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>번호</th>
<th>제목</th>
<th>등록일자</th>
<th>조회수</th>
</tr>
</thead>
<tbody>
{makeList()}
</tbody>
</table>
</div>
);
}
export default BoardList;
props로 게시글 목록 데이터를 받아서 Board 컴포넌트의 배열로 변환하여 렌더링하는 컴포넌트입니다.
index.tsx
import React, { useReducer, FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { useQuery } from 'react-apollo-hooks';
import { Link } from 'react-router-dom';
import { IPageIndexState, IBoardProps, TChangePageAction } from './Type';
import BoardList from './BoardList';
import Queries from './Queries';
function PageReducer (state: IPageIndexState, action: TChangePageAction) {
let { index } = state;
const { type, length } = action;
switch(type){
case 'DECREMENT':
//첫 페이지일 때
if(index == 0) return state;
return { index: --index };
case 'INCREMENT':
//마지막 페이지일 때
if(length < 5) return state;
return { index: ++index };
}
}
const Index: FunctionComponent<RouteComponentProps<IBoardProps>> = ({ match }) => {
let [ { index }, dispatch ] = useReducer(PageReducer, { index: Number(match.params.pageIndex) || 0 });
const { data, loading, error } = useQuery(Queries.GET_BOARD_LIST,{ variables: { index }, fetchPolicy: "no-cache" });
let length = 0;
if( data ){
length = data.getBoardList.length;
}
return (
( loading ) ? <span>불러오는 중...</span> :
( error ) ? <span>목록을 불러오는데 실패했습니다</span> :
<section id="intro" className="wrapper style1 fullscreen">
<div className="inner">
<h2>글 목록</h2>
<BoardList boardList={data.getBoardList} pageIndex={index} />
<ul className="actions">
<li><Link to="/BoardWriter/0" className="button">글쓰기</Link></li>
<li><a href="#" onClick={() => dispatch({type: "DECREMENT", length})} className="button primary">이전</a></li>
<li><a href="#" onClick={() => dispatch({type: "INCREMENT", length})} className="button primary">다음</a></li>
</ul>
</div>
</section>
);
}
export default Index;
@useQuery
페이지 번호를 인자로 넘겨서 해당하는 게시글 데이터들만 가져옵니다. 지난 포스팅에서 한 페이지의 개수를 5개로 정했기 때문에 최대 5개의 게시글만 가져오게 됩니다.
fetchPolicy는 메모리 캐시를 적용하지 않고 렌더링 할 때 서버에서 새로 목록을 가져오게 합니다. 만약 캐시를 적용하게 되면 신규 글 작성 시 목록으로 자동으로 이동할 때 메모리 캐시에서 데이터를 가져오기 때문에 방금 작성한 신규 글이 나타나지 않게 됩니다.
@useReducer
이전과 다음 버튼을 클릭할 때 페이지 번호를 변경하여 재 렌더링을 통해 페이지 목록을 갱신합니다.
여기서 사용한 훅 외에도 useState, useEffect 등 다양한 훅들이 있습니다. 궁금하신 분들은 여기를 참고하세요.
BoardWriter.tsx
import React, { useState, FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { IPutBoard, IBoardWriterProps } from './Type';
import { useMutation } from 'react-apollo-hooks';
import Queries from './Queries';
const BoardPoster: FunctionComponent<RouteComponentProps<IBoardWriterProps>> = ({ match, history }) => {
const id = Number(match.params.id);
const postData: IPutBoard = {
id: id || 0,
title: '',
content: '',
isPublic: false
};
const [ state, setState ] = useState(postData);
const [ postBoard] = useMutation(Queries.POST_BOARD);
const [ patchBoard ] = useMutation(Queries.PATCH_BOARD);
// 공개 여부 체크박스 이벤트
const onCheckboxClick = (e: React.MouseEvent) => {
setState({
...state,
isPublic: !state.isPublic
});
};
// 작성 폼 입력 시 데이터 변경 이벤트
const onChangeState = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setState({
...state,
[e.target.id]: e.target.value
});
};
// 신규 게시글 작성
const onPostBoard = async () => {
if( !validator() ) return false;
const { title, content, isPublic } = state;
try {
const response = await postBoard({ variables: { title, content, isPublic } });
afterSave(response.data.postBoard.id);
} catch(error) {
alert('에러가 발생했습니다. 에러 코드: 001');
console.error(error.message);
}
};
// 기존 게시글 수정
const onPatchBoard = async () => {
if( !validator() ) return false;
const { id, title, content, isPublic } = state;
try{
const response = await patchBoard({ variables: { id, title, content, isPublic } });
afterSave(response.data.patchBoard.id);
} catch(error) {
alert('에러가 발생했습니다. 에러 코드: 002');
console.error(error.message);
}
};
const validator = () => {
const { title, content } = state;
if( !title || !content ){
alert('제목과 내용을 입력하세요');
return false;
}
return true;
};
const afterSave = (id: number) => {
if( id > 0 ){
alert('저장되었습니다');
history.push("/Board/0");
} else {
alert('저장되지 않았습니다. 다시 시도해주세요.');
}
}
// 페이지 벗어날 때 확인
const onLeavePage = () => {
if( !confirm('작성 중이던 내용이 사라집니다. 정말 나가시겠습니까?') ){
return false;
}
history.push("/Board/0");
}
return (
<section id="intro" className="wrapper style1 fullscreen">
<div className="inner">
<section>
<h3>글쓰기</h3>
<form action="#">
<div className="row gtr-uniform">
<div className="col-6 col-12-xsmall">
<input type="text" id="title" value={state.title} onChange={onChangeState} placeholder="Title" />
</div>
<div className="col-4 col-12-small">
<input type="checkbox" checked={state.isPublic} onChange={()=>{}} />
<label onClick={onCheckboxClick}>공개</label>
</div>
<div className="col-12">
<textarea id="content" value={state.content} onChange={onChangeState} placeholder="Content"></textarea>
</div>
</div>
</form>
<ul className="actions">
<li><a href="#" className="button" onClick={onLeavePage}>목록</a></li>
// (1)
<li><a href="#" className="button" onClick={ ( id == 0 ) ? onPostBoard : onPatchBoard}>작성</a></li>
</ul>
</section>
</div>
</section>
)
}
export default BoardPoster;
@RouteComponentProps
react-router 패키지에서 제공하는 interface입니다. props에서 match, history 등을 사용하기 위해 선언합니다.
@useMutation
데이터를 변경하기 위해 사용하는 react-apollo-hook입니다. variables속성으로 인자를 넘겨줄 수 있습니다.
@(1)
신규 작성이라면 id값을 0으로 설정하고 수정이라면 수정할 게시글의 id값을 가져올 것이기 때문에 분기시켜줍니다.
BoardDetail.tsx
import React, { FunctionComponent } from 'react';
import { RouteComponentProps } from 'react-router';
import { Link } from 'react-router-dom';
import { useQuery } from 'react-apollo-hooks';
import { IBoard, IBoardDetailProps } from './Type';
import Query from './Queries';
const BoardDetail: FunctionComponent<RouteComponentProps<IBoardDetailProps>> = ({ match }) => {
// (1)
const { id, pageIndex } = match.params;
const { data, loading, error } = useQuery(Query.GET_BOARD, { variables: { id } });
if( loading ) return <span>불러오는 중...</span>;
if( error ) return <span>데이터를 불러오는데 실패했습니다</span>;
const boardData: IBoard = data.getBoard;
return (
<section id="intro" className="wrapper style1 fullscreen">
<div className="inner">
<section>
<blockquote><h3>{boardData.title}</h3></blockquote>
<form action="#">
<div className="row gtr-uniform">
<div className="col-12">
<pre><code>{boardData.content}</code></pre>
</div>
</div>
</form>
<ul className="actions">
<li><Link to={`/Board/${pageIndex}`} className="button">목록</Link></li>
<li><Link to={`/BoardWriter/${id}`} className="button">수정</Link></li>
</ul>
</section>
</div>
</section>
);
}
export default BoardDetail;
게시글 번호에 해당하는 데이터를 가져온 뒤 렌더링합니다.
@(1)
Route의 인자로 받은 게시글 번호와 게시글을 조회할 당시의 페이지 번호입니다.
목록 버튼을 클릭하면 조회할 당시의 페이지 번호에 맞는 목록으로 이동합니다.
수정 버튼을 클릭하면 해당 게시글 번호와 함께 수정 페이지로 이동합니다.
# 실행
Front End 구성도 완료되었습니다. 이제 제대로 작동하는지 확인합니다.
$ npm start
HOME메뉴와 SAMPLE메뉴 사이에 게시판 메뉴가 잘 나오는지 확인하고 클릭하여 제대로 Route 되는지 체크합니다.
화면이 정상적으로 이동되었다면 글쓰기 버튼을 클릭하여 새 글을 작성합니다. 이후 작성 버튼을 클릭하여 저장합니다.
목록으로 이동된 후 정상적으로 글이 갱신되었는지 확인합니다.
방금 등록된 글의 제목을 클릭하여 상세보기로 이동합니다.
이외에도 수정 및 페이지 이동이 정상적으로 작동하는 걸 확인할 수 있습니다.
# 마치며
간단한 게시판의 구현이 끝났습니다. 이후에는 댓글 기능을 추가하고, 관리자와 사용자를 구분하여 공개 여부에 따른 목록 호출과 글 수정, 삭제가 되도록 합니다.
# GitHub
'프로젝트 > 나만의 블로그' 카테고리의 다른 글
Spring Boot & GraphQL & React-Apollo [게시판 만들기] (1) (1) | 2019.08.31 |
---|---|
Spring Boot & Nashorn으로 SSR [BE] (0) | 2019.08.25 |
React & Apollo [FE] (0) | 2019.08.24 |
React & Parcel 개발 환경 구성 [FE] (0) | 2019.08.24 |
Spring Boot & GraphQL (2) [BE] (2) | 2019.08.18 |
- Total
- Today
- Yesterday
- PostgreSQL
- Docker
- 프로그래머스[스택/큐]
- JPA
- 실행 문맥
- CI
- 웹 사이트 최적화
- Apollo
- execution context
- Pipeline
- javascript
- Nashorn
- Handshake
- 프로그래머스[정렬]
- Spring Boot
- 동적계획법
- 프로그래머스[Lv1]
- 프로그래머스[해시]
- graphql
- react
- typescript
- CRP 최적화
- 프로그래머스[힙]
- CD
- 프로그래머스
- Jenkins
- Kubernetes
- 프로그래머스[이분탐색]
- 알고리즘
- Web
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |