create

【7.コメント投稿・取得編】Ruby on Rails + ReactでSNSアプリを作る

pepe87
記事内に商品プロモーションを含む場合があります

こんにちは、ミニマリストいずです。

Rail +ReactでSNS機能を持ったWEBアプリの作り方を紹介していく連続企画の第7弾です。

今回はコメントの投稿と取得を実装していきます。

初学者の方にもわかるようにまとめていきますが、不明点がありましたら以下から質問をいただければと思います。

作成するアプリの機能紹介(再掲)

サンプル動画(再掲)

作成する機能一覧(再掲)

作成する機能一覧
  • ユーザ管理機能
    • 新規登録
    • ログイン
  • メモカテゴリ機能
    • 作成
    • 取得
    • 動画内ではクレドと表現されている部分
  • メモ機能
    • 作成
    • 一覧取得
    • 詳細取得
    • 動画内ではアクションメモと表現されている部分
  • コメント機能
    • 作成
    • 取得

Webアプリの基本であるCRUDを実装しています。

どのアプリを作るにも参考になると思いますので、初学者の方にもおすすめのアプリになっています。

メモ詳細の追加(バックエンド)

コメントの追加、確認をできるように、メモの詳細を確認できる様にしていきます。

コントローラーにコードを追加していきます。

コントローラーのコード追加

1class Api::V1::MemosController < ApplicationController
2  before_action :authenticate_api_v1_user!, only: [:create, :my_memos]
3  before_action :set_user, only: [:create, :my_memos]
4
5  def index
6    memos = Memo.includes(:category, :user).order(created_at: 'DESC')
7    render json: memos.to_json(include: {category: {only: :name}, user: {only: :name}}), status: :ok
8  end
9  
10  def create
11    memo = Memo.new(memo_params)
12    if memo.save
13      render json: memo, status: :created
14    else
15      render json: memo.errors, status: :unprocessable_entity
16    end
17  end
18  
19  def show
20    memo = Memo.includes(:category, :user).find(params[:id])
21    render json: memo.to_json(include: {category: {only: :name}, user: {only: :name}}), status: :ok
22  end
23
24  def my_memos
25    memos = Memo.includes(:category, :user).where(user_id: @user.id).order(created_at: 'DESC')
26    render json: memos.to_json(include: {category: {only: :name}, user: {only: :name}}), status: :ok
27  end
28
29  private
30
31  def memo_params
32    params.require(:memo).permit(:content, :category_id).merge(user_id: @user.id)
33  end
34
35  def set_user
36    @user = current_api_v1_user
37  end
38end
39

ルーティングの追加

次にルーティングの追加もします。

1Rails.application.routes.draw do
2  namespace :api do
3    namespace :v1 do
4      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
5        registrations: 'api/v1/auth/registrations',
6        sessions: 'api/v1/auth/sessions',
7      }
8      
9      resources :categories, only: [:index, :create]
10      resources :memos, only: [:index, :create, :show] do 
11        collection do
12          get :my_memos
13        end
14      end
15      resources :users, only: [] do
16        collection do
17          get :user_name
18        end
19      end
20    end
21  end  
22end
23

復習ポイントの部分、自分の言葉で説明できていそうですか?

誰かに説明しようとすると理解ができていたかがわかりやすいので、ぜひ言語化に取り組んでみてください。

言語化できないな、理解できていないかもなと思ったら、以下から質問もしてみてくださいね。

メモ詳細の追加(フロントエンド)

コメント機能をつける際に、chakra-uiのiconを使うために以下コマンドを実行します。

yarn add @chakra-ui/icons

コマンドを実施できたら、Memoの詳細画面に遷移できるように、一覧画面からの遷移をできる様にします。

