React.js Codelab 2016 – Express 와 React.js 를 사용한 웹 어플리케이션 만들기 (4)


3편에서는 계정인증 부분을 완료했었습니다.

어떤가요? 조금 복잡했나요? 이 파일 수정하고.. 저 파일 수정하고, 조금 정신없었을 수도 있지만,

결국엔 비슷한 동작이 계속해서 반복되기 때문에 몇번 해보면 쉬워진답니다.

이런 동작이 반복되죠

  1. action type 만들기
  2. action creator 만들기
  3. reducer 만들기
  4. 컴포넌트에서 사용

이번 편에서는 메모를 작성하는 부분을 완성해보도록 하겠습니다.


16. Write 컴포넌트 만들기

wrte

Codelab 링크: http://codepen.io/velopert/pen/LkZKpx

 

Write 컴포넌트 생성 (src/components/Write.js)

import React from 'react';

class Write extends React.Component {
    render() {
        return (
            <div>Write</div>
        );
    }
}

export default Write;

 

컴포넌트 인덱스에 Write 컴포넌트 추가 (src/components/index.js)

import Header from './Header';
import Authentication from './Authentication';
import Write from './Write';

export { Header, Authentication, Write };

 

Home 컴포넌트에서 현재 로그인상태라면 Write 보여주기

import React from 'react';
import { connect } from 'react-redux';
import { Write } from 'components';

class Home extends React.Component {
    
    render() {
        const write = ( <Write/> );
        return (
            <div>
                { this.props.isLoggedIn ? write : undefined }
            </div>
        );
    }
}

const mapStateToProps = (state) => {
    return {
        isLoggedIn: state.authentication.status.isLoggedIn
    };
};

export default connect(mapStateToProps)(Home);

 

Write 컴포넌트를 위한 스타일 추가 (src/style.css)

/* WRITE */

.write .materialize-textarea {
    padding: 0px;
    padding-bottom: 36px;
    margin-bottom: 0px;
    font-size: 18px;
}

.write .card-content {
    padding-bottom: 10px;
}

.write .card-action {
    text-align: right;
}

 

Write 컴포넌트 뷰 만들기 (src/styles.css)

import React from 'react';

class Write extends React.Component {
    render() {
        return (
            <div className="container write">
                <div className="card">
                    <div className="card-content">
                        <textarea className="materialize-textarea" placeholder="Write down your memo"></textarea>
                    </div>
                    <div className="card-action">
                        <a>POST</a>
                    </div>
                </div>
            </div>
        );
    }
}

export default Write;

dd

위에 Navbar 랑 Write 컴포넌트랑 너무 가깝지 않나요?

Home 에서 여백을 주도록 하겠습니다 (Write 에서 하지 않는 이유는, 나중에 메모를 읽을떄도 여백이 필요하기 때문인데, 로그인상태가 아니라면 Write가 보여지지 않기 때문입니다)

 

style.css 에 wrapper 클래스 추가 (src/style.css)

.wrapper {
    margin-top: 20px;
}

 

Home 컨테이너 컴포넌트에 wrapper 스타일 클래스 적용 (src/container/Home.js)

    render() {
        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? <Write/> : undefined }
            </div>
        );
    }

 

Write 컴포넌트 textarea 에 state 사용, defaultProps / propTypes 설정 (src/components/Write.js)

import React from 'react';

class Write extends React.Component {

    constructor(props) {
        super(props);

        this.state = {
            contents: ''
        };

        this.handleChange = this.handleChange.bind(this);

    }

    handleChange(e) {
        this.setState({
            contents: e.target.value
        });
    }

    render() {
        return (
            <div className="container write">
                <div className="card">
                    <div className="card-content">
                        <textarea
                            className="materialize-textarea"
                            placeholder="Write down your memo"
                            value={this.state.contents}
                            onChange={this.handleChange}></textarea>
                    </div>
                    <div className="card-action">
                        <a>POST</a>
                    </div>
                </div>
            </div>
        );
    }
}

Write.propTypes = {
    onPost: React.PropTypes.func
};

Write.defaultProps = {
    onPost: (contents) => { console.error('post function not defined'); }
};

export default Write;

 

Action Type 추가하기 (src/actions/ActionTypes.js)

/* MEMO */
export const MEMO_POST = "MEMO_POST";
export const MEMO_POST_SUCCESS = "MEMO_POST_SUCCESS";
export const MEMO_POST_FAILURE = "MEMO_POST_FAILURE";

 

memo 액션파일 만들기 (src/actions/memo.js)

import {
    MEMO_POST,
    MEMO_POST_SUCCESS,
    MEMO_POST_FAILURE
} from './ActionTypes';
import axios from 'axios';

/* MEMO POST */
export function memoPostRequest(contents) {
    return (dispatch) => {
        // to be implemented
    };
}

export function memoPost() {
    return {
        type: MEMO_POST
    };
}

export function memoPostSuccess() {
    return {
        type: MEMO_POST_SUCCESS
    };
}

export function memoPostFailure(error) {
    return {
        type: MEMO_POST_FAILURE,
        error
    };
}

 

memoPostRequest 구현하기 (src/acitons/memo.js)

/* MEMO POST */
export function memoPostRequest(contents) {
    return (dispatch) => {
        // inform MEMO POST API is starting
        dispatch(memoPost());

        return axios.post('/api/memo/', { contents })
        .then((response) => {
            dispatch(memoPostSuccess());
        }).catch((error) => {
            dispatch(memoPostFailure(error.response.data.code));
        });
    };
}

 

memo 리듀서 만들기 (src/reducers/memo.js)

import * as types from 'actions/ActionTypes';
import update from 'react-addons-update';

const initialState = {
    post: {
        status: 'INIT',
        error: -1
    }
};

export default function memo(state, action) {
    if(typeof state === "undefined") {
        state = initialState;
    }

    switch(action.type) {
        case types.MEMO_POST:
            return update(state, {
                post: {
                    status: { $set: 'WAITING' },
                    error: { $set: -1 }
                }
            });
        case types.MEMO_POST_SUCCESS:
            return update(state, {
                post: {
                    status: { $set: 'SUCCESS' }
                }
            });
        case types.MEMO_POST_FAILURE:
            return update(state, {
                post: {
                    status: { $set: 'FAILURE' },
                    error: { $set: action.error }
                }
            });
        default:
            return state;
    }
}

 

