3편에서는 계정인증 부분을 완료했었습니다.
어떤가요? 조금 복잡했나요? 이 파일 수정하고.. 저 파일 수정하고, 조금 정신없었을 수도 있지만,
결국엔 비슷한 동작이 계속해서 반복되기 때문에 몇번 해보면 쉬워진답니다.
이런 동작이 반복되죠
- action type 만들기
- action creator 만들기
- reducer 만들기
- 컴포넌트에서 사용
이번 편에서는 메모를 작성하는 부분을 완성해보도록 하겠습니다.
16. Write 컴포넌트 만들기
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;
위에 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 컴포넌트
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 git@github.com: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. 무한 스크롤링 구현하기
코드펜 링크: 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 가 출력되는데,
그 구간에서 스크롤 될 때마다 출력이 된다는거죠..
저희는 그 구간에 들어갔을때 처음에만 한번 코드를 실행하게 해야하는데, 이 문제는 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)
무한스크롤링 구현이 다 끝난것 같지만! 아직 안 끝났습니다.
지금 문제점이 하나 남아있는데요, 다음 이미지를 살펴봅시다.
만약에 사용자 화면의 해상도가 무척 높다면.. 스크롤바가 생기지 않겠죠?
이럴 경우엔 메모가 더 있음에도 불구하고 화면을 확대하거나 창 크기를 줄이지 않는이상 이전 메모들을 읽을 방법이 없습니다.
한번 여러분들도 페이지를 축소한다음에 새로고침을 해보세요.
이렇게 문제가 있는걸 아는데.. 그냥 넘어갈 수는 없겠죠?
해결 방법은, 초기 로딩을 한다음에, 스크롤바가 만약에 안생겼다면 이전 메모로딩을 스크롤바가 생길 때 까지 반복하면됩니다.
스크롤바가 있는지 없는지 체크를 하려면 다음 값을 비교하면됩니다: $(“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) 한 방법으로 버그를 해결하였습니다.
자, 이제 남은 작업들은 메모 수정/삭제/별주기/유저검색 입니다.
지금까지 잘 따라와주셨다면, 앞으로 할 것들도 쉽게 끝낼 수 있을거에요.