다음의 그림을 바탕으로 글쓰기를 만들어보도록 하겠습니다.
게시글 쓰기는 postType에 따라 게시글의 상태가 변화합니다. 따라서 리덕스를 적용하면서 postType의 state에 따라 게시글이 변화할 수 있도록 모듈을 먼저 만들도록 하겠습니다.
우선 api요청을 만들어보도록 하겠습니다.
import client from './client';
export const write = ({
userId,
postType,
category,
title,
content,
rentalPrice,
date,
writer,
images
}) => {
if(postType === '빌려주세요') {
const formData = new FormData();
formData.append('userId', userId);
formData.append('postType', postType);
formData.append('title', title);
formData.append('content', content);
formData.append('writer', writer);
client.post('/post-service/write', formData);
} else {
const formData = new FormData();
formData.append('userId', userId);
formData.append('category', category);
formData.append('images', images);
formData.append('type', type);
formData.append('title', title);
formData.append('content', content);
formData.append('rentalPrice', rentalPrice);
formData.append('date', date);
formData.append('writer', writer);
images.forEach((image) => formData.append("images", image));
client.post('/post-service/write', formData);
}
}
export const readAllPosts = () => client.get('/post-service');
export const readPostsByStatus = status => client.get(`/post-service/posts/status/${status}`)
export const readPostById = id => client.get(`/post-service/post/${id}`);
export const readPostsByUserId = userId => client.get(`/post-service/${userId}/posts`);
export const readPostsByCategory = category => client.get(`/post-service/category/${category}`);
export const readPostsByKeyword = keyword => client.get(`/post-service/keyword/${keyword}`);
export const deletePost = id => client.get(`/post-service/${id}/delete`);
export const writeComment = ({ id, comment }) => client.post(`/post-service/${id}/comments`, comment);
export const deleteComment = id => client.delete(`/post-service/${id}/comments`);
1) write - POST /post-service/write : 글을 저장하는 요청입니다. type에 따라 전송할 데이터를 달리하도록 만들었습니다.
2) readAllPosts - GET /post-service/ : 전체 게시글 리스트를 불러오는 메서드입니다. PostPage의 분류 탭에서 전체를 선택하면 게시글의 COMPLETE_RENTAL상태와 DELETE_POST상태를 제외하고 데이터를 불러오는 요청입니다.
3) readPostsByStatus - GET /post-service/posts/status/${status} : 게시글의 상태에 따라 게시글을 불러오는 메서드입니다. 앞서 post-service를 작성할 때 상태값을 설정했었습니다. 이를 이용하여 분류 탭에서 대여 가능을 누르면 READY_RENTAL, 대여 불가를 누르면 COMPLETE_RENTAL, 요청을 누르면 REQUEST_RENTAL, 2)의 데이터를 불러오는 요청입니다.
4) readPostById - GET /post-service/post/${id} : 한 개의 게시글에 대한 상세 정보 데이터를 불러오는 요청입니다.
5) readPostsByUserId - GET /post-service/${userId}/posts : 유저가 작성한 게시글들을 불러오는 요청입니다.
6) readPostsByCategory - GET /post-service/category/${category} : 홈 화면에서 카테고리별로 데이터를 볼 수 있게 게시글들을 불러오는 요청입니다.
7) readPostsByKeyword - GET /post-service/keyword/${keyword} : 검색 창에서의 키워드를 바탕으로 게시글들을 불러오는 요청입니다.
8) deletePost - POST /post-service/${id}/delete : 게시글을 삭제하는 요청입니다. 실제 삭제를 하지는 않고 status의 값을 DELETE_POST로 변경합니다.
9) writeComment - POST /post-service/${id}/comments : 게시글에 대한 댓글을 작성하는 요청입니다.
10) deleteComment - DELETE /post-service/${id}/comments : 댓글을 삭제하는 요청입니다.
api 메서드를 작성했으니 이에 대한 리덕스 모듈, 액션 함수를 만들도록 하겠습니다.
이전에 작성했었던 register모듈과 비슷한 구조입니다.
import { createAction, handleActions } from "redux-actions";
import createRequestSaga, {
createRequestActionTypes
} from "../lib/createRequestSaga";
import * as postsAPI from '../lib/api/posts';
import { takeLatest } from "redux-saga/effects";
const INITIALIZE = 'write/INITIALIZE';
const CHANGE_FIELD = 'write/CHANGE_FIELD';
const [
WRITE_POST,
WRITE_POST_SUCCESS,
WRITE_POST_FAILURE,
] = createRequestActionTypes('write/WRITE_POST');
export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
key,
value
}));
export const writePost = createAction(WRITE_POST, ({
userId,
postType,
category,
rentalPrice,
title,
content,
date,
writer,
images
}) => ({
userId,
postType,
category,
rentalPrice,
title,
content,
date,
writer,
images
}));
const writePostSaga = createRequestSaga(WRITE_POST, postsAPI.write);
export function* writeSaga() {
yield takeLatest(WRITE_POST, writePostSaga);
}
const initialState = {
userId: '',
postType: '',
category: '',
rentalPrice: null,
title: '',
content: '',
date: null,
writer: '',
images: null,
post: null,
postError: null,
};
const write = handleActions(
{
[INITIALIZE]: state => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
...state,
[key]: value,
}),
[WRITE_POST]: state => ({
...state,
post: null,
postError: null,
}),
[WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
},
initialState,
);
export default write;
1) 액션 타입 설정
INITIALIZE = 'write/INITIALIZE'
: 게시글의 상태값을 초기화하도록 하는 액션타입입니다.
CHANGE_FIELD = 'write/CHANGE_FIELD'
: 각 input의 변화되는 값을 저장하도록 하는 액션타입입니다.
[WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE] = createRequestActionTypes('write/WRITE_POST')
: 게시글 저장을 위한 액션타입입니다.
2) 액션 생성
initialize = createAction(INITIALIZE)
: initialize라는 변수명으로 액션을 생성하고, 해당 액션을 사용하면 write의 state값이 초기화됩니다.
changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({ key, value }))
: changeField라는 변수명으로 액션을 생성하고 write의 변수들에 맞는 key, value값을 인자로 받아 저장하도록 합니다.
writePost = createAction(WRITE_POST, ({ userId, postType, category, rentalPrice, title, content, date, writer, images }) => ({ userId, postType, category, rentalPrice, title, content, date, writer, images }))
: writePost라는 이름으로 액션을 만들고, 게시글 저장에 필요한 값을 받아
저장합니다.
3) 사가 생성
const writePostSaga = createRequestSaga(WRITE_POST, postsAPI.write);
: 액션 타입과 api 요청을 담아 사가를 생성합니다.
export function* writeSaga() { yield takeLatest(WRITE_POST, writePostSaga) }
: 위의 사가를 제너레이트 함수 writeSaga에 담아 루트 사가에 담습니다.
4) 페이로드 값 저장
{
[INITIALIZE]: state => initialState,
[CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
...state,
[key]: value,
}),
[WRITE_POST]: state => ({
...state,
post: null,
postError: null,
}),
[WRITE_POST_SUCCESS]: (state, { payload: post }) => ({
...state,
post,
}),
[WRITE_POST_FAILURE]: (state, { payload: postError }) => ({
...state,
postError,
}),
},
initialState,
);
INITIALIZE, CHANGE_FIELD, WRITE_POST, WRITE_POST_SUCCESS, WRITE_POST_FAILURE에 대한 요청을 수행하고 나서의 페이로드 값을 state에 담는 메서드입니다.
import { combineReducers } from "redux";
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import user, { userSaga } from "./user";
import write, { writeSaga } from "./write";
import loading from './loading';
const rootReducer = combineReducers(
{
loading,
auth,
user,
write,
},
);
export function* rootSaga() {
yield all([authSaga(), userSaga(), writeSaga()]);
}
export default rootReducer;
루트 리듀서에 앞서 작성한 write, writeSaga를 담습니다.
그럼 이제 이 리덕스와 액션들을 이용하기 위한 페이지를 만들어보도록 하겠습니다.
다음의 명령어로 패키지들을 설치하겠습니다.
npm install react-images-upload react-google-flight-datepicker react-select
import React from 'react';
import HeaderTemplate from '../components/common/HeaderTemplate';
const WritePage = () => {
return(
<>
<HeaderTemplate />
</>
);
};
export default WritePage;
import React from 'react';
import { Route } from 'react-router-dom';
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import MyPage from './pages/MyPage';
import PostPage from './pages/PostPage';
import RegisterPage from './pages/RegisterPage';
import WritePage from './pages/WritePage';
const App = () => {
return(
<>
<Route
component={ HomePage }
path="/"
exact
/>
<Route
component={ MyPage }
path="/user/my-account"
exact
/>
<Route
component={ LoginPage }
path="/auth/login"
exact
/>
<Route
component={ RegisterPage }
path="/auth/register"
exact
/>
<Route
component={ PostPage }
path="/posts"
exact
/>
<Route
component={ WritePage }
path="/posts/write"
exact
/>
</>
);
};
export default App;
우선 헤더 컴포넌트를 WritePage에 넣고 페이지 라우팅이 잘 되는지 확인을 하겠습니다.
라우팅이 잘 되는 것을 확인했으니 글쓰기 폼을 만들어보도록 하겠습니다.
import React from 'react';
import styled from 'styled-components';
const WriteTemplateBlock = styled.div`
width: 100%;
display: flex;
flex-direction: column;
align-items: c e n t e r;
padding-top: 180px;
`;
const WriteTemplate = ({ children }) => {
return(
<>
<WriteTemplateBlock>
{ children }
</WriteTemplateBlock>
</>
);
};
export default WriteTemplate;
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
const WriteHeaderBlock = styled.div`
color: ${ palette.blue[2] };
width: 60%;
font-size: 25px;
font-weight: bold;
text-align: left;
`;
const WriteHeaderTemplate = () => {
return(
<WriteHeaderBlock>
오늘은 어떤 물건들을<br/>
빌려주실 건가요?
</WriteHeaderBlock>
);
};
export default WriteHeaderTemplate;
import React, { useState } from "react";
import Responsive from "../common/Responsive";
import styled from "styled-components";
import palette from "../../lib/styles/palettes";
import RadioForm from "../common/RadioForm";
import RadioItem from "../common/RadioItem";
import ImageUploader from 'react-images-upload'
import Select from 'react-select';
import Input from "../common/Input";
import { RangeDatePicker } from 'react-google-flight-datepicker';
import 'react-google-flight-datepicker/dist/main.css';
import WriteButtonContainer from "./WriteButtonContainer";
const WriteFormBlock = styled(Responsive)`
padding-top: 5rem;
padding-bottom: 5rem;
`;
const PostTypeArea = styled.div`
width: 200px;
margin-top: 20px;
margin-bottom: 2rem;
`;
const TitleInput = styled.input`
width: 100%;
font-size: 1rem;
padding-bottom: 0.5rem;
border: none;
border-bottom: 1px solid ${ palette.blue[2] };
margin-top: 1rem;
margin-bottom: 2rem;
outline: none;
`;
const ContentInput = styled.textarea`
width: 100%;
height: 400px;
font-size: 1rem;
border: none;
border-bottom: 1px solid ${ palette.blue[2] };
margin-top: 1rem;
margin-bottom: 1rem;
outline: none;
resize: none;
`;
const ErrorMessage = styled.div`
color: red;
text-align: c e n t e r;
font-size: 14px;
margin-top: 1rem;
`;
const WriteForm = ({
onChangeField,
onDrop,
onUpdate,
onSelect,
options,
option,
type
}) => {
const [error, setError] = useState('');
return(
<>
{
type === '빌려주세요' ?
<>
<WriteFormBlock>
<form>
<PostTypeArea>
<RadioForm>
<RadioItem
id="postBorrow"
name="postType"
value="빌려주세요"
for="postBorrow"
onChange={ onChangeField }
/>
<RadioItem
id="postRental"
name="postType"
value="빌려줄게요"
for="postRental"
onChange={ onChangeField }
/>
</RadioForm>
</PostTypeArea>
<TitleInput
autoComplete="title"
name="title"
placeholder="게시글 제목을 작성해주세요"
onChange={ onChangeField }
/>
<ContentInput
autoComplete="content"
name="content"
placeholder="게시글 내용을 작성해주세요"
onChange={ onChangeField }
/>
</form>
{ error && <ErrorMessage>{ error }</ErrorMessage> }
</WriteFormBlock>
<WriteButtonContainer
error={ error }
setError={ setError }
/>
</> :
<>
<WriteFormBlock>
<form encType="multipart/form-data">
<PostTypeArea>
<RadioForm>
<RadioItem
id="postBorrow"
name="postType"
value="빌려주세요"
for="postBorrow"
onChange={ onChangeField }
/>
<RadioItem
id="postRental"
name="postType"
value="빌려줄게요"
for="postRental"
onChange={ onChangeField }
/>
</RadioForm>
</PostTypeArea>
<Select
onChange={ onSelect }
options={ options }
value={ option }
placeholder="카테고리를 정해주세요"
/>
<TitleInput
autoComplete="title"
name="title"
placeholder="게시글 제목을 작성해주세요"
onChange={ onChangeField }
/>
<ContentInput
autoComplete="content"
name="content"
placeholder="게시글 내용을 작성해주세요"
onChange={ onChangeField }
/>
<Input
autoComplete="rentalPrice"
name="rentalPrice"
placeholder="가격을 입력해주세요"
onChange={ onChangeField }
/>
<RangeDatePicker
onChange={ onUpdate }
startDatePlaceholder="시작 날짜"
endDatePlaceholder="종료 날짜"
disabled={false}
/>
<ImageUploader
withIcon={ true }
buttonText='이미지를 선택해주세요'
onChange={ onDrop }
name="images"
imgExtension={['.jpg', '.gif', '.png', '.gif']}
maxFileSize={ 5242880 }
withPreview={ true }
/>
</form>
{ error && <ErrorMessage>{ error }</ErrorMessage> }
</WriteFormBlock>
<WriteButtonContainer
error={ error }
setError={ setError }
/>
</>
}
</>
);
};
export default WriteForm;
WriteForm을 작성하면서 2가지의 경우에 따라 폼이 달라지도록 만들었습니다. 바로 type의 상태 값에 따라 바뀌는데요, 빌려주세요의 경우 상품에 대한 대여를 요청하는 케이스이므로 title, content를 위한 input만을 사용했고, 빌려줄게요의 경우 상품을 대여해주겠다는 케이스이므로 이미지, 날짜, 카테고리 등을 위한 input을 추가했습니다.
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField } from '../../modules/write';
import WriteForm from './WriteForm';
const WriteContainer = () => {
const dispatch = useDispatch();
const { user } = useSelector(({ user }) => ({
user: user.user,
}));
const { postType } = useSelector(({ write }) => ({
postType: write.postType,
}));
const onDrop = (pictures, urls) => {
dispatch(changeField({
key: "images",
value: pictures
}));
};
const onChangeField = e => {
const { value, name } = e.target;
dispatch(changeField({
key: name,
value
}));
};
const onUpdate = (startDate, endDate) => {
var start = splitString(startDate);
var end = splitString(endDate);
dispatch(changeField({
key: "date",
value: [start, end],
}));
};
const onSelect = (value) => {
dispatch(changeField({
key: "category",
value: value.value
}))
setOption(value);
};
const [option, setOption] = useState('');
const options = [
{ value: '가전제품', label: '가전제품' },
{ value: '도서류', label: '도서류' },
{ value: '의류', label: '의류' },
{ value: '캠핑용품', label: '캠핑용품' },
];
useEffect(() => {
if(user) {
dispatch(changeField({
key: "writer",
value: user.nickname,
}));
}
}, [dispatch, user]);
useEffect(() => {
if(user) {
dispatch(changeField({
key: "userId",
value: user.userId
}));
}
}, [dispatch, user]);
function splitString(str) {
var _arr = `${ str }`.split(' ');
return _arr[0] + ' ' + _arr[1] + ' ' + _arr[2] + ' ' + _arr[3];
}
return (
<WriteForm
onChangeField={ onChangeField }
onDrop={ onDrop }
onUpdate={ onUpdate }
onSelect={ onSelect }
options={ options }
option={ option }
postType={ postType }
/>
);
};
export default WriteContainer;
WriteForm을 감싸는 컴포넌트입니다. 이 컴포넌트에서 WriteForm의 input에 필요한 change 메서드들을 만들고 인자값으로 전달해줍니다. 카테고리의 경우 일단 임시로 총 4가지의 카테고리만 만들었고, 추후에 추가적으로 업데이트 하도록 하겠습니다.
import React from "react";
import styled from "styled-components";
import FullButton from "../../common/FullButton";
const WriteButtonBlock = styled.div`
display: flex;
justify-content: c e n t e r;
align-items: c e n t e r;
width: 600px;
margin-bottom: 100px;
`;
const CustomFullButton = styled(FullButton)`
margin-right: 25px;
margin-left: 25px;
width: 200px;
&:hover {
margin-right: 25px;
margin-left: 25px;
width: 200px;
}
`;
const WriteButton = ({ onCancel, onPublish }) => {
return(
<>
<WriteButtonBlock>
<CustomFullButton onClick={ onPublish }>
등록하기
</CustomFullButton>
<CustomFullButton red onClick={ onCancel }>
취소
</CustomFullButton>
</WriteButtonBlock>
</>
);
};
export default WriteButton;
글쓰기, 취소를 위한 버튼입니다. 등록하기를 누르면 앞서 작성했던 모듈의 액션함수가 호출되고, 취소를 누르면 이전 페이지로 돌아갑니다.
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router';
import { initialize, writePost } from '../../modules/write';
import WriteButton from './common/WriteButton';
const WriteButtonContainer = ({ history, error, setError }) => {
const dispatch = useDispatch();
const {
userId,
postType,
category,
rentalPrice,
title,
content,
date,
writer,
images,
post,
postError,
} = useSelector(({ write }) => ({
userId: write.userId,
postType: write.postType,
category: write.category,
rentalPrice: write.rentalPrice,
title: write.title,
content: write.content,
date: write.date,
writer: write.writer,
images: write.images,
post: write.post,
postError: write.postError,
}));
const onPublish = () => {
if(title === '') {
setError('제목을 입력해주세요');
return;
}
if(content === '') {
setError('내용을 적어주세요');
return;
}
if(category === '' && postType === '빌려줄게요') {
setError('카테고리를 지정해주세요');
return;
}
if(rentalPrice === null && postType === '빌려줄게요') {
setError('가격을 입력해주세요');
return;
}
if(category === '' && postType === '빌려줄게요') {
setError('카테고리를 지정해주세요');
return;
}
if(date === null && postType === '빌려줄게요') {
setError('날짜를 정해주세요');
return;
}
if(images === null && postType === '빌려줄게요') {
setError('이미지를 넣어주세요');
return;
}
if(postError) {
setError('에러 발생!');
return;
}
dispatch(writePost({
userId,
postType,
category,
rentalPrice,
title,
content,
date,
writer,
images,
}));
};
const onCancel = () => {
dispatch(initialize());
history.goBack();
};
useEffect(() => {
if(post) {
history.onPublish("/posts");
}
}, [history, post]);
return (
<WriteButton
onPublish={ onPublish }
onCancel={ onCancel }
/>
);
};
export default withRouter(WriteButtonContainer);
WriteForm에서 저장된 state값들을 이 버튼 컨테이너 컴포넌트에서 불러와 글쓰기를 위한 클릭 메서드에 사용됩니다. 그리고 history를 이용하여 취소 버튼을 클릭시 이전 페이지로 돌아갑니다.
import React from 'react';
import HeaderTemplate from '../components/common/HeaderTemplate';
import WriteHeaderTemplate from '../components/posts/WriteHeaderTemplate';
import WriteTemplate from '../components/posts/WriteTemplate';
import WriteContainer from '../components/posts/WriteContainer';
const WritePage = () => {
return(
<>
<HeaderTemplate />
<WriteTemplate>
<WriteHeaderTemplate />
<WriteContainer />
</WriteTemplate>
</>
);
};
export default WritePage;
최종적으로 작성된 컴포넌트를 WritePage에 불러오고 테스트를 진행하겠습니다.
1) 빌려주세요
2) 빌려줄게요
state가 잘 저장되는지도 확인하겠습니다.
1) 빌려주세요
2) 빌려줄게요
state값이 잘 저장됨을 볼 수 있습니다. 그러면 모듈부터 디자인까지 완료가 되었으므로 다음 포스트에서는 post-service를 수정하고 테스트를 진행하겠습니다.
리액트를 다루는 기술 - 저자 : 김민준