리듀서 인덱스 수정 (src/reducers/index.js)

import authentication from './authentication';
import memo from './memo';

import { combineReducers } from 'redux';

export default combineReducers({
    authentication,
    memo
});

 

Home 컨테이너 컴포넌트에서 Redux 연결 (src/container/Home.js)

import React from 'react';
import { connect } from 'react-redux';
import { Write } from 'components';
import { memoPostRequest } from 'actions/memo';

class Home extends React.Component {
    render() {
        const write = (
            <Write/>
        );

        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
            </div>
        );
    }
}

const mapStateToProps = (state) => {
    return {
        isLoggedIn: state.authentication.status.isLoggedIn,
        postStatus: state.memo.post
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        memoPostRequest: (contents) => {
            return dispatch(memoPostRequest(contents));
        }
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

postStatus 와 memoPostRequest 가 매핑되었습니다.

 

Home 컨테이너 컴포넌트에 handlePost 구현하기 (src/container/Home.js)

import React from 'react';
import { connect } from 'react-redux';
import { Write } from 'components';
import { memoPostRequest } from 'actions/memo';

class Home extends React.Component {

    constructor(props) {
        super(props);        
        this.handlePost = this.handlePost.bind(this);
    }

    /* POST MEMO */
    handlePost(contents) {
        return this.props.memoPostRequest(contents).then(
            () => {
                if(this.props.postStatus.status === "SUCCESS") {
                    // TRIGGER LOAD NEW MEMO
                    // TO BE IMPLEMENTED
                    Materialize.toast('Success!', 2000);
                } else {
                    /*
                        ERROR CODES
                            1: NOT LOGGED IN
                            2: EMPTY CONTENTS
                    */
                    let $toastContent;
                    switch(this.props.postStatus.error) {
                        case 1:
                            // IF NOT LOGGED IN, NOTIFY AND REFRESH AFTER
                            $toastContent = $('<span style="color: #FFB4BA">You are not logged in</span>');
                            Materialize.toast($toastContent, 2000);
                            setTimeout(()=> {location.reload(false);}, 2000);
                            break;
                        case 2:
                            $toastContent = $('<span style="color: #FFB4BA">Please write something</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                        default:
                            $toastContent = $('<span style="color: #FFB4BA">Something Broke</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                    }
                }
            }
        );
    }


    render() {
        const write = (
            <Write onPost={this.handlePost}/>
        );

        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
            </div>
        );
    }
}

/* CODES */

export default connect(mapStateToProps, mapDispatchToProps)(Home);

아직 구현은 하지 않았지만, 메모가 작성되고 나면, 새 메모를 불러오도록 할 것입니다.

로그인 상태가 아니라면 알림을 띄우고 2초뒤 새로고침합니다.

handlePost 를 onPost 로 Write 컴포넌트에 전해줍니다.

 

Write 컴포넌트에서 위에서 받은 onPost 사용하기 (src/components/Write.js)

import React from 'react';

class Write extends React.Component {

    constructor(props) {
        super(props);

        this.state = {
            contents: ''
        };

        this.handleChange = this.handleChange.bind(this);
        this.handlePost = this.handlePost.bind(this);

    }

    handleChange(e) {
        this.setState({
            contents: e.target.value
        });
    }

    handlePost() {
        let contents = this.state.contents;
        
        this.props.onPost(contents).then(
            () => {
                this.setState({
                    contents: ""
                });
            }
        );
    }

    render() {
        return (
            <div className="container write">
                <div className="card">
                    <div className="card-content">
                        <textarea
                            className="materialize-textarea"
                            placeholder="Write down your memo"
                            value={this.state.contents}
                            onChange={this.handleChange}></textarea>
                    </div>
                    <div className="card-action">
                        <a onClick={this.handlePost}>POST</a>
                    </div>
                </div>
            </div>
        );
    }
}

/* CODES */

 

여기에도, handlePost 메소드를 만들어주세요. .then() 을 통하여 작성이완료되면 내용을 비웁니다.

POST 버튼이 클릭 됐을 시 handlePost 를 실행하게 하세요.

작성기능을 얼추 끝났습니다. 이제 읽기 기능을 구현 하고나면 다시 작성기능을 수정하겠습니다 – 작성 후 새 메모 불러오기

 

17. 읽기 기능 구현하기 – Memo 와 MemoList 컴포넌트

dfg

Codepen 링크: http://codepen.io/velopert/pen/YWYoQY?editors=1111

메모 컴포넌트는 위와 같이 자신의 메모라면 수정/삭제 를 할 수 있도록 옵션 버튼이 우측상단에 생깁니다.

Materializecss 의 Dropdown 기능을 통하여 저 옵션메뉴를 구현하였습니다.

Edit 버튼을 누르면 컴포넌트가 Write 컴포넌트와 비슷하게 변하도록 할 것입니다 (이 부분은 나중에 구현합니다)

 

Memo 컴포넌트 파일 생성 (src/components/Memo.js)

import React from 'react';

class Memo extends React.Component {
    render() {
        return (
            <div>Memo</div>
        );
    }
}

export default Memo;

 

MemoList 컴포넌트 파일 생성

import React from 'react';

class MemoList extends React.Component {
    render() {
        return (
            <div>MemoList</div>
        );
    }
}

export default MemoList;

 

컴포넌트 인덱스에 추가 (src/components/index.js)

import Header from './Header';
import Authentication from './Authentication';
import Write from './Write';
import Memo from './Memo';
import MemoList from './MemoList';

export { Header, Authentication, Write, Memo, MemoList };

 

Memo 컴포넌트를 위한 스타일 추가 (src/style.css)

/* Memo */

.memo .info {
    font-size: 18px;
    padding: 20px 20px 0px 20px;
    color: #90A4AE;
}

.memo .info .username {
    color: #263238;
    font-weight: bold;
    cursor: pointer;
}

.memo .card-content {
    word-wrap: break-word;
    white-space: pre-wrap;
}

.icon-button {
    color: #9e9e9e;
    cursor: pointer;
}

.icon-button:hover {
    color: #C5C5C5;
}

.icon-button:hover {
    color: #C5C5C5;
}

.icon-button:active {
    color: #ff9800;
}

.memo .option-button {
    position: absolute;
    right: 20px;
    top: 20px;
}

.memo .card-content {
    font-size: 18px;
}

.memo .footer {
    border-top: 1px solid #ECECEC;
    height: 45px;
}

.star {
    position: relative;
    left: 15px;
    top: 11px;
}

.star-count {
    position: relative;
    left: 20px;
    top: 4px;
    font-size: 13px;
    font-weight: bold;
    color: #777;
}

 

Memo 컴포넌트 뷰 만들기 (src/components/Memo.js)

import React from 'react';

class Memo extends React.Component {
    render() {
        return (
            <div className="container memo">
                <div className="card">
                    <div className="info">
                        <a className="username">Writer</a> wrote a log · 1 seconds ago
                        <div className="option-button">
                            <a className='dropdown-button' id='dropdown-button-id' data-activates='dropdown-id'>
                                <i className="material-icons icon-button">more_vert</i>
                            </a>
                            <ul id='dropdown-id' className='dropdown-content'>
                                <li><a>Edit</a></li>
                                <li><a>Remove</a></li>
                            </ul>
                        </div>
                    </div>
                    <div className="card-content">
                        Contents
                    </div>
                    <div className="footer">
                        <i className="material-icons log-footer-icon star icon-button">star</i>
                        <span className="star-count">0</span>
                    </div>
                </div>
            </div>
        );
    }
}

 

MemoList 에 Memo 컴포넌트 렌더링하기

import React from 'react';
import { Memo } from 'components';

class MemoList extends React.Component {
    render() {
        return (
            <div>
                <Memo/>
            </div>
        );
    }
}

지금으로서는 임시로 Memo 컴포넌트 하나만 렌더링합니다

 

Home 컴포넌트에 MemoList 컴포넌트 렌더링하기 (src/components/Home.js)

/* CODES */
import { Write, MemoList } from 'components';
/* CODES */

class Home extends React.Component {
    render() {
        /* CODES */
        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
                <MemoList/>
            </div>
        );
    }
}

/* CODES */

 

Memo 컴포넌트 propTypes, defaultProps 지정하기 (src/components/Memo.js)

import React from 'react';

class Memo extends React.Component {
    /* CODES */
}

Memo.propTypes = {
    data: React.PropTypes.object,
    ownership: React.PropTypes.bool
};

Memo.defaultProps = {
    data: {
        _id: 'id1234567890',
        writer: 'Writer',
        contents: 'Contents',
        is_edited: false,
        date: {
            edited: new Date(),
            created: new Date()
        },
        starred: []
    },
    ownership: true
}

export default Memo;

이 컴포넌트는 Memo에 필요한 값들을 객체형으로 받아오게 하겠습니다.

나중에 컴포넌트 Mapping 을 할텐데 이렇게 하는편이 편하기 때문입니다 (원한다면 객체형으로 하지 않고 분리하셔도 됩니다)

ownership prop  은 해당 메모가 자신의 메모인지 아닌지 여부를 확인하는 값입니다.

 

Memo 컴포넌트 렌더링 할 때 props 값 사용하기 (src/components/Memo.js)

import TimeAgo from 'react-timeago';
/* CODES */
    render() {
        const { data, ownership } = this.props;

        return(
            <div className="container memo">
               <div className="card">
                   <div className="info">
                       <a className="username">{data.writer}</a> wrote a log · <TimeAgo date={data.date.created}/>
                       <div className="option-button">
                           <a className='dropdown-button'
                                id={`dropdown-button-${data._id}`}
                                data-activates={`dropdown-${data._id}`}>
                               <i className="material-icons icon-button">more_vert</i>
                           </a>
                           <ul id={`dropdown-${data._id}`} className='dropdown-content'>
                               <li><a>Edit</a></li>
                               <li><a>Remove</a></li>
                           </ul>
                       </div>
                   </div>
                   <div className="card-content">
                       {data.contents}
                   </div>
                   <div className="footer">
                       <i className="material-icons log-footer-icon star icon-button">star</i>
                       <span className="star-count">{data.starred.length}</span>
                   </div>
               </div>
           </div>
        );
    }

4번줄의 코드는 ES6 의 비구조화 할당입니다

“1 second ago” 형식으로 편하게 계산하여 나타내기 위하여 React-Timeago 라는 컴포넌트가 사용되었습니다.

아직 Edited 시간을 알려주는 부분과 현재 Star 를 자신이 클릭했는지 안했는지 나타내는부분은 구현되지 않았습니다.

`…${expression}…` 같은 표현은, ES6의 Template Literals 라는 문법입니다. 문자열 템플릿 안에 변수/상수 값을 손쉽게 넣을 수 있습니다.

(이걸 사용하지 않는다면 ‘dropdown-‘ + ___  뭐 이런식으로 했어야겠죠? Template Literals 를 사용하면 읽기가 더 편합니다.

 

Memo 컴포넌트 memo View 를 따로 분리하기 (src/components/Memo.js)

    render() {
        const memoView = (
            <div className="card">
                /* CODES */
            </div>
        );
        
        return (
            <div className="container memo">
                { memoView }
            </div>
        );
    }

위와 같이 card div 부분을 잘라내서 memoView 상수에 담고, 렌더링할때 memoView 를 렌더링하도록 설정하세요.

이렇게 한 이유는, 나중에 Edit 모드일때는 Write 와 비슷한 뷰를 보여주게 할 것이기 때문에 미리 작업을 한것입니다.

 

Memo 컴포넌트의 Dropdown 메뉴 추가 작업

Dropdown 메뉴가, 자신의 메모일때만 보여주게하고 (ownership 이 true일때)

Dropdown 메뉴 활성화 작업을 componentDidMount 와 componentDidUpdate 에서 하도록 하겠습니다.

지금으로서는, 활성화를 따로 안해도 작동하긴 하지만, 해당 컴포넌트가 유동적으로 생성되고 업데이트될 경우에는 저희가 따로 활성화작업을 해야합니다.

class Memo extends React.Component {
    render() {
        
        const dropDownMenu = (
            <div className="option-button">
                <a className='dropdown-button'
                     id={`dropdown-button-${data._id}`}
                     data-activates={`dropdown-${data._id}`}>
                    <i className="material-icons icon-button">more_vert</i>
                </a>
                <ul id={`dropdown-${data._id}`} className='dropdown-content'>
                    <li><a>Edit</a></li>
                    <li><a>Remove</a></li>
                </ul>
            </div>
        );
        
        const memoView = (
            <div className="card">
                <div className="info">
                    <a className="username">{this.props.data.writer}</a> wrote a log · <TimeAgo date={this.props.data.date.created}/> 
                    { ownership ? dropDownMenu : undefined }
                /*C ODES.. */
    }
    
    
    componentDidUpdate() {
        // WHEN COMPONENT UPDATES, INITIALIZE DROPDOWN
        // (TRIGGERED WHEN LOGGED IN)
        $('#dropdown-button-'+this.props.data._id).dropdown({
            belowOrigin: true // Displays dropdown below the button
        });
    }

    componentDidMount() {
        // WHEN COMPONENT MOUNTS, INITIALIZE DROPDOWN
        // (TRIGGERED WHEN REFRESHED)
        $('#dropdown-button-'+this.props.data._id).dropdown({
            belowOrigin: true // Displays dropdown below the button
        });
    }
}

 

MemoList 컴포넌트 propTypes 및 defaultProps 설정 (src/components/MemoList.js)

/* CODES.. */

MemoList.propTypes = {
    data: React.PropTypes.array,
    currentUser: React.PropTypes.string
};

MemoList.defaultProps = {
    data: [],
    currentUser: ''
};

export default MemoList;

 

Home 컨테이너 컴포넌트에서 Mock Data 전달 (src/components/Home.js)

    render() {
        const write = (
            <Write
                onPost={this.props.handlePost}
            />
        );

        var mockData = [
            {
                "_id": "578b958ec1da760909c263f4",
                "writer": "velopert",
                "contents": "Testing",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T14:26:22.428Z",
                    "created": "2016-07-17T14:26:22.428Z"
                },
                "starred": []
            },
            {
                "_id": "578b957ec1da760909c263f3",
                "writer": "velopert",
                "contents": "Data",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T14:26:06.999Z",
                    "created": "2016-07-17T14:26:06.999Z"
                },
                "starred": []
            },
            {
                "_id": "578b957cc1da760909c263f2",
                "writer": "velopert",
                "contents": "Mock",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T14:26:04.195Z",
                    "created": "2016-07-17T14:26:04.195Z"
                },
                "starred": []
            },
            {
                "_id": "578b9579c1da760909c263f1",
                "writer": "velopert",
                "contents": "Some",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T14:26:01.062Z",
                    "created": "2016-07-17T14:26:01.062Z"
                },
                "starred": []
            },
            {
                "_id": "578b9576c1da760909c263f0",
                "writer": "velopert",
                "contents": "Create",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T14:25:58.619Z",
                    "created": "2016-07-17T14:25:58.619Z"
                },
                "starred": []
            },
            {
                "_id": "578b8c82c1da760909c263ef",
                "writer": "velopert",
                "contents": "blablablal",
                "__v": 0,
                "is_edited": false,
                "date": {
                    "edited": "2016-07-17T13:47:46.611Z",
                    "created": "2016-07-17T13:47:46.611Z"
                },
                "starred": []
            }
        ];
        
        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
                <MemoList data={mockData} currentUser="velopert"/>
            </div>
        );
    }

