Rental Application (React & Spring boot Microservice) - 30 : 게시글(3)

yellow_note·2021년 9월 15일
0

#1 게시글 뷰어

게시글 상세 페이지를 만들어보도록 하겠습니다.


빌려주세요 타입과 빌려줄게요 타입에 관한 게시글 뷰어 두 가지 형태를 구현하겠습니다.

#2 게시글 리덕스 모듈

  • ./src/modules/post.js
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 [
    READ_POST,
    READ_POST_SUCCESS,
    READ_POST_FAILURE,
] = createRequestActionTypes('post/READ_POST');
const UNLOAD_POST = 'post/UNLOAD_POST';

export const readPost = createAction(READ_POST, postId => postId);
export const unloadPost = createAction(UNLOAD_POST);

const readPostSaga = createRequestSaga(READ_POST, postsAPI.readPostById);
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;

이전에 만들었던 posts 모듈과 비슷한 구조입니다. 다만 다른 액션이 있다면 unloadPost인데요, 이 액션 없이 페이지에서 나가게 되면 state값에 이전 게시글 상태가 존재하게 되므로 이를 방지하기 위해 state를 초기화해주는 액션입니다.

  • ./src/modules/index.js
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 post, { postSaga } from "./post";
import loading from './loading';

const rootReducer = combineReducers(
    {
        loading,
        auth,
        user,
        write,
        post,
    },
);

export function* rootSaga() {
    yield all([authSaga(), userSaga(), writeSaga(), postSaga()]);
}

export default rootReducer;

이렇게 리덕스 모듈, 사가를 만들었으니 이를 이용할 페이지를 구성해보도록 하겠습니다.

#3 게시글 페이지 만들기

다음의 라이브러리를 설치하도록 하겠습니다.

npm install react-responsive-carousel
  • ./src/pages/PostDetailPage.js
import React from 'react';
import HeaderTemplate from '../components/common/HeaderTemplate';

const PostDetailPage = () => {
    return(
        <>
            <HeaderTemplate />
        </>
    );
};

export default PostDetailPage;
  • ./src/App.js
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 PostDetailPage from './pages/PostDetailPage';
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
            />
            <Route 
                component={ PostDetailPage }
                path="/posts/post/:postId"
            />
        </>
    );
};

export default App;

위와 같이 postId를 경로에 넣어 해당 게시글로 이동할 수 있게 라우팅을 진행하겠습니다. post-service를 구동하지 않은 상태이지만 임의의 값을 넣어도 페이지 이동이 가능합니다.

게시글을 볼 수 있는 뷰어 컴포넌트를 만들도록 하겠습니다.

  • ./src/components/posts/PostViewer.js
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import FullButton from '../common/FullButton';
import Shortcut from '../common/Shortcut';
import paper_plane_outline from '../../static/img/paper-plane-outline.svg';
import CommentContainer from '../comment/CommentContainer';
import ImageSlider from '../common/ImageSlider';

const PostViewerBlock = styled.div`
    padding-top: 200px;
    margin-left: 10rem;
    margin-right: 10rem;
`;

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`
    width: 80%;
    margin-top: 1rem;
    color: ${ palette.gray[6] };
    span + span:before {
        color: ${ palette.gray[5] };
        padding-left: 0.25rem;
        padding-right: 0.25rem;
        content: '\\B7';
    }
`;

const MessageArea = styled.div`
    float: left;
    display: flex;
    margin-right: 50px;
    align-items: center;
`;

const RentalArea = styled.div`
    float: left;
    display: flex;
    align-items: center;
`;

const PostArticle = styled.div`
    display: flex;
    justify-content: center;
    align-item: center;
    width: 100%;
    border-bottom: 1px solid ${ palette.gray[2] };
`;

const PostContent = styled.div`
    font-size: 1.3125rem;
    color: ${ palette.gray[8] };
`;

const PostNav = styled.div`
    display: flex;
    justify-content: flex-end;
    width: 100%;
    border-bottom: 1px solid ${ palette.gray[2] };
`;