1import React, { useState, useEffect } from 'react';
2import { useNavigate, } from 'react-router-dom';
3import {
4    SimpleGrid,
5    Center,
6    Card, 
7    Heading,
8    CardHeader, 
9    CardBody,
10    Stack,
11    StackDivider,
12    Text,
13    CardFooter,
14} from "@chakra-ui/react";
15
16import axios from 'axios';
17
18export default function Memos() {
19    const [ memos, setMemos ] = useState([]);
20    const navigate = useNavigate();
21    
22    useEffect(() => {
23        fetchGetMemos();
24    }
25    , []);
26
27    const navMemoShow = (id) => {
28        navigate(`/memo/${id}`);
29      };
30    
31    async function fetchGetMemos() {
32        try {
33            const res = await axios.get("http://localhost:3010/api/v1/memos");
34        
35            if (!res.status || (res.status < 200 && res.status >= 300)) {
36                throw new Error(`HTTP error! status: ${res.status}`);
37            };
38        
39            setMemos(res.data);
40        }
41        catch (error) {
42            console.error('Error getting memos:', error);
43        }
44    }
45    
46    return (
47        <Center>
48        <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={10}>
49            {memos.map((memo, index) => (
50                <Card key={index}>
51                    <CardHeader>
52                        <Heading size="md">{memo.category.name}</Heading>
53                    </CardHeader>
54                    <CardBody onClick={() => navMemoShow(memo.id)}>
55                        <Stack divider={<StackDivider />} spacing='4'></Stack>
56                        <Text>{memo.content}</Text>
57                    </CardBody>
58                    <CardFooter>
59                        <Text>ユーザ名: {memo.user.name}</Text>
60                    </CardFooter>
61                </Card>
62            ))}
63        </SimpleGrid>
64        </Center>
65    );
66}
1import { BrowserRouter, Routes, Route } from "react-router-dom";
2import NewCategory from "./component/page/NewCategory";
3import Memos from "./component/page/Memos";
4import Memo from "./component/page/Memo";
5import NewMemo from "./component/page/NewMemo";
6import MyMemos from "./component/page/MyMemos";
7import UserRegist from "./component/page/UserRegist";
8import Login from "./component/page/Login";
9
10function App() {
11  return (
12    <BrowserRouter>
13      <Routes>
14        <Route path={`/`} element={<Memos />} />
15        <Route path={`/memo/:id`} element={<Memo />} />
16        <Route path={`/category/new`} element={<NewCategory />} />      
17        <Route path={`/memo/new`} element={<NewMemo />} />
18        <Route path={`/my_memos`} element={<MyMemos />} />
19        <Route path={`/registration`} element={<UserRegist/>} />
20        <Route path={`/login`} element={<Login />} />
21      </Routes>
22    </BrowserRouter>
23  );
24}
25
26export default App;

メモの詳細画面のコードも追加していきます。

1import React, {useState, useEffect} from "react";
2import { useParams } from 'react-router-dom';
3
4import {
5  Box,
6  Card, 
7  CardHeader, 
8  CardBody, 
9  CardFooter,
10  Heading, 
11  Text,
12  Stack,
13  StackDivider,
14} from "@chakra-ui/react";
15
16import { ChatIcon } from '@chakra-ui/icons'
17
18import axios from 'axios';
19
20export default function Memo() {
21  const { id } = useParams();
22  const [memo, setMemo] = useState();
23
24  useEffect(() => {
25    fetchGetMemo()
26  }, []);
27
28  async function fetchGetMemo() {
29    try {
30        const res = await axios.get(`http://localhost:3010/api/v1/memos/${id}`);
31    
32        if (!res.status || (res.status < 200 && res.status >= 300)) {
33            throw new Error(`HTTP error! status: ${res.status}`);
34        };
35
36        setMemo(res.data);
37    }
38    catch (error) {
39        console.error('Error getting memos:', error);
40    }
41}
42
43  return (
44    <>
45        {memo &&
46            <Card>
47                <CardHeader>
48                    <Heading size='md'>{memo.category.name}</Heading>
49                </CardHeader>
50                <CardBody>
51                    <Stack divider={<StackDivider />} spacing='4'>
52
53                    <Box>
54                        <Heading size='xs' textTransform='uppercase'>
55                        内容
56                        </Heading>
57                        <Text pt='2' fontSize='sm'>
58                        {memo.content}
59                        </Text>
60                    </Box>
61                    </Stack>
62                </CardBody>
63                <CardFooter display="flex" justifyContent="space-between">
64                    <Box>
65                        <ChatIcon mr="4px" />
66                    </Box>
67                    <Box>
68                        {memo.user.name}
69                    </Box>
70                </CardFooter>
71            </Card>}
72    </>
73  );
74}

