【7.コメント投稿・取得編】Ruby on Rails + ReactでSNSアプリを作る
こんにちは、ミニマリストいずです。
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}
以下の様に動作していたらOKです。
まとめ
SNSアプリを作成していくにあたり、RailsとReactを連携し、コメントの投稿・取得ができるようになりました。
こちらで本アプリは完結となります。サンプルのアプリとは見た目の乖離があったり、一部の機能は未実装ですが、ここまで紹介してきたコードを利用いただくと実装できる様になっています。
また、リファクタリングもできる箇所が大量にありますので、挑戦していただければと思っています。
質問がある方は以下からご連絡ください。