LifeSports Application(ReactNative & Nest.js) - 21. post-service(2)

#1 리덕스 모듈

post-service에서 작성한 endpoint들을 위한 api를 작성하겠습니다.

  • ./src/lib/api/post.js
import client from './client';

export const write = ({
    type,
    title,
    content,
    userId,
    writer,
    rental
}) => client.post(`http://10.0.2.2:8000/post-service/`, {
    type,
    title,
    content,
    userId,
    writer,
    rental
});

export const getAll = () => client.get(`http://10.0.2.2:8000/post-service/`);

export const getPostsByType = type => client.get(`http://10.0.2.2:8000/post-service/posts/type/${type}`);

export const getOne = _id => client.get(`http://10.0.2.2:8000/post-service/${_id}/post`);

export const getPostsByUserId = userId => client.get(`http://10.0.2.2:8000/post-service/${userId}/posts`);

export const getPostsByKeyword = keyword => client.get(`http://10.0.2.2:8000/post-service/posts/keyword/${keyword}`);

export const deleteOne = _id => client.delete(`http://10.0.2.2:8000/post-service/${id}/post}`);

post 작성과 삭제를 위한 리덕스 모듈을 작성하겠습니다.

  • ./src/modules/post.js
import { createAction, handleActions } from "redux-actions";
import createRequestSaga, { createRequestActionTypes } from "../lib/createRequestSaga";
import * as postAPI from '../lib/api/post';
import { takeLatest } from "@redux-saga/core/effects";

const INITIALIZE = 'post/INITIALIZE';

const CHANGE_FIELD = 'post/CHANGE_FIELD';

const [
    WRITE,
    WRITE_SUCCESS,
    WRITE_FAILURE
] = createRequestActionTypes('post/WRITE');

const [
    DELETE,
    DELETE_SUCCESS,
    DELETE_FAILURE
] = createRequestActionTypes('post/DELETE');

export const initialize = createAction(INITIALIZE);

export const changeField = createAction(CHANGE_FIELD, ({
    key,
    value
}) => ({
    key,
    value
}));

export const writePost = createAction(WRITE, ({
    type,
    title,
    content,
    userId,
    writer,
    rental
}) => ({
    type,
    title,
    content,
    userId,
    writer,
    rental
}));

export const deletePost = createAction(DELETE, _id => _id);

const writePostSaga = createRequestSaga(WRITE, postAPI.write);

const deletePostSaga = createRequestSaga(DELETE, postAPI.deleteOne);

export function* postSaga() {
    yield takeLatest(WRITE, writePostSaga);
    yield takeLatest(DELETE, deletePostSaga);
};

const initialState = {
    type: null,
    title: null,
    content: null,
    userId: null,
    writer: null,
    rental: '',
    post: null,
    postError: null,
    message: null,
};

const post = handleActions(
    {
        [INITIALIZE]: state => initialState,
        [CHANGE_FIELD]: (state, { payload: { key, value }}) => ({
            ...state,
            [key]: value,
        }),
        [WRITE_SUCCESS]: (state, { payload: post }) => ({
            ...state,
            post,
        }),
        [WRITE_FAILURE]: (state, { payload: postError }) => ({
            ...state,
            postError,
        }),
        [DELETE_SUCCESS]: (state, { payload: message }) => ({
            ...state,
            message,
        }),
        [DELETE_FAILURE]: (state, { payload: postError }) => ({
            ...state,
            postError
        }),
    },
    initialState,
);

export default post;

이어서 post를 읽어오는 리덕스 모듈을 작성하겠습니다.

  • ./src/modules/posts.js
import { createAction, handleActions } from "redux-actions";
import createRequestSaga, { createRequestActionTypes } from "../lib/createRequestSaga";
import * as postAPI from '../lib/api/post';
import { takeLatest } from "@redux-saga/core/effects";

const INITIALIZE = 'posts/INITIALIZE';

const [
    READ_POST,
    READ_POST_SUCCESS,
    READ_POST_FAILURE
] = createRequestActionTypes('posts/READ_POST');

const [
    LIST_ALL,
    LIST_ALL_SUCCESS,
    LIST_ALL_FAILURE
] = createRequestActionTypes('posts/LIST_ALL');

const [
    LIST_POSTS_TYPE,
    LIST_POSTS_TYPE_SUCCESS,
    LIST_POSTS_TYPE_FAILURE
] = createRequestActionTypes('posts/LIST_POST_TYPES')

const [
    LIST_POSTS_USERID,
    LIST_POSTS_USERID_SUCCESS,
    LIST_POSTS_USERID_FAILURE
] = createRequestActionTypes('posts/LIST_POSTS_USERID');