원래는 AJAX 요청을 하여 서버에서 데이터를 불러와야하지만

그 작업을 하기 이전에 테스팅을 위하여 mock data (테스트용 가짜 데이터) 를 만들어서 전달하세요

 

MemoList 컴포넌트 받은 데이터 배열을 컴포넌트 매핑

class MemoList extends React.Component {
    render() {
        const mapToComponents = data => {
            return data.map((memo, i) => {
                return (<Memo 
                            data={memo}
                            ownership={ (memo.writer === this.props.currentUser) }
                            key={memo._id}
                />);
            });
        };
        
        return (
            <div>
                {mapToComponents(this.props.data)}
            </div>
        );
    }
}

데이터배열을 컴포넌트배열로 매핑한후 렌더링하였습니다.

Mock 데이터를 잘 렌더링했나요? 그렇다면, 이제 서버에서 데이터를 가져올 차례입니다

 

Action Type 추가하기 (src/actions/ActionTypes.js)

export const MEMO_LIST = "MEMO_LIST";
export const MEMO_LIST_SUCCESS = "MEMO_LIST_SUCCESS";
export const MEMO_LIST_FAILURE = "MEMO_LIST_FAILURE";

 

memo 액션파일 수정하기 (src/actions/memo.js)

import {
    MEMO_POST,
    MEMO_POST_SUCCESS,
    MEMO_POST_FAILURE,
    MEMO_LIST,
    MEMO_LIST_SUCCESS,
    MEMO_LIST_FAILURE
} from './ActionTypes'

