LoginSignup
0
0

More than 3 years have passed since last update.

[MERN⑩] Posts & Comments

Last updated at Posted at 2020-12-02

~~~~~~~~~~ (Contents) MERN ~~~~~~~~~~~
[MERN①] Express & MongoDB Setup
https://qiita.com/niyomong/private/3281af84486876f897f7
[MERN②]User API Routes & JWT Authentication
https://qiita.com/niyomong/private/c11616ff7b64925f9a2b
[MERN③] Profile API Routes
https://qiita.com/niyomong/private/8cff4e6fa0e81b92cb49
[MERN④] Post API
https://qiita.com/niyomong/private/3ce66f15375ad04b8989
[MERN⑤] Getting Started With React & The Frontend
https://qiita.com/niyomong/private/a5759e2fb89c9f222b6b
[MERN⑥] Redux Setup & Alerts
https://qiita.com/niyomong/private/074c27259924c7fd306b
[MERN⑦] React User Authentication
https://qiita.com/niyomong/private/37151784671eff3b92b6
[MERN⑧] Dashboard & Profile Management
https://qiita.com/niyomong/private/ab7e5da1b1983a226aca
[MERN⑨] Profile Display
https://qiita.com/niyomong/private/42426135e959c7844dcb
[MERN⑩] Posts & Comments
https://qiita.com/niyomong/private/19c78aea482b734c3cf5
[MERN11] デプロイ
https://qiita.com/niyomong/private/150f9000ce51548134ad
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

1. Post Reducer, Action & Initial Component

① GET_POSTSとPOST_ERRORアクションを設置

//...
export const ACCOUNT_DELETED = 'ACCOUNT_DELETED';
+ export const GET_POSTS = 'GET_POSTS';
+ export const POST_ERROR = 'POST_ERROR';

② postReducerを生成

reducers/post.js
//...
import profile from './profile';
+ import post from './post';

export default combineReducers({
  alert,
  auth,
  profile,
+  post,
});
reducers/post.js
import { GET_POSTS, POST_ERROR } from '../actions/types';

const initialState = {
  posts: [],
  post: null,
  loading: true,
  error: {},
};

export default function (state = initialState, action) {
  const { type, payload } = action;

  switch (type) {
    case GET_POSTS:
      return {
        ...state,
        posts: payload,
        loading: false,
      };
    case POST_ERROR:
      return {
        ...state,
        error: payload,
        loading: false,
      };
    default:
      return state;
  }
}

③ postアクション作成

actions/post.js
import axios from 'axios';
import { setAlert } from './alert';
import { GET_POSTS, POST_ERROR } from './types';