const [
    LIST_POSTS_KEYWORD,
    LIST_POSTS_KEYWORD_SUCCESS,
    LIST_POSTS_KEYWORD_FAILURE
] = createRequestActionTypes('posts/LIST_POSTS_KEYWORD');

export const readPost = createAction(READ_POST, _id => _id);

export const listAll = createAction(LIST_ALL);

export const listType = createAction(LIST_POSTS_TYPE, type => type);

export const listUserId = createAction(LIST_POSTS_USERID, userId => userId);

export const listKeyword = createAction(LIST_POSTS_KEYWORD, keyword => keyword);

const readPostSaga = createRequestSaga(READ_POST, postAPI.getOne);

const listAllSaga = createRequestSaga(LIST_ALL, postAPI.getAll);

const listTypeSaga = createRequestSaga(LIST_POSTS_TYPE, postAPI.getPostsByType);

const listUserIdSaga = createRequestSaga(LIST_POSTS_USERID, postAPI.getPostsByUserId);

const listKeywordSaga = createRequestSaga(LIST_POSTS_KEYWORD, postAPI.getPostsByKeyword);

export function* postsSaga() {
    yield takeLatest(READ_POST, readPostSaga);
    yield takeLatest(LIST_ALL, listAllSaga);
    yield takeLatest(LIST_POSTS_TYPE, listTypeSaga);
    yield takeLatest(LIST_POSTS_USERID, listUserIdSaga);
    yield takeLatest(LIST_POSTS_KEYWORD, listKeywordSaga);
};

const initialState = {
    post: null,
    posts: null,
    postsError: null,
};

const posts = handleActions(
    {
        [INITIALIZE]: state => initialState,
        [READ_POST_SUCCESS]: (state, { payload: post }) => ({
            ...state,
            post,
        }),
        [READ_POST_FAILURE]: (state, { payload: postsError }) => ({
            ...state,
            postsError,
        }),
        [LIST_ALL_SUCCESS]: (state, { payload: posts }) => ({
            ...state,
            posts,
        }),
        [LIST_ALL_FAILURE]: (state, { payload: postsError }) => ({
            ...state,
            postsError,
        }),
        [LIST_POSTS_TYPE_SUCCESS]: (state, { payload: posts }) => ({
            ...state,
            posts,
        }),
        [LIST_POSTS_TYPE_FAILURE]: (state, { payload: postsError }) => ({
            ...state,
            postsError
        }),
        [LIST_POSTS_USERID_SUCCESS]: (state, { payload: posts }) => ({
            ...state,
            posts,
        }),
        [LIST_POSTS_USERID_FAILURE]: (state, { payload: postsError }) => ({
            ...state,
            postsError
        }),
        [LIST_POSTS_KEYWORD_SUCCESS]: (state, { payload: posts }) => ({
            ...state,
            posts,
        }),
        [LIST_POSTS_KEYWORD_FAILURE]: (state, { payload: postsError }) => ({
            ...state,
            postsError
        }),
    },
    initialState,
);

export default posts;

모듈들을 완성했으니 UI를 작성하면서 사용을 해보도록 하겠습니다.

#2 UI

react native에서의 UI를 작성해보도록 하겠습니다.

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

npm install --save react-native-redio-buttons-ext
  • ./src/pages/post/components/WriteNav.js
import React from 'react';
import { useDispatch } from 'react-redux';
import { StyleSheet, Text, View } from 'react-native';
import { SegmentedControls } from 'react-native-radio-buttons-ext';
import { changeField } from '../../../modules/post';

const WriteNav = () => {
    const dispatch = useDispatch();
    const values = [
        '함께해요', 
        '도와주세요'
    ];
    const onSelect = e => {
        dispatch(changeField({
            key: 'type',
            value: e
        }));
    };

    return(
        <View style={ styles.container }>
            <View style={ styles.label }>
                <Text style={ styles.font }>
                    게시글 종류를 정해주세요
                </Text>
            </View>
            <View style={ styles.radio }>
                <SegmentedControls options={ values }
                                   onSelection={ onSelect }
                />
            </View>
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        width: 370,
        justifyContent: 'flex-start',
        marginLeft: 20,
        marginTop: 40,
    },
    label: {
        marginBottom: 10,
        marginLeft: 10,
    },
    font: {
        fontWeight: 'bold'
    },
    radio: {
        width: 200,
        marginBottom: 20
    }
});

export default WriteNav;