/* CODES */

/* MEMO LIST */

/*
    Parameter:
        - isInitial: whether it is for initial loading
        - listType:  OPTIONAL; loading 'old' memo or 'new' memo
        - id:        OPTIONAL; memo id (one at the bottom or one at the top)
        - username:  OPTIONAL; find memos of following user
*/
export function memoListRequest(isInitial, listType, id, username) {
    return (dispatch) => {
        // To be implemented
    };
}

export function memoList() {
    return {
        type: MEMO_LIST
    };
}

export function memoListSuccess(data, isInitial, listType) {
    return {
        type: MEMO_LIST_SUCCESS,
        data,
        isInitial,
        listType
    };
}

export function memoListFailure() {
    return {
        type: MEMO_LIST_FAILURE
    };
}

memoListRequest 의 경우 지금까지 만들었었던 thunk 들과는 달리 파라미터들이 좀 많은데요,

나중에 추가적으로 메모를 로딩할때 ( 새 메모 로딩 및 오래된 메모 로딩), 그리고 특정유저의 메모를 로딩할때도 이 함수가 사용됩니다.

 

memoListRequest 구현하기 (src/actions/memo.js)

export function memoListRequest(isInitial, listType, id, username) {
    return (dispatch) => {
        // inform memo list API is starting
        dispatch(memoList());
        
        let url = '/api/memo';
        
        /* url setup depending on parameters,
           to  be implemented.. */
          
        return axios.get(url)
        .then((response) => {
            dispatch(memoListSuccess(response.data, isInitial, listType));
        }).catch((error) => {
            dispatch(memoListFailure());
        });
    };
}

지금은 일단, 초기 로딩 부분만 구현하였습니다.

 

memo 리듀서 수정하기 (src/reducers/memo.js)

const initialState = {
    post: {
        status: 'INIT',
        error: -1
    },
    list: {
        status: 'INIT',
        data: [],
        isLast: false
    }
};

export default function memo(state, action) {
    /* CODES */

    switch(action.type) {
        /* CODES */
        case types.MEMO_LIST:
            return update(state, {
                list: {
                    status: { $set: 'WAITING' },
                }
            });
        case types.MEMO_LIST_SUCCESS: 
            if(action.isInitial) {
                return update(state, {
                    list: {
                        status: { $set: 'SUCCESS' },
                        data: { $set: action.data },
                        isLast: { $set: action.data.length < 6 }
                    }
                })
            }
            // loading older or newer memo
            // to be implemented..
            return state;
        case types.MEMO_LIST_FAILURE:
            return update(state, {
                list: {
                    status: { $set: 'FAILURE' }
                }
            })
        default:
            return state;
    }
}

isLast 값은 현재 로딩된 페이지가 마지막페이지인지 아닌지 알려줍니다.

한 페이지에 6개의 메모를 보여주는데요, 로드한 메모가 6개 미만이라면, 더 이상 메모가 없다는것을 의미합니다.

(저희가 나중에 무한 스크롤링 기능을 구현할텐데 마지막 페이지에 도달하면 스크롤링이 멈추도록 해야겠죠?)

 

Home 컨테이너 컴포넌트에서 memoListRequest 사용하기 (src/containers/Home.js)

import { memoPostRequest, memoListRequest } from 'actions/memo';

class Home extends React.Component {
    
    componentDidMount() {
        this.props.memoListRequest(true).then(
            () => {
                console.log(this.props.memoData);
            }
        );
    }
    
    /* Codes.. */
}

const mapStateToProps = (state) => {
    return {
        isLoggedIn: state.authentication.status.isLoggedIn,
        postStatus: state.memo.post,
        currentUser: state.authentication.status.currentUser,
        memoData: state.memo.list.data
    };
};

const mapDispatchToProps = (dispatch) => {
    return {
        memoPostRequest: (contents) => {
            return dispatch(memoPostRequest(contents));
        }, 
        memoListRequest: (isInitial, listType, id, username) => {
            return dispatch(memoListRequest(isInitial, listType, id, username));
        }
    };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);

mapStateToProps 에 state.authentication.status.currentUser 과 state.memo.list.data 를 연결해주고, mapDispatchToProps 에 memoListRequest 를 전달해준다음,

componentDidMount()에서 memoListRequest 함수를 사용하세요. 실행 후, 콘솔에 결과를 프린트하도록 해보세요.

잘 됐나요?

 

Home 컨테이너 컴포넌트 서버에서 받은 결과값 MemoList 로 전달 (src/containers/Home.js)

/* MORE CODES */
    render() {
        const write = (
            <Write
                onPost={this.props.handlePost}
            />
        );

        
        return (
            <div className="wrapper">
                { this.props.isLoggedIn ? write : undefined }
                <MemoList data={this.props.memoData} currentUser={this.props.currentUser}/>
            </div>
        );
    }
/* MORE CODES */

아까전에 만들었었던 mockData 를 지우고, 진짜 데이터를 MemoList 로 전달해주세요.

currentUser props 도 실제 currentUser 값을 전달하세요.

식은죽 먹기죠? 브라우저에서 잘 로딩하는지 테스트해봅시다.

CHECKPOINT: https://github.com/velopert/react-codelab-memopad/tree/17_MEMO_READ

git clone [email protected]:velopert/react-codelab-memopad.git
cd react-codelab-memopad
git checkout 17_MEMO_READ
# 이후, src 디렉토리를 복사에서 자신의 프로젝트에 덮어씌우세요.

 

18. 읽기 기능 구현하기 – 추가 로딩 (새 메모 / 이전 메모)

새로운 메모 혹은 이전 메모를 읽어오는것 생각보다 간단합니다.

현재 페이지에 로딩되어있는 데이터 중에서 (초기로딩 된 데이터) 가장 위에 있는 메모의 _id 값보다 높은 _id 를 갖고있는 메모를 쿼리하면 새로운 메모들을 읽을 수 있고

가장 아래에 있는 메모의 _id 값보다 낮은 _id 를 갖고있는 메모를 쿼리하면 이전 메모들을 읽을 수 있습니다

그럼, Express 라우터에 API를 추가해봅시다

메모 추가 로딩 API 구현하기 (server/routes/memo.js)

/* CODES */