以下の様に表示されたらOKです。

コメント機能(バックエンド)

以下の様にマイグレーション、モデル、コントローラー、ルーティングを追加します。

1class CreateComments < ActiveRecord::Migration[6.1]
2  def change
3    create_table :comments do |t|
4      t.string :content
5      t.references :memo, index: true, foreign_key: true
6      t.references :user, index: true, foreign_key: true
7      t.timestamps
8    end
9  end
10end
1class Memo < ApplicationRecord
2  belongs_to :category
3  belongs_to :user
4  validates :category, :content, presence: true
5end
6
1class Api::V1::CommentsController < ApplicationController
2    before_action :authenticate_api_v1_user!, only: [:create]
3    before_action :find_user, only: [:create]
4  
5    def index
6      comments = Comment.includes(:user).where(memo_id: params[:memo_id]).order(id: "DESC")
7      render json: comments.to_json(include: {user: {only: [:id, :name]}})
8    end
9  
10    def create
11      comment = Comment.new(comment_params)
12  
13      if comment.save
14        redirect_to action: "index", memo_id: params[:memo_id]
15      else
16        head :unprocessable_entity
17      end
18    end
19  
20    private
21  
22    def find_user
23      @user = User.find_by(uid: request.headers["uid"])
24    end
25  
26    def comment_params
27      params.permit(:content, :memo_id).merge(user_id: @user.id)
28    end
29  end
1Rails.application.routes.draw do
2  namespace :api do
3    namespace :v1 do
4      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
5        registrations: 'api/v1/auth/registrations',
6        sessions: 'api/v1/auth/sessions',
7      }
8      
9      resources :categories, only: [:index, :create]
10      resources :memos, only: [:index, :create, :show] do 
11        collection do
12          get :my_memos
13        end
14      end
15      resources :comments, only: [:index, :create]
16      resources :users, only: [] do
17        collection do
18          get :user_name
19        end
20      end
21    end
22  end  
23end

コメント機能(フロントエンド)

以下の様にメモ詳細画面のコードを変更します。