WriteNav 컴포넌트는 게시글의 종류('함께해요', '도와주세요')를 정하는 역할을 합니다.

  • ./src/pages/post/components/WriteContent.js
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { changeField } from '../../../modules/post';
import palette from '../../../styles/palette';
import RentalList from './RentalList';

const WriteContent = () => {
    const dispatch = useDispatch();
    const { type } = useSelector(({ post }) => ({ type: post.type }));
    const onChange = e => {
        const inputAccessoryViewID = e.target._internalFiberInstanceHandleDEV.memoizedProps.inputAccessoryViewID;
        const value = e.nativeEvent.text;

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

    return(
        <View>
            <View style={ styles.container }>
                <View style={ styles.label }>
                    <Text style={ styles.font }>
                        제목을 적어주세요
                    </Text>
                </View>
                <TextInput style={ styles.title_input }
                           inputAccessoryViewID="title"
                           onChange={ onChange }
                />
            </View>
            <View style={ styles.container }>
                <View style={ styles.label }>
                    <Text style={ styles.font }>
                        내용을 적어주세요
                    </Text>
                </View>
                <TextInput style={ styles.content_input }
                           inputAccessoryViewID="content"
                           multiline={ true }
                           textAlignVertical="top"
                           onChange={ onChange }
                />
            </View>
            {  
                type === '함께해요' &&
                <View style={ styles.container }>
                    <View style={ styles.label }>
                        <Text style={ styles.font }>
                            대관 내역이에요
                        </Text>
                    </View>
                    <RentalList />
                </View>
            }
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        width: 370,
        justifyContent: 'flex-start',
        marginLeft: 20,
        marginTop: 40,
        borderBottomColor: palette.gray[3],
        borderBottomWidth: 2,
    },
    label: {
        marginBottom: 10,
        marginLeft: 10,
    },
    font: {
        fontWeight: 'bold'
    },
    title_input: {
        width: 370,
        height: 40,
        justifyContent: 'center',
        alignItems: 'center',
        borderRadius: 6,
        backgroundColor: palette.white[0],
        color: palette.black[0]
    },
    content_input: {
        width: 370,
        height: 200,
        justifyContent: 'center',
        alignItems: 'center',
        borderRadius: 6,
        backgroundColor: palette.white[0],
        color: palette.black[0]
    }
});

export default WriteContent;

해당 컴포넌트에서는 제목, 내용을 작성할 수 있습니다. 그리고 최상단에서의 게시글 타입에 따라 함께해요의 경우엔 현재 대관한 내역을 게시글에 담을 수 있으며, 도와주세요의 경우에는 대관 내역을 보이지 않게 하도록 설정하였습니다.

  • ./src/pages/post/components/RentalList.js
import React, { useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { getRentals } from '../../../modules/rentals';
import palette from '../../../styles/palette';
import RentalCard from './RentalCard';

const RentalList = () => {
    const dispatch = useDispatch();
    const { 
        userId,
        rentals,
    } = useSelector(({ 
        user,
        rentals
    }) => ({
        userId: user.user.userId,
        rentals: rentals.rentals,
    }));

    useEffect(() => {
        dispatch(getRentals(userId))    
    }, [dispatch, userId]);

    return(
        <View style={ styles.container }>
            {
                rentals ?
                rentals.map((item, i) => {
                    return <RentalCard item={ item }/>
                }) : 
                <Text>
                    대관 내역이 없어요!
                </Text>
            }
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        width: 370,
        justifyContent: 'center',
    }
});

export default RentalList;

대관 내역 리스트입니다. rentals 리덕스 모듈을 활용하여 getRentals를 호출해서 현재 사용자가 대관한 리스트를 불러올 수 있게 하는 컴포넌트입니다.

  • ./src/pages/post/components/RentalCard.js
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { changeField } from '../../../modules/post';
import palette from '../../../styles/palette';

const RentalCard = ({ item }) => {
    const dispatch = useDispatch();
    const { rental } = useSelector(({ post }) => ({ rental: post.rental }));
    const onChange = e => {
        dispatch(changeField({
            key: 'rental',
            value: item
        }));
    };

    return (
        <View style={ 
            rental.rentalId !== item.rentalId ?
            styles.card :
            styles.select_card
        }>
            <TouchableOpacity onPress={ onChange }>
                <View>
                    <Text style={ 
                        rental.rentalId === item.rentalId &&
                        styles.select_font
                    }>
                        { item.mapName }
                    </Text>
                </View>
                <View>
                    <Text style={ 
                        rental.rentalId === item.rentalId &&
                        styles.select_font
                    }>
                        { item.date }
                    </Text>
                </View>
           </TouchableOpacity>
        </View>
    );
};