/*
    READ ADDITIONAL (OLD/NEW) MEMO: GET /api/memo/:listType/:id
*/
router.get('/:listType/:id', (req, res) => {
    let listType = req.params.listType;
    let id = req.params.id;

    // CHECK LIST TYPE VALIDITY
    if(listType !== 'old' && listType !== 'new') {
        return res.status(400).json({
            error: "INVALID LISTTYPE",
            code: 1
        });
    }
    
    // CHECK MEMO ID VALIDITY
    if(!mongoose.Types.ObjectId.isValid(id)) {
        return res.status(400).json({
            error: "INVALID ID",
            code: 2
        });
    }
    
    let objId = new mongoose.Types.ObjectId(req.params.id);
    
    if(listType === 'new') {
        // GET NEWER MEMO
        Memo.find({ _id: { $gt: objId }})
        .sort({_id: -1})
        .limit(6)
        .exec((err, memos) => {
            if(err) throw err;
            return res.json(memos);
        });
    } else {
        // GET OLDER MEMO
        Memo.find({ _id: { $lt: objId }})
        .sort({_id: -1})
        .limit(6)
        .exec((err, memos) => {
            if(err) throw err;
            return res.json(memos);
        });
    }
});

export default router;

 

memoListRequest 추가구현하기 (src/actions/memo.js)

export function memoListRequest(isInitial, listType, id, username) {
    return (dispatch) => {
        // inform memo list API is starting
        dispatch(memoList());
        
        let url = '/api/memo';
        
        if(typeof username==="undefined") {
            // username not given, load public memo
            url = isInitial ? url : `${url}/${listType}/${id}`;
            // or url + '/' + listType + '/' +  id
        } else {
            // load memos of specific user
            /* to be implemented */
        }
          
        return axios.get(url)
        .then((response) => {
            dispatch(memoListSuccess(response.data, isInitial, listType));
        }).catch((error) => {
            dispatch(memoListFailure());
        });
    };
}

저희가 방금 구현한 API 에 맞춰서 파라미터에 따라 URL 을 설정해줍니다.

 

memo 리듀서 MEMO_LIST_SUCCESS 부분 수정하기 (src/reducers/memo.js)

        case types.MEMO_LIST_SUCCESS: 
            if(action.isInitial) {
                return update(state, {
                    list: {
                        status: { $set: 'SUCCESS' },
                        data: { $set: action.data },
                        isLast: { $set: action.data.length < 6 }
                    }
                })
            } else {
                if(action.listType === 'new') {
                    return update(state, {
                        list: {
                            status: { $set: 'SUCCESS' },
                            data: { $unshift: action.data },
                        }
                    });
                } else {
                    return update(state, {
                        list: {
                            status: { $set: 'SUCCESS' },
                            data: { $push: action.data },
                            isLast: { $set: action.data.length < 6 }
                        }
                    });    
                }
                
          sdf  }

배열의 앞 부분에 데이터를 추가할땐 $unshift 연산자를, 뒷 부분에 추가 할 땐 $push 연산자를 사용합니다.

 

Home 컨테이너 컴포넌트에서 5초마다 새 메모 로딩 (src/containers/Home.js)

/* CODES */

class Home extends React.Component {
    
    constructor(props) {
        super(props);
        this.handlePost = this.handlePost.bind(this);
        this.loadNewMemo = this.loadNewMemo.bind(this);
    }

    componentDidMount() {
        // LOAD NEW MEMO EVERY 5 SECONDS
        const loadMemoLoop = () => {
            this.loadNewMemo().then(
                () => {
                    this.memoLoaderTimeoutId = setTimeout(loadMemoLoop, 5000);
                }
            );
        };
        
        this.props.memoListRequest(true).then(
            () => {
                // BEGIN NEW MEMO LOADING LOOP
                loadMemoLoop();
            }
        );
    }
    
    componentWillUnmount() {
        // STOPS THE loadMemoLoop
        clearTimeout(this.memoLoaderTimeoutId);
    }
    
    loadNewMemo() {
        // CANCEL IF THERE IS A PENDING REQUEST
        if(this.props.listStatus === 'WAITING') 
            return new Promise((resolve, reject)=> {
                resolve();
            });
        
        // IF PAGE IS EMPTY, DO THE INITIAL LOADING
        if(this.props.memoData.length === 0 )
            return this.props.memoListRequest(true);
            
        return this.props.memoListRequest(false, 'new', this.props.memoData[0]._id);
    }
    
    /* CODES */

}

const mapStateToProps = (state) => {
    return {
        isLoggedIn: state.authentication.status.isLoggedIn,
        postStatus: state.memo.post,
        currentUser: state.authentication.status.currentUser,
        memoData: state.memo.list.data,
        listStatus: state.memo.list.status
    };
};

/* CODES */

mapStateToProps 에서 state.memo.list.status 를 매핑해주세요.
loadNewMemo 는 새 메모를 읽어들일 때 사용되는 메소드입니다.

메모 요청 상태가 ‘WAITING’ 일 때는 로딩을 하지 않도록 하게 했는데요,
잠시 후, 새 메모를 작성 할 때 새 메모를 읽게 끔 트리거 하는 기능도 구현 할 텐데, 상태가 ‘WAITING’ 일때 무시하는 코드를 넣지 않으면
똑같은 요청을 두번 할 수 도 있게 되기 때문입니다.

이 부분에서 그냥 return 을 해도 되지만, 비어있는 Promise 를 리턴한 이유는, Write 에서 해당 메소드를 입력하고 .then 을 사용 할 수 있게 만들기 위함입니다
(메소드를 실행 하고, 성공메시지 / Write 내용초기화를 할건데, 여기서 그냥 return; 을 날려버리면 만약에 요청이 중첩됐을 때 먹통이 됩니다)

그리고, 페이지가 비어있을 경우에는 초기로딩을 시도하도록 하였습니다.

constructor 에서 this 와 binding 해주세요.

componentDidMount 에서, timeout 기능을 통하여 이 작업을 5초마다 반복하도록 설정하였습니다.

 

작성 시, 새 메모를 읽도록 트리거하기 (src/containers/Home.js)

class Home extends React.Component {
    