1import React, {useState, useEffect} from "react";
2import { useParams } from 'react-router-dom';
3
4import {
5    Box,
6    Card, 
7    CardHeader, 
8    CardBody, 
9    CardFooter,
10    Heading, 
11    Text,
12    Center,
13    Textarea,
14    Stack,
15    StackDivider,
16    Modal,
17    ModalOverlay,
18    ModalContent,
19    ModalHeader,
20    ModalFooter,
21    ModalBody,
22    Button,
23    useDisclosure,
24    useToast,
25} from "@chakra-ui/react";
26
27import { ChatIcon } from '@chakra-ui/icons'
28
29import axios from 'axios';
30
31export default function Memo() {
32  const { id } = useParams();
33  const toast = useToast();
34  const [memo, setMemo] = useState();
35  const [comment, setComment] = useState("");
36  const [comments, setComments] = useState([]);
37  const { isOpen, onOpen, onClose } = useDisclosure();
38
39  useEffect(() => {
40    async function fetchData() {
41        if (memo) {
42            fetchGetComments();
43        }
44        else{
45            await fetchGetMemo(); 
46        }
47    }
48    fetchData();
49}, [memo]);
50
51  async function fetchGetMemo() {
52    try {
53        const res = await axios.get(`http://localhost:3010/api/v1/memos/${id}`);
54    
55        if (!res.status || (res.status < 200 && res.status >= 300)) {
56            throw new Error(`HTTP error! status: ${res.status}`);
57        };
58
59        setMemo(res.data);
60    }
61    catch (error) {
62        console.error('Error getting memos:', error);
63    }
64}
65
66 async function fetchGetComments() {
67      try {
68            const params = new URLSearchParams();
69            params.append('memo_id', memo.id);
70            const res = await axios.get(`http://localhost:3010/api/v1/comments?${params.toString()}"`);
71
72            if (!res.status || res.status < 200 && res.status >= 300) {
73                throw new Error(`HTTP error! status: ${res.status}`);
74            }
75        
76            setComments(res.data);
77      } catch (error) {
78        console.error('Error fetching data:', error.message);
79
80        toast({
81          title: 'コメントの取得に失敗しました',
82          status: 'error',
83          isClosable: true,
84        });
85      }
86    }
87
88async function fetchCreateComment() {
89    try {
90        const res = await axios.post(
91            "http://localhost:3010/api/v1/comments",
92            {
93                content: comment,
94                memo_id: memo.id
95            },
96            {
97                headers: {
98                    'access-token': localStorage.getItem('access-token'),
99                    'client': localStorage.getItem('client'),
100                    'uid': localStorage.getItem('uid'),
101                }
102            }
103        );
104
105      if (!res.status || res.status < 200 && res.status >= 300) {
106        throw new Error(`HTTP error! status: ${res.status}`);
107      }
108
109      setComment("");
110      setComments(res.data);
111      onClose();
112    } catch (error) {
113      console.error('Error creating comment:', error);
114
115      toast({
116        title: 'コメントの取得に失敗しました',
117        status: 'error',
118        isClosable: true,
119      });
120    }
121  }
122
123  return (
124    <>
125        {memo &&
126            <Card>
127                <CardHeader>
128                    <Heading size='md'>{memo.category.name}</Heading>
129                </CardHeader>
130                <CardBody>
131                    <Stack divider={<StackDivider />} spacing='4'>
132
133                    <Box>
134                        <Heading size='xs' textTransform='uppercase'>
135                        内容
136                        </Heading>
137                        <Text pt='2' fontSize='sm'>
138                        {memo.content}
139                        </Text>
140                    </Box>
141                    </Stack>
142                </CardBody>
143                <CardFooter display="flex" justifyContent="space-between">
144                    <Box>
145                        <ChatIcon mr="4px" onClick={localStorage.getItem('uid') && onOpen} />
146                        {comments.length}
147                    </Box>
148                    <Box>
149                        {memo.user.name}
150                    </Box>
151                </CardFooter>
152            </Card>}
153            <Center>
154          <Text mt="12px" mb="24px" fontSize="24px" fontWeight="bold">
155            Comments
156          </Text>
157        </Center>
158        {console.log(comments)}
159        {comments.map((comment, index) => {
160          return (
161            <Card key={index} mb="12px">
162              <CardBody>
163                {comment.content}
164              </CardBody>
165              <CardFooter display="flex" justifyContent="flex-end">
166                {comment.user.name}
167              </CardFooter>
168            </Card>
169          );
170        })}
171        <Modal isOpen={isOpen} onClose={onClose}>
172          <ModalOverlay />
173          <ModalContent>
174            <ModalHeader>Comment</ModalHeader>
175
176            <ModalBody>
177              <Textarea
178                mb="10px"
179                placeholder='コメントを入力'
180                value={comment}
181                onChange={(e) => setComment(e.target.value)} />
182            </ModalBody>
183
184            <ModalFooter>
185              <Center>
186                <Button onClick={() => fetchCreateComment()}>
187                  送信
188                </Button>
189              </Center>
190            </ModalFooter>
191          </ModalContent>
192        </Modal>
193    </>
194  );
195}

URLSearchParamsは、URLのクエリパラメータを操作するための便利なJavaScriptの組み込みクラスです。クエリパラメータは、URLの末尾に?の後に追加され、key=valueの形式で表されます。これらのパラメータは、Webサーバーに追加情報を送信するために使用されます。

GETでは、今までの書き方だとparamsを送れないため、この様に記述しています。

以下の様に動作していたらOKです。

まとめ

SNSアプリを作成していくにあたり、RailsとReactを連携し、コメントの投稿・取得ができるようになりました。

こちらで本アプリは完結となります。サンプルのアプリとは見た目の乖離があったり、一部の機能は未実装ですが、ここまで紹介してきたコードを利用いただくと実装できる様になっています。

また、リファクタリングもできる箇所が大量にありますので、挑戦していただければと思っています。

質問がある方は以下からご連絡ください。

ABOUT ME
いず
いず
1995年生まれ・愛知県出身・都心で二人暮らし。IT系PDM・プロコーチ・プログラミング学習サービス代表。趣味:ミニマリスト・コーヒー・デザイン
記事URLをコピーしました