// Get posts
export const getPosts = () => async (dispatch) => {
  try {
    const res = await axios.get('/api/posts');
    dispatch({
      type: GET_POSTS,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

postsルートをApp.jsに設置

src/App.js
//...
+ import Posts from './components/posts/Posts';
import PrivateRoute from './components/routing/PrivateRoute';
//...
      <Switch>
        <Route exact path="/register" component={Register} />
        <Route exact path="/login" component={Login} />
+       <Route exact path="/posts" component={Posts} />
        <PrivateRoute exact path="/dashboard" component={Dashboard} />
//...

⑤ 左上のサービス名にPosts一覧リンクを設置

components/layout/Navbar.js
//...
  return (
    <nav className="navbar bg-dark">
      <h1>
+       <Link to="/posts">
          <i className="fas fa-code" /> Refnote
        </Link>
      </h1>

      {!loading && (
        <Fragment>{isAuthenticated ? authLinks : guestLinks}</Fragment>
//...

2. Post Item Component

① Postsコンポーネントを生成。

components/posts/Posts.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import PostItem from './PostItem';
import { getPosts } from '../../actions/post';

const Posts = ({ getPosts, post: { posts, loading } }) => {
  useEffect(() => {
    getPosts();
  }, [getPosts]);

  return loading ? (
    <Spinner />
  ) : (
    <Fragment>
      <h1 className="large text-primary">Posts</h1>
      <p className="lead">
        <i className="fas fa-user" /> Welcome to the Posts
      </p>
      {/* PostForm */}
      <div className="Posts">
        {posts.map((post) => (
          <PostItem key={post._id} post={post} />
        ))}
      </div>
    </Fragment>
  );
};

Posts.propTypes = {
  getPosts: PropTypes.func.isRequired,
  post: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  post: state.post,
});

export default connect(mapStateToProps, { getPosts })(Posts);

② PostItemコンポーネントを生成。

以下項目追加。
・写真{avatar}、ユーザ名{name}、投稿日時{date}、投稿内容{text}、likes数{likes.length}、comments数{comments.length}、認証済の場合の削除ボタン{!auth.loading && user ===...}
・ProfileDisplayリンク設置{/profile/${user}}

components/posts/PostItem.js
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Moment from 'react-moment';
import { connect } from 'react-redux';

const PostItem = ({
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
    <div class="post bg-white p-1 my-1">
      <div>
        <Link to={`/profile/${user}`}>
          <img class="round-img" src={avatar} alt="" />
          <h4>{name}</h4>
        </Link>
      </div>
      <div>
        <p class="my-1">{text}</p>
        <p class="post-date">
          Posted on <Moment format="YYY/MM/DD">{date}</Moment>
        </p>
        <button type="button" class="btn btn-light">
          <i class="fas fa-thumbs-up" />{' '}
          {likes.length > 0 && <span>{likes.length}</span>}
        </button>
        <button type="button" class="btn btn-light">
          <i class="fas fa-thumbs-down"></i>
        </button>
        <Link to={`/posts/${_id}`} class="btn btn-primary">
          Discussion{' '}
          {comments.length > 0 && (
            <span class="comment-count">{comments.length}</span>
          )}
        </Link>
              {auth.isAuthenticated === false
                ? null
                : !auth.loading &&
                  user === auth.user._id && (
                    <button
                      onClick={(e) => deletePost(_id)}
                      type="button"
                      className="btn btn-danger"
                    >
                      <i className="fas fa-times"></i>
                    </button>
                  )}
      </div>
    </div>
  );
};

PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

export default connect(mapStateToProps, {})(PostItem);

3. Like & Unlike Functionality

① TYPEアクションにUPDATE_LIKES設置
actions/types.js
//...
+ export const UPDATE_LIKES = 'UPDATE_LIKES';

② Likeアクションを設置
・Get postsをコピペしてAddLikeを加工
・api/postsAPIにputしたい(payloadは、postのidとlikes)
・removeLikeはaddLikeとほぼ同じ。

actions/post.js
//...
+ import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types';
//...
// 以下、Get postsをコピペして加工
// Add like
export const AddLike = (id) => async (dispatch) => {
  try {
    const res = await axios.put(`/api/posts/like/${id}`);
    dispatch({
      type: UPDATE_LIKES,
      payload: { id, likes: res.data },
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

// Remove like
export const removeLike = (id) => async (dispatch) => {
  try {
    const res = await axios.put(`/api/posts/unlike/${id}`);
    dispatch({
      type: UPDATE_LIKES,
      payload: { id, likes: res.data },
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ UPDATE_LIKE Reducerを設置

・postsを操作する。each post
・postのidとpayloadのid(actionで設置)がマッチしていれば、postとlikesを返す。マッチしていなければ、do nothing(ただpostを返す)

reducers/post.js
+ import { GET_POSTS, POST_ERROR, UPDATE_LIKES } from '../actions/types';
//...以下追加。
    case UPDATE_LIKES:
      return {
        ...state,
        posts: state.posts.map((post) =>
          post._id === payload.id ? { ...post, likes: payload.likes } : post
        ),
        loading: false,
      };
    default:
      return state;
  }
}

④ PostItemコンポーネントにLikeボタン設置

components/posts/PostItem.js
//...
+ import { addLike, removeLike } from '../../actions/post';

const PostItem = ({
+  addLike,
+  removeLike,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
//...
        <button
+          onClick={(e) => addLike(_id)}
          type="button"
          className="btn btn-light"
        >
          <i className="fas fa-thumbs-up" />{' '}
          {likes.length > 0 && <span>{likes.length}</span>}
        </button>
        <button
+          onClick={(e) => removeLike(_id)}
          type="button"
          className="btn btn-light"
        >
          <i className="fas fa-thumbs-down"></i>
        </button>
//...
PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
+  addLike: PropTypes.func.isRequired,
+  removeLike: PropTypes.func.isRequired,
};

+ export default connect(mapStateToProps, { addLike, removeLike })(PostItem);

4. Deleting Posts

① DELETE_POSTタイプ

actions/types.js
//...
+ export const DELETE_POST = 'DELETE_POST';

② deletePostアクション設置

・removeLikeアクションをコピペして更新

actions/post.js
//...
+ import { DELETE_POST, GET_POSTS, POST_ERROR, UPDATE_LIKES } from './types';
//...
// Delete post
export const deletePost = (id) => async (dispatch) => {
  try {
    await axios.delete(`/api/posts/${id}`);
    dispatch({
      type: DELETE_POST,
      payload: id,
    });

    dispatch(setAlert('Post Removed', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ DELETE_POST reducer

reducers/post.js
//..
  UPDATE_LIKES,
+  DELETE_POST,
} from '../actions/types';
//...
+   case DELETE_POST:
+     return {
+       ...state,
+       posts: state.posts.filter((post) => post._id !== payload),
+       loading: false,
+      };
//...

④ Deleteボタン設置。

components/posts/PostItem.js
//...
+ import { addLike, removeLike, deletePost } from '../../actions/post';

const PostItem = ({
  addLike,
  removeLike,
+  deletePost,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
}) => {
  return (
//...
         {!auth.loading && user === auth.user._id && (
           <button
+            onClick={(e) => deletePost(_id)}
             type="button"
             className="btn btn-danger"
           >
             <i className="fas fa-times"></i>
           </button>
         )}
//...
PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  addLike: PropTypes.func.isRequired,
+  removeLike: PropTypes.func.isRequired,
+  deletePost: PropTypes.func.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

+ export default connect(mapStateToProps, { addLike, removeLike, deletePost })(
  PostItem
);

5. Adding Posts

① ADD_POSTアクションタイプ設置

actions/types.js
//...
+ export const ADD_POST = 'ADD_POST';

② addPostアクション

・deletePostアクションをコピペして編集。

actions/post.js
//...
import {
+  ADD_POST,
  DELETE_POST,
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
} from './types';
//...
// Add post
export const addPost = (formData) => async (dispatch) => {
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  try {
    const res = await axios.post('/api/posts', formData, config);

    dispatch({
      type: ADD_POST,
      payload: res.data,
    });

    dispatch(setAlert('Post Created', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

④ ADD_POST Reducer

~~~ 説明 ~~~
posts: [payload(Newポスト), ...state.posts(既存ポスト配列)]
→ 配列の順番もNewポストが一番上に来るようになる。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
+  ADD_POST,
} from '../actions/types';
//...
  switch (type) {
//...
+   case ADD_POST:
+     return {
+       ...state,
+       posts: [payload, ...state.posts],
+       loading: false,
+     };
//...

⑤ポストフォームコンポーネントを生成

【前提】 rafcp + htmlテンプレ + Fragment+className以外の追加を + で表記。

components/posts/PostForm.js
+ import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
+ import { connect } from 'react-redux';
+ import { addPost } from '../../actions/post';

+ const PostForm = ({ addPost }) => {
+  const [text, setText] = useState('');

  return (
    <Fragment>
      <div className="post-form">
        <div className="bg-primary p">
          <h3>Say Something...</h3>
        </div>
        <form
          className="form my-1"
+          onSubmit={(e) => {
+            e.preventDefault();
+            addPost({ text });
+            setText('');
+          }}
        >
          <textarea
            name="text"
            cols="30"
            rows="5"
            placeholder="Create a post"
+            value={text}
+            onChange={(e) => setText(e.target.value)}
            required
          ></textarea>
          <input type="submit" className="btn btn-dark my-1" value="Submit" />
        </form>
      </div>
    </Fragment>
  );
};

PostForm.propTypes = {
+  addPost: PropTypes.func.isRequired,
};

+ export default connect(null, { addPost })(PostForm);

⑥Postコンポーネントにを追加。

components/posts/Post.js
//...
import PostItem from './PostItem';
+ import PostForm from './PostForm';
import { getPosts } from '../../actions/post';
//...
  return loading ? (
    <Spinner />
  ) : (
    <Fragment>
      <h1 className="large text-primary">Posts</h1>
      <p className="lead">
        <i className="fas fa-user" /> Welcome to the Posts
      </p>
+      <PostForm />
      <div className="Posts">
        {posts.map((post) => (
          <PostItem key={post._id} post={post} />
        ))}
      </div>
    </Fragment>
  );
};
//...

6. Single Post Display

GET_POSTアクションタイプ

actions/types.js
//...
export const GET_POSTS = 'GET_POSTS';
+ export const GET_POST = 'GET_POST';
//...

① getPost

・getPostsをコピペして一部修正。

actions/post.js
//...
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
+  GET_POST,
} from './types';
//...
//以下すべて追加。
// Get post
export const getPost = (id) => async (dispatch) => {
  try {
    const res = await axios.get('/api/posts/${id}');
    dispatch({
      type: GET_POST,
      payload: res.data,
    });
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

② GET_POSTリデューサーを設置。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
+  GET_POST,
} from '../actions/types';
//...
  switch (type) {
    case GET_POSTS:
      return {
        ...state,
        posts: payload,
        loading: false,
      };
+   case GET_POST:
+     return {
+       ...state,
+       post: payload,
+       loading: false,
+     };

③ Postルート設置。

src/App.js
//...
import Posts from './components/posts/Posts';
+ import Post from './components/post/Post';
//...
          <Route exact path="/posts" component={Posts} />
+         <Route exact path="/posts/:id" component={Post} />
//...

④ PostItemにshowActionsを設置

~~~ 説明 ~~~
showActions: 他のコンポーネントでshowActions内のFragmentは表示させないようにする
・PostItemコンポーネント上では表示させたいので、true設定
・Postコンポーネント上では非表示にしたいので、false設定

components/posts/PostItem.js
//...
const PostItem = ({
  addLike,
  removeLike,
  deletePost,
  auth,
  post: { _id, text, name, avatar, user, likes, comments, date },
+  showActions,
}) => {
  return (
//...
+         {showActions && (
+           <Fragment>
             <button
               onClick={(e) => addLike(_id)}
               type="button"
               className="btn btn-light"
             >
//...
             {!auth.loading && user === auth.user._id && (
               <button
                 onClick={(e) => deletePost(_id)}
                 type="button"
                 className="btn btn-danger"
               >
                 <i className="fas fa-times"></i>
               </button>
             )}
+           </Fragment>
+         )}
        </div>
      </div>
    </Fragment>
  );
};

+ PostItem.defaultProps = {
+  showActions: true,
+ };

PostItem.propTypes = {
  post: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  addLike: PropTypes.func.isRequired,
  removeLike: PropTypes.func.isRequired,
  deletePost: PropTypes.func.isRequired,
};
//...

⑤ Postコンポーネント

PostItemコンポーネントを持ってくる(しかし、showActionsをfalseにする)

components/post/Post.js
import React, { Fragment, useEffect } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { connect } from 'react-redux';
import Spinner from '../layout/Spinner';
import PostItem from '../posts/PostItem';
import { getPost } from '../../actions/post';

const Post = ({ getPost, post: { post, loading }, match }) => {
  useEffect(() => {
    getPost(match.params.id);
  }, [getPost]);

  return loading || post === null ? (
    <Spinner />
  ) : (
    <Fragment>
      <Link to="/posts" className="btn">
        Back To Posts
      </Link>
      <PostItem post={post} showActions={false} />
    </Fragment>
  );
};

Post.propTypes = {
  getPost: PropTypes.func.isRequired,
  post: PropTypes.object.isRequired,
};

const mapStateToProps = (state) => ({
  post: state.post,
});

export default connect(mapStateToProps, { getPost })(Post);

7. Adding Comments

① アクションタイプ設定

actions/types.js
//...
export const ADD_POST = 'ADD_POST';
+ export const ADD_COMMENT = 'ADD_COMMENT';
+ export const REMOVE_COMMENT = 'REMOVE_COMMENT';

② addComment + deleteComment アクションを追加。

・addComment: addPostアクションをコピペして修正。
・deleteComment: addCommentアクションをコピペして修正。

actions/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
  GET_POST,
+  ADD_COMMENT,
+  REMOVE_COMMENT,
} from './types';
//...
//以下全て追加。
// Add comment
export const addComment = (postId, formData) => async (dispatch) => {
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
  };

  try {
    const res = await axios.post(
      `/api/posts/comment/${postId}`,
      formData,
      config
    );

    dispatch({
      type: ADD_COMMENT,
      payload: res.data,
    });

    dispatch(setAlert('Comment Added', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

// Delete comment
export const deleteComment = (postId, commentId) => async (dispatch) => {
  try {
    const res = await axios.delete(`/api/posts/comment/${postId}/${commentId}`);

    dispatch({
      type: REMOVE_COMMENT,
      payload: commentId,
    });

    dispatch(setAlert('Comment Removed', 'success'));
  } catch (err) {
    dispatch({
      type: POST_ERROR,
      payload: { msg: err.response.statusText, status: err.response.status },
    });
  }
};

③ ADD_COMMENT + REMOVE_COMMENT リデューサー設置

comments: payload -> postのすべてのコメントデータを持ってくる。
comments: state.post.comments.filter((comment) => comment._id !== payload) -> 削除したcomment._id以外のすべてのコメントデータを持ってくる。

reducers/post.js
import {
  GET_POSTS,
  POST_ERROR,
  UPDATE_LIKES,
  DELETE_POST,
  ADD_POST,
  GET_POST,
+  ADD_COMMENT,
+  REMOVE_COMMENT,
} from '../actions/types';
//...
    case ADD_COMMENT:
      return {
        ...state,
        post: { ...state.post, comments: payload },
        loading: false,
      };
    case REMOVE_COMMENT:
      return {
        ...state,
        post: {
          ...state.post,
          comments: state.post.comments.filter(
            (comment) => comment._id !== payload
          ),
        },
        loading: false,
      };
    default:
      return state;
  }
}

④ コメントフォームを設置。

・Fragment内は、PostFormからコピペして修正。

components/post/CommentForm.js
import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { addComment } from '../../actions/post';

const CommentForm = ({ postId, addComment }) => {
  const [text, setText] = useState('');

  return (
    <Fragment>
      <div className="post-form">
        <div className="bg-primary p">
          <h3>Leave a Comment</h3>
        </div>
        <form
          className="form my-1"
          onSubmit={(e) => {
            e.preventDefault();
            addComment(postId, { text });
            setText('');
          }}
        >
          <textarea
            name="text"
            cols="30"
            rows="5"
            placeholder="Create a post"
            value={text}
            onChange={(e) => setText(e.target.value)}
            required
          ></textarea>
          <input type="submit" className="btn btn-dark my-1" value="Submit" />
        </form>
      </div>
    </Fragment>
  );
};

CommentForm.propTypes = {
  addComment: PropTypes.func.isRequired,
};

export default connect(null, { addComment })(CommentForm);

⑤ PostページにCommentFormを設置。

components/post/Post.js
//...
import PostItem from '../posts/PostItem';
+ import CommentForm from '../post/CommentForm';
import { getPost } from '../../actions/post';
//...
  return loading || post === null ? (
    <Spinner />
  ) : (
    <Fragment>
      <Link to="/posts" className="btn">
        Back To Posts
      </Link>
      <PostItem post={post} showActions={false} />
+      <CommentForm postId={post._id} />
    </Fragment>
  );
};
//...

8. Comment Display & Delete


useIDはcommentから持ってくる。

components/post/CommentItem.js
import React, { Fragment } from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import Moment from 'react-moment';
import { deleteComment } from '../../actions/post';

const CommentItem = ({
  postId,
  comment: { _id, text, name, avatar, user, date },
  auth,
  deleteComment,
}) => {
  return (
    <Fragment>
      <div class="post bg-white p-1 my-1">
        <div>
          <Link to={`/profile/${user}`}>
            <img class="round-img" src={avatar} alt="" />
            <h4>{name}</h4>
          </Link>
        </div>
        <div>
          <p class="my-1">{text}</p>
          <p class="post-date">
            Posted on <Moment format="YYY/MM/DD">{date}</Moment>
          </p>
          {!auth.loading && user === auth.user._id && (
            <button
              onClick={(e) => deleteComment(postId, _id)}
              type="button"
              className="btn btn-danger"
            >
              <i className="fas fa-times" />
            </button>
          )}
        </div>
      </div>
    </Fragment>
  );
};

CommentItem.propTypes = {
  postId: PropTypes.string.isRequired,
  comment: PropTypes.object.isRequired,
  auth: PropTypes.object.isRequired,
  deleteComment: PropTypes.func.isRequired,
};

const mapStateToProps = (state) => ({
  auth: state.auth,
});

export default connect(mapStateToProps, { deleteComment })(CommentItem);
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0