    /* POST MEMO */
    handlePost(contents) {
        return this.props.memoPostRequest(contents).then(
            () => {
                if(this.props.postStatus.status === "SUCCESS") {
                    // TRIGGER LOAD NEW MEMO
                    this.loadNewMemo().then(
                        () => {
                            Materialize.toast('Success!', 2000);
                        }
                    );
                } else {

                    /*
                        ERROR CODES
                            1: NOT LOGGED IN
                            2: EMPTY CONTENTS
                    */
                    let $toastContent;
                    switch(this.props.postStatus.error) {
                        case 1:
                            // IF NOT LOGGED IN, NOTIFY AND REFRESH AFTER
                            $toastContent = $('<span style="color: #FFB4BA">You are not logged in</span>');
                            Materialize.toast($toastContent, 2000);
                            setTimeout(()=> {location.reload(false)}, 2000);
                            break;
                        case 2:
                            $toastContent = $('<span style="color: #FFB4BA">Please write something</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                        default:
                            $toastContent = $('<span style="color: #FFB4BA">Something Broke</span>');
                            Materialize.toast($toastContent, 2000);
                            break;
                    }

                }
            }
        );
    }
    
    /* CODES */
}

loadMemo 라는 props 를 받아와서 메모작성이 성공하였을때 실행하도록 하였습니다
메모가 작성되면, 새 메모를 읽어오도록 명령하고, 해당 작업이 끝나면 성공했다고 알림을 띄웁니다.

 

간단하죠?

한번 메모를 작성해봅시다. 바로바로 리로딩이 되나요?

 

19. 무한 스크롤링 구현하기

f

코드펜 링크: https://codepen.io/velopert/pen/grRAbQ

무한 스크롤링을 구현하기 전에, 위 링크를 들어가서 원리를 파악하도록 해봅시다.

 

무한스크롤링의 원리는 생각보다 간단합니다.

스크롤바의 위치를 계산해서, 스크롤바가 아랫부분에 닿으면 (혹은 가까워지면) 내용울 추가하도록 하는 것 입니다.

위 예제에선 text 를 jQuery 를 통하여 append 하도록 하였지만,

우리는, 굳이 append 를 쓸 필요 없이, memoListRequest 로 이전 메모들을 불러오도록 명령하면 되겠죠?

스크롤바의 위치를  계산하는 부분에서, 우린 꼭 jQuery를 사용 할 필요는 없습니다.

순수 자바스크립트를 사용 할 수도 있지만, jQuery를 사용하는편이 더 쉽고, 어짜피 Materializecss 때문에 jQuery를 로드한 상태니
jQuery를 사용하도록 하겠습니다.

 

Home 컨테이너 컴포넌트 스크롤 리스너 작성 (src/containers/Home.js)

    componentDidMount() {
        /* CODES */
        
        
        $(window).scroll(() => {
            // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
            if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
                console.log("LOAD NOW");
            }
        });
                
                
    }

이렇게 코드를 추가하고나서 테스팅을 해보세요.

(스크롤바가 없어서 테스팅을 못한다면 페이지를 확대하거나 창의 크기를 줄여보세요)

스크롤을 해보면, 스크롤바가 하단에 가까워졌을시, 콘솔에 LOAD NOW 가 출력되는데,

v

그 구간에서 스크롤 될 때마다 출력이 된다는거죠..

저희는 그 구간에 들어갔을때 처음에만 한번 코드를 실행하게 해야하는데, 이 문제는 state 를 사용하여 간단하게 해결 할 수 있습니다.

    constructor(props) {
        super(props);
        this.loadNewMemo = this.loadNewMemo.bind(this);

        this.handlePost = this.handlePost.bind(this);

        this.state = {
            loadingState: false
        };
    }

    componentDidMount() {
        /* CODES */
        $(window).scroll(() => {
            // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
            if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
                if(!this.state.loadingState){
                    console.log("LOAD NOW");
                    this.setState({
                        loadingState: true
                    });
                }
            } else {
                if(this.state.loadingState){
                    this.setState({
                        loadingState: false
                    });
                }
            }
        });
                
                
    }

한번 코드를 테스팅해보세요. 잘 되나요?

 

이제 실제 기능을 구현해보겠습니다

class Home extends React.Component {
    
    constructor(props) {
        super(props);
        
        this.handlePost = this.handlePost.bind(this);

        this.loadNewMemo = this.loadNewMemo.bind(this);
        this.loadOldMemo = this.loadOldMemo.bind(this);
        
        this.state = {
            loadingState: false
        };
    }
    
    componentDidMount() {
        /* CODES */
        
        $(window).scroll(() => {
            // WHEN HEIGHT UNDER SCROLLBOTTOM IS LESS THEN 250
            if ($(document).height() - $(window).height() - $(window).scrollTop() < 250) {
                if(!this.state.loadingState){
                    this.loadOldMemo();
                    this.setState({
                        loadingState: true
                    });
                }
            } else {
                if(this.state.loadingState){
                    this.setState({
                        loadingState: false
                    });
                }
            }
        });
                
                
    }
    
    componentWillUnMount() {
        // STOPS THE loadMemoLoop
        clearTimeout(this.memoLoaderTimeoutId);
        
        // REMOVE WINDOWS SCROLL LISTENER
        $(window).unbind();
    }
    
    /* CODES */
    
    loadOldMemo() {
        // CANCEL IF USER IS READING THE LAST PAGE
        if(this.props.isLast) {
            return new Promise(
                (resolve, reject)=> {
                    resolve();
                }
            );
        }
        
        // GET ID OF THE MEMO AT THE BOTTOM
        let lastId = this.props.memoData[this.props.memoData.length - 1]._id;
        
        // START REQUEST
        return this.props.memoListRequest(false, 'old', lastId).then(() => {
            // IF IT IS LAST PAGE, NOTIFY
            if(this.props.isLast) {
                Materialize.toast('You are reading the last page', 2000);
            }
        });
    }
    
    /* CODES */
}

const mapStateToProps = (state) => {
    return {
        /* CODES */
        isLast: state.memo.list.isLast
    };
};

/* CODES */

이번에 수정된 코드가 꽤 많은데요, 우선 mapStateToProps 부분에 state.memo.list.isLast 를 연결해주었습니다.
이는 마지막 페이지에 도달 했을 시, 요청을 취소하기 위함입니다.

그리고 loadOldMemo 라는 메소드를 만들었습니다. 만약에 현재 읽고 있는 페이지가 마지막 페이지라면 요청이 취소됩니다.
나중에 이 메소드를 사용하고 .then() 을 사용 할 수 있도록 취소 할 땐, 비어있는 Promise 를 리턴합니다.

이전 메모들을 불러오기위하여 페이지에 로드된 메모 중 최하단 메모의 id 를 API로 전해줍니다.
API 를 실행 후, 만약에 방금 읽어들인 페이지가 마지막페이지라면 알림을 띄웁니다.

loadOldMemo 메소드가 완성되었다면, constructor 에서 this binding 을 해주고,
componentDidMount 에서 아까 console.log 를 loadOldMemo(); 로 바꿔주세요.

마지막으로, componentWillUnmount 에는 스크롤 리스너를 제거하는 unbind 코드를 추가합니다.

자, 이제 테스트를 해보세요. 잘 되나요?

 

버그잡기.. (src/containers/Home.js)

무한스크롤링 구현이 다 끝난것 같지만! 아직 안 끝났습니다.

지금 문제점이 하나 남아있는데요, 다음 이미지를 살펴봅시다.

zxcc

만약에 사용자 화면의 해상도가 무척 높다면..  스크롤바가 생기지 않겠죠?
이럴 경우엔 메모가 더 있음에도 불구하고 화면을 확대하거나 창 크기를 줄이지 않는이상 이전 메모들을 읽을 방법이 없습니다.

한번 여러분들도 페이지를 축소한다음에 새로고침을 해보세요.
이렇게 문제가 있는걸 아는데.. 그냥 넘어갈 수는 없겠죠?

해결 방법은, 초기 로딩을 한다음에, 스크롤바가 만약에 안생겼다면 이전 메모로딩을 스크롤바가 생길 때 까지 반복하면됩니다.
스크롤바가 있는지 없는지 체크를 하려면 다음 값을 비교하면됩니다: $(body).height() < $(window).height() 

    componentDidMount() {
        /* CODES */
        
        const loadUntilScrollable = () => {
            // IF THE SCROLLBAR DOES NOT EXIST,
            if($("body").height() < $(window).height()) {
                this.loadOldMemo().then(
                    () => {
                        // DO THIS RECURSIVELY UNLESS IT'S LAST PAGE
                        if(!this.props.isLast) {
                            loadUntilScrollable();
                        }
                    }
                );
            }
        };
        
        this.props.memoListRequest(true).then(
            () => {
                // BEGIN NEW MEMO LOADING LOOP
                loadUntilScrollable();
                loadMemoLoop();
            }
        );
        
        
        /* CODES */
                
                
    }

재귀적인(Recursive) 한 방법으로 버그를 해결하였습니다.

자, 이제 남은 작업들은 메모 수정/삭제/별주기/유저검색 입니다.

지금까지 잘 따라와주셨다면, 앞으로 할 것들도 쉽게 끝낼 수 있을거에요.

  • 김범준

    좋은 강좌 감사드립니다.
    덕분에 보고 많이 배우고 있습니다.

    이번 강좌에서 containers/Home.js 파일의 경우
    authentication 이 authentification 으로 잘못 기재된 것 같습니다.

    확인 부탁드립니다.
    감사합니다.

    • 강좌에서 fi 를 붙인곳이 많네요
      ㅋㅋ이때 헷갈렸었나봐요..

      github저장소에 올린건 제대로 했었는데..

      조만간 전체적으로 수정하도록 하겠습니다.

  • 조재민

    질문있습니다.
    React에서 Materialize css를 사용할 때 public/index.html에 몇 가지 코드를 추가해서 사용하셨는데, 이런 식으로 하면 모든 Materialize의 component들을 다 사용할 수 있는건가요? 제가 따로 SideNav를 추가하려고 같은 코드를 찾아서 복붙해서 실행시켜보니 demo에서 보여지는 것과 너무 차이가나네요. 혹시 React에서 materialize를 사용할 때는 추가로 해줘야하는 작업이 있는건가요? 제가 복붙한 코드의 링크는 http://materializecss.com/side-nav.html 입니다.

    • 우선 붙여넣으시고 class를 className으로 교체하시는거 잊지마시구요, componentDidMount에서 initialize해주시면 문제없이 작동할거예요

  • hspark

    immutable을 사용하지 않고 스프레드 연산자로 배열에 어떻게 추가하나요???
    memo reducer에서 새 메모 받아 오는데 스프레드 연산자로 하려고 했는데 안되네요.ㅠㅠ

  • ggoban

    강의와 진행을 다르게 해서 그런지 문제가 발생하면 처리하는데 한참 걸리네요 ㅠ session 문제가 지속적으로 발생하는데 결국 원인은 express sessin id 가 매번 새로 생성이 되서(로그인 시 생성된 세션, 메인으로 이동하면 새로운 세션, f5를 누르면 또 새로운 세션) loginInfo 를 정상적으로 찾지 못해 발생합니다. 관련된 내용을 검색해보곤 있는데 마땅한 해결책이 없네요.. 편법으로는 해결이 가능하긴 한데..OTL

  • Coding Mentor

    handlePost(contents) {
    return this.props.memoPostRequest(contents).then(
    () => {

    안녕하세요 벨로퍼트님. 궁금한게 있는데 위에 보면 memoPostRequest에 이어서 .then 을 쓰고있는데요.

    memoPostRequest를 보니까 return dispatch(…..); 이던데.

    즉 dispatch()를 리턴하고있는데 원래 dispatch가 Promise를 반환하나요?

    dispatch가 뭘 반환하는지 대해 리액트 공부하면서 한번도 들어본적이 없어서 좀 어렵네요

    • 강그루 Groo Gang

      저도 궁금했던 부분인데 thunk문서 정보 감사합니다.

  • 강그루 Groo Gang

    Write컴포넌트에서 handleChange는 input의 value를 state에 전달만해서 글쓰기 버튼 누르면 state만 바뀌고 input의 value는 안 바뀌지 않나요?

    • 강그루 Groo Gang

      (자답입니다) 아니네요 value에 state를 연결시켜주는 것을 깜빡했네요!!