const styles = StyleSheet.create({
    card: {
        width: 350,
        height: 70,
        backgroundColor: palette.gray[2],
        margin: 10,
        padding: 15,
        borderRadius: 10
    },
    select_card: {
        width: 350,
        height: 70,
        backgroundColor: palette.blue[1],
        margin: 10,
        padding: 15,
        borderRadius: 10
    },
    select_font: {
        color: palette.white[0]
    }
});

export default RentalCard;

각 대관 데이터를 클릭하게 되면 post.rental state에 담길 수 있도록 하는 컴포넌트입니다. 선택된 데이터는 파란색으로 표시가 됩니다.

  • ./src/pages/post/components/WriteFooter.js
import React from 'react';
import { StyleSheet, View } from 'react-native';
import WriteButton from './WriteButton';

const WriteFooter = () => {
    return(
        <View style={ styles.container }>
            <WriteButton />
        </View>
    );
};

const styles = StyleSheet.create({
    container: {
        width: 370,
        alignItems: 'flex-end',
        margin: 20,
    },
});

export default WriteFooter;

맨 하단에서 작성 완료 버튼을 감싸주는 컴포넌트입니다.

  • ./src/pages/post/components/WriteButton.js
import React, { useEffect } from 'react';
import { StyleSheet, Text, ToastAndroid, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initialize, writePost } from '../../../modules/post';
import palette from '../../../styles/palette';

const WriteButton = () => {
    const dispatch = useDispatch();
    const navigation = useNavigation();
    const {
        type,
        title,
        content,
        userId,
        writer,
        nickname,
        rental,
        post
    } = useSelector(({ 
        user,
        post 
    }) => ({
        type: post.type,
        title: post.title,
        content: post.content,
        userId: user.user.userId,
        nickname: user.user.nickname,
        writer: post.writer,
        rental: post.rental,
        post: post.post
    }));
    const showToastForError = e => {
        ToastAndroid.show(
            "Empty input! check inputs", 
            ToastAndroid.SHORT
        );
    };
    const onWrite = e => {
        if([
            type,
            title,
            content,
            userId,
            writer,
        ].includes(null)) {
            showToastForError();

            return;
        }

        dispatch(writePost({
            type,
            title,
            content,
            userId,
            writer,
            rental
        }));
    };

    useEffect(() => {
        dispatch(changeField({
            key: 'userId',
            value: userId
        }));
    }, [dispatch, userId]);

    useEffect(() => {
        dispatch(changeField({
            key: 'writer',
            value: nickname
        }));
    }, [dispatch, nickname]);

    useEffect(() => {
        if(post) {
            dispatch(initialize());

            navigation.navigate("Post");
        }
    }, [dispatch, post]);

    return <TouchableOpacity style={ styles.shape }
                             onPress={ onWrite }
            >
                <Text style={ styles.font }>
                    작성 완료
                </Text>
           </TouchableOpacity>;
};

const styles = StyleSheet.create({
    shape: {
        width: 120,
        height: 40,
        backgroundColor: palette.blue[1],
        borderRadius: 5,
        justifyContent: 'center',
        alignItems: 'center'
    },
    font: {
        color: palette.white[0],
        fontWeight: 'bold'
    }
});

export default WriteButton;

state에 저장된 값을 전부 불러와 작성 완료 버튼을 동작하게 하는 컴포넌트입니다. 게시글의 요소 중 하나라도 비어있으면 showToastForError 메서드가 호출되어 toast 메시지를 보여줍니다. 최종적으로 정상적으로 게시글이 작성 완료가 된다면 게시글 홈으로 이동합니다.

구현이 잘 되었는지 테스트를 진행해보도록 하겠습니다.

#3 테스트

  • post-service의 main.ts 파일로 가서 port번호를 7100으로 설정하겠습니다.

konga를 이용하여 post-service를 등록을 마친 후, 다음의 순서대로 테스트를 진행해보도록 하겠습니다.

1) 함께해요 - input이 비어있는 오류

2) 함께해요 - 대관 데이터를 포함한 게시글

3) 함께해요 - 대관 데이터가 포함되지 않은 게시글

2), 3)에 관한 데이터 REST 요청이 잘 진행됨을 알 수 있습니다 데이터베이스 상태도 확인해보도록 하겠습니다.

4) 도와주세요 - input이 비어있는 오류

5) 도와주세요

1) ~ 5)의 테스트를 완료한 결과 정상적으로 수행됨을 볼 수 있습니다. 다음 포스트에서는 게시글 상세페이지를 작업해보도록 하겠습니다.

좋은 웹페이지 즐겨찾기