지금까지는 회원 인증 시스템과 글쓰기 기능의 구현을 완료했습니다. 이번에는 등록한 포스트를 조회할 수 있는 기능을 구현하겠습니다. 포스트를 읽는 것에는 두 가지가 있는데 첫 번째는 포스트 하나를 읽는 포스트 읽기 기능이고, 두 번째는 여러 포스트를 조회하는 포스트 목록 기능입니다.
PostView UI
src/components/post/PostView.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const PostViewBlock = styled(Responsive)`
margin-top: 4rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${palette.gray[2]};
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin: 0;
}
`;
const SubInfo = styled.div`
margin-top: 1rem;
color: ${palette.gray[6]};
/* span 사이에 가운뎃점 문자 보여 주기 */
span + span:before {
color: ${palette.gray[5]};
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const Tags = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.cyan[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.cyan[6]};
}
}
`;
const PostContent = styled.div`
font-size: 1.3125rem;
color: ${palette.gray[8]};
`;
const PostView = () => {
return (
<PostViewBlock>
<PostHead>
<h1>제목</h1>
<SubInfo>
<span>tester</span>
<span>{new Date().toLocaleDateString()}</span>
</SubInfo>
<Tags>
<div className="tag">#태그1</div>
<div className="tag">#태그2</div>
<div className="tag">#태그3</div>
</Tags>
</PostHead>
<PostContent
dangerouslySetInnerHTML={{ __html: `<p>HTML <b>내용</b>입니다.</p>` }}
/>
</PostViewBlock>
);
};
export default PostView;
src/pages/postPage.js
import React from 'react';
import HeaderContainer from '../containers/common/HeaderContainer';
import PostView from '../components/post/PostView';
const PostPage = () => {
return (
<>
<HeaderContainer />
<PostView />
</>
);
};
export default PostPage;
코드를 보면 PostContent에 dangerouslySetInnerHTML이라는 값을 설정해 주었습니다. 리액트에서는 <div>{html}</div>와 같이 HTML을 그대로 렌더링하는 형태로 JSX를 작성하면 HTML태그가 적용되지 않고 일반 텍스트 형태로 나타나 버립니다. 따라서 HTML을 적용하고 싶다면 dangerouslySetInnerHTML이라는 props를 설정해 주어야 합니다.
API연동하기
src/lib/api/posts.js
import client from './client';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
src/modules/post.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/api/createRequestSaga';
import * as postAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const [READ_POST, READ_POST_SUCCESS, READ_POST_FAILURE] =
createRequestActionTypes('post/READ_POST');
// 포스트 페이지에서 벗어날 때 데이터 비우기
const UNLOAD_POST = 'post/UNLOAD_POST';
export const readPost = createAction(READ_POST, (id) => id);
export const unloadPost = createAction(UNLOAD_POST);
const readPostSaga = createRequestSaga(READ_POST, postAPI.readPost);
export function* postSaga() {
yield takeLatest(READ_POST, readPostSaga);
}
const initialState = {
post: null,
error: null,
};
const post = handleActions(
{
[READ_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[READ_POST_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
[UNLOAD_POST]: () => initialState,
},
initialState,
);
export default post;
src/modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import write, { writeSaga } from './write';
import post, { postSaga } from './post';
const rootReducer = combineReducers({
auth,
loading,
user,
write,
post,
});
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga(), postSaga()]);
}
export default rootReducer;
src/containers/post/PostViewContainer.js
/* eslint-disable no-unused-vars */
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { readPost, unloadPost } from '../../modules/post';
import PostView from '../../components/post/PostView';
import { useParams } from 'react-router-dom';
const PostViewContainer = () => {
// 처음 마운트 될 때 포스트 읽기 API요청
const { postId } = useParams();
const dispatch = useDispatch();
const { post, error, loading } = useSelector(({ post, loading }) => ({
post: post.post,
error: post.error,
loading: loading['post/READ_POST'],
}));
useEffect(() => {
dispatch(readPost(postId));
// 언마운트될 떄 리덕스에서 포스트 데이터 없애기
return () => {
dispatch(unloadPost());
};
}, [dispatch, postId]);
return <PostView post={post} loading={loading} error={error} />;
};
export default PostViewContainer;
src/pages/PostPage.js
import React from 'react';
import HeaderContainer from '../containers/common/HeaderContainer';
import PostViewContainer from '../containers/post/PostViewContainer';
const PostPage = () => {
return (
<>
<HeaderContainer />
<PostViewContainer />
</>
);
};
export default PostPage;
src/components/post/PostView.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
const PostViewBlock = styled(Responsive)`
margin-top: 4rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${palette.gray[2]};
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin: 0;
}
`;
const SubInfo = styled.div`
margin-top: 1rem;
color: ${palette.gray[6]};
/* span 사이에 가운뎃점 문자 보여 주기 */
span + span:before {
color: ${palette.gray[5]};
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const Tags = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.cyan[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.cyan[6]};
}
}
`;
const PostContent = styled.div`
font-size: 1.3125rem;
color: ${palette.gray[8]};
`;
const PostView = ({ post, loading, error }) => {
// 에러 발생 시
if (error) {
if (error.response && error.response.status === 409) {
return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>;
}
return <PostViewBlock>오류 발생!</PostViewBlock>;
}
// 로딩 중이거나 아직 포스트 데이터가 없을 때
if (loading || !post) {
return null;
}
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo>
<span>{user.username}</span>
<span>{new Date(publishedDate).toLocaleDateString()}</span>
</SubInfo>
<Tags>
{tags.map((tag) => (
<div className="tag" key={tag}>
#{tag}
</div>
))}
</Tags>
</PostHead>
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewBlock>
);
};
export default PostView;
이제 새 post를 작성하고 결과를 확인해 보겠습니다.
PostList UI준비하기
PostList라는 컴포넌트를 만듭니다. 이 컴포넌트에서는 포스트들을 배열로 받아서 렌더링해 줍니다. 사용자가 로그인 중이라면 페이지 상단 우측에 새 글 작성하기 버튼을 보여줍니다.
src/components/post/PostView.js
import React from 'react';
import styled from 'styled-components';
import Responsive from '../common/Responsive';
import Button from '../common/Button';
import palette from '../../lib/styles/palette';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
const PostListBlock = styled(Responsive)`
margin-top: 3rem;
`;
const WritePostButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 3rem;
`;
const PostItemBlock = styled.div`
padding-top: 3rem;
padding-bottom: 3rem;
/* 맨 위 포스트는 padding-top없음 */
&:first-child {
padding-top: 0;
}
& + & {
border-top: 1px solid ${palette.gray[2]};
}
h2 {
font-size: 2rem;
margin-bottom: 0;
margin-top: 0;
&:hover {
color: ${palette.gray[6]};
}
}
p {
margin-top: 2rem;
}
`;
const PostItem = () => {
return (
<PostItemBlock>
<h2>제목</h2>
<SubInfo username="username" publishedDate={new Date()} />
<Tags tags={['태그1', '태그2', '태그3']} />
<p>포스트 내용의 일부분...</p>
</PostItemBlock>
);
};
const PostList = () => {
return (
<PostListBlock>
<WritePostButtonWrapper>
<Button cyan to="/write">
새 글 작성하기
</Button>
</WritePostButtonWrapper>
<div>
<PostItem />
<PostItem />
<PostItem />
</div>
</PostListBlock>
);
};
export default PostList;
src/components/common/SubInfo.js
import React from 'react';
import styled, { css } from 'styled-components';
import { Link } from 'react-router-dom';
import palette from '../../lib/styles/palette';
const SubInfoBlock = styled.div`
${(props) =>
props.hasMarginTop &&
css`
margin-top: 1rem;
`}
color: ${palette.gray[6]};
/* span 사이에 가운뎃점 문자 보여주기 */
span + span:before {
color: ${palette.gray[4]};
padding-left: 0.25rem;
padding-right: 0.25rem;
content: '\\B7';
}
`;
const SubInfo = ({ username, publishedDate, hasMarginTop }) => {
return (
<SubInfoBlock hasMarginTop={hasMarginTop}>
<span>
<b>
<Link to={`/@${username}`}>{username}</Link>
</b>
</span>
<span>{new Date(publishedDate).toLocaleDateString()}</span>
</SubInfoBlock>
);
};
export default SubInfo;
src/components/common/Tags.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import { Link } from 'react-router-dom';
const TagsBlock = styled.div`
margin-top: 0.5rem;
.tag {
display: inline-block;
color: ${palette.cyan[7]};
text-decoration: none;
margin-right: 0.5rem;
&:hover {
color: ${palette.cyan[6]};
}
}
`;
const Tags = ({ tags }) => {
return (
<TagsBlock>
{tags.map((tag) => (
<Link className="tag" to={`/?tag=${tag}`} key={tag}>
#{tag}
</Link>
))}
</TagsBlock>
);
};
export default Tags;
이 PostView에서 사용하는 컴포넌트가 겹치므로 이들을 따로 빼네서 Tags로 SubInfo로 빼내서 재사용하였습니다.
또한 PostView도 재사용 하도록 하겠습니다.
src/components/post/PostView.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import Responsive from '../common/Responsive';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
const PostViewBlock = styled(Responsive)`
margin-top: 4rem;
`;
const PostHead = styled.div`
border-bottom: 1px solid ${palette.gray[2]};
padding-bottom: 3rem;
margin-bottom: 3rem;
h1 {
font-size: 3rem;
line-height: 1.5;
margin: 0;
}
`;
const PostContent = styled.div`
font-size: 1.3125rem;
color: ${palette.gray[8]};
`;
const PostView = ({ post, loading, error }) => {
// 에러 발생 시
if (error) {
if (error.response && error.response.status === 409) {
return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>;
}
return <PostViewBlock>오류 발생!</PostViewBlock>;
}
// 로딩 중이거나 아직 포스트 데이터가 없을 때
if (loading || !post) {
return null;
}
const { title, body, user, publishedDate, tags } = post;
return (
<PostViewBlock>
<PostHead>
<h1>{title}</h1>
<SubInfo
username={user.username}
publishedDate={publishedDate}
hasMarginTop
/>
<Tags tags={tags} />
</PostHead>
<PostContent dangerouslySetInnerHTML={{ __html: body }} />
</PostViewBlock>
);
};
export default PostView;
이렇게 여러 곳에서 재사용할 수 있는 컴포넌트는 이렇게 따로 분리하여 사용하면, 코드의 양도 줄일 수 있을 뿐만 아니라 유지 보수성도 높일 수 있어서 좋습니다.
포스트 목록 조회 API 연동하기
postList컴포넌트에서 실제 데이터를 보여 줄 수 있도록 APi를 연동해야 합니다. 우리가 사용할 list API는 username, page, tag값을 쿼리 값으로 넣어서 사용합니다.
import client from './client';
import qs from 'qs';
export const writePost = ({ title, body, tags }) =>
client.post('/api/posts', { title, body, tags });
export const readPost = (id) => client.get(`/api/posts/${id}`);
export const listPosts = ({ page, username, tags }) => {
const queryString = qs.stringify({
page,
username,
tags,
});
return client.get(`/api/posts?${queryString}`);
};
Koa Server
src/api/posts/post.ctrl.js - write
/**
* 포스트 목록 조회
* GET /api/posts?username=&tag=&page=
*/
export const list = async (ctx) => {
const removeHtmlAndShorten = (body) => {
const filtered = sanitizeHtml(body, sanitizeOption);
return filtered.length < 200 ? filtered : `${filtered.slice(0, 200)}...`;
};
const page = parseInt(ctx.query.page || '1', 10);
if (page < 1) {
ctx.status = 400;
return;
}
const { tag, username } = ctx.query;
// tag, username값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음
const query = {
...(username ? { 'user.username': username } : {}),
...(tag ? { tags: tag } : {}),
};
console.log('query >> ', query);
try {
const posts = await Post.find(query)
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.lean()
.exec();
console.log('posts >> ', posts);
const postCount = await Post.countDocuments(query).exec();
ctx.set('Last-page', Math.ceil(postCount / 10));
ctx.body = posts.map((post) => ({
...post,
body: removeHtmlAndShorten(post.body),
}));
} catch (e) {
ctx.throw(500, e);
}
};
API의 형식은 그냥 query로 tag와 username을 넣으면 이에 해당하는 post를 찾아주는 API입니다.
이제 API를 처리해 줄 리덕스를 만들겠습니다.
src/modules/posts.js
import { createAction, handleActions } from 'redux-actions';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/api/createRequestSaga';
import * as postAPI from '../lib/api/posts';
import { takeLatest } from 'redux-saga/effects';
const [LIST_POSTS, LIST_POSTS_SUCCESS, LIST_POSTS_FAILURE] =
createRequestActionTypes('posts/LIST_POSTS');
export const listPosts = createAction(
LIST_POSTS,
({ tag, username, page }) => ({
tag,
username,
page,
}),
);
const listPostsSaga = createRequestSaga(LIST_POSTS, postAPI.listPosts);
export function* postsSaga() {
yield takeLatest(LIST_POSTS, listPostsSaga);
}
const initialState = {
posts: null,
error: null,
};
const posts = handleActions(
{
[LIST_POSTS_SUCCESS]: (state, { payload: posts }) => ({
...state,
posts,
}),
[LIST_POSTS_FAILURE]: (state, { payload: error }) => ({
...state,
error,
}),
},
initialState,
);
export default posts;
src/modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
import write, { writeSaga } from './write';
import post, { postSaga } from './post';
import posts, { postsSaga } from './posts';
const rootReducer = combineReducers({
auth,
loading,
user,
write,
post,
posts,
});
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga(), postSaga(), postsSaga()]);
}
export default rootReducer;
src/containers/posts/PostListContainer.js
import React, { useEffect } from 'react';
import qs from 'qs';
import { useDispatch, useSelector } from 'react-redux';
import PostList from '../../components/posts/PostList';
import { listPosts } from '../../modules/posts';
import { useParams, useLocation } from 'react-router-dom';
const PostListContainer = () => {
const dispatch = useDispatch();
const location = useLocation();
const { username } = useParams();
const { posts, error, loading, user } = useSelector(
({ posts, loading, user }) => ({
posts: posts.posts,
error: posts.error,
loading: loading['posts/LIST_POSTS'],
user: user.user,
}),
);
useEffect(() => {
const { tag, page } = qs.parse(location.search, {
ignoreQueryPrefix: true,
});
dispatch(listPosts({ tag, username, page }));
}, [dispatch, location.search, username]);
return (
<PostList
loading={loading}
error={error}
posts={posts}
showWriteButton={user}
/>
);
};
export default PostListContainer;
postList컴포넌트를 사용할 떄 showWriteButton props를 현재 로그인 중엔 사용자의 정보를 지니고 있는 user객체로 설정해 주었습니다. 이렇게 하면 user객체가 유효할 때, 즉 사용자가 로그인 중일때만 포스트를 작성하는 버튼을 나타나게 할 수 있습니다.
또한 parameter로 전달된 username을 받아오고 쿼리스트링으로 전달된 tag와 page를 받아옵니다. 그리고 이 정보를 바탕으로 listPosts 액션 함수를 dispatch해 주어서 정보를 받아옵니다.
src/components/posts/PostList.js
import React from 'react';
import styled from 'styled-components';
import Responsive from '../common/Responsive';
import Button from '../common/Button';
import palette from '../../lib/styles/palette';
import SubInfo from '../common/SubInfo';
import Tags from '../common/Tags';
import { Link } from 'react-router-dom';
const PostListBlock = styled(Responsive)`
margin-top: 3rem;
`;
const WritePostButtonWrapper = styled.div`
display: flex;
justify-content: flex-end;
margin-bottom: 3rem;
`;
const PostItemBlock = styled.div`
padding-top: 3rem;
padding-bottom: 3rem;
/* 맨 위 포스트는 padding-top없음 */
&:first-child {
padding-top: 0;
}
& + & {
border-top: 1px solid ${palette.gray[2]};
}
h2 {
font-size: 2rem;
margin-bottom: 0;
margin-top: 0;
&:hover {
color: ${palette.gray[6]};
}
}
p {
margin-top: 2rem;
}
a {
text-decoration: none;
color: black;
}
`;
const PostItem = ({ post }) => {
const { publishedDate, user, tags, title, body, _id } = post;
return (
<PostItemBlock>
<h2>
<Link to={`/@${user.username}/${_id}`}>{title}</Link>
</h2>
<SubInfo
username={user.username}
publishedDate={new Date(publishedDate)}
/>
<Tags tags={tags} />
<p>{body}</p>
</PostItemBlock>
);
};
const PostList = ({ posts, loading, error, showWriteButton }) => {
// 에러 발생 시
if (error) {
return <PostListBlock>에러가 발생하였습니다.</PostListBlock>;
}
return (
<PostListBlock>
<WritePostButtonWrapper>
{showWriteButton && (
<Button cyan to="/write">
새 글 작성하기
</Button>
)}
</WritePostButtonWrapper>
{/* 로딩 중이 아니고, 포스트 배열이 존재할 때만 보여 줌 */}
<div>
{!loading &&
posts &&
posts.map((post) => <PostItem post={post} key={post._id} />)}
</div>
</PostListBlock>
);
};
export default PostList;
또한 해당 제목을 클릭하게 되면 해당 포스트를 보여줄 수 있는 PostView 컴포넌트로 연결되게 Link를 사용하였습니다. 그리고 PostItem에서는 전달된 posts들의 정보를 바탕으로 화면에 적절히 렌더링 시켜주는 작업을 진행하였습니다. 또한 error객체가 유효하면 오류가 있다고 알려주고 showWriteButton에 user객체를 넣어주었는데 이가 유효하면 새 글 작성하기 버튼을 보여주게끔 처리해 주었습니다.
HTML 필터링하기
sanitize-html을 활용하여 HTML을 필터링 해 보겠습니다. 단순히 HTML을 제거하는 기능뿐만 아니라 특정 HTML만을 허용하는 기능도 있기 때문에 글쓰기 API에서 사용하면 손쉽게 악성 스크립트 삽입을 막을 수 있습니다.
백엔드 서버 Koa서버를 수정합니다.
src/api/posts/posts.ctrl.js - list & update & write
import Post from '../../models/post';
import mongoose from 'mongoose';
import Joi from 'joi';
import sanitizeHtml from 'sanitize-html';
const { ObjectId } = mongoose.Types;
const sanitizeOption = {
allowTags: [
'h1',
'h2',
'b',
'i',
'u',
's',
'p',
'ul',
'ol',
'li',
'blockquote',
'a',
'img',
],
allowedAttributes: {
a: ['href', 'name', 'target'],
img: ['src'],
li: ['class'],
},
allowedSchemes: ['data', 'http'],
};
export const getPostById = async (ctx, next) => {
const { id } = ctx.params;
if (!ObjectId.isValid(id)) {
ctx.status = 400;
return;
}
try {
const post = await Post.findById(id);
// 포스트가 존재하지 않을 때
if (!post) {
ctx.status = 404;
return;
}
ctx.state.post = post;
} catch (e) {
ctx.throw(500, e);
}
return next();
};
export const checkOwnPost = (ctx, next) => {
const { user, post } = ctx.state;
if (post.user._id.toString() !== user._id) {
ctx.status = 403;
return;
}
return next();
};
/**
* 포스트 작성
* POST /api/posts
* { title, body }
*/
export const write = async (ctx) => {
// REST API의 Request Body는 ctx.request.body에서 조회할 수 있습니다.
const schema = Joi.object().keys({
// 객체가 다음 필드를 가지고 있음을 검증
title: Joi.string().required(),
body: Joi.string().required(),
tags: Joi.array().items(Joi.string()).required(),
});
const result = schema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
const { title, body, tags } = ctx.request.body;
const post = new Post({
title,
body: sanitizeHtml(body, sanitizeHtml),
tags,
user: ctx.state.user,
});
try {
await post.save();
ctx.body = post;
} catch (e) {
ctx.throw(500, e);
}
};
/**
* 포스트 목록 조회
* GET /api/posts?username=&tag=&page=
*/
export const list = async (ctx) => {
const removeHtmlAndShorten = (body) => {
const filtered = sanitizeHtml(body, sanitizeOption);
return filtered.length < 200 ? filtered : `${filtered.slice(0, 200)}...`;
};
const page = parseInt(ctx.query.page || '1', 10);
if (page < 1) {
ctx.status = 400;
return;
}
const { tag, username } = ctx.query;
// tag, username값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음
const query = {
...(username ? { 'user.username': username } : {}),
...(tag ? { tags: tag } : {}),
};
console.log('query >> ', query);
try {
const posts = await Post.find(query)
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.lean()
.exec();
console.log('posts >> ', posts);
const postCount = await Post.countDocuments(query).exec();
ctx.set('Last-page', Math.ceil(postCount / 10));
ctx.body = posts.map((post) => ({
...post,
body: removeHtmlAndShorten(post.body),
}));
} catch (e) {
ctx.throw(500, e);
}
};
/**
* 특정 포스트 조회
* GET /api/posts/:id
*/
export const read = async (ctx) => {
ctx.body = ctx.state.post;
};
/**
* 특정 포스트 제거
* DELETE /api/posts/:id
*/
export const remove = async (ctx) => {
const { id } = ctx.params;
console.log(id);
try {
await Post.findByIdAndRemove(id).exec();
ctx.status = 204;
} catch (e) {
ctx.throw(500, e);
}
};
/**
* 포스트 수정(특정 필드 변경)
* PATCH /api/posts/:id
* { title, body }
*/
export const update = async (ctx) => {
// PATCH 메서드는 주어진 필드만 교체합니다.
const { id } = ctx.params;
// write에서 사용한 schema와 비슷한데, required()가 없습니다.
const schema = Joi.object().keys({
title: Joi.string(),
body: Joi.string(),
tags: Joi.array().items(Joi.string()),
});
const result = schema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
const nextData = { ...ctx.request.body };
if (nextData.body) {
nextData.body = sanitizeHtml(nextData.body, sanitizeOption);
}
try {
const post = await Post.findByIdAndUpdate(id, nextData, {
new: true,
}).exec();
if (!post) {
ctx.status = 404;
return;
}
ctx.body = post;
} catch (e) {
ctx.throw(500, e);
}
};
sanitizeOption을 작성해서 write함, list함수, update함수를 수정해 주었습니다.
이를 기반으로 postman에서 로그인하고 write API를 호출해서 악성 스크립트를 body에 적었을 때 잘 걸러지는지 확인해 보겠습니다.
이처럼 body에 <script>태그가 없어진 것을 보아 정상 작동하는 것을 확인할 수 있습니다.
페이지네이션 구현하기
list API를 만들 때 마지막 페이지 번호를 HTTP헤더를 통해 클라이언트에 전달하도록 설정했습니다. 그러나 요청을 관리하는 사가를 쉽게 만들기 위해 작성한 createRequestSaga에서는 SUCCESS액션을 발생시킬 때 payload에 response.data값만 넣어주기 때문에 현재 구조로는 헤더를 확인할 수 없습니다.
src/lib/api/createRequestSaga.js
import { call, put } from 'redux-saga/effects';
import { startLoading, finishLoading } from '../../modules/loading';
export const createRequestActionTypes = (type) => {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return [type, SUCCESS, FAILURE];
};
export default function createRequestSaga(type, request) {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return function* ({ payload }) {
yield put(startLoading(type));
try {
const response = yield call(request, payload);
yield put({
type: SUCCESS,
payload: response.data,
meta: response,
});
} catch (e) {
yield put({
type: FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(type));
};
}
이렇게 액션 값으로 meta값을 response로 넣어주게 되면 나중에 HTTP헤더 및 상태 코드를 쉽게 조회할 수 있습니다.
src/modules/posts.js
import { call, put } from 'redux-saga/effects';
import { startLoading, finishLoading } from '../../modules/loading';
export const createRequestActionTypes = (type) => {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return [type, SUCCESS, FAILURE];
};
export default function createRequestSaga(type, request) {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return function* ({ payload }) {
yield put(startLoading(type));
try {
const response = yield call(request, payload);
yield put({
type: SUCCESS,
payload: response.data,
meta: response,
});
} catch (e) {
yield put({
type: FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(type));
};
}
이렇게 하여 스토어 안에 마지막 페이지 번호를 lastPage라는 값으로 담아 둘 수 있게 되었습니다. 다음으로는 페이지네이션을 위한 Pagination.js와 PaginationContainer.js를 만들어보도록 하겠습니다.
src/components/posts/Pagination.js
import React from 'react';
import styled from 'styled-components';
import qs from 'qs';
import Button from '../common/Button';
const PaginationBlock = styled.div`
width: 320px;
margin: 0 auto;
display: flex;
justify-content: space-between;
margin-bottom: 3rem;
`;
const PageNumber = styled.div``;
const buildLink = ({ username, tag, page }) => {
const query = qs.stringify({ tag, page });
return username ? `/@${username}?${query}` : `/?${query}`;
};
const Pagination = ({ page, lastPage, username, tag }) => {
return (
<PaginationBlock>
<Button
disabled={page === 1}
to={
page === 1 ? undefined : buildLink({ username, tag, page: page - 1 })
}
>
이전
</Button>
<PageNumber>{page}</PageNumber>
<Button
disabled={page === lastPage}
to={
page === lastPage
? undefined
: buildLink({ username, tag, page: page + 1 })
}
>
다음
</Button>
</PaginationBlock>
);
};
export default Pagination;
src/components/common/Button.js
import React from 'react';
import styled, { css } from 'styled-components';
import palette from '../../lib/styles/palette';
import { Link } from 'react-router-dom';
const buttonStyle = css`
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: bold;
padding: 0.25rem 1rem;
color: white;
outline: none;
cursor: pointer;
background: ${palette.gray[8]};
&:hover {
background: ${palette.gray[6]};
}
${(props) =>
props.cyan &&
css`
background: ${palette.cyan[5]};
&:hover {
background: ${palette.cyan[4]};
}
`}
${(props) =>
props.fullWidth &&
css`
padding-top: 0.75rem;
padding-bottom: 0.75rem;
width: 100%;
font-size: 1.125rem;
`}
&:disabled {
background: ${palette.gray[3]};
color: ${palette.gray[5]};
cursor: not-allowed;
}
`;
const StyledButton = styled.button`
${buttonStyle}
`;
const StyledLink = styled(Link)`
${buttonStyle}
`;
const Button = (props) => {
return props.to ? (
<StyledLink {...props} cyan={props.cyan ? 1 : 0} />
) : (
<StyledButton {...props} />
);
};
export default Button;
비활성화 스타일은 :disable CSS셀렉터를 사용하여 적용할 수 있습니다.
src/containers/posts/PaginationContainer.js
import React from 'react';
import Pagination from '../../components/posts/Pagination';
import { useSelector } from 'react-redux';
import { useParams, useLocation } from 'react-router-dom';
import qs from 'qs';
const PaginationContainer = () => {
const { username } = useParams();
const location = useLocation();
const { lastPage, posts, loading } = useSelector(({ posts, loading }) => ({
lastPage: posts.lastPage,
posts: posts.posts,
loading: loading['posts/LIST_POSTS'],
}));
if (!posts || loading) return null;
// page가 만약에 안들어 온다면 어떻게하지 => defualt 1
// username, tag는 없어도 되는 선택적인 값인데 page는 필수이기 때문
const { tag, page = 1 } = qs.parse(location.search, {
ignoreQueryPrefix: true,
});
return (
<Pagination
page={parseInt(page, 10)}
lastPage={lastPage}
tag={tag}
username={username}
/>
);
};
export default PaginationContainer;
src/pages/PostListPage.js
import React from 'react';
import PostListContainer from '../containers/posts/PostListContainer';
import HeaderContainer from '../containers/common/HeaderContainer';
import PaginationContainer from '../containers/posts/PaginationContainer';
const PostListPage = () => {
return (
<div>
<HeaderContainer />
<PostListContainer />
<PaginationContainer />
</div>
);
};
export default PostListPage;
첫 번쨰 페이지일 때는 이전 버튼이 비활성화되고, 마지막 페이지일 때는 다음 버튼이 비활성화됩니다. 계정명이나 태그를 클릭해서 확인해도 페이지네이션 기능이 정확하게 작동하는 것을 확인할 수 있습니다.
'React > ReactJs' 카테고리의 다른 글
React - FrontEnd Project - 6 (0) | 2022.03.22 |
---|---|
React - FrontEnd Project - 4 (0) | 2022.03.20 |
React - React Router v6 (0) | 2022.03.18 |
React - FrontEnd Project - 3 (0) | 2022.03.17 |
React - FrontEnd project - 2 (0) | 2022.03.15 |