const RentalButton = styled(FullButton)`
    width: 100px;
    &:hover {
        width: 100px;
    }
`;

const StyledShorcut = styled(Shortcut)`
    color: ${palette.blue[4]};
`;
const PostViewer = ({
    post, 
    error, 
    loading
}) => {
    const dummyData = [
        { "images": "https://picsum.photos/id/0/1000/1000.jpg" },
        { "images": "https://picsum.photos/id/1/1000/1000.jpg" },
        { "images": "https://picsum.photos/id/2/1000/1000.jpg" },
        { "images": "https://picsum.photos/id/3/1000/1000.jpg" },
        { "images": "https://picsum.photos/id/4/1000/1000.jpg" },
    ]
    // if(error) {
    //     if(error.response && error.response.status === 404) {
    //         return <PostViewerBlock>존재하지 않는 포스트입니다.</PostViewerBlock>
    //     }

    //     return <PostViewerBlock>오류 발생!</PostViewerBlock>
    // }

    // if(loading || !post) {
    //     return null;
    // }

    return(
        <PostViewerBlock>
            <PostHead>
                <h1>title</h1>
                <SubInfo>
                    <span>
                        <b>writer</b>
                    </span>
                    <span>
                        {/* { post.createdAt } */}
                    </span>
                </SubInfo>
            </PostHead>
            <PostArticle>
                <ImageSlider Images={ dummyData }/>
            </PostArticle>
            <PostContent 
                // dangerouslySetInnerHTML={{ __html: post.content }}
            />
            {
                // post.type === '빌려줄게요' &&
                <PostNav>
                    <MessageArea>
                        <StyledShorcut
                            path='/messages'
                            src={ paper_plane_outline }
                        />
                    </MessageArea>
                    <RentalArea>
                        <Link to={{
                                pathname: '/rentals',
                                state: {
                                    post: post
                                }}
                            }
                        >
                            <RentalButton>
                                빌리기
                            </RentalButton>
                        </Link>
                    </RentalArea>
                </PostNav>
            }
            <CommentContainer />
        </PostViewerBlock>
    );
};

export default PostViewer;

이미지 슬라이더를 위한 더미 데이터를 만들었고 이 더미 데이터를 사용할 수 있도록 이미지 슬라이더를 만들고, 댓글창도 동시에 구현해보도록 하겠습니다.

  • ./src/components/common/ImageCard
import React from 'react';
import styled from 'styled-components';

const ImageBlock = styled.div`
    width: 100%;
    height: 60%;
`;

const Image = styled.img`
`;

const ImageCard = ({ image, i }) => {
    return(
        <ImageBlock>
            <Image 
                src={ image.images } 
                alt="legend"
            />
        </ImageBlock>
    );
};

export default ImageCard;
  • ./src/components/common/ImageSlider
import React from 'react';
import { Carousel } from 'react-responsive-carousel';
import "react-responsive-carousel/lib/styles/carousel.min.css";
import styled from 'styled-components';
import ImageCard from './ImageCard';

const SliderBlock = styled.div`
    width: 500px;
`;

const ImageSlider = ({ Images }) => {
    return(
        <SliderBlock>
            <Carousel showArrows={ true }>
                {
                    Images.map((image, i) => {
                        return  <ImageCard 
                                    image={ image }
                                    i={ i }
                                />
                    })
                }
            </Carousel>
        </SliderBlock>
    );
};

export default ImageSlider;

댓글 리덕스 모듈도 같이 만들어보도록 하겠습니다.

  • ./src/modules/writeComment.js
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 = 'comment/INITIALIZE';
const CHANGE_FIELD = 'comment/CHANGE_FIELD';
const [
    COMMENT,
    COMMENT_SUCCESS,
    COMMENT_FAILURE,
] = createRequestActionTypes('comment/WRITE_COMMENT');

export const initialize = createAction(INITIALIZE);
export const changeField = createAction(CHANGE_FIELD, ({ key, value }) => ({
    key,
    value
}));
export const write = createAction(COMMENT, ({
    comment,
    writer,
    postId
}) => ({
    comment,
    writer,
    postId
}));

const writeSaga = createRequestSaga(COMMENT, postsAPI.writeComment);
export function* writeCommentSaga() {
    yield takeLatest(COMMENT, writeSaga);
}

const initialState = {
    comment: '',
    writer: '',
    postId: '',
    success: '',
    failure: '',
};

const writeComment = handleActions(
    {
        [INITIALIZE]: state => initialState,
        [CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
            ...state,
            [key]: value,
        }),
        [COMMENT]: state => ({
            ...state,
            success: null,
            failure: null,
        }),
        [COMMENT_SUCCESS]: (state, { payload: success }) => ({
            ...state,
            success,
        }),
        [COMMENT_FAILURE]: (state, { payload: failure }) => ({
            ...state,
            failure,
        }),
    },
    initialState,
);

export default writeComment;

post-service에서 댓글 쓰기에 대한 반환 값을 댓글의 id값으로 했으므로 전체 상태를 담을 필요가 없어 payload값을 success, failure에 담도록 하겠습니다.

  • ./src/modules/index.js
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 post, { postSaga } from "./post";
import postList, { postListSaga } from "./postList";
import writeComment, { writeCommentSaga } from "./writeComment";
import loading from './loading';

const rootReducer = combineReducers(
    {
        loading,
        auth,
        user,
        write,
        post,
        postList,
        writeComment
    },
);

export function* rootSaga() {
    yield all([
        authSaga(), 
        userSaga(), 
        writeSaga(), 
        postSaga(), 
        postListSaga(),
        writeCommentSaga()
    ]);
}

export default rootReducer;
  • ./src/components/comment/WriteBar.js
import React from 'react';
import Input from '../common/Input';

const WriteBar = ({ onChange }) => {
    return <Input name="writeBar"
                  type="text"
                  placeholder="댓글을 입력해주세요"
                  onChange={ onChange }
           />;
};

export default WriteBar;
  • ./src/components/comment/WriteButton.js
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';
import writeComment from '../../modules/writeComment';

const ButtonBlock = styled.div`
    width: 60px;
    float: left;
`;

const Button = styled.button`
    width: 60px;
    height: 40px;
    border-radius: 4px;
    background-color: ${ palette.blue[1] };
    color: #ffffff;
    outline: none;
    border: none;
    &: hover {
        width: 60px;
        height: 40px;
        border-radius: 4px;
        background-color: ${ palette.blue[2] };
        color: #ffffff;
        outline: none;
        border: none;
    }
`;

const WriteButton = () => {
    const dispatch = useDispatch();
    const { 
        comment,
        writer,
        postId,
    } = useSelector(({ 
        writeComment,
        user,
        post,
    }) => ({ 
        comment: writeComment.comment,
        writer: user.nickname,
        postId: post.postId
    }));

    const onSubmit = e => {
        e.preventDefault();

        dispatch(writeComment({ postId, comment, writer }));
    };

    return (
        <ButtonBlock>
            <Button onClick={ onSubmit }>
                댓글 달기
            </Button>
        </ButtonBlock>
    );
};

export default WriteButton;
  • ./src/components/commment/WriteContainer.js
import React from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { changeField } from '../../modules/writeComment';
import WriteBar from './WriteBar';
import WriteButton from './WriteButton';

const WriteContainerBlock = styled.div`
    width: 100%;
    display: flex;
    align-items: center;
    margin-bottom: 1.5rem;
`;

const WriteContainer = () => {
    const dispatch = useDispatch();
    const onChange = e => {
        e.preventDefault();

        const { name, value } = e.target;

        dispatch(changeField({
            key: name,
            value
        }));
    };

    return (
        <WriteContainerBlock>
            <WriteBar onChange={ onChange }/>
            <WriteButton />
        </WriteContainerBlock>
    );
};

export default WriteContainer;
  • ./src/components/comment/CommentItem.js
import React from 'react';
import styled from 'styled-components';
import palette from '../../lib/styles/palettes';

const ItemBlock = styled.div`
    width: 100%;
    border-bottom: 1px solid ${ palette.gray[2] };
    margin-bottom: 1rem;
    padding-bottom: 0.5rem;
`;

const Header = styled.div`
    float: left;
    width: 100%;
    display: flex;
    align-items: center;
`;

const WriterBlock = styled.div`
    font-size: 1.3rem;
    font-weight: bold;
`;

const CreatedAtBlock = styled.div`
    font-size: 0.8rem;
    margin-left: 15px;
    color: ${ palette.gray[6] }
`;

const Content = styled.div`
    float: left;
    margin-top: 1.5rem;
    margin-bottom: 0.8125rem;
`;

const CommentItem = ({item, i}) => {
    return(
        <ItemBlock>
            <Header>
                <WriterBlock>
                    { item.writer }
                </WriterBlock>
                <CreatedAtBlock>
                    { item.createdAt }
                </CreatedAtBlock>
            </Header>
            <Content>
                { item.comment }
            </Content>
        </ItemBlock>
    );
};

export default CommentItem;
  • ./src/components/comment/CommentList.js
import React from 'react';
import styled from 'styled-components';
import CommentItem from './CommentItem';

const ListBlock = styled.div`
    width: 100%;
    display: flex;
    flex-direction: column;
    float: left;
`;

const CommentList = () => {
    const dummyData = [
        {"commentId": "1", "comment": "test-01", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "2", "comment": "test-02", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "3", "comment": "test-03", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "4", "comment": "test-04", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "5", "comment": "test-05", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "6", "comment": "test-06", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "7", "comment": "test-07", "createdAt": "2000-01-01", "writer": "test-01"},
        {"commentId": "8", "comment": "test-08", "createdAt": "2000-01-01", "writer": "test-01"},
    ];
    
    return(
        <ListBlock>
            { 
                dummyData.map((item, i) => {
                    return (
                        <CommentItem
                            item={ item }
                            i={ i }
                        />   
                    )     
                })
            }
        </ListBlock>
    );
};

export default CommentList;
  • ./src/components/comment/CommentContainer.js
import React from 'react';
import styled from 'styled-components';
import CommentList from './CommentList';
import WriteContainer from './WriteContainer';

const CommentBlock = styled.div`
    margin-top: 3rem;
`;

const CommentContainer = () => {
    return(
        <CommentBlock>
            <WriteContainer />
            <CommentList />
        </CommentBlock>
    );
};

export default CommentContainer;

여기까지가 PostViewer를 위한 컴포넌트 구성요소들입니다. 그러면 이 PostViewer를 PostViewerContainer에서 호출하고 리덕스 모듈을 이용하여 데이터를 가져오는 코드와 연결시키도록 하겠습니다.

  • ./src/components/posts/PostViewerContainer.js
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { withRouter } from 'react-router';
import { readPost, unloadPost } from '../../modules/post';
import PostViewer from './PostViewer';

const PostViewerContainer = ({ match }) => {
    const { postId } = match.params;
    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 <PostViewer 
                post={ post } 
                loading={ loading } 
                error={ error } 
            />;
};

export default withRouter(PostViewerContainer);

우선 테스트를 위해 리덕스 모듈에 관한 부분을 주석처리하고 페이지가 잘 작동하는지 확인해보도록 하겠습니다.

#4 테스트


페이지가 잘 작동하는 것 같습니다. 테스트 페이지를 기준으로 해서 대략적으로 설명할 부분이 있는데 이미지 슬라이더의 경우 측면의 화살표, 하단의 네비게이션을 이용을 할 수 있습니다. 그리고 이미지 슬라이더 아래 컴포넌트에는 게시글 주인에게 메시지를 보낼 수 있으며, 빌리기 버튼을 통해 대여를 진행할 수 있습니다.

여기까지 포스트를 마치고 다음 포스트에서는 게시글 리스트를 볼 수 있는 페이지 디자인을 만들고 글쓰기와 post-service를 연동해보도록 하겠습니다.

참고

  • 리액트를 다루는 기술 - 저자: 김민준

0개의 댓글

관련 채용 정보