20210610のRailsに関する記事は26件です。

RailsでインポートするCSVにBOMをつけたら列名に一致するカラムの値を取り出せなくなった

気づけば簡単な話なのですが、RailsでCSVインポート機能を作っている時にサンプルCSVがもしエクセルで開いても文字化けしないようにとBOMをつけたら、読み込む時に一致するカラムの値を取り出せなくなったので記録しておこうと思います。 TL;DR BOMがついている時にCSVの値をカラム名で指定してしまうと列名の最初の文字にBOM(ZERO WIDTH NO-BREAK SPACE, 文字コード65279)が入ってしまい、読み込む際に列名を指定しても適切に取得できなくなります。 見えないので最初の文字をString#ordメソッドなどで比較しないと気づきにくいです。 解決方法は追記に書いてあります。 本文 下記がBOMをつけてダウンロードするようにしたサンプルCSVのapplication_controller.rb です。 他のコントローラでこのコントローラを継承して、rowsメソッドを実装する形になります。 controllers/admin/csv/sample/application_controller.rb class Admin::Csv::Sample::ApplicationController < Admin::Csv::ApplicationController def show respond_to do |format| format.csv do send_data create_csv, type: :csv, filename: "sample_#{controller_name}.csv" end end end protected # 子のコントローラでオーバーライドを強制 def rows raise NotImplementedError.new("You must implement #{self.class}##{__method__}") end def create_csv bom = %w[EF BB BF].map { |e| e.hex.chr }.join CSV.generate(bom) do |csv| rows.each do |row| csv << row end end end end 下記は例としてsports_controller.rb controllers/admin/csv/sample/sports_controller.rb class Admin::Csv::Sample::SportsController < Admin::Csv::Sample::ApplicationController private def rows [ ["name"], ["バスケットボール"], ] end end このようにしてダウンロードしたCSVをモデルに記述したメソッドでインポートしようとします。 models/sport.rb class Sport < ApplicationRecord class << self def import(file) CSV.foreach(file.path, headers: true) do |row| record = find_by(id: row["id"]) || new record.attributes = row.to_hash.slice(*updatable_attributes) record.save end end def updatable_attributes ["name"] end end end controllers/admin/csv/sports_controller.rb class Admin::Csv::SportsController < Admin::Csv::ApplicationController def new end def create Sport.import(params[:file]) flash[:notice] = t('action.imported') redirect_to admin_sports_url end end 着目すべきは以下の行です。 record.attributes = row.to_hash.slice(*updatable_attributes) ここではモデルのインスタンスの各フィールドに許可された値を挿入しています。 sport.rbで許可された値はnameのみなので、{'name': 'バスケットボール'}のような値が入るはずですが、BOMがついているとrow.to_hashで返ってくるkeyの部分がBOM付きのnameとなりupdatable_attributesのnameという文字列と一致しません。 これによってCSVインポートしても値が更新されないという現象が発生してしまいます。 しかもぱっと見は同じ文字列に見えるのでなかなか気づきにくいです。 これを解決するにはsampleのCSVにBOMをつけないか、列名ではなく列の順序などで値を取得するなどの方法が考えられるかと思います。 BOM付きのCSVをインポートする場合はお気をつけください。 追記 @mcfishさんのご指摘によりCSVクラスの特異メソッドforeachの引数encodingにBOMを考慮した'BOM|UTF-8'を設定すればBOM付きのCSVでも問題なくインポートできました。 ご指摘頂きありがとうございます。 CSV.foreach(file.path, headers: true, endoging: 'BOM|UTF-8') do |row|
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

index.html.erbに全ての投稿を表示させる方法 Rails

ホロライブの桃鈴ねねさんが大好きです。 なので、Webページに全てのアーカイブを表示したいと思います。 ※あらかじめテーブルに全てのアーカイブ情報を取得してます。 app/controllers/archive_controller.rb class ArchiveController < ApplicationController def index @archive = Archive.all end end これで@archiveにあらかじめarchivesテーブルに登録しておいた情報を格納します。 app/views/archive/index.html.erb <%= render partial: "archive" , collection: @archive %> renderメソッドを使うことで、@archiveというインスタンス変数を_archive.html.erbでも使用できます。 更にオプションでcollectionを使用することで、@archiveに格納されている全ての情報を使用できます。 app/views/archive/_archive.html.erb <div class="card"> <%= link_to "#{archive.archiveurl}" do %> <%= image_tag (archive.archiveimg) %> <% end %> </div> この辺のビューは調整が必要ですが、簡単にやればこんな感じ。 archiveimgにはサムネイルの画像URLが格納されています。 archiveurlには動画のURLが格納されています。 ポイントはrenderメソッドを使用することと、collectionというオプションを使用すること、partialを使用することで、一つのファイルの記述量を減らすことが出来ます。 とりあえずこれで、動画情報を全て表示する、という目標が達成できました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails6 でwebpackerを使ってBootstrapが導入できなかった時にやったこと

ターミナル上で下記コマンドを入力 Bootstrapが関係しているパッケージがpopper v2のため? 現状このコマンドで解決できる程度の理解しかないので、 コメントで教えていただけるとありがたいです。 yarn remove popper.js yarn add @popperjs/core
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsのビューの基本中の基本

Railsにおいて、ビューの基本は app/assets/layouts/application.html.erb を経由するということ。 逆に言えば、application.html.erbに記述していないと、仮にcontrollerとそれに対応するviewを作っても何も表示されません。 そのため、最低限、application.html.erbには下記の記述が必要です。 app/assets/layouts/application.html.erb <%= yield %> これを記述しておけば、各controllerに対応するviewを書いておけばそれぞれ呼び出されます。 これだけだと流石に、無駄が多いので、各種ビューを見て重複する部分はapplication.html.erbにまとめておけば良いと思います。 (例えばheadタグ要素とか。)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6でJQueryが動かない

JQueryをインストール Rails6でのJQuery導入方法はこちらを参照しました。 JQueryが動かない 無事に動作確認(こちらを参照)を済ませ、application.jsファイルにJQueryを記述するも動かず... Chromeの検証からConsoleを確認すると次のようなエラーが Uncaught DOMException: Failed to execute 'querySelectorAll' on 'Document'  //記述したJQueryコード is not a valid selector. 動作確認はできているため、どうやらapplication.jsの読み込み時にエラーしている様子。 エラー文やネット上を調べていると、「$('クラス名')」の箇所でエラーが起きていると推測できました。 改めてapplication.jsの記載を確認すると、下記のようになっていました。 import Rails, { $ } from "@rails/ujs" import Turbolinks from "turbolinks" import * as ActiveStorage from "@rails/activestorage" import "channels" import ('jquery') Rails.start() Turbolinks.start() ActiveStorage.start() 1行目の{ $ }がなにやら怪しい...と思い下記のように修正しました。 import Rails from "@rails/ujs" #ここを修正 import Turbolinks from "turbolinks" import * as ActiveStorage from "@rails/activestorage" import "channels" import ('jquery') Rails.start() Turbolinks.start() ActiveStorage.start() これで無事に動きました! import Rails, { $ } from "@rails/ujs" の行を触った覚えもなく、rails newで新しく立ち上げたプロジェクトにJQueryを導入して確かめましたが再現性はありませんでした... この一行が記述された原因を特定することはできませんでしたが、解決することはできたので同じようなエラーでお困りの方の参考になれば幸いです!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Docker / Rails / Passenger] binding.pryに対して、'stty: standard input: Inappropriate ioctl for device' 発生

Prerequisites (前提) Item Type / Version OS Docker (ruby:2.5.7) Ruby on Rails 5.2.3 Application Server passenger(6.0.7) ※application serverは、pumaではなかった。 pumaへと変更することでこの問題は解消した。 docker-compose.yml version: '3' services: web: # ------ 省略 -------- ports: - 3000:3000 # binding.pryで処理を止め、コマンドプロンプト入力を行うために必要な設定2つ stdin_open: true tty: true 以下のコマンドでdockerコンテナを起動すると、 docker上ではなくてホストOS上でRuby on Railsのサーバーが動作しているかのようにログを表示させることができる。 そして、コマンドプロンプト上でのキー入力が可能になる。 $ docker-compose up -d # container_nameの部分には、 # docker-compose up -d の実行結果として表示されるcontainer名を指定すればよい $ docker attach container_name Phenomenon (事象) Ruby on Railsの開発を行う上で大変便利なpryやpry-railsが使えなかった。 具体的には、binding.pryと書いた箇所へ処理が到達すると、以下のようなエラーメッセージが表示された。 # ------ 省略 -------- App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: stty: 'standard input': Inappropriate ioctl for device App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: stty: 'standard input' App 269 output: : Inappropriate ioctl for device App 269 output: [1] pry(#<Hotels::Queries::SearchByAmenities>)> 注目すべきエラーメッセージはこれ。 stty: standard input: Inappropriate ioctl for device Solution (解決策) ネットの海をさまよってみたものの、どこに書いてある方法も役には立たなかった。 役に立つ方法を見つけられなかっただけ、という可能性が十分あります...(´;ω;`)ブワッ 仕方ないので、試しに、Application Serverを、passengerから普段使っているpumaへと変更したところ、あっさり解決。 Gemfile # gem 'passenger' <- コメントアウトした gem 'puma' # <- 書き足した この状態でbundle installし直し、Application Serverを再起動。 その後、binding.pryを書いておいた処理へと到達させたところ、無事キー入力可能な状態になった。 [1] pry(#<Hotels::Queries::SearchByAmenities>)> 本番でpassenger使っていて、どうしてもこの問題を回避したいのであれば、開発中だけpumaを使うのもありかもしれない。 もしpassengerを使う理由がなく、かつ自分に決定権があれば、真面目にpumaへの切り替えを検討していると思う。 Discussion (議論の余地がある部分) passenger単独でもこの事象が発生するのか、それとも、Dockerと組み合わせた時にだけこの事象が発生するのか、確かめることができていません。 つまり、「OSにrubyをインストールし、OSのrubyを使ってpassengerをApplication Serverとして利用している」という場合に、同様の事象が発生する可能性をまだ潰せていません。 (もうDockerなしで開発するということを考えたくないので)この検証はしないのですが、もしpassenger単独でこの事象が発生するのであれば、passengerの方で何かしら対策を用意してくれているような気がしないでもないです。 逆に、Dockerとの組み合わせによる問題であった場合は、完全に放置されていたとしてもおかしくない....₍₍ (ง ˘ω˘ )ว ⁾⁾スヤッスヤッ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【RSpec 基本】 Rails初学者の自分が知らなかった開発環境ごとにDBが違う話

プログラミング初学者の私が、RSpecを書いていて 環境ごとにDBが違うことを知らずハマったので、記事に書き留めておきます。 プログラミング初学者の方の参考になれば嬉しいです。 また、間違い等ありましたらご指摘頂けると助かります。 それでは、見ていきましょう!! 【RSpec 基本】 Rails初学者の自分が知らなかった開発環境ごとにDBが違う話 RSpecを書く中で 「テストってそもそもプロダクトコードをブラウザで動かしながらやってるの?」 「RSpecで定義した変数と、プロダクトコードに定義した変数ってどう違う?」 「RSpecで定義したデータって、テストのあとはどうなるの?」 こんな疑問が頭の中にブワーッと入ってきて、「何がわからないのかわからない」状態になりました。笑 そんな初学者あるある?な疑問を解決していきます! Railsの環境には3つの種類がある 結論から言うと、下記の4点を知ることで疑問が解決しました! 「Railsには 本番環境、開発環境、テスト環境の3つの環境が用意されており、それぞれで違うDBを参照している。」 「RSpecは テスト環境で実行されるため、作成されたレコードはテスト環境用のDBに格納され、テスト終了後に削除される。」 テスト時には新しくヘッドレス(見えない)ブラウザを立ち上げて、テスト用DBから取得した内容を表示したページを検証している。 表示ページのHTMLはプロダクトコードを元に生成される。 Railsの「3つの環境」については他の記事でもたくさん解説されています。 【Rails】Railsの3つの環境 【Rails】configのenvironments配下のファイルの意味と記述内容を理解する。(development.rb, production.rb, test.rbとは?開発環境・本番環境・テスト環境の違い) 本番環境 実際にユーザーがシステムを使用する環境のことです。 具体的に言うと、普段私たちが使っている「amazon」や「楽天市場」が本番環境にアップされたサービスです。 例え話をすると、、 プログラミングを初めると必ずといって良いほど 「herokuにデプロイする」 という言葉を見かけます。 これはコーディングしたプログラムをherokuにデプロイする(pushする)ことでURLが作られて、そのURLを友だちに送ったりすると作ったサービスを見られるようになります。 (本番環境にアップしないとそのサービスを第3者がインターネット上で見ることはできません) 誰でも見られるそのサービスは「本番環境で動いている」ということになります。 開発環境 これが普段、私達がプログラミングをして、ブラウザで動きをチェックしている環境です! Railsではデフォルトの設定がこの「開発環境」になっています。 ちなみにそれぞれの環境の設定をしているファイルは config/environmentsの中にある以下の3つです! development.rb(開発環境) production.rb(本番環境) test.rb(テスト環境) テスト環境 そして、残りのひとつがテスト環境です。 プロダクトコードのHTMLをヘッドレス(見えない)ブラウザで表示し、「RSpec」や「Minitest」などによって定義されたテスト用DBのレコードを参照して、テストを実行します。 つまり、localhostでアクセスするページに表示されている内容と、テストで検証している内容には全く関係がありません。 ちなみにRailsでは「Minitest」がデフォルトになっています。 なぜ環境ごとにDBが分かれているのか 環境ごとにDBが分かれていないと、DBには開発用のダミーデータと顧客のデータが混在することになり、間違えて削除してしまう、ということになりかねないからです。 先程も例に挙げた「amazon」を使って説明します。 「amazon」では顧客のクレジットカードや住所などの情報を入力します。 これらの重要な情報をDBに保存し、管理するわけですが、DBがひとつだけの場合、顧客のデータを消してしまうことがあるかもしれません。 そして本番環境では常にサービスが動いています。 本番環境のDBに不具合が発生すると、買い物したくてもできなくなってしまうということです。 amazonくらい大規模になるとその損失は計り知れませんね。。。 おわりに 今回はRSpecで私自身が引っかかったところについて記事を書きました。 まだまだ知識が少なく、間違っている点など見付けた際はぜひご教授頂けると助かります。 それでは、最後まで読んで頂きありがとうございました。 追記 コメントにてご指摘を頂きました。 Railsの環境は名前を付けて増やすことができます。 以下に解説記事のリンクを貼っておきます。 https://qiita.com/yusabana/items/a1f4fe2c37b20db2a3f6 またひとつ勉強になりました!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails] chart.jsを使って投稿に連動したグラフを作る!

はじめに オリジナルアプリの作成にて、投稿に連動したグラフを作成したので作成方法をアウトプット! 導入方法 1:chart.jsをCDN経由でRailsアプリに導入 2:グラフ生成のコード記述 3:すこーしコード書き換えて投稿と連動させる そもそもCDNってなに? CDN(Content Delivery Network)の略で、 ウェブコンテンツを効率的かつスピーディーに配信できるように工夫されたネットワークのことのようです。 これによってオリジンサーバーの負担を減らせるみたい。 感覚的には、アマゾンの倉庫をいろいろな場所に設けて、(東京の人は東京の倉庫から出荷!、大阪の人は大阪の倉庫から出荷!みたいな感じで)負担を減らそう!ってことで理解してます。 では、本題、、、 1:chart.jsをCDN経由でRailsアプリに導入 以下のリンクからCDNを作成! https://www.jsdelivr.com/package/npm/chart.js?version=2.8.0 view > layouts > application.html.erbのheadタグにscriptを記述 application.thml.erb <!DOCTYPE html> <html> <head> <title>Nutrik</title> <%= csrf_meta_tags %> <%= csp_meta_tag %> <script type="text/javascript" src="https://js.pay.jp/v1/"></script> <%= stylesheet_link_tag 'application', media: 'all'%> <%= javascript_pack_tag 'application' %> <script src=”ここに貼り付ける!!!”></script> </head> <body> <%= yield %> </body> </html> 2:グラフ生成のコード記述 index.html.erb <div class="chart"> <canvas id="myChart" ></canvas> <script> var ctx = document.getElementById("myChart"); var myChart = new Chart(ctx, { type: 'radar', data: { labels: ["1群", "2群", "3群", "4群", "5群", "6群"], datasets: [ { label: '6つの基礎食品群', backgroundColor: 'rgba(255,165,0,0.5)', borderColor: 'rgba(255,165,0,0.2)', data: [1,2,3,4,5,6] }, ] }, options: { maintainAspectRatio: false, scale: { ticks: { suggestedMin: 0, suggestedMax: 7 } } } }); </script> </div> そしたらこんな感じのグラブができます! 3:すこーしコード書き換えて投稿と連動させる こんな感じでコードを少し書き換えてあげる。 index.html.erb data: [1,2,3,4,5,6] index.html.erb data: [<%= @food_group[1] %>, <%= @food_group[2] %>, <%= @food_group[3] %>, <%= @food_group[4] %>, <%= @food_group[5]%>, <%= @food_group[6] %>] 私の場合、持ってきたいデータが特殊だったので、少しコントローラーで加工して、インスタンス変数でビューに渡しています。 終わりに 結果、すごく簡単にグラフが作れる!! 参考記事
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

バリデーションの種類について Rails

多分よく使うであろうバリデーション #入力必須 presence: true #一意性(被ったらエラー) uniqueness: true #最小文字数 length: { minimum: n } #最大文字数 length: { maximum: n } #パスワードは英数字を含める PASSWORD_REGEX = /\A(?=.*?[a-z])(?=.*?[\d])[a-z\d]+\z/i.freeze validates_format_of :password #email @を含める VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i format: { with: VALID_EMAIL_REGEX } #全角カタカナ format: { with: /\A[ァ-ヶー-]+\z/, message: '全角カタカナで入力して下さい' } #全角カタカナ、ひらがな、漢字 format: { with: /\A[ぁ-んァ-ン一-龥々]+\z/, message: '全角ひらがな、全角カタカナ、漢字で入力して下さい' } 参考文献 https://qiita.com/h1kita/items/772b81a1cc066e67930e https://qiita.com/necojackarc/items/cad2d4eb80f0629ad196 Railsガイドもわかりやすいです。 https://railsguides.jp/active_record_validations.html
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

imgix + S3 + CarrierWave

背景 開発した自作アプリ(レシピ投稿サイト)のトップページで、画像表示があまりに重く遅かったため、初心者なりに改善できないか色々試行錯誤して、何とか改善することができました。今回はその記録と、日本語資料があまりないimgix(外部API)の使い方をまとめておきます! ?ちなみにトップページはこんな感じです  スライダーの画像も入れると20枚強くらいですね まず結果から 最初にトップページをAWSにデプロイした時、画像がなかなか表示されなかったので、GTmetrix(Webサイトのパフォーマンスを評価するツール)にかけてみました。そして、目が点になりました。 32.3秒・・・!? 遅すぎる。さすがにこれは何とかしなければ。 色々と試行錯誤を重ね・・・結果、こうなりました? おぉ、1.7秒・・・! 涙が出るほど嬉しかったです、、 念のため検証ツールのネットワークタブからも確認。 キャッシュ無効で671ms。問題なさそうです。 大まかな流れ CarrierWaveでの画像投稿を可能にする CarrierWaveの投稿画像をS3に保存されるように設定 imgixを登録・設定 大まかな流れはこんな感じです。 技術的に未熟者でだいぶ遠回りしている感もありますが(特に#1)、その背景もこの後ご説明します、、、 なお、imgixだけ知りたい! という方は、飛ばして#3からご覧ください(S3利用が前提です) 環境 ruby 2.6.3 rails 5.2.6 OS:Linux(CentOS) IDE:Cloud9 実装の前に、経緯のお話 上記の流れになった背景を先に整理しておきたいと思います。 ここは私の試行錯誤の履歴ですので、手順だけご覧になりたい方は飛ばしていただいて構いません。 〜imgixにたどり着くまで〜 表示速度を上げるためにはじめに取り組んだのは、投稿画像の圧縮でしたが、ギリギリ許容できる画質まで落とした状態で約30秒かかっていました。しかも、お世辞にも良い画質とは言えなかったため、ユーザー目線で考えるとこれはちょっと、、という課題も新たに発生してしまいました。そもそも、ユーザーはスマホなどで撮影した画像をそのまま投稿するはずで、投稿サイトである以上、対応を考えなければいけません。 「違和感のない画質を維持しつつ、表示速度を改善する」 そんなこと初心者の私にできるのだろうか、、 と不安になりながら方法を探す中で、外部APIであるimgixにたどりつきました。 imgixの公式ページにはサービスについてこう書いてあります: imgix transforms, optimizes, and intelligently caches your entire image library for fast websites and apps using simple and robust URL parameters. 画像の最適化をしてくれ、かつキャッシュによってWebサイトを高速化できる。 画像最適化付きCDNという位置付けのようです。メリットとしては、以下のような点があげられます。 自サーバーの負荷が削減されることでWEBサーバーからのレスポンス速度低下を抑えることができる わざわざリサイズや圧縮せずとも、元画像を入れておき適切なパラメータを付与した画像URLにするだけで適切な画像を返してくれる Japonlineの記事より引用 元々見ていたのはCloudinaryというAPIだったのですが、導入難度がやや高めであるのに対し、imgixは画像URLを使うことで比較的容易に導入でき、公式が紹介しているrails向けのライブラリもあったので、imgixを使ってみることにしました。 〜refileからCarrierWaveに乗り換え〜 そこでまずimgix上で無料登録を済ませ、imxgix-railsの公式ドキュメント(こちら)でRailsアプリへの導入方法を確認しました。 私は元々画像投稿にrefileを使用しており、refileとの併用も可能とは書いてあったのですが、refileはURLやパスをDBに保存しないので、imgix-railsとrefileを繋げるためのヘルパーメソッドの定義が必要と書かれていました。 そのヘルパーメソッドの記述内容も親切にGitHubに書かれているのですが・・・これが、お恥ずかしながら初心者の私には何をしているのかがよく理解できず。理解できていないものをコピペしてうまくいっても、本質的でないと思いました。CarrierWaveなら投稿画像にパスがつくため、接続のための事前設定なしに利用できるとも記載されていたため、思い切ってCarrierWaveに置き換えることにしました(これには賛否両論あるとは思いますが、初心者なりにできることを必死で考えた結果と、温かい目で見ていただければ幸いです、、) 以上の試行錯誤を経て、CarrierWaveで無事画像が表示されることを確認した上で、S3でバケットを作成し、画像がS3にアップロードされることも確認が取れたので、railsアプリケーション上でimgixの設定を行いました(そこに至るまで本当に苦労しました、、) 〜CarrierWave+S3 で既に表示速度は大幅改善していたが、、〜 外部ストレージを設定した時点で、既に速度自体は大幅に改善していたので、もはやimgixを導入しなくてもいいのではないかと思ったのですが、GTmetrixで確認したところGTmetrix Grade(総合評価)が「F」になっていました(元々はD〜Eだったので悪化)。愕然。 表示速度は改善していたのですが、PageSpeedInsightsにもかけてみたところ、「画像サイズが不適切」なことが主な問題なようでした。imgixの画像最適化によってこの部分は修正できそうだったため、導入後あらためて確認したところ、「C」ランクに改善し、圧縮後の粗かった画像も綺麗に表示されるようになりました。 〜ちょっと余談〜 なお、imgixは国内では一休.comや日経新聞で導入されているようです。 導入事例も含めimgixがどういうサービスかはimgix という画像変換サービス(メモ)によくまとまっています。 まだ国内では導入事例が少ないimgixですが、表示速度の改善方法を英語で調べていたことで、このAPIに早い段階で出会えたのはラッキーでした。 ところで、読み方はnginx風なのかと思いきや「 image・icks (イメジックス)」のようです。ずっと読み方わからなかったのですが、公式の解説動画でそう呼ばれていました。 実装手順 CarrierWaveでの画像投稿(STEP1)及びS3バケットの作成(STEP2)については、【Rails】 CarrierWaveチュートリアルを参考にしました。とても丁寧に解説されているので先にご一読をおすすめします。 (STEP1の内容はほぼ上記の記事をもとに書いています。割愛しても良いかと思ったのですが、一部記述内容が異なるのと、アップローダー名やカラム名をSTEP3以降の内容と揃えて記載しておいた方がわかりやすいと考えたため、上記記事の内容をかなり拝借しながら記述しております。ご了承ください) ではそれぞれ見ていきましょう。 STEP1. CarrierWaveでの画像投稿を可能にする ※すでにCarrierWaveを使用している人はSTEP2に飛んでください。 1-1. Gem追加 gemfile gem 'carrierwave', '~> 2.0' 保存したらbundle installします。 1-2. Uploader追加 terminal rails g uploader アップローダー名 例)rails g uploader PostRecipe これで、app/uploaders/post_recipe_uploader.rbが作成されます。 ここには、アップロードするファイルの保存パスやサイズなどを指定することができます。 今回画像ファイルのサイズなどはimgix側で最適化してもらうので特に設定はしませんが、どのストレージにアップロードするかを指定しておきます。 app/uploaders/post_recipe_uploader.rb class PostRecipeUploader < CarrierWave::Uploader::Base # Choose what kind of storage to use for this uploader: if Rails.env.production? storage :fog else storage :file end # Override the directory where uploaded files will be stored. # This is a sensible default for uploaders that are meant to be mounted: def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end end 3行目で、本番環境の場合はstorage :fog(今回はS3)を、開発環境の場合はstorage :file(アプリケーションのpublic/uploads/モデル名/画像用カラム名/id配下)を、画像の保存先に指定しています。開発環境で投稿した画像までS3で管理する必要はないので、明示的に分けています。 なお、store_dirでは保存されるディレクトリを設定しています。 ここはデフォルトの表記のままで問題ありません。 開発環境とはいえ、public/uploads/配下に保存される画像がGitHubにあがるのはセキュリティの観点からもあまりよろしくないので、念のため.gitignoreに追加しておきましょう。 .gitignore /public/uploads 1-3. アップロード画像用のカラムを追加 terminal rails g migration add_post_recipe_image_to_post_recipes post_recipe_image:string アップロードする画像の情報を保存するpost_recipe_imageカラムを、PostRecipeモデルに作成します(カラム名やモデル名は適当な内容に修正してください)。なお、このカラムには画像データではなく、画像のファイル名が保存され、ビューで画像を表示する際には、「画像が格納されているパス」と「DBに保存されているファイル名」が使われます。 コマンドを実行したら、マイグレーションファイルが作成されるので、rails db:migrateしましょう。 1-4. コントローラーのストロングパラメーターに追記 post_recipes_controllerのストロングパラメーターに、先ほど作成したpost_recipe_imageカラムを追記します。 app/controllers/post_recipes_controller.rb def post_recipe_params params.require(:post_recipe).permit( :user_id, :title, :introduction, :post_recipe_image, #ここを追加 #以下略 ) end 1-5. Uploaderクラスとカラムを紐づける app/models/post_recipe.rb mount_uploader :post_recipe_image, PostRecipeUploader 先ほど作成した画像用カラムと、Uploaderの紐付けを行います。 紐付けを行うことで、画像アップロード時に、Uploaderに記述した諸設定を利用できます(例えば、アップロード時にどこに画像を保存するか等)。 画像用カラムを作成したモデル(今回の場合はPostRecipeモデル)ファイルに、上記を記述します。 1-6. ビューにファイル選択ボックスを追加 app/views/post_recipes/new.html.erb <%= form_with model: @post_recipe, local:true do |f| %> <div> <div class="from-group"> <h6>写真をアップロード</h6> <%= f.file_field :post_recipe_image %> </div> </div> <% end %> 上記のf.file_filed :カラム名と記述することで、画像投稿が行えます。 もし編集画面もある場合は、同じように追記/修正しましょう。 なお、画像の表示については後ほどimgixのix_image_tagを使用するため、CarrierWaveの画像表示タグにする必要はありませんが、CarrierWaveで投稿した画像を表示するには、<%= image_tag @post_recipe.post_recipe_image.url %>のように書きます(post_recipe_imageの部分は、カラム名です)。 refileから移行する場合 モデル、コントローラー(ストロングパラメーター)、ビューなどのファイルにあるrefile関連の記述は削除し、refileの画像用カラムも削除しておきます。 STEP2. CarrierWaveの投稿画像がS3に保存されるように設定 2-1. S3のバケット作成 S3のバケット作成については他に様々な記事で紹介されているため、割愛します。 私は「実装手順」の冒頭で紹介した記事の「AWS設定」に沿って作成しました。 上記記事の「CarrierWave設定」以降は、記事と少々内容が変わってくるので、次の項でまとめます。 2-2. CarrierWaveにS3の設定を追加 まず、gemfileにfog-awsを追加します。 投稿画像の保存先を外部ストレージ(S3)にするのを助けてくれるgemです。 保存したらbundle installします。 gemfile gem 'fog-aws' ?次に、以下のファイルをコマンドで作成します。 terminal touch config/initializers/carrierwave.rb ?作成したcarrierwave.rbに、S3バケット名とIAMユーザーの情報を記述します。 config/initializers/carrierwave.rb require 'carrierwave/storage/abstract' require 'carrierwave/storage/file' require 'carrierwave/storage/fog' CarrierWave.configure do |config| config.storage :fog config.fog_provider = 'fog/aws' config.fog_directory = '作成したバケット名' config.fog_credentials = { provider: 'AWS', aws_access_key_id: Rails.application.credentials.aws[:access_key_id], aws_secret_access_key: Rails.application.credentials.aws[:secret_access_key], region: 'ap-northeast-1', path_style: true } end IAMユーザーのaws_access_key_idとaws_secret_access_keyの値は、credentials.yml.encに記載します。 credentials.yml.encとmaster.keyは秘密情報を管理する仕組みで、環境変数を使わず秘密情報を管理できます。master.keyは、credentials.yml.encを複合化(暗号化されたデータを元に戻すこと)します。この仕組みについては、【Rails】Rails5.2以降で追加された「credentials.yml.enc」について簡単にまとめてみた!が参考になります。 ?では、credentials.yml.encを編集し、IAMユーザーの情報を保存します。 直接エディタからは編集できないため、vimで記述します。 terminal EDITOR=vim bin/rails credentials:edit credentials.yml.enc # Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. secret_key_base: yyy aws: access_key_id: xxx #ここを追記 secret_access_key: xxx #ここを追記 xxxの部分に実際の値を記入し、escしてから:wqで保存します。 secret_key_baseはデフォルトの値のままです。 これでCarrierWaveでアップロードした画像が、S3に保存されます。 STEP3. imgixを登録・設定 3-1. 会員登録 公式サイトより会員登録を行います。 3ヶ月間は$10のクレジットが付いてくるため、実質無料です(2021年6月時点)。 無料期間後の料金については、「リアルタイム画像処理機能が充実した CDN、「imgix」 を試してみたらとても簡単で便利だった件」が参考になります。 必須項目を入力してサインアップします。 3-2. Sourceの追加 サインアップ後、ログインするとDashboardが表示され、下図のような画面が表示されます。 ADD A SOURCEをクリックし、Sourceを作成します。 ちなみに、Sourceは複数作成できます。 General Source Type: 「AmazonS3」を選択 AWS Settings Access Key ID: IAMのアクセスキーの値を入力 Secret Access Key: IAMのシークレット・アクセスキーの値を入力 S3 Bucket: S3バケット名を入力 Path Prefix: 「uploads」を入力 Domains imgix Subdomain: imgixのサブドメインとなる値を自由に入力 他の項目は未入力で問題ありません。 入力できたらSAVEをクリックし、デプロイされたらSourceの設定は完了です。 3-3. Railsアプリケーションとimgixを接続 開発環境に戻ります。 ?まずapplication.rbのmodule アプリ名内に、以下を記述します。 config/application.rb Rails.application.configure do config.imgix = { source: ENV['IMGIX_SOURCE'] } end sourceには、先ほどimgix上で設定したsourceのサブドメインを入力します。 念のため、環境変数化しておきます。 .env IMGIX_SOURCE="yyy.imgix.net(sourceのサブドメイン)" ?次に、storage.ymlに以下を追記します。 Active Storageに認識させるS3の情報が、imgixのsourceと同じになるように設定します。 config/storage.yml amazon: service: S3 access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> region: ap-northeast-1(AWSリージョン名) bucket: S3バケット名 これでimgixがS3に保存されているマスター画像にアクセスできるようになりました。 3-4. ビューファイルにix_image_tagを追記 imgix-railsの公式ドキュメントにあるように、ix_image_tagは、imgixが画像のリサイズ、クロップなどを行う上で必要なパラメーターを渡してくれるヘルパーメソッドです。 app/views/homes/top.html.erb <%= ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 150, h: 150, fit: 'crop' }, tag_options: {class: 'rounded-circle'}) %> <%= link_to ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 150, h: 150, fit: 'crop' }, tag_options: {class: 'rounded-circle'}), post_recipe_path(@post_recipe.id) %> ?post_recipe_imageの部分はCarrierWaveの設定で追加した画像用カラム名を指定します。 画像にリンクを貼る場合は、link_toを用いればOKです。 classやalt属性を指定する場合はtag_options内に、リサイズの値やクロップの指定はurl_params内に記述します。 ちなみにfallbackの画像を表示したい場合は、assets/images配下にfallback用の画像を格納し、以下のように記述すれば、画像が投稿された場合とそうでない場合とで表示を区別できます。 app/views/homes/top.html.erb <% if @post_recipe.post_recipe_image.blank? %> <%= image_tag('no_recipe_image.jpg', size: '70', class:'rounded-circle') %> <% else %> <%= ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 70, h: 70, fit: 'crop' }, tag_options: {class: 'rounded-circle'}) %> <% end %> これで本番環境にデプロイし、画像がうまく表示されていれば成功です。 最後に、デプロイ時に.envファイルを手動で転送するのを忘れないようにしましょう(これを転送してあげないと、imgixのsourceが読めずにエラーになります)。 終わりに refileで記述していた時は、画像の見せたい部分がうまく表示されないこともありましたが、imgixで最適化したことでうまくクロップされ、画質も格段に良くなりました。 さらに、S3と併用したことで、表示速度もアップすることができました。私が探した限りでは、「Railsアプリ x imgix x S3」の導入方法は日本語の記事が出回っていなかったため、今回整理してみました。何かのお役に立てれば幸いです。お疲れ様でした! 参考資料
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

30秒かかった画像表示を1.7秒に改善した話(imgix + S3 + CarrierWave)

背景 開発した自作アプリ(レシピ投稿サイト)のトップページで、画像表示があまりに重く遅かったため、初心者なりに改善できないか色々試行錯誤して、何とか改善することができました。今回はその記録と、日本語資料があまりないimgix(外部API)の使い方をまとめておきます! ?ちなみにトップページはこんな感じです  スライダーの画像も入れると20枚強くらいですね まず結果から 最初にトップページをAWSにデプロイした時、画像がなかなか表示されなかったので、GTmetrix(Webサイトのパフォーマンスを評価するツール)にかけてみました。そして、目が点になりました。 32.3秒・・・!? 遅すぎる。さすがにこれは何とかしなければ。 色々と試行錯誤を重ね・・・結果、こうなりました? おぉ、1.7秒・・・! 涙が出るほど嬉しかったです、、 念のため検証ツールのネットワークタブからも確認。 キャッシュ無効で671ms。問題なさそうです。 大まかな流れ CarrierWaveでの画像投稿を可能にする CarrierWaveの投稿画像をS3に保存されるように設定 imgixを登録・設定 大まかな流れはこんな感じです。 技術的に未熟者でだいぶ遠回りしている感もありますが(特に#1)、その背景もこの後ご説明します、、、 なお、imgixだけ知りたい! という方は、飛ばして#3からご覧ください(S3利用が前提です) 環境 ruby 2.6.3 rails 5.2.6 OS:Linux(CentOS) IDE:Cloud9 実装の前に、経緯のお話 上記の流れになった背景を先に整理しておきたいと思います。 ここは私の試行錯誤の履歴ですので、手順だけご覧になりたい方は飛ばしていただいて構いません。 〜imgixにたどり着くまで〜 表示速度を上げるためにはじめに取り組んだのは、投稿画像の圧縮でしたが、ギリギリ許容できる画質まで落とした状態で約30秒かかっていました。しかも、お世辞にも良い画質とは言えなかったため、ユーザー目線で考えるとこれはちょっと、、という課題も新たに発生してしまいました。そもそも、ユーザーはスマホなどで撮影した画像をそのまま投稿するはずで、投稿サイトである以上、対応を考えなければいけません。 「違和感のない画質を維持しつつ、表示速度を改善する」 そんなこと初心者の私にできるのだろうか、、 と不安になりながら方法を探す中で、外部APIであるimgixにたどりつきました。 imgixの公式ページにはサービスについてこう書いてあります: imgix transforms, optimizes, and intelligently caches your entire image library for fast websites and apps using simple and robust URL parameters. 画像の最適化をしてくれ、かつキャッシュによってWebサイトを高速化できる。 画像最適化付きCDNという位置付けのようです。メリットとしては、以下のような点があげられます。 自サーバーの負荷が削減されることでWEBサーバーからのレスポンス速度低下を抑えることができる わざわざリサイズや圧縮せずとも、元画像を入れておき適切なパラメータを付与した画像URLにするだけで適切な画像を返してくれる Japonlineの記事より引用 元々見ていたのはCloudinaryというAPIだったのですが、導入難度がやや高めであるのに対し、imgixは画像URLを使うことで比較的容易に導入でき、公式が紹介しているrails向けのライブラリもあったので、imgixを使ってみることにしました。 〜refileからCarrierWaveに乗り換え〜 そこでまずimgix上で無料登録を済ませ、imxgix-railsの公式ドキュメント(こちら)でRailsアプリへの導入方法を確認しました。 私は元々画像投稿にrefileを使用しており、refileとの併用も可能とは書いてあったのですが、refileはURLやパスをDBに保存しないので、imgix-railsとrefileを繋げるためのヘルパーメソッドの定義が必要と書かれていました。 そのヘルパーメソッドの記述内容も親切にGitHubに書かれているのですが・・・これが、お恥ずかしながら初心者の私には何をしているのかがよく理解できず。理解できていないものをコピペしてうまくいっても、本質的でないと思いました。CarrierWaveなら投稿画像にパスがつくため、接続のための事前設定なしに利用できるとも記載されていたため、思い切ってCarrierWaveに置き換えることにしました(これには賛否両論あるとは思いますが、初心者なりにできることを必死で考えた結果と、温かい目で見ていただければ幸いです、、) 以上の試行錯誤を経て、CarrierWaveで無事画像が表示されることを確認した上で、S3でバケットを作成し、画像がS3にアップロードされることも確認が取れたので、railsアプリケーション上でimgixの設定を行いました(そこに至るまで本当に苦労しました、、) 〜CarrierWave+S3 で既に表示速度は大幅改善していたが、、〜 外部ストレージを設定した時点で、既に速度自体は大幅に改善していたので、もはやimgixを導入しなくてもいいのではないかと思ったのですが、GTmetrixで確認したところGTmetrix Grade(総合評価)が「F」になっていました(元々はD〜Eだったので悪化)。愕然。 表示速度は改善していたのですが、PageSpeedInsightsにもかけてみたところ、「画像サイズが不適切」なことが主な問題なようでした。imgixの画像最適化によってこの部分は修正できそうだったため、導入後あらためて確認したところ、「C」ランクに改善し、圧縮後の粗かった画像も綺麗に表示されるようになりました。 〜ちょっと余談〜 なお、imgixは国内では一休.comや日経新聞で導入されているようです。 導入事例も含めimgixがどういうサービスかはimgix という画像変換サービス(メモ)によくまとまっています。 まだ国内では導入事例が少ないimgixですが、表示速度の改善方法を英語で調べていたことで、このAPIに早い段階で出会えたのはラッキーでした。 ところで、読み方はnginx風なのかと思いきや「 image・icks (イメジックス)」のようです。ずっと読み方わからなかったのですが、公式の解説動画でそう呼ばれていました。 実装手順 CarrierWaveでの画像投稿(STEP1)及びS3バケットの作成(STEP2)については、【Rails】 CarrierWaveチュートリアルを参考にしました。とても丁寧に解説されているので先にご一読をおすすめします。 (STEP1の内容はほぼ上記の記事をもとに書いています。割愛しても良いかと思ったのですが、一部記述内容が異なるのと、アップローダー名やカラム名をSTEP3以降の内容と揃えて記載しておいた方がわかりやすいと考えたため、上記記事の内容をかなり拝借しながら記述しております。ご了承ください) ではそれぞれ見ていきましょう。 STEP1. CarrierWaveでの画像投稿を可能にする ※すでにCarrierWaveを使用している人はSTEP2に飛んでください。 1-1. Gem追加 gemfile gem 'carrierwave', '~> 2.0' 保存したらbundle installします。 1-2. Uploader追加 terminal rails g uploader アップローダー名 例)rails g uploader PostRecipe これで、app/uploaders/post_recipe_uploader.rbが作成されます。 ここには、アップロードするファイルの保存パスやサイズなどを指定することができます。 今回画像ファイルのサイズなどはimgix側で最適化してもらうので特に設定はしませんが、どのストレージにアップロードするかを指定しておきます。 app/uploaders/post_recipe_uploader.rb class PostRecipeUploader < CarrierWave::Uploader::Base # Choose what kind of storage to use for this uploader: if Rails.env.production? storage :fog else storage :file end # Override the directory where uploaded files will be stored. # This is a sensible default for uploaders that are meant to be mounted: def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end end 3行目で、本番環境の場合はstorage :fog(今回はS3)を、開発環境の場合はstorage :file(アプリケーションのpublic/uploads/モデル名/画像用カラム名/id配下)を、画像の保存先に指定しています。開発環境で投稿した画像までS3で管理する必要はないので、明示的に分けています。 なお、store_dirでは保存されるディレクトリを設定しています。 ここはデフォルトの表記のままで問題ありません。 開発環境とはいえ、public/uploads/配下に保存される画像がGitHubにあがるのはセキュリティの観点からもあまりよろしくないので、念のため.gitignoreに追加しておきましょう。 .gitignore /public/uploads 1-3. アップロード画像用のカラムを追加 terminal rails g migration add_post_recipe_image_to_post_recipes post_recipe_image:string アップロードする画像の情報を保存するpost_recipe_imageカラムを、PostRecipeモデルに作成します(カラム名やモデル名は適当な内容に修正してください)。なお、このカラムには画像データではなく、画像のファイル名が保存され、ビューで画像を表示する際には、「画像が格納されているパス」と「DBに保存されているファイル名」が使われます。 コマンドを実行したら、マイグレーションファイルが作成されるので、rails db:migrateしましょう。 1-4. コントローラーのストロングパラメーターに追記 post_recipes_controllerのストロングパラメーターに、先ほど作成したpost_recipe_imageカラムを追記します。 app/controllers/post_recipes_controller.rb def post_recipe_params params.require(:post_recipe).permit( :user_id, :title, :introduction, :post_recipe_image, #ここを追加 #以下略 ) end 1-5. Uploaderクラスとカラムを紐づける app/models/post_recipe.rb mount_uploader :post_recipe_image, PostRecipeUploader 先ほど作成した画像用カラムと、Uploaderの紐付けを行います。 紐付けを行うことで、画像アップロード時に、Uploaderに記述した諸設定を利用できます(例えば、アップロード時にどこに画像を保存するか等)。 画像用カラムを作成したモデル(今回の場合はPostRecipeモデル)ファイルに、上記を記述します。 1-6. ビューにファイル選択ボックスを追加 app/views/post_recipes/new.html.erb <%= form_with model: @post_recipe, local:true do |f| %> <div> <div class="from-group"> <h6>写真をアップロード</h6> <%= f.file_field :post_recipe_image %> </div> </div> <% end %> 上記のf.file_filed :カラム名と記述することで、画像投稿が行えます。 もし編集画面もある場合は、同じように追記/修正しましょう。 なお、画像の表示については後ほどimgixのix_image_tagを使用するため、CarrierWaveの画像表示タグにする必要はありませんが、CarrierWaveで投稿した画像を表示するには、<%= image_tag @post_recipe.post_recipe_image.url %>のように書きます(post_recipe_imageの部分は、カラム名です)。 refileから移行する場合 モデル、コントローラー(ストロングパラメーター)、ビューなどのファイルにあるrefile関連の記述は削除し、refileの画像用カラムも削除しておきます。 STEP2. CarrierWaveの投稿画像がS3に保存されるように設定 2-1. S3のバケット作成 S3のバケット作成については他に様々な記事で紹介されているため、割愛します。 私は「実装手順」の冒頭で紹介した記事の「AWS設定」に沿って作成しました。 上記記事の「CarrierWave設定」以降は、記事と少々内容が変わってくるので、次の項でまとめます。 2-2. CarrierWaveにS3の設定を追加 まず、gemfileにfog-awsを追加します。 投稿画像の保存先を外部ストレージ(S3)にするのを助けてくれるgemです。 保存したらbundle installします。 gemfile gem 'fog-aws' ?次に、以下のファイルをコマンドで作成します。 terminal touch config/initializers/carrierwave.rb ?作成したcarrierwave.rbに、S3バケット名とIAMユーザーの情報を記述します。 config/initializers/carrierwave.rb require 'carrierwave/storage/abstract' require 'carrierwave/storage/file' require 'carrierwave/storage/fog' CarrierWave.configure do |config| config.storage :fog config.fog_provider = 'fog/aws' config.fog_directory = '作成したバケット名' config.fog_credentials = { provider: 'AWS', aws_access_key_id: Rails.application.credentials.aws[:access_key_id], aws_secret_access_key: Rails.application.credentials.aws[:secret_access_key], region: 'ap-northeast-1', path_style: true } end IAMユーザーのaws_access_key_idとaws_secret_access_keyの値は、credentials.yml.encに記載します。 credentials.yml.encとmaster.keyは秘密情報を管理する仕組みで、環境変数を使わず秘密情報を管理できます。master.keyは、credentials.yml.encを複合化(暗号化されたデータを元に戻すこと)します。この仕組みについては、【Rails】Rails5.2以降で追加された「credentials.yml.enc」について簡単にまとめてみた!が参考になります。 ?では、credentials.yml.encを編集し、IAMユーザーの情報を保存します。 直接エディタからは編集できないため、vimで記述します。 terminal EDITOR=vim bin/rails credentials:edit credentials.yml.enc # Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. secret_key_base: yyy aws: access_key_id: xxx #ここを追記 secret_access_key: xxx #ここを追記 xxxの部分に実際の値を記入し、escしてから:wqで保存します。 secret_key_baseはデフォルトの値のままです。 これでCarrierWaveでアップロードした画像が、S3に保存されます。 STEP3. imgixを登録・設定 3-1. 会員登録 公式サイトより会員登録を行います。 3ヶ月間は$10のクレジットが付いてくるため、実質無料です(2021年6月時点)。 無料期間後の料金については、「リアルタイム画像処理機能が充実した CDN、「imgix」 を試してみたらとても簡単で便利だった件」が参考になります。 必須項目を入力してサインアップします。 3-2. Sourceの追加 サインアップ後、ログインするとDashboardが表示され、下図のような画面が表示されます。 ADD A SOURCEをクリックし、Sourceを作成します。 ちなみに、Sourceは複数作成できます。 General Source Type: 「AmazonS3」を選択 AWS Settings Access Key ID: IAMのアクセスキーの値を入力 Secret Access Key: IAMのシークレット・アクセスキーの値を入力 S3 Bucket: S3バケット名を入力 Path Prefix: 「uploads」を入力 Domains imgix Subdomain: imgixのサブドメインとなる値を自由に入力 他の項目は未入力で問題ありません。 入力できたらSAVEをクリックし、デプロイされたらSourceの設定は完了です。 3-3. Railsアプリケーションとimgixを接続 開発環境に戻ります。 ?まずapplication.rbのmodule アプリ名内に、以下を記述します。 config/application.rb Rails.application.configure do config.imgix = { source: ENV['IMGIX_SOURCE'] } end sourceには、先ほどimgix上で設定したsourceのサブドメインを入力します。 念のため、環境変数化しておきます。 .env IMGIX_SOURCE="yyy.imgix.net(sourceのサブドメイン)" ?次に、storage.ymlに以下を追記します。 Active Storageに認識させるS3の情報が、imgixのsourceと同じになるように設定します。 config/storage.yml amazon: service: S3 access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> region: ap-northeast-1(AWSリージョン名) bucket: S3バケット名 これでimgixがS3に保存されているマスター画像にアクセスできるようになりました。 3-4. ビューファイルにix_image_tagを追記 imgix-railsの公式ドキュメントにあるように、ix_image_tagは、imgixが画像のリサイズ、クロップなどを行う上で必要なパラメーターを渡してくれるヘルパーメソッドです。 app/views/homes/top.html.erb <%= ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 150, h: 150, fit: 'crop' }, tag_options: {class: 'rounded-circle'}) %> <%= link_to ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 150, h: 150, fit: 'crop' }, tag_options: {class: 'rounded-circle'}), post_recipe_path(@post_recipe.id) %> ?post_recipe_imageの部分はCarrierWaveの設定で追加した画像用カラム名を指定します。 画像にリンクを貼る場合は、link_toを用いればOKです。 classやalt属性を指定する場合はtag_options内に、リサイズの値やクロップの指定はurl_params内に記述します。 ちなみにfallbackの画像を表示したい場合は、assets/images配下にfallback用の画像を格納し、以下のように記述すれば、画像が投稿された場合とそうでない場合とで表示を区別できます。 app/views/homes/top.html.erb <% if @post_recipe.post_recipe_image.blank? %> <%= image_tag('no_recipe_image.jpg', size: '70', class:'rounded-circle') %> <% else %> <%= ix_image_tag(@post_recipe.post_recipe_image.path, url_params: { w: 70, h: 70, fit: 'crop' }, tag_options: {class: 'rounded-circle'}) %> <% end %> これで本番環境にデプロイし、画像がうまく表示されていれば成功です。 最後に、デプロイ時に.envファイルを手動で転送するのを忘れないようにしましょう(これを転送してあげないと、imgixのsourceが読めずにエラーになります)。 終わりに refileで記述していた時は、画像の見せたい部分がうまく表示されないこともありましたが、imgixで最適化したことでうまくクロップされ、画質も格段に良くなりました。 さらに、S3と併用したことで、表示速度もアップすることができました。私が探した限りでは、「Railsアプリ x imgix x S3」の導入方法は日本語の記事が出回っていなかったため、今回整理してみました。何かのお役に立てれば幸いです。お疲れ様でした! 参考資料
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rake db:create で ActiveRecord::NoDatabaseError

Rails 環境の構築中に、rake db:create で以下のエラーが発生。 ActiveRecord::NoDatabaseError: could not connect to server: No such file or directory Is the server running locally and accepting connections on Unix domain socket "/var/run/postgresql/.s.PGSQL.5432"? ググると .pid ファイル消すといいとかって記事が色々ヒットするけど、全然なおらない。 2h くらい引っかかっていた時、さっきまで動いてた別のブランチで、同じ手順で構築し直して rake db:create するとなぜか動く。 作業していたファイルの差分を見ると・・・、 default: &default adapter: postgresql encoding: unicode - host: db - username: postgres - password: password - pool: 5 そりゃ動かないよね。凡ミス。 おしまい。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

deviseを使用した時のログインページのビューについてRails

注意点1 form withの中身 <%= form_with model: @user, url: user_session_path,class: 'registration-main', local: true do |f| %> #省略 <% end %> まずmodel名を記述する。 今回は@userというインスタンス変数を使用しています。 更にurlにrails routeで確認したsession pathを記述します。 要はモデル名とpathがないと、仮にログインボタンを押しても、どのモデルを参照にするのか、その際のルーティングは?というところが不明なので、ログインできません。 注意点2 各入力欄の中身 <%= f.email_field :email, class:"input-default", id:"email", placeholder:"PC・携帯どちらでも可", autofocus: true %> 例えば、これはemailの欄ですが、fieldの記述の後に、カラム名を記述します。 それを元にUsersテーブルのカラム名と合致するかどうか、参照します。 placeholderは入力前に表示される文字列です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

activeadminの基礎的なコードを理解したい

はじめに activeadminを使った際に、基礎的なコードだが何をしているものかわからなかったので、分からなかった箇所をまとめてみた。 分からなかった下記のコード app/admin/texts.rb ActiveAdmin.register Text do permit_params :genre, :title, :content index do selectable_column id_column column :genre, :text, &:genre_i18n column :created_at column :updated_at actions end show do attributes_table do row :id row :genre, :text, &:genre_i18n row :content row :created_at row :updated_at end active_admin_comments end form do |f| f.semantic_errors f.inputs do input :genre, as: :select, collection: Text.genres_i18n.invert input :title input :content end f.actions end filter :genre, as: :select, collection: Text.genres_i18n.invert.transform_values { |v| Text.genres[v] } filter :title filter :content filter :created_at filter :updated_at end ActiveAdmin.register ActiveAdmin.register Text do #処理 end app/admin/配下に、管理画面を追加するコマンドで自動生成されるファイルに標準で記述されている構文。この中に処理を記述していく。 permit_params permit_params :genre, :title, :content permit_paramsはデータモデルの有効なパラメータの設定。明示したフィールドのアクセスを許可する。 filter filter :genre, as: :select, collection: Text.genres_i18n.invert.transform_values { |v| Text.genres[v] } filter :title filter :content filter :created_at filter :updated_at filterは管理者画面の右側(検索ボックス)のカスタマイズを指定する。 as:は指定した形式にオーバーライドする。 collection:は渡すデータを指定する。 index index do selectable_column id_column column :genre, :text, &:genre_i18n column :created_at column :updated_at actions end アクションに対応した処理を記述。 selectable_columnはチェックボックスの配置を指定。 id_columnはそのまま表示するコンテンツ。 columnはモデルのフィールドに対応したコンテンツを表示。 actionsはpermit_paramsの値に応じて閲覧編集削除のリンクを表示。 ちなみに対応するカラムのenumはこちら enums.ja.yml ja: genre: &genre invisible: '非表示' basic: 'Basic' git: 'Git' ruby: 'Ruby' rails: 'Ruby on Rails' php: 'PHP' enums: text: genre: *genre movie: genre: *genre show show do attributes_table do row :id row :genre, :text, &:genre_i18n row :content row :created_at row :updated_at end active_admin_comments end attributes_tableはデフォルトの外観を維持する。 rowはモデルのフィールドに対応したコンテンツの表示。 active_admin_commentsはコメントフォームの表示。 from form do |f| f.semantic_errors f.inputs do input :genre, as: :select, collection: Text.genres_i18n.invert input :title input :content end f.actions end formはフォームのカスタマイズを指定。 f.semantic_errorsはエラーメッセージの表示。   f.inputsは入力フィールドを表示。 f.inputはモデルのフィールドに対応した入力フィールドを表示。 f.actionsはsubmit/cancelボタンの表示。 最後に rails g active_admin:resource モデル名を実行した際に作成されるリソースをベースに、上記のコードでカスタマイズするという内容だった。 参考元 Active/Admin公式ドキュメント
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コピペでできるログイン状況に応じて、ログインを表示するのか、ログアウトを表示するのか Rails

<% if user_signed_in? %> <li><%= link_to 'マイページ', "/users/#{current_user.id}" ,class: "***"%></li> <li><%= link_to 'ログアウト', destroy_user_session_path, method: :delete, class: "***"%> </li> <% else %> <li><%= link_to 'ログイン', new_user_session_path, class: "***" %></li> <li><%= link_to '新規登録', new_user_registration_path, class: "***" %></li> <% end %> マイページのところはユーザー名を表示させることもできます。 ユーザー名を表示させたいときは <%= link_to current_user.name, "/users/#{current_user.id}",class: "***" %> にすればOKです。 解説 いくらコピペでOKとは言いましたが、モデル名には気をつけてください。 (ほとんどの場合はuserだと思いますが) <% if user_signed_in? %> でユーザーがログインしている時の処理を記述します。 各種pathは rails routes で確認してください。 ログアウトの時はメソッドを追記する必要があります。 この辺はログイン機能があるアプリではほぼデフォルトの表示だと思うので。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ransackで孫のモデルの検索をしたいときの書き方

概要 Ransackを使っていると子のモデルのさらに子が持つフィールドで検索したくなることが結構あるのではないかと思います。 公式のREADMEを軽く読んでもそのような方法を実現するコード例が見当たらなかったので、記事にしてみようと思います。 方法 例として特定のタグを持つ記事を書いたユーザーを検索したい場合などを例にしてみようと思います。 モデル構造は下記のような形です。 models/user.rb class User has_many :posts, dependent: :destroy end models/post.rb class Post has_many :post_categories, dependent: :destroy has_many :categories, through: :post_categories end models/category.rb class Category has_many :post_categories, dependent: :destroy has_many :posts, through: :post_categories end models/post_category.rb class PostCategory belongs_to :post belongs_to :category end この時ユーザー一覧ページで特定のカテゴリの記事を書いたユーザーを検索するには下記のように記述します。 なおCategoryはnameというカラムをカテゴリ名として持っているとします。 views/users/index.html.slim form = search_form_for @q, url: users_path do |f| .form-group = f.label :posts_categories_id_eq, "記事カテゴリで検索" = f.collection_select :posts_categories_id_eq, Category.all, :id, :name, {include_blank: true}, class: 'form__field' .form-group = f.submit "検索", class: 'button button--primary mr-2' フィールド名を子テーブル名_孫テーブル名_孫テーブルのフィールド名_検索方法(eq, contなど)とすれば検索できるかと思うので、試してみてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails]もっと見るボタンで全文を表示させる

はじめに 自作のRailsアプリケーションでもっと見るボタンを実装したのでメモ。以下のようなイメージで投稿した記事を最初に折りたたんだ状態で「もっと見る」ボタンを押すと全文を表示させるというものです。 実装方法 ※筆者はindexページで実装しています。 また今回はJQueryでAjaxを使わずに行います。 まず手を加える前のコードは以下のようになっています。 index.html.erb <% @posts.each do |post| %> <%= post.body%> <% end %> 今回は200文字以上の場合にもっと見るボタンを付け足すと想定します。 index.html.erb <div class="content-body"> <% @posts.each do |post| %> <%if post.body.length > 200 %> <p><%= post.body.truncate(200)%></p> <p>&lt;もっと見る&gt;</p> <%else%> <p><%= post.body %></p> <%end%> <% end %> </div> 上記のようにif文で200文字以上の時は文字をtruncateで省略し200文字未満の場合はそのまま表示します 次にJQueryを使うための追記をします。 以下のように追記をします。 <div class="content-body"> <% @posts.each do |post| %> <%if post.body.length > 200 %> <!--追記--> <div class="truncated"> <p><%= post.body.truncate(200)%></p> <!--class="see-more"を追記--> <p class="see-more">&lt;もっと見る&gt;</p> </div> <!-- 追記 style= "display: noneで 通常時は見えなくする--> <div class="untruncated" style="display: none"> <p><%= post.body %></p> </div> <% else %> <p><%= post.body %></p> <% end %> <% end %> </div> 追記した部分はコメントを追加しています。 最後にjsを設定します jsファイル $('.see-more').click(function(){ $(this).closest(".content-body").find(".truncated").hide(); $(this).closest(".content-body").find(".untruncated").show(); }) 以上を設定すると実装完了です。 説明するとsee-moreをクリックした時に(1行目)その1番近い親要素であるcontent-bodyの中のtruncatedを隠す(2行目)。そのあとcontent-bodyの中のuntruncatedを表示させる(3行目) 以上になります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[SQL] MySQLでSQLを学ぶ~DML編~

はじめに 前回まではSQLの中でもDDLを中心に説明をまとめていました。 今回はDML。データを操作するSQLをまとめていきたいと思います。 前回使用した「sqltest」データベースと「goods」テーブルを使って説明をまとめていきます。 DML(Data Manipulation Language) データを操作するSQLで、私は面接でこれの有名な文を四つ答えるように質問されて惨敗しました。 命令 機能 INSERT データの登録 UPDATE データの更新 DELETE データの削除 SELECT データの検索 主なDML文は上記のとおりです。 SELECT文 SELECT文はデータの取得をする際に使います。 SELECTを使ったカラムの指定の仕方は下記のとおりです mysql> SELECT 《カラム名》 FROM 《テーブル名》 もし、テーブルに登録されている全てのカラムのデータを取得する時は下記のようにします。 mysql> SELECT * FROM 《テーブル名》 SELECT句においてはこのアスタリスクはワイルドカードと言います。 ワイルドカード ワイルドカードとは「すべてのパターンにマッチするもの」という意味があり、文字の代わりとして使うことができる特殊な記号の一つです。 SELECT句ではアスタリスクが「全てのカラム」という意味のワイルドカードとして定義されているので、全てのデータを取得ができます。 INSERT文 INSERT文はテーブルにデータを登録するためのSQL文です。 INTO句と組み合わせて使用します。 また設定する値はVALIE句を使います。 全てのカラムに値を入れる場合は下記のような書き方になります。 mysql> INSERT INTO 《テーブル名》 VALUES(値1, 値2, 値3); 特定のカラムにだけ値を入れる場合は下記のように書きます。 mysql> INSERT INTO テーブル名(カラム名1, カラム名2) VALUES(値1, 値2); それでは実際にデータの登録を行なってみます。 ターミナル mysql> INSERT INTO goods VALUES(1, "ペン", 120); Query OK, 1 row affected (0.04 sec) 2行目が表示されれば成功です。 ちゃんと登録されているか確認してみましょう。 ターミナル mysql> SELECT * FROM goods; +------+--------+-------+ | id | name | price | +------+--------+-------+ | 1 | ペン | 120 | +------+--------+-------+ 1 row in set (0.04 sec) 左から順にカラムに入っていますね 順番を間違えないように気をつけたいところです。 次は特定のカラムにデータを登録してみます。 ターミナル mysql> INSERT INTO goods(id, name) VALUE(2, "消しゴム"); Query OK, 1 row affected (0.00 sec) 2行目が表示されれば成功しているはずです。 確認しましょう。 ターミナル mysql> SELECT * FROM goods; +------+--------------+-------+ | id | name | price | +------+--------------+-------+ | 1 | ペン | 120 | | 2 | 消しゴム | NULL | +------+--------------+-------+ 2 rows in set (0.00 sec) 今回はpriceに何も入れなかったのでNULLが入っていますね。 UPDATE文 次はデータを更新するために使用するUPDATE文です。 UPDATEの記述の後にテーブルを指定し、SET句というものを置いてその後、変更内容を記述、その後に**WHERE句をいて条件を指定できます。 mysql> UPDATE 《テーブル名》 SET 《変更内容》 WHERE 《条件》; 実際に上記にデータを当てはめて、データを更新します。 ターミナル mysql> UPDATE goods SET price = 100 WHERE id = 2; Query OK, 1 row affected (0.02 sec) Rows matched: 1 Changed: 1 Warnings: 0 データを確認します ターミナル mysql> SELECT * FROM goods; +------+--------------+-------+ | id | name | price | +------+--------------+-------+ | 1 | ペン | 120 | | 2 | 消しゴム | 100 | +------+--------------+-------+ 2 rows in set (0.00 sec) 消しゴムのpriceが100に変わりました。 DELETE文 データを削除するDELETE文の使い方を見てみます。 テーブル名を指定するFROM句と条件を指定するWHERE句を使います。 mysql> DELETE FROM 《テーブル名》 WHERE 《条件》; 実際にidが2のレコードを削除します。 ターミナル mysql> DELETE FROM goods WHERE id = 2; Query OK, 1 row affected (0.01 sec) 2行目が表示されれば成功です。 確認しましょう。 ターミナル mysql> SELECT * FROM goods; +------+--------+-------+ | id | name | price | +------+--------+-------+ | 1 | ペン | 120 | +------+--------+-------+ 1 row in set (0.01 sec) 消しゴムが消えましたね! 最後に この四つの文は面接で聞かれて答えられなくて悔しかったので、逆に覚えました 笑 しかしながら、詳しい使い方は覚えられていないので、使いこなせるよう、一文ごと覚えるように手を動かして復習していきたいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails で別オリジンのリクエスト with Cookie を受け付ける

別ポートの localhost で立ち上げた Nuxt (フロントコード) から Rails 側で開いた API へのリクエストが CORS 制約に引っかかったので対処。 gem を追加。 gem 'rack-cors' 以下を追記。 config/application.rb config.middleware.insert_before 0, Rack::Cors do allow do origins 'localhost:8000' # ポート番号はよしなに resource "*", headers: :any, methods: [:get, :post, :options, :head], credentials: true end end credentials: true をつけないと Cookie を扱えないようです。 おしまい。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

閲覧数を管理する足跡モデルの設計 - その1 DB設計とその実装

閲覧数を管理するモデルの設計にあたって 目的:SQLの学習 ポートフォリオサイトの機能拡張とSQLの学習を目的に「この作品を閲覧しているユーザーはこんな作品に興味があります」機能を追加をします。 この機能の追加に向け、どのユーザーがどの作品を閲覧したかを管理する、足跡モデルを作成します。 利用イメージ ユーザーがログイン(current_user) ユーザーが作品を閲覧 → リクエスト (works_controllerのshowメソッド) 新規足跡オブジェクトを追加、または既存の足跡オブジェクトのカウントを+1する 作品viewを表示 DB設計(関連付けと足跡モデルについて) テーブル設計は以下のように設定する。 各モデルの相関 class User < ApplicationRecord has_many :works has_many :footprints, dependent: :destroy end class Work < ApplicationRecord belongs_to :user has_many :footprints end class Footprint < ApplicationRecord belongs_to :user belongs_to :work validates :user_id, presence: true, uniqueness: { scope: :work_id } validates :work_id, presence: true validates :counts, presence: true end Footprintモデルの各カラム create_table "footprints", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "counts", default: 1, null: false t.bigint "user_id", null: false t.bigint "work_id", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.index ["user_id", "work_id"], name: "index_footprints_on_user_id_and_work_id", unique: true t.index ["user_id"], name: "index_footprints_on_user_id" t.index ["work_id"], name: "index_footprints_on_work_id" end こだわりポイント user_idとwork_idの組み合わせは一意 あるユーザーによるある作品の閲覧数はcountsで管理する 足跡オブジェクトが作成された時点でcountsのデフォルト値は1 閲覧される度にcountsに+1する 足跡作成メソッド models/work.rb class Work < ApplicationRecord : def create_footprint_by(user) if Footprint.find_by(user_id: user.id, work_id: id).present? footprint = Footprint.find_by(user_id: user.id, work_id: id) counts = footprint.counts footprint.update_attribute(:counts, counts + 1) else Footprint.create(user_id: user.id, work_id: id) end : end 足跡作成メソッドのこだわり点 ユーザーと作品の組み合わせが存在するかどうかで条件分岐 存在する場合にはcounts+1 存在しない場合には足跡オブジェクト作成 実際の動作に基づき足跡を追加する controller/works_controller.rb class WorksController < ApplicationController : def show @work = Work.includes(:user).find(params[:id]) @work.create_footprint_by(current_user) @footprints = Footprint.select("SUM(footprints.counts) as total").find_by(work_id: @work.id) end : end views/works/show.html.erb <div class="work__footprints"> <i class="far fa-eye"></i><%= @footprints.total %> </div> 課題点 今回の変更で可能になった点 足跡モデルの作成・関連付け・デフォルト値の設定 足跡オブジェクトの自動作成(create_footprint_by) 足跡の表示  Footprint.select("SUM(footprints.counts) as total").find_by(work_id: @work.id) これらの設定により、実際の流れに基づき足跡を作成する事ができた。 一方で課題点 1. ModelへのSQLの発行が乱立している。 controller/works_controller.rb def show @work = Work.includes(:user).find(params[:id]) # 1回目 SELECT @work.create_footprint_by(current_user) # 2回目 UPDATE @footprints = Footprint.select("SUM(footprints.counts) as total").find_by(work_id: @work.id) # 3回目 SELECT end コントローラーにて、個別のSQLへのリクエストが3度も行われてしまっている。3回目のSQLに関しては、1回目と同一にする事ができると考えられる事から、以下のように変更する。 @work = Work.select("works.*, SUM(footprints.counts) as total").joins(:footprints).includes(:user).find(params[:id]) # 1回目 SELECT @work.create_footprint_by(current_user) # 2回目 UPDATE しかしこの場合には、2つの問題が発生する。 workの呼び出し後に、足跡が作成または追加されている。(create_footprint_byメソッドの位置) joinsにおける内部結合の特徴 = 結合相手がいない行は結合結果から消滅する これらの解決や原因などについては次の記事でまとめるものとする。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Devise 利用下の db:seed で "Could not find a valid mapping" エラー

Rails に Devise を入れ、いろいろいじっている時に以下のエラーに遭遇。 $ docker-compose run web rake db:seed Could not find a valid mapping for #<HogeHoge ~> routes.rb に devise_for の指定がないといけない模様 Rails.application.routes.draw do # ... devise_for :hoge_hoges # ... end おしまい。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】リアルタイムチャット機能実装(Action Cable)1/3

目的 Railsで作成したアプリに、Action Cableを用いてリアルタイムチャット機能を導入する。 開発環境 macOS: Big Sur Rubyバージョン: 2.6.5 Railsバージョン: 6.0.0 前提 アプリが作成されている。 手順 はじめに コントローラーの作成 モデルの作成 ルーティングの設定 アクションの定義 ビューファイルの作成 はじめに 今回はかなり長くなってしまうので、3回に分けて投稿させて頂きます。 1回目の今回は、アプリの下準備としてチャット機能実装、 2回目は、Action Cableを用いての実装、 3回は、本番環境で使用するための実装をしていきます! コントローラーの作成 では早速始めていきます! 今回のアプリには画面の遷移は必要ないので、newアクションを指定してコントローラーを作成します。 newと指定することでnew.html.erbも生成されます。 ターミナル % rails g controller messages new モデルの作成 次はモデルの作成です! ターミナル % rails g model message text:text モデルの作成時にmessagesテーブルにtext型でtextカラムを追加しています。 ターミナル % rails db:migrate マイグレーションも忘れずに! ルーティングの設定 続いてルーティングの設定です。 newとcreateアクションのみ設定します。 routes.rb Rails.application.routes.draw do root 'messages#new' resources :messages, only: [:create] end アクションの定義 次はアクションの定義です。 app/controllers/messages_controller.rb class MessagesController < ApplicationController def new @messages = Message.all @message = Message.new end def create @message = Message.new(text: params[:message][:text]) end end ビューファイルの作成 最後にビューファイルの作成です。 メッセージの一覧とフォームを表示させます。 app/views/messages/new.html.erb <%= form_with model: @message do |f| %> <%= f.text_field :text %> <%= f.submit '送信' %> <% end %> <div id='messages'> <% @messages.reverse_each do |message| %> <p><%= message.text %></p> <% end %> </div> これで準備は完了です! 最後に 以上で下準備は完了です。 次回はAction Cableの実装を行っていきます。 では。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Workload IdentityでGoogle Coud Storageの署名付きURLを発行する

やること google-cloud-ruby で、Workload Identityを使用した認証を行う場合に、 Google Cloud Storageの 署名付きURLの発行が行い得ない問題を解消するものである 発生している問題 前提 workload identityによって接続されるサービスアカウントを ワークロードサービスアカウント とする 筆者の環境では、GKEのnode上に存在するRuby on Railsにworkload identityによってワークロードサービスアカウントが接続される ワークロードサービスアカウントに対する権限の設定は問題がない。 (使用するStorage bucketに対して roles/storage.objectAdmin) 署名付きURLの発行を試みる storage = Google::Cloud::Storage.new storage.bucket('任意のバケット名', skip_lookup: true).file('バケットに存在するファイル名', skip_lookup: true).signed_url(method: 'GET', expires: 300) => Google::Cloud::Storage::SignedUrlUnavailable (Service account credentials 'issuer (client_email)' is missing. To generate service account credentials see https://cloud.google.com/iam/docs/service-accounts) Google::Cloud::Storage::SignedUrlUnavailable によって怒られる 一方でファイル情報は取得できる 念の為、権限が正しそうかを確認する storage = Google::Cloud::Storage.new storage.bucket('任意のバケット名', skip_lookup: true).file('バケットに存在するファイル名') => #<Google::Cloud::Storage::File:0x00005624a9833208 @service=Google::Cloud::Storage::Service(project_id), @gapi=#<Google::Apis::StorageV1::Object:0x00005624a9887740 @bucket="hogehoge", @content_type="image/jpeg", @id="hogehoge/sample.jpeg/000", @kind="storage#object", ....> OK なぜか 一部、妄想も含まれてるかもなので参考程度に... 環境に適用されるサービスアカウントの資格情報は、色々省略された情報しか保持されていない 多くのAPIを使用する場合には問題がないが、 Google Cloud Storageの署名付きURLの発行に必要な資格情報が含まれないケースが有る workload identityによって提供される資格情報ももしかしたら、省略されてしまっているのではないか 参考 解決するには 1. ワークロードサービスアカウントの資格情報を直接所持させる pod内にワークロードサービスアカウントの資格情報を保有させるというやり方。鍵の管理やサービスアカウントの資格情報は10年で切れるという点で、あまりお薦めはできない。 2. Cloud IAMCredentials signBlob によって署名を作成して使用する 今回はこの方法を取る。署名を行う際に、直接APIを叩いて署名を実行するというやり方である。 署名付きのURLを発行するときに、 サービスアカウントの識別子(Issuer)を使用するが、環境に適用されるサービスアカウントの場合は取得できない。Issuerを環境に設定されているサービスアカウントとは別に設定した上で、署名付きURLを発行するときに指定する必要がある。しかし、google-cloud-rubyにはIssuer だけ を外から流し込む事はできないので、署名ロジックも一緒に以下の様に変更する。 サービスアカウントへの権限を追加 このやり方の場合、ワークロードサービスアカウントに roles/iam.serviceAccountTokenCreator の権限が必要となるため付与する 署名付きURLの発行方法を変更する ここの Using Cloud IAMCredentials signBlob to create the signature によるやり方で、 issuerとしてワークロードサービスアカウントを指定すればOK 参考
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails API + React でマッチングアプリを作ってみた

概要 タイトル通り。バックエンドにRails(API)、フロントエンドにReactを採用したマッチングアプリ的なものを作ってみたのでアウトプットしておきます。 完成イメージ 割とそれっぽい雰囲気になってます。 使用技術 バックエンド Ruby3 Rails6(APIモード) MySQL8 Docker フロントエンド React TypeScript Material-UI 今回はバックエンドのみDockerで環境構築していきます。 実装の流れ だいぶ長旅になるので、これからどういった流れで作業を進めていくのかザックリ整理しておきます。 環境構築 Rails($ rails new) React($ create react-app) 認証機能を作成 gem「devise_token_auth」などを使用 マッチング機能を作成 中間テーブルなどを活用 バックエンドとフロントエンドを分離しているため、あっちこっち手を動かす事になりますが、あらかじめご了承ください。 環境構築 何はともあれ、環境構築からスタートです。 Rails まずはRailsから。 作業ディレクトリ&各種ファイルを作成 $ mkdir rails-react-matching-app && cd rails-react-matching-app $ mkdir backend && cd backend $ touch Dockerfile $ touch docker-compose.yml $ touch entrypoint.sh $ touch Gemfile $ touch Gemfile.lock ./Dockerfile FROM ruby:3.0 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs ENV APP_PATH /myapp RUN mkdir $APP_PATH WORKDIR $APP_PATH COPY Gemfile $APP_PATH/Gemfile COPY Gemfile.lock $APP_PATH/Gemfile.lock RUN bundle install COPY . $APP_PATH COPY entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"] ./docker-compose.yml version: "3" services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: password command: --default-authentication-plugin=mysql_native_password volumes: - mysql-data:/var/lib/mysql - /tmp/dockerdir:/etc/mysql/conf.d/ ports: - 3306:3306 api: build: context: . dockerfile: Dockerfile command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" volumes: - .:/myapp - ./vendor/bundle:/myapp/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development ports: - "3001:3000" depends_on: - db volumes: mysql-data: ./entrypoint.sh #!/bin/bash set -e # Remove a potentially pre-existing server.pid for Rails. rm -f /myapp/tmp/pids/server.pid # Then exec the container's main process (what's set as CMD in the Dockerfile). exec "$@" ./Gemfile # frozen_string_literal: true source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "rails", "~> 6" /Gemfile.lock # 空欄でOK 最終的に次のような構成になっていればOK。 rails-react-matching-app └── backend ├── docker-compose.yml ├── Dockerfile ├── entrypoint.sh ├── Gemfile └── Gemfile.lock rails new いつものコマンドでプロジェクトを作成します。 $ docker-compose run api rails new . --force --no-deps -d mysql --api Gemfileが更新されたので再ビルド。 $ docker-compose build database.ymlを編集 ./config/database.yml default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password # デフォルトだと空欄になっているはずなので変更 host: db # デフォルトだとlocalhostになっているはずなので変更 development: <<: *default database: myapp_development test: <<: *default database: myapp_test production: <<: *default database: <%= ENV["DATABASE_NAME"] %> username: <%= ENV["DATABASE_USERNAME"] %> password: <%= ENV["DATABASE_PASSWORD"] %> host: <%= ENV["DATABASE_HOST"] %> データベースを作成 $ docker-compose run api rails db:create localhost:3001にアクセス $ docker-compose up -d localhost:3001 にアクセスしていつもの画面が表示されればOK。 テストAPIを作成 動作確認用のテストAPiを作成します。 $ docker-compose run api rails g controller api/v1/test ./app/controllers/api/v1/test_controller.rb class Api::V1::TestController < ApplicationController def index render json: { status: 200, message: "Hello World!"} end end ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] end end end curlコマンドで呼び出してみましょう。 $ curl http://localhost:3001/api/v1/test {"status":200,"message":"Hello World!"} 正常にJSONが返ってくればOK。 CORSを設定 今のままの状態でReact(クライアント)から直接呼び出そうとするとCORSエラーで弾かれてしまうため、その辺の設定を行います。 参照記事: CORSとは? Railsの場合、CORS設定を簡単に行えるgemが存在するのでそちらを使いましょう。 rb./Gemfile gem 'rack-cors' 今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。(26行目くらい) そちらのコメントアウトを外すだけでOKです。 $ docker-compose build Gemfileを更新したので再度ビルド。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end あとは「./config/initializers/cors.rb」をいじくって外部ドメインからアクセス可能なようにしておきます。 React 次にReact側です。 create-react-app おなじみの「create-react-app」でアプリの雛形を作ります。 # ルートディレクトリに移動した後 $ mkdir frontend && cd frontend $ yarn create react-app . --template typescript tsconfig.jsonを修正 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス 「../../」みたいな記述をしなくて済みます。 不要なファイルを整理 この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 「./src/index.tsx」と「./src/App.tsx」を次のように変更します。 ./src/index.tsx import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) ./src/App.tsx import React from "react" const App: React.FC = () => { return ( <h1>Hello World!</h1> ) } export default App 一旦、動作確認してみましょう。 $ yarn start localhost:3000 にアクセスして「Hello World!」と返ってくればOK。 APIクライアントを作成 Rails側で作成したAPIを呼び出すための準備を行います。 $ mkdir src/lib $ mkdir src/lib/api $ touch src/lib/api/client.ts $ touch src/lib/api/test.ts $ yarn add axios axios-case-converter $ yarn add -D @types/axios axios HTTPクライアント用のライブラリ @types/axios 型定義用のライブラリ axios-case-converter axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ ./src/lib/api/client.ts import applyCaseMiddleware from "axios-case-converter" import axios from "axios" // applyCaseMiddleware: // axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換 // または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ // ヘッダーに関してはケバブケースのままで良いので適用を無視するオプションを追加 const options = { ignoreHeaders: true } const client = applyCaseMiddleware(axios.create({ baseURL: "http://localhost:3001/api/v1" }), options) export default client 慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本となるため、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。 ./src/lib/api/test.ts import client from "lib/api/client" // 動作確認用 export const execTest = () => { return client.get("/test") } ./src/App.tsx import React, { useEffect, useState } from "react" import { execTest } from "lib/api/test" const App: React.FC = () => { const [message, setMessage] = useState<string>("") const handleExecTest = async () => { const res = await execTest() if (res.status === 200) { setMessage(res.data.message) } } useEffect(() => { handleExecTest() }, []) return ( <h1>{message}</h1> ) } export default App 再び localhost:3000 にアクセスして「Hello World!」と返ってくればRaiks側との通信に成功です。 もしダメな場合、大抵はDockerのコンテナを再起動していないなどが原因(config/~をいじくったため、反映させるためには再起動する必要がある)なので、 $ docker-compose down $ docker-compose up -d などで再起動をかけてください。 認証機能を作成 環境構築が済んだので、認証機能を作成していきます。 Rails 今回、認証機能は「dvise」および「devise_token_auth」というgemを使って実装します。 deviseをインストール ./Gemfile gem 'devise' gem 'devise_token_auth' Gemfileを更新したので再度ビルド。 $ docker-compose build devise本体とdevise_token_authをインストールし、Userモデルを作成します。 $ docker-compose run api rails g devise:install $ docker-compose run api rails g devise_token_auth:install User auth $ docker-compose run api rails db:migrate 「./app/config/initializers/devise_token_auth.rb」という設定ファイルが自動生成されているはずなので次のように変更してください。 ./app/config/initializers/devise_token_auth.rb # frozen_string_literal: true DeviseTokenAuth.setup do |config| config.change_headers_on_each_request = false config.token_lifespan = 2.weeks config.token_cost = Rails.env.test? ? 4 : 10 config.headers_names = {:'access-token' => 'access-token', :'client' => 'client', :'expiry' => 'expiry', :'uid' => 'uid', :'token-type' => 'token-type' } end また、ヘッダー情報を外部に公開するため、「./config/initializers/cors.rb」を次のように修正します。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記 methods: [:get, :post, :put, :patch, :delete, :options, :head] end end メール認証設定 今回はサンプルという事もあり、簡略化のためアカウント作成時のメール認証はスキップする方向で進めますが、後ほど実運用を想定した場合は必ず必要になると思うので一応やっておきます。 ./config/environments/development.rb config.action_mailer.default_url_options = { host: 'localhost', port: 3001 } なお、開発環境でメール確認を行う場合は letter_opener_web などが便利だと思います。 各種コントローラーを作成&修正 各種コントローラーの作成および修正を行います。 $ docker-compose run api rails g controller api/v1/auth/registrations $ docker-compose run api rails g controller api/v1/auth/sessions ./app/controllers/api/v1/auth/registrations_controller.rb # アカウント作成用コントローラー class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name) end end ./app/controllers/api/v1/auth/sessions_controller.rb # 認証確認用コントローラー class Api::V1::Auth::SessionsController < ApplicationController def index if current_api_v1_user render json: { status: 200, current_user: current_api_v1_user } else render json: { status: 500, message: "ユーザーが存在しません" } end end end ./app/controllers/application_controller.rb class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken skip_before_action :verify_authenticity_token helper_method :current_user, :user_signed_in? end deviseにおいて「current_user」というヘルパーメソッドは定番ですが、今回はルーティングの部分で「api/v1/」というnamespaceを切る(後述)ので「current_api_v1_user」としなければならない点に注意です。 ルーティングを設定 ルーティングの設定もお忘れなく。 ./app/config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end 動作確認 早速ですが、curlコマンドでアカウント作成およびサインインができるか試しみてましょう。 アカウント作成 $ curl -X POST http://localhost:3001/api/v1/auth -d "[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password" { "status": "success", "data": { "email": "test@example.com", "uid": "test@example.com", "id": 1, "provider": "email", "allow_password_change": false, "name": "test", "nickname": null, "image": null, "created_at": "2021-06-08T20:27:40.489Z", "updated_at": "2021-06-08T20:27:40.608Z" } } サインイン $ curl -X POST -v http://localhost:3001/api/v1/auth/sign_in -d "[email]=test@example.com&[password]=password" Note: Unnecessary use of -X or --request, POST is already inferred. * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 3001 (#0) > POST /api/v1/auth/sign_in HTTP/1.1 > Host: localhost:3001 > User-Agent: curl/7.64.1 > Accept: */* > Content-Length: 44 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 44 out of 44 bytes < HTTP/1.1 200 OK < X-Frame-Options: SAMEORIGIN < X-XSS-Protection: 1; mode=block < X-Content-Type-Options: nosniff < X-Download-Options: noopen < X-Permitted-Cross-Domain-Policies: none < Referrer-Policy: strict-origin-when-cross-origin < Content-Type: application/json; charset=utf-8 < Vary: Accept, Origin < access-token: xg-4vaua1T2KTUZUFfdYDg < token-type: Bearer < client: 2Rdmffk44hfoFCdmNafMSw < expiry: 1624393713 < uid: test@example.com < ETag: W/"fc564a9145d11564e827b204d5a4ce36" < Cache-Control: max-age=0, private, must-revalidate < X-Request-Id: 9a749e3d-0ba6-4e53-bbfb-1baff500e2e0 < X-Runtime: 0.363334 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"data":{"email":"test@example.com","uid":"test@example.com","id":1,"provider":"email","allow_password_change":false,"name":"test","nickname":null,"image":null}}* Closing connection 0 それぞれこんな感じで返ってくれば無事成功です。 なお、サインイン時に返ってくる access-token client uid この3つは後ほどReact側で認証を行う際に必要となる値なので、重要なものだと頭に入れておきましょう。 React 例の如く次はReact側の実装です。 各種ディレクトリ・ファイルを準備 $ mkdir components $ mkdir components/layouts $ mkdir components/pages $ mkdir components/utils $ mkdir interfaces $ touch components/layouts/CommonLayout.tsx $ touch components/layouts/Header.tsx $ touch components/pages/Home.tsx $ touch components/pages/SignIn.tsx $ touch components/pages/SignUp.tsx $ touch components/utils/AlertMessage.tsx $ touch interfaces/index.ts $ mv components interfaces src $ touch src/lib/api/auth.ts 最終的に次のような構成になっていればOK。 rails-react-auth ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── Home.tsx │   │   │   ├── SignIn.tsx │   │   │   └── SignUp.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── client.ts │   │   └── test.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── .gitignore ├── package.json ├── README.md ├── tsconfig.json └── yarn.lock 各種ライブラリをインストール $ yarn add @material-ui/core @material-ui/icons @material-ui/lab @material-ui/pickers @date-io/date-fns@1.3.13 date-fns js-cookie react-router-dom $ yarn add -D @types/js-cookie @types/react-router-dom material-ui UIを整える用のライブラリ date-fns 日付関連を操作するためのライブラリ(v1.3.13じゃないとエラーが発生するので注意) js-cookie Cookieを操作するためのライブラリ react-router-dom ルーティング設定用のライブラリ @types/◯○ 型定義用のライブラリ 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string nickname?: string image?: string allowPasswordChange: boolean } 認証API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpData, SignInData } from "interfaces/index" // サインアップ(新規アカウント作成) export const signUp = (data: SignUpData) => { return client.post("auth", data) } // サインイン(ログイン) export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト(ログアウト) export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証済みのユーザーを取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("/auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } サインイン中かどうかを判別するための各値(access-token、client、uid)をどこで保持するかについて、色々と議論の余地はあるようですが、今回はCookie内に含める事とします。 参照: React(SPA)での認証についてまとめ 各種ビューを作成 各種ビューの部分を作成します。 ./src/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数 export const AuthContext = createContext({} as { loading: boolean setLoading: React.Dispatch<React.SetStateAction<boolean>> isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() // 認証済みのユーザーがいるかどうかチェック // 確認できた場合はそのユーザーの情報を取得 const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, setLoading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser}}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/" component={Home} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import Typography from "@material-ui/core/Typography" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import MenuIcon from "@material-ui/icons/Menu" import { signOut } from "lib/api/auth" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ iconButton: { marginRight: theme.spacing(2), }, title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none" } })) const Header: React.FC = () => { const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // サインアウト時には各Cookieを削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } const AuthButtons = () => { // 認証完了後はサインアウト用のボタンを表示 // 未認証時は認証用のボタンを表示 if (!loading) { if (isSignedIn) { return ( <Button color="inherit" className={classes.linkBtn} onClick={handleSignOut} > サインアウト </Button> ) } else { return ( <Button component={Link} to="/signin" color="inherit" className={classes.linkBtn} > サインイン </Button> ) } } else { return <></> } } return ( <> <AppBar position="static"> <Toolbar> <IconButton edge="start" className={classes.iconButton} color="inherit" > <MenuIcon /> </IconButton> <Typography component={Link} to="/" variant="h6" className={classes.title} > Sample </Typography> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> <Grid item> {children} </Grid> </Grid> </Container> </main> </> ) } export default CommonLayout ./src/components/pages/Home.tsx import React, { useContext } from "react" import { AuthContext } from "App" // とりあえず認証済みユーザーの名前やメールアドレスを表示 const Home: React.FC = () => { const { isSignedIn, currentUser } = useContext(AuthContext) return ( <> { isSignedIn && currentUser ? ( <> <h2>メールアドレス: {currentUser?.email}</h2> <h2>名前: {currentUser?.name}</h2> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインイン用ページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // 成功した場合はCookieに各値を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 } })) // サインアップ用ページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignUpData = { name: name, email: email, password: password, passwordConfirmation: passwordConfirmation } try { const res = await signUp(data) console.log(res) if (res.status === 200) { // アカウント作成と同時にサインインさせてしまう // 本来であればメール確認などを挟むべきだが、今回はサンプルなので Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={event => setName(event.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={event => setPasswordConfirmation(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/utils/AlertMessage.tsx import React from "react" import Snackbar from "@material-ui/core/Snackbar" import MuiAlert, { AlertProps } from "@material-ui/lab/Alert" const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert( props, ref, ) { return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} /> }) interface AlertMessageProps { open: boolean setOpen: Function severity: "error" | "success" | "info" | "warning" message: string } // アラートメッセージ(何かアクションを行なった際の案内用に使い回す) const AlertMessage = ({ open, setOpen, severity, message}: AlertMessageProps) => { const handleCloseAlertMessage = (e?: React.SyntheticEvent, reason?: string) => { if (reason === "clickaway") return setOpen(false) } return ( <> <Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: "top", horizontal: "center" }} onClose={handleCloseAlertMessage} > <Alert onClose={handleCloseAlertMessage} severity={severity}> {message} </Alert> </Snackbar> </> ) } export default AlertMessage 動作確認 サインアップ サインイン トップページ アラートメッセージ 特に問題が無さそうであれば認証機能は完成です。 マッチング機能を作成 だいぶそれっぽい雰囲気になってきたので、最後にマッチング機能を作成していきます。 Rails マッチング機能に関しては中間テーブルなどを活用する事で実現していきます。 テーブル設計 全体的なテーブルはこんな感じです。 ※雑ですみません...。 Userモデルにカラムを追加 deviseで作成したUserモデルに email name image といったカラムがデフォルトで入っていますが、マッチングアプリという観点からするとこれだけの情報ではやや物足りないため、 gender(性別) birthday(誕生日) prefecture(都道府県) profile(自己紹介) といったカラムを別途追加していきたいと思います。 $ docker-compose run api rails g migration AddColumnsToUsers ./db/migrate/20210609035043_add_columns_to_users.rb class AddColumnsToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :gender, :integer, null: false, default: 0, after: :nickname add_column :users, :birthday, :date, after: :email add_column :users, :profile, :string, limit: 1000, after: :birthday add_column :users, :prefecture, :integer, null: false, default: 1, after: :profile remove_column :users, :nickname, :string # 特に使う予定の無いカラムなので削除 end end マイグレーションファイルを上記のように変更後、 $ docker-compose run api rails db:migrate を実行してデータベースに反映させてください。 画像アップロード機能を作成 そういえば、まだ画像アップロード機能が無いので実装していきましょう。今回は定番のgem「carrierwave」を使用します。 ./Gemfile gem 'carrierwave' Gemfileを更新したので再度ビルド。 $ docker-compose build アップローダーを作成。 $ docker-compose run api rails g uploader Image すると「./app/uploaders/image_uploader.rb」が自動生成されるので次のように変更します。 .app/uploaders/image_uploader.rb class ImageUploader < CarrierWave::Uploader::Base storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 受け付け可能なファイルの拡張子を指定 def extension_allowlist %w(jpg jpeg png) end end また、「./config/initializers/」配下にcarrierwave設定用のファイルを作成。 $ touch config/initializers/carrierwave.rb ./config/initializers/carrierwave.rb CarrierWave.configure do |config| config.asset_host = "http://localhost:3001" config.storage = :file config.cache_storage = :file end Userモデルにアップローダーをマウントします。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 追記 end あとは「./app/controllers/api/v1/auth/registrations_controller.rb」内のストロングパラメータに先ほど追加したカラムを記述しておきます。 ./app/controllers/api/v1/auth/registrations_controller.rb class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name, :image, :gender, :prefecture, :birthday) end end これで準備は完了です。 あとは動作確認のためルートディレクトリに適当な画像を「sample.jpg」という名前配置し、次のcurlコマンドを実行しましょう。 $ curl -F "email=imagetest@example.com" -F "password=password" -F "password=password_confirmation" -F "name=imagetest" -F "gender=0" -F "birthday=2000-01-01" -F "prefecture=13" -F "profile=画像テストです" -F "image=@sample.jpg" http://localhost:3001/api/v1/auth { "status": "success", "data": { "email": "imagetest@example.com", "uid": "imagetest@example.com", "image": { "url": "http://localhost:3001/uploads/user/image/3/sample.jpg" }, "id": 3, "provider": "email", "allow_password_change": false, "name": "imagetest", "gender": 0, "birthday": "2000-01-01", "profile": null, "prefecture": 13, "created_at": "2021-06-09T08:53:03.944Z", "updated_at": "2021-06-09T08:53:04.116Z" } } こんな感じで画像のパスが保存されていればOKです。 Likeモデルを作成 今回、マッチングが成立するための条件を 双方のユーザーが相手に対して「いいね」を押す事 とするため、誰が誰に対して押したのかという情報を記録するためのLikeモデルを作成します。 $ docker-compose run api rails g model Like マイグレーションファイルを次のように変更。 ./db/migrate/20210609090711_create_likes.rb class CreateLikes < ActiveRecord::Migration[6.1] def change create_table :likes do |t| t.integer :from_user_id, null: false # 誰が t.integer :to_user_id, null: false # 誰に対して t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate Userモデルとのリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 以下を追記 has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね end ./app/models/like.rb class Like < ApplicationRecord belongs_to :to_user, class_name: "User", foreign_key: :to_user_id belongs_to :from_user, class_name: "User", foreign_key: :from_user_id end ChatRoomモデル・ChatRoomUserモデルを作成 マッチングが成立した際はメッセージのやりとりを行うための部屋が必要になるため、ChatRoomモデルおよびChatRoomUserモデルを作成します。 ChatRoom メッセージのやりとりを行う部屋 ChatRoomUser どの部屋にどのユーザーがいるのかという情報を記録 $ docker-compose run api rails g model ChatRoom $ docker-compose run api rails g model ChatRoomUser マイグレーションファイルをそれぞれ次のように変更。 ./db/migrate/20210609092254_create_chat_rooms.rb class CreateChatRooms < ActiveRecord::Migration[6.1] def change create_table :chat_rooms do |t| t.timestamps end end end ./db/migrate/20210609092312_create_chat_room_users.rb class CreateChatRoomUsers < ActiveRecord::Migration[6.1] def change create_table :chat_room_users do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoom、ChatRoomUser、それぞれのリレーションを作成しましょう。 ./app/models/user.eb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね # 以下を追記 has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 中間テーブルのchat_room_usersを介してusersを取得 end ./app/models/chat_room_user.rb class ChatRoomUser < ApplicationRecord belongs_to :chat_room belongs_to :user end Messageモデルを作成 あとはメッセージそのものとなるMessageモデルを作成します。なお、メッセージにはどの部屋のものなのか、誰が送信したものなのかといった情報も一緒に記録しておきたいところです。 $ docker-compose run api rails g model Message マイグレーションファイルを次のように変更。 ./db/migrate/20210609093540_create_messages.rb class CreateMessages < ActiveRecord::Migration[6.1] def change create_table :messages do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.string :content, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoomとそれぞれリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/message.rb class Message < ApplicationRecord belongs_to :chat_room belongs_to :user end 各種コントローラーを作成 上記のモデルたちを操作するためのコントローラーを作成していきます。 $ docker-compose run api rails g controller api/v1/likes $ docker-compose run api rails g controller api/v1/chat_rooms $ docker-compose run api rails g controller api/v1/messages $ docker-compose run api rails g controller api/v1/users ./app/controllers/api/v1/likes_controller.rb class Api::V1::LikesController < ApplicationController def index render json: { status: 200, active_likes: current_api_v1_user.active_likes, # 自分からのいいね passive_likes: current_api_v1_user.passive_likes # 相手からのいいね } end def create is_matched = false # マッチングが成立したかどうかのフラグ active_like = Like.find_or_initialize_by(like_params) passsive_like = Like.find_by( from_user_id: active_like.to_user_id, to_user_id: active_like.from_user_id ) if passsive_like # いいねを押した際、相手からのいいねがすでに存在する場合はマッチング成立 chat_room = ChatRoom.create # メッセージ交換用の部屋を作成 # 自分 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: active_like.from_user_id ) # 相手 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: passsive_like.from_user_id ) is_matched = true end if active_like.save render json: { status: 200, like: active_like, is_matched: is_matched } else render json: { status: 500, message: "作成に失敗しました" } end end private def like_params params.permit(:from_user_id, :to_user_id) end end ./app/controllers/api/v1/chat_rooms_controller.rb class Api::V1::ChatRoomsController < ApplicationController before_action :set_chat_room, only: %i[show] def index chat_rooms = [] current_api_v1_user.chat_rooms.order("created_at DESC").each do |chat_room| # 部屋の情報(相手のユーザーは誰か、最後に送信されたメッセージはどれか)をJSON形式で作成 chat_rooms << { chat_room: chat_room, other_user: chat_room.users.where.not(id: current_api_v1_user.id)[0], last_message: chat_room.messages[-1] } end render json: { status: 200, chat_rooms: chat_rooms } end def show other_user = @chat_room.users.where.not(id: current_api_v1_user.id)[0] messages = @chat_room.messages.order("created_at ASC") render json: { status: 200, other_user: other_user, messages: messages } end private def set_chat_room @chat_room = ChatRoom.find(params[:id]) end end ./app/controllers/api/v1/messages_controller.rb class Api::V1::MessagesController < ApplicationController def create message = Message.new(message_params) if message.save render json: { status: 200, message: message } else render json: { status: 500, message: "作成に失敗しました" } end end private def message_params params.permit(:chat_room_id, :user_id, :content) end end ./app/controllers/api/v1/users_controller.rb class Api::V1::UsersController < ApplicationController before_action :set_user, only: %i[show update] def index # 自分以外かつ性別の異なるユーザーを取得 users = User.where.not(id: current_api_v1_user.id, gender: current_api_v1_user.gender).order("created_at DESC") render json: { status: 200, users: users } end def show render json: { status: 200, user: @user } end def update @user.name = user_params[:name] @user.prefecture = user_params[:prefecture] @user.profile = user_params[:profile] @user.image = user_params[:image] if user_params[:image] != "" if @user.save render json: { status: 200, user: @user } else render json: { status: 500, message: "更新に失敗しました" } end end private def set_user @user = User.find(params[:id]) end def user_params params.permit(:name, :prefecture, :profile, :image) end end 各種ルーティングを設定 最後にルーティングの設定を行えばRails側の準備は全て完了です。 ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] resources :likes, only: %i[index create] resources :chat_rooms, only: %i[index show] resources :messages, only: %i[create] resources :users, only: %i[index show update] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end React いよいよ仕上げです。Railsで準備したAPIを呼び出しつつ、ビューの部分を作り込んでいきます。 各種ディレクトリ・ファイルを準備 $ touch src/components/pages/Users.tsx $ touch src/components/pages/ChatRooms.tsx $ touch src/components/pages/ChatRoom.tsx $ touch src/components/pages/NotFound.tsx $ mkdir src/data $ touch src/data/genders.ts $ touch src/data/prefectures.ts $ touch src/lib/api/users.ts $ touch src/lib/api/likes.ts $ touch src/lib/api/chat_rooms.ts $ touch src/lib/api/messages.ts 最終的に次のような構成になっていればOK。 rails-react-matching-app ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── ChatRoom.tsx │   │   │   ├── ChatRooms.tsx │   │   │   ├── Home.tsx │   │   │   ├── NotFound.tsx │   │   │   ├── SignIn.tsx │   │   │   ├── SignUp.tsx │   │   │   └── Users.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── data │   │   ├── genders.ts │   │   └── prefectures.ts │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── chat_rooms.ts │   │   ├── client.ts │   │   ├── likes.ts │   │   ├── messages.ts │   │   ├── test.ts │   │   └── users.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string gender: number prefecture: number birthday: Date image: string } export interface SignUpFormData extends FormData { append(name: keyof SignUpData, value: String | Blob, fileName?: string): any } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string image: { url: string } gender: number birthday: String | number | Date profile: string prefecture: number allowPasswordChange: boolean createdAt?: Date updatedAt?: Date } export interface UpdateUserData { id: number | undefined | null name?: string prefecture?: number profile?: string image?: string } export interface UpdateUserFormData extends FormData { append(name: keyof UpdateUserData, value: String | Blob, fileName?: string): any } // いいね export interface Like { id?: number fromUserId: number | undefined | null toUserId: number | undefined | null } // チャットルーム export interface ChatRoom { chatRoom: { id: number } otherUser: User, lastMessage: Message } // メッセージ export interface Message { chatRoomId: number userId: number | undefined content: string createdAt?: Date } マスターデータを作成 性別や都道府県といった不変的な情報はマスターデータとして保持しておきます。 ./src/data/genders.ts // 性別のマスターデータ export const genders: string[] = [ "男性", "女性", "その他" ] ./src/data/prefectures.ts // 都道府県のマスターデータ export const prefectures: string[] = [ "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県", "徳島県", "香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県" ] API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpFormData, SignInData } from "interfaces/index" // サインアップ export const signUp = (data: SignUpFormData) => { return client.post("auth", data) } // サインイン export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証中ユーザーの情報を取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/users.ts import client from "lib/api/client" import { UpdateUserFormData} from "interfaces/index" import Cookies from "js-cookie" // 都道府県が同じで性別の異なるユーザー情報一覧を取得(自分以外) export const getUsers = () => { return client.get("users", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でユーザー情報を個別に取得 export const getUser = (id: number | undefined) => { return client.get(`users/${id}`) } // ユーザー情報を更新 export const updateUser = (id: number | undefined | null, data: UpdateUserFormData) => { return client.put(`users/${id}`, data) } ./src/lib/api/likes.ts import client from "lib/api/client" import { Like } from "interfaces/index" import Cookies from "js-cookie" // 全てのいいね情報(自分から、相手から両方)を取得 export const getLikes = () => { return client.get("likes", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // いいねを作成 export const createLike= (data: Like) => { return client.post("likes", data) } ./src/lib/api/chat_rooms.ts import client from "lib/api/client" import Cookies from "js-cookie" // マッチングしたユーザーとの全てのチャットルーム情報を取得 export const getChatRooms = () => { return client.get("chat_rooms", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でチャットルーム情報を個別に取得 export const getChatRoom = (id: number) => { return client.get(`chat_rooms/${id}`, { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/messages.ts import client from "lib/api/client" import { Message } from "interfaces/index" // メッセージを作成 export const createMessage = (data: Message) => { return client.post("messages", data) } 各種ビューを作成 各種ビューの部分を作成します。 ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> {children} </Grid> </Container> </main> </> ) } export default CommonLayout ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import Typography from "@material-ui/core/Typography" import IconButton from "@material-ui/core/IconButton" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import PersonIcon from "@material-ui/icons/Person" import SearchIcon from "@material-ui/icons/Search" import ChatBubbleIcon from "@material-ui/icons/ChatBubble" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none", marginLeft: theme.spacing(1) } })) const Header: React.FC = () => { const { loading, isSignedIn } = useContext(AuthContext) const classes = useStyles() // 認証済みかどうかで表示ボタンを変更 const AuthButtons = () => { if (!loading) { if (isSignedIn) { return ( <> <IconButton component={Link} to="/users" edge="start" className={classes.linkBtn} color="inherit" > <SearchIcon /> </IconButton> <IconButton component={Link} to="/chat_rooms" edge="start" className={classes.linkBtn} color="inherit" > <ChatBubbleIcon /> </IconButton> <IconButton component={Link} to="/home" edge="start" className={classes.linkBtn} color="inherit" > <PersonIcon /> </IconButton> </> ) } else { return ( <> <IconButton component={Link} to="/signin" edge="start" className={classes.linkBtn} color="inherit" > <ExitToAppIcon /> </IconButton> </> ) } } else { return <></> } } return ( <> <AppBar position="static"> <Toolbar> <Typography component={Link} to="/users" variant="h6" className={classes.title} > Sample </Typography> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインインページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // ログインに成功した場合はCookieに各情報を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/home") setEmail("") setPassword("") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="最低6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import "date-fns" import DateFnsUtils from "@date-io/date-fns" // バージョンに注意(https://stackoverflow.com/questions/59600125/cannot-get-material-ui-datepicker-to-work) import { makeStyles, Theme } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import TextField from "@material-ui/core/TextField" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import { MuiPickersUtilsProvider, KeyboardDatePicker } from "@material-ui/pickers" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpFormData } from "interfaces/index" import { prefectures } from "data/prefectures" import { genders } from "data/genders" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(1), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, inputFileButton: { textTransform: "none", color: theme.palette.primary.main }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // サインアップページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [gender, setGender] = useState<number>() const [prefecture, setPrefecture] = useState<number>() const [birthday, setBirthday] = useState<Date | null>( new Date("2000-01-01T00:00:00"), ) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // アップロードした画像のデータを取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビューを表示 const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // フォームデータを作成 const createFormData = (): SignUpFormData => { const formData = new FormData() formData.append("name", name) formData.append("email", email) formData.append("password", password) formData.append("passwordConfirmation", passwordConfirmation) formData.append("gender", String(gender)) formData.append("prefecture", String(prefecture)) formData.append("birthday", String(birthday)) formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await signUp(data) console.log(res) if (res.status === 200) { Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/home") setName("") setEmail("") setPassword("") setPasswordConfirmation("") setGender(undefined) setPrefecture(undefined) setBirthday(null) console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordConfirmation(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">性別</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={gender} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setGender(e.target.value as number)} label="性別" > { genders.map((gender: string, index: number) => <MenuItem value={index}>{gender}</MenuItem> ) } </Select> </FormControl> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index +1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <MuiPickersUtilsProvider utils={DateFnsUtils}> <Grid container justify="space-around"> <KeyboardDatePicker fullWidth inputVariant="outlined" margin="dense" id="date-picker-dialog" label="誕生日" format="MM/dd/yyyy" value={birthday} onChange={(date: Date | null) => { setBirthday(date) }} KeyboardButtonProps={{ "aria-label": "change date", }} /> </Grid> </MuiPickersUtilsProvider> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/pages/Users.tsx import React, { useState, useEffect, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Dialog from "@material-ui/core/Dialog" import DialogContent from "@material-ui/core/DialogContent" import Avatar from "@material-ui/core/Avatar" import Button from "@material-ui/core/Button" import Divider from "@material-ui/core/Divider" import FavoriteIcon from "@material-ui/icons/Favorite" import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder" import AlertMessage from "components/utils/AlertMessage" import { prefectures } from "data/prefectures" import { getUsers } from "lib/api/users" import { getLikes, createLike } from "lib/api/likes" import { User, Like } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) } })) // ユーザー一覧ページ const Users: React.FC = () => { const { currentUser } = useContext(AuthContext) const classes = useStyles() const initialUserState: User = { id: 0, uid: "", provider: "", email: "", name: "", image: { url: "" }, gender: 0, birthday: "", profile: "", prefecture: 13, allowPasswordChange: true } const [loading, setLoading] = useState<boolean>(true) const [users, setUsers] = useState<User[]>([]) const [user, setUser] = useState<User>(initialUserState) const [userDetailOpen, setUserDetailOpen] = useState<boolean>(false) const [likedUsers, setLikedUsers] = useState<User[]>([]) const [likes, setLikes] = useState<Like[]>([]) const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const userAge = (): number | void => { const birthday = user.birthday.toString().replace(/-/g, "") if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const userPrefecture = (): string => { return prefectures[(user.prefecture) - 1] } // いいね作成 const handleCreateLike = async (user: User) => { const data: Like = { fromUserId: currentUser?.id, toUserId: user.id } try { const res = await createLike(data) console.log(res) if (res?.status === 200) { setLikes([res.data.like, ...likes]) setLikedUsers([user, ...likedUsers]) console.log(res?.data.like) } else { console.log("Failed") } if (res?.data.isMatched === true) { setAlertMessageOpen(true) setUserDetailOpen(false) } } catch (err) { console.log(err) } } // ユーザー一覧を取得 const handleGetUsers = async () => { try { const res = await getUsers() console.log(res) if (res?.status === 200) { setUsers(res?.data.users) } else { console.log("No users") } } catch (err) { console.log(err) } setLoading(false) } // いいね一覧を取得 const handleGetLikes = async () => { try { const res = await getLikes() console.log(res) if (res?.status === 200) { setLikedUsers(res?.data.activeLikes) } else { console.log("No likes") } } catch (err) { console.log(err) } } useEffect(() => { handleGetUsers() handleGetLikes() }, []) // すでにいいねを押されているユーザーかどうかの判定 const isLikedUser = (userId: number | undefined): boolean => { return likedUsers?.some((likedUser: User) => likedUser.id === userId) } return ( <> { !loading ? ( users?.length > 0 ? ( <Grid container justify="center"> { users?.map((user: User, index: number) => { return ( <div key={index} onClick={() => { setUser(user) setUserDetailOpen(true) }}> <Grid item style={{ margin: "0.5rem", cursor: "pointer" }}> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", textAlign: "center" }} > {user.name} </Typography> </Grid> </div> ) }) } </Grid> ) : ( <Typography component="p" variant="body2" color="textSecondary" > まだ1人もユーザーがいません。 </Typography> ) ) : ( <></> ) } <Dialog open={userDetailOpen} keepMounted onClose={() => setUserDetailOpen(false)} > <DialogContent> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1rem" }}> <Typography variant="body1" component="p" gutterBottom style={{ textAlign: "center" }}> {user.name} {userAge()}歳 ({userPrefecture()}) </Typography> <Divider /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> <Typography variant="body2" component="p" color="textSecondary" style={{ marginTop: "0.5rem" }}> {user.profile ? user.profile : "よろしくお願いします。" } </Typography> </Grid> </Grid> <Grid container justify="center"> <Button variant="outlined" onClick={() => isLikedUser(user.id) ? void(0) : handleCreateLike(user)} color="secondary" startIcon={isLikedUser(user.id) ? <FavoriteIcon /> : <FavoriteBorderIcon />} disabled={isLikedUser(user.id) ? true : false} style={{ marginTop: "1rem", marginBottom: "1rem" }} > {isLikedUser(user.id) ? "いいね済み" : "いいね"} </Button> </Grid> </DialogContent> </Dialog> <AlertMessage open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="success" message="マッチングが成立しました!" /> </> ) } export default Users ./src/components/pages/ChatRooms.tsx import React, { useEffect, useState } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import Divider from '@material-ui/core/Divider'; import ListItemText from '@material-ui/core/ListItemText'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import { getChatRooms } from "lib/api/chat_rooms" import { ChatRoom } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ root: { flexGrow: 1, minWidth: 340, maxWidth: "100%" }, link: { textDecoration: "none", color: "inherit" } })) // チャットルーム一覧ページ const ChatRooms: React.FC = () => { const classes = useStyles() const [loading, setLoading] = useState<boolean>(true) const [chatRooms, setChatRooms] = useState<ChatRoom[]>([]) const handleGetChatRooms = async () => { try { const res = await getChatRooms() if (res.status === 200) { setChatRooms(res.data.chatRooms) } else { console.log("No chat rooms") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRooms() }, []) return ( <> { !loading ? ( chatRooms.length > 0 ? ( chatRooms.map((chatRoom: ChatRoom, index: number) => { return ( <Grid container key={index} justify="center"> <List> {/* 個別のチャットルームへ飛ばす */} <Link to={`/chatroom/${chatRoom.chatRoom.id}`} className={classes.link}> <div className={classes.root}> <ListItem alignItems="flex-start" style={{padding: 0 }}> <ListItemAvatar> <Avatar alt="avatar" src={chatRoom.otherUser.image.url} /> </ListItemAvatar> <ListItemText primary={chatRoom.otherUser.name} secondary={ <div style={{ marginTop: "0.5rem" }}> <Typography component="span" variant="body2" color="textSecondary" > {chatRoom.lastMessage === null ? "まだメッセージはありません。" : chatRoom.lastMessage.content.length > 30 ? chatRoom.lastMessage.content.substr(0, 30) + "..." : chatRoom.lastMessage.content} </Typography> </div> } /> </ListItem> </div> </Link> <Divider component="li" /> </List> </Grid> ) }) ) : ( <Typography component="p" variant="body2" color="textSecondary" > マッチング中の相手はいません。 </Typography> ) ) : ( <></> ) } </> ) } export default ChatRooms ./src/components/pages/ChatRoom.tsx import React, { useEffect, useState, useContext } from "react" import { RouteComponentProps } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import TextField from "@material-ui/core/TextField" import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" import SendIcon from "@material-ui/icons/Send" import { getChatRoom } from "lib/api/chat_rooms" import { createMessage } from "lib/api/messages" import { User, Message } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10), margin: "0 auto" }, formWrapper : { padding: "2px 4px", display: "flex", alignItems: "center", width: 340 }, textInputWrapper : { width: "100%" }, button: { marginLeft: theme.spacing(1) } })) type ChatRoomProps = RouteComponentProps<{ id: string }> // 個別のチャットルームページ const ChatRoom: React.FC<ChatRoomProps> = (props) => { const classes = useStyles() const { currentUser } = useContext(AuthContext) const id = parseInt(props.match.params.id) // URLからidを取得 const [loading, setLoading] = useState<boolean>(true) const [otherUser, setOtherUser] = useState<User>() const [messages, setMeesages] = useState<Message[]>([]) const [content, setContent] = useState<string>("") const handleGetChatRoom = async () => { try { const res = await getChatRoom(id) console.log(res) if (res?.status === 200) { setOtherUser(res?.data.otherUser) setMeesages(res?.data.messages) } else { console.log("No other user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRoom() }, []) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: Message = { chatRoomId: id, userId: currentUser?.id, content: content } try { const res = await createMessage(data) console.log(res) if (res.status === 200) { setMeesages([...messages, res.data.message]) setContent("") } } catch (err) { console.log(err) } } // Railsから渡ってくるtimestamp(ISO8601)をdatetimeに変換 const iso8601ToDateTime = (iso8601: string) => { const date = new Date(Date.parse(iso8601)) const year = date.getFullYear() const month = date.getMonth() + 1 const day = date.getDate() const hour = date.getHours() const minute = date.getMinutes() return year + "年" + month + "月" + day + "日" + hour + "時" + minute + "分" } return ( <> { !loading ? ( <div style={{ maxWidth: 360 }}> <Grid container justify="center" style={{ marginBottom: "1rem" }}> <Grid item> <Avatar alt="avatar" src={otherUser?.image.url || ""} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", marginBottom: "1rem", textAlign: "center" }} > {otherUser?.name} </Typography> </Grid> </Grid> { messages.map((message: Message, index: number) => { return ( <Grid key={index} container justify={message.userId === otherUser?.id ? "flex-start" : "flex-end"}> <Grid item> <Box borderRadius={message.userId === otherUser?.id ? "30px 30px 30px 0px" : "30px 30px 0px 30px"} bgcolor={message.userId === otherUser?.id ? "#d3d3d3" : "#ffb6c1"} color={message.userId === otherUser?.id ? "#000000" : "#ffffff"} m={1} border={0} style={{ padding: "1rem" }} > <Typography variant="body1" component="p"> {message.content} </Typography> </Box> <Typography variant="body2" component="p" color="textSecondary" style={{ textAlign: message.userId === otherUser?.id ? "left" : "right" }} > {iso8601ToDateTime(message.createdAt?.toString() || "100000000")} </Typography> </Grid> </Grid> ) }) } <Grid container justify="center" style={{ marginTop: "2rem" }}> <form className={classes.formWrapper} noValidate autoComplete="off"> <TextField required multiline value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setContent(e.target.value)} className={classes.textInputWrapper} /> <Button variant="contained" color="primary" disabled={!content ? true : false} onClick={handleSubmit} className={classes.button} > <SendIcon /> </Button> </form> </Grid> </div> ) : ( <></> ) } </> ) } export default ChatRoom ./src/components/pages/Home.tsx import React, { useContext, useEffect, useState, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import IconButton from "@material-ui/core/IconButton" import SettingsIcon from "@material-ui/icons/Settings" import Dialog from "@material-ui/core/Dialog" import TextField from "@material-ui/core/TextField" import DialogActions from "@material-ui/core/DialogActions" import DialogContent from "@material-ui/core/DialogContent" import DialogTitle from "@material-ui/core/DialogTitle" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import Button from "@material-ui/core/Button" import Avatar from "@material-ui/core/Avatar" import Divider from "@material-ui/core/Divider" import { AuthContext } from "App" import { prefectures } from "data/prefectures" import { signOut } from "lib/api/auth" import { getUser, updateUser } from "lib/api/users" import { UpdateUserFormData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) }, card: { width: 340 }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // ホーム(マイページ的な) const Home: React.FC = () => { const { isSignedIn, setIsSignedIn, currentUser, setCurrentUser } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const [editFormOpen, setEditFormOpen] = useState<boolean>(false) const [name, setName] = useState<string | undefined>(currentUser?.name) const [prefecture, setPrefecture] = useState<number | undefined>(currentUser?.prefecture || 0) const [profile, setProfile] = useState<string | undefined>(currentUser?.profile) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") // アップロードした画像の情報を取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビュー const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const currentUserAge = (): number | void => { const birthday = currentUser?.birthday.toString().replace(/-/g, "") || "" if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const currentUserPrefecture = (): string => { return prefectures[(currentUser?.prefecture || 0) - 1] } const createFormData = (): UpdateUserFormData => { const formData = new FormData() formData.append("name", name || "") formData.append("prefecture", String(prefecture)) formData.append("profile", profile || "") formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await updateUser(currentUser?.id, data) console.log(res) if (res.status === 200) { setEditFormOpen(false) setCurrentUser(res.data.user) console.log("Update user successfully!") } else { console.log(res.data.message) } } catch (err) { console.log(err) console.log("Failed in updating user!") } } // サインアウト用の処理 const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // Cookieから各情報を削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } return ( <> { isSignedIn && currentUser ? ( <> <Card className={classes.card}> <CardContent> <Grid container justify="flex-end"> <Grid item> <IconButton onClick={() => setEditFormOpen(true)} > <SettingsIcon color="action" fontSize="small" /> </IconButton> </Grid> </Grid> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={currentUser?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1.5rem"}}> <Typography variant="body1" component="p" gutterBottom> {currentUser?.name} {currentUserAge()}歳 ({currentUserPrefecture()}) </Typography> <Divider style={{ marginTop: "0.5rem"}}/> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> { currentUser.profile ? ( <Typography variant="body2" component="p" color="textSecondary"> {currentUser.profile} </Typography> ): ( <Typography variant="body2" component="p" color="textSecondary"> よろしくお願いいたします。 </Typography> ) } <Button variant="outlined" onClick={handleSignOut} color="primary" fullWidth startIcon={<ExitToAppIcon />} style={{ marginTop: "1rem"}} > サインアウト </Button> </Grid> </Grid> </CardContent> </Card> <form noValidate autoComplete="off"> <Dialog open={editFormOpen} keepMounted onClose={() => setEditFormOpen(false)} > <DialogTitle style={{ textAlign: "center"}}> プロフィールの変更 </DialogTitle> <DialogContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index + 1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <TextField placeholder="1000文字以内で書いてください。" variant="outlined" multiline fullWidth label="自己紹介" rows="8" value={profile} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setProfile(e.target.value) }} /> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } </DialogContent> <DialogActions> <Button onClick={handleSubmit} color="primary" disabled={!name || !profile ? true : false} > 送信 </Button> </DialogActions> </Dialog> </form> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/NotFound.tsx import React from "react" // 存在しないページにアクセスされた場合の表示 const NotFound: React.FC = () => { return ( <h2>404 Not Found</h2> ) } export default NotFound ./src/components/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import ChatRooms from "components/pages/ChatRooms" import ChatRoom from "components/pages/ChatRoom" import Users from "components/pages/Users" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import NotFound from "components/pages/NotFound" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数(contextで管理) export const AuthContext = createContext({} as { loading: boolean isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser }}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/home" component={Home} /> <Route exact path="/users" component={Users} /> <Route exact path="/chat_rooms" component={ChatRooms} /> <Route path="/chatroom/:id" component={ChatRoom} /> <Route component={NotFound} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App 動作確認 あとは全体的に問題が無いか動作確認しましょう。 サインアップ サインイン ホーム ユーザー一覧 マッチング一覧 チャットルーム ※ユーザーデータなどはそれぞれ適当に入れてください。 あとがき 以上、Rails APIモード + React + Material-UIでマッチングアプリを作ってみました。 だいぶコード量が多いので、所々で雑になってしまっているかもしれません。特にエラーハンドリングの部分とか全然できてないと思います...。 今回はあくまでサンプルという事で、細かい部分の訂正は各々でお願いします。 こんな感じで趣味カードみたいなものを追加するとよりそれっぽくなるかも。これベースに色々工夫してみていただけると幸いです。 一応、GitHubのリンクも載せておくので、もし動かない部分などあったらそちらと照らし合わせて間違ってる部分は無いか確認してください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails API + Reactでマッチングアプリを作ってみる

概要 タイトル通り。バックエンドにRails(API)、フロントエンドにReactを採用したマッチングアプリ的なものを作ってみたのでアウトプットしておきます。 完成イメージ 割とそれっぽい雰囲気になってます。 使用技術 バックエンド Ruby3 Rails6(APIモード) MySQL8 Docker フロントエンド React TypeScript Material-UI 今回はバックエンドのみDockerで環境構築していきます。 実装の流れ だいぶ長旅になるので、これからどういった流れで作業を進めていくのかザックリ整理しておきます。 環境構築 Rails($ rails new) React($ create react-app) 認証機能を作成 gem「devise_token_auth」などを使用 マッチング機能を作成 中間テーブルなどを活用 バックエンドとフロントエンドを分離しているため、あっちこっち手を動かす事になりますが、あらかじめご了承ください。 環境構築 何はともあれ、環境構築からスタートです。 Rails まずはRailsから。 作業ディレクトリ&各種ファイルを作成 $ mkdir rails-react-matching-app && cd rails-react-matching-app $ mkdir backend && cd backend $ touch Dockerfile $ touch docker-compose.yml $ touch entrypoint.sh $ touch Gemfile $ touch Gemfile.lock ./Dockerfile FROM ruby:3.0 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs ENV APP_PATH /myapp RUN mkdir $APP_PATH WORKDIR $APP_PATH COPY Gemfile $APP_PATH/Gemfile COPY Gemfile.lock $APP_PATH/Gemfile.lock RUN bundle install COPY . $APP_PATH COPY entrypoint.sh /usr/bin/ RUN chmod +x /usr/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] EXPOSE 3000 CMD ["rails", "server", "-b", "0.0.0.0"] ./docker-compose.yml version: "3" services: db: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: password command: --default-authentication-plugin=mysql_native_password volumes: - mysql-data:/var/lib/mysql - /tmp/dockerdir:/etc/mysql/conf.d/ ports: - 3306:3306 api: build: context: . dockerfile: Dockerfile command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'" volumes: - .:/myapp - ./vendor/bundle:/myapp/vendor/bundle environment: TZ: Asia/Tokyo RAILS_ENV: development ports: - "3001:3000" depends_on: - db volumes: mysql-data: ./entrypoint.sh #!/bin/bash set -e # Remove a potentially pre-existing server.pid for Rails. rm -f /myapp/tmp/pids/server.pid # Then exec the container's main process (what's set as CMD in the Dockerfile). exec "$@" ./Gemfile # frozen_string_literal: true source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } gem "rails", "~> 6" /Gemfile.lock # 空欄でOK 最終的に次のような構成になっていればOK。 rails-react-matching-app └── backend ├── docker-compose.yml ├── Dockerfile ├── entrypoint.sh ├── Gemfile └── Gemfile.lock rails new いつものコマンドでプロジェクトを作成します。 $ docker-compose run api rails new . --force --no-deps -d mysql --api Gemfileが更新されたので再ビルド。 $ docker-compose build database.ymlを編集 ./config/database.yml default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password # デフォルトだと空欄になっているはずなので変更 host: db # デフォルトだとlocalhostになっているはずなので変更 development: <<: *default database: myapp_development test: <<: *default database: myapp_test production: <<: *default database: <%= ENV["DATABASE_NAME"] %> username: <%= ENV["DATABASE_USERNAME"] %> password: <%= ENV["DATABASE_PASSWORD"] %> host: <%= ENV["DATABASE_HOST"] %> データベースを作成 $ docker-compose run api rails db:create localhost:3001にアクセス $ docker-compose up -d localhost:3001 にアクセスしていつもの画面が表示されればOK。 テストAPIを作成 動作確認用のテストAPiを作成します。 $ docker-compose run api rails g controller api/v1/test ./app/controllers/api/v1/test_controller.rb class Api::V1::TestController < ApplicationController def index render json: { status: 200, message: "Hello World!"} end end ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] end end end curlコマンドで呼び出してみましょう。 $ curl http://localhost:3001/api/v1/test {"status":200,"message":"Hello World!"} 正常にJSONが返ってくればOK。 CORSを設定 今のままの状態でReact(クライアント)から直接呼び出そうとするとCORSエラーで弾かれてしまうため、その辺の設定を行います。 参照記事: CORSとは? Railsの場合、CORS設定を簡単に行えるgemが存在するのでそちらを使いましょう。 rb./Gemfile gem 'rack-cors' 今回はAPIモードで作成しているため、すでにGemfile内に記載されているはず。(26行目くらい) そちらのコメントアウトを外すだけでOKです。 $ docker-compose build Gemfileを更新したので再度ビルド。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end end あとは「./config/initializers/cors.rb」をいじくって外部ドメインからアクセス可能なようにしておきます。 React 次にReact側です。 create-react-app おなじみの「create-react-app」でアプリの雛形を作ります。 # ルートディレクトリに移動した後 $ mkdir frontend && cd frontend $ yarn create react-app . --template typescript tsconfig.jsonを修正 ./tsconfig.json { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, ...省略... "baseUrl": "src" ← 追記 }, "include": [ "src" ] } 「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。 これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。 baseUrlを指定しない場合 import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス baseUrlを指定した場合 import Hoge from "components/Hoge" // baseUrlからの相対パス 「../../」みたいな記述をしなくて済みます。 不要なファイルを整理 この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 「./src/index.tsx」と「./src/App.tsx」を次のように変更します。 ./src/index.tsx import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) ./src/App.tsx import React from "react" const App: React.FC = () => { return ( <h1>Hello World!</h1> ) } export default App 一旦、動作確認してみましょう。 $ yarn start localhost:3000 にアクセスして「Hello World!」と返ってくればOK。 APIクライアントを作成 Rails側で作成したAPIを呼び出すための準備を行います。 $ mkdir src/lib $ mkdir src/lib/api $ touch src/lib/api/client.ts $ touch src/lib/api/test.ts $ yarn add axios axios-case-converter $ yarn add -D @types/axios axios HTTPクライアント用のライブラリ @types/axios 型定義用のライブラリ axios-case-converter axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ ./src/lib/api/client.ts import applyCaseMiddleware from "axios-case-converter" import axios from "axios" // applyCaseMiddleware: // axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換 // または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ // ヘッダーに関してはケバブケースのままで良いので適用を無視するオプションを追加 const options = { ignoreHeaders: true } const client = applyCaseMiddleware(axios.create({ baseURL: "http://localhost:3001/api/v1" }), options) export default client 慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本となるため、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。 ./src/lib/api/test.ts import client from "lib/api/client" // 動作確認用 export const execTest = () => { return client.get("/test") } ./src/App.tsx import React, { useEffect, useState } from "react" import { execTest } from "lib/api/test" const App: React.FC = () => { const [message, setMessage] = useState<string>("") const handleExecTest = async () => { const res = await execTest() if (res.status === 200) { setMessage(res.data.message) } } useEffect(() => { handleExecTest() }, []) return ( <h1>{message}</h1> ) } export default App 再び localhost:3000 にアクセスして「Hello World!」と返ってくればRaiks側との通信に成功です。 もしダメな場合、大抵はDockerのコンテナを再起動していないなどが原因(config/~をいじくったため、反映させるためには再起動する必要がある)なので、 $ docker-compose down $ docker-compose up -d などで再起動をかけてください。 認証機能を作成 環境構築が済んだので、認証機能を作成していきます。 Rails 今回、認証機能は「dvise」および「devise_token_auth」というgemを使って実装します。 deviseをインストール ./Gemfile gem 'devise' gem 'devise_token_auth' Gemfileを更新したので再度ビルド。 $ docker-compose build devise本体とdevise_token_authをインストールし、Userモデルを作成します。 $ docker-compose run api rails g devise:install $ docker-compose run api rails g devise_token_auth:install User auth $ docker-compose run api rails db:migrate 「./app/config/initializers/devise_token_auth.rb」という設定ファイルが自動生成されているはずなので次のように変更してください。 ./app/config/initializers/devise_token_auth.rb # frozen_string_literal: true DeviseTokenAuth.setup do |config| config.change_headers_on_each_request = false config.token_lifespan = 2.weeks config.token_cost = Rails.env.test? ? 4 : 10 config.headers_names = {:'access-token' => 'access-token', :'client' => 'client', :'expiry' => 'expiry', :'uid' => 'uid', :'token-type' => 'token-type' } end また、ヘッダー情報を外部に公開するため、「./config/initializers/cors.rb」を次のように修正します。 ./config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins "localhost:3000" # React側はポート番号3000で作るので「localhost:3000」を指定 resource "*", headers: :any, expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記 methods: [:get, :post, :put, :patch, :delete, :options, :head] end end メール認証設定 今回はサンプルという事もあり、簡略化のためアカウント作成時のメール認証はスキップする方向で進めますが、後ほど実運用を想定した場合は必ず必要になると思うので一応やっておきます。 ./config/environments/development.rb config.action_mailer.default_url_options = { host: 'localhost', port: 3001 } なお、開発環境でメール確認を行う場合は letter_opener_web などが便利だと思います。 各種コントローラーを作成&修正 各種コントローラーの作成および修正を行います。 $ docker-compose run api rails g controller api/v1/auth/registrations $ docker-compose run api rails g controller api/v1/auth/sessions ./app/controllers/api/v1/auth/registrations_controller.rb # アカウント作成用コントローラー class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name) end end ./app/controllers/api/v1/auth/sessions_controller.rb # 認証確認用コントローラー class Api::V1::Auth::SessionsController < ApplicationController def index if current_api_v1_user render json: { status: 200, current_user: current_api_v1_user } else render json: { status: 500, message: "ユーザーが存在しません" } end end end ./app/controllers/application_controller.rb class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken skip_before_action :verify_authenticity_token helper_method :current_user, :user_signed_in? end deviseにおいて「current_user」というヘルパーメソッドは定番ですが、今回はルーティングの部分で「api/v1/」というnamespaceを切る(後述)ので「current_api_v1_user」としなければならない点に注意です。 ルーティングを設定 ルーティングの設定もお忘れなく。 ./app/config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end 動作確認 早速ですが、curlコマンドでアカウント作成およびサインインができるか試しみてましょう。 アカウント作成 $ curl -X POST http://localhost:3001/api/v1/auth -d "[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password" { "status": "success", "data": { "email": "test@example.com", "uid": "test@example.com", "id": 1, "provider": "email", "allow_password_change": false, "name": "test", "nickname": null, "image": null, "created_at": "2021-06-08T20:27:40.489Z", "updated_at": "2021-06-08T20:27:40.608Z" } } サインイン $ curl -X POST -v http://localhost:3001/api/v1/auth/sign_in -d "[email]=test@example.com&[password]=password" Note: Unnecessary use of -X or --request, POST is already inferred. * Trying ::1... * TCP_NODELAY set * Connected to localhost (::1) port 3001 (#0) > POST /api/v1/auth/sign_in HTTP/1.1 > Host: localhost:3001 > User-Agent: curl/7.64.1 > Accept: */* > Content-Length: 44 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 44 out of 44 bytes < HTTP/1.1 200 OK < X-Frame-Options: SAMEORIGIN < X-XSS-Protection: 1; mode=block < X-Content-Type-Options: nosniff < X-Download-Options: noopen < X-Permitted-Cross-Domain-Policies: none < Referrer-Policy: strict-origin-when-cross-origin < Content-Type: application/json; charset=utf-8 < Vary: Accept, Origin < access-token: xg-4vaua1T2KTUZUFfdYDg < token-type: Bearer < client: 2Rdmffk44hfoFCdmNafMSw < expiry: 1624393713 < uid: test@example.com < ETag: W/"fc564a9145d11564e827b204d5a4ce36" < Cache-Control: max-age=0, private, must-revalidate < X-Request-Id: 9a749e3d-0ba6-4e53-bbfb-1baff500e2e0 < X-Runtime: 0.363334 < Transfer-Encoding: chunked < * Connection #0 to host localhost left intact {"data":{"email":"test@example.com","uid":"test@example.com","id":1,"provider":"email","allow_password_change":false,"name":"test","nickname":null,"image":null}}* Closing connection 0 それぞれこんな感じで返ってくれば無事成功です。 なお、サインイン時に返ってくる access-token client uid この3つは後ほどReact側で認証を行う際に必要となる値なので、重要なものだと頭に入れておきましょう。 React 例の如く次はReact側の実装です。 各種ディレクトリ・ファイルを準備 $ mkdir components $ mkdir components/layouts $ mkdir components/pages $ mkdir components/utils $ mkdir interfaces $ touch components/layouts/CommonLayout.tsx $ touch components/layouts/Header.tsx $ touch components/pages/Home.tsx $ touch components/pages/SignIn.tsx $ touch components/pages/SignUp.tsx $ touch components/utils/AlertMessage.tsx $ touch interfaces/index.ts $ mv components interfaces src $ touch src/lib/api/auth.ts 最終的に次のような構成になっていればOK。 rails-react-auth ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── Home.tsx │   │   │   ├── SignIn.tsx │   │   │   └── SignUp.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── client.ts │   │   └── test.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── .gitignore ├── package.json ├── README.md ├── tsconfig.json └── yarn.lock 各種ライブラリをインストール $ yarn add @material-ui/core @material-ui/icons @material-ui/lab @material-ui/pickers @date-io/date-fns@1.3.13 date-fns js-cookie react-router-dom $ yarn add -D @types/js-cookie @types/react-router-dom material-ui UIを整える用のライブラリ date-fns 日付関連を操作するためのライブラリ(v1.3.13じゃないとエラーが発生するので注意) js-cookie Cookieを操作するためのライブラリ react-router-dom ルーティング設定用のライブラリ @types/◯○ 型定義用のライブラリ 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string nickname?: string image?: string allowPasswordChange: boolean } 認証API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpData, SignInData } from "interfaces/index" // サインアップ(新規アカウント作成) export const signUp = (data: SignUpData) => { return client.post("auth", data) } // サインイン(ログイン) export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト(ログアウト) export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証済みのユーザーを取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("/auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } サインイン中かどうかを判別するための各値(access-token、client、uid)をどこで保持するかについて、色々と議論の余地はあるようですが、今回はCookie内に含める事とします。 参照: React(SPA)での認証についてまとめ 各種ビューを作成 各種ビューの部分を作成します。 ./src/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数 export const AuthContext = createContext({} as { loading: boolean setLoading: React.Dispatch<React.SetStateAction<boolean>> isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() // 認証済みのユーザーがいるかどうかチェック // 確認できた場合はそのユーザーの情報を取得 const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, setLoading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser}}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/" component={Home} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import Typography from "@material-ui/core/Typography" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import MenuIcon from "@material-ui/icons/Menu" import { signOut } from "lib/api/auth" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ iconButton: { marginRight: theme.spacing(2), }, title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none" } })) const Header: React.FC = () => { const { loading, isSignedIn, setIsSignedIn } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // サインアウト時には各Cookieを削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } const AuthButtons = () => { // 認証完了後はサインアウト用のボタンを表示 // 未認証時は認証用のボタンを表示 if (!loading) { if (isSignedIn) { return ( <Button color="inherit" className={classes.linkBtn} onClick={handleSignOut} > サインアウト </Button> ) } else { return ( <Button component={Link} to="/signin" color="inherit" className={classes.linkBtn} > サインイン </Button> ) } } else { return <></> } } return ( <> <AppBar position="static"> <Toolbar> <IconButton edge="start" className={classes.iconButton} color="inherit" > <MenuIcon /> </IconButton> <Typography component={Link} to="/" variant="h6" className={classes.title} > Sample </Typography> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> <Grid item> {children} </Grid> </Grid> </Container> </main> </> ) } export default CommonLayout ./src/components/pages/Home.tsx import React, { useContext } from "react" import { AuthContext } from "App" // とりあえず認証済みユーザーの名前やメールアドレスを表示 const Home: React.FC = () => { const { isSignedIn, currentUser } = useContext(AuthContext) return ( <> { isSignedIn && currentUser ? ( <> <h2>メールアドレス: {currentUser?.email}</h2> <h2>名前: {currentUser?.name}</h2> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインイン用ページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // 成功した場合はCookieに各値を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 400 } })) // サインアップ用ページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignUpData = { name: name, email: email, password: password, passwordConfirmation: passwordConfirmation } try { const res = await signUp(data) console.log(res) if (res.status === 200) { // アカウント作成と同時にサインインさせてしまう // 本来であればメール確認などを挟むべきだが、今回はサンプルなので Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={event => setName(event.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={event => setEmail(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={event => setPassword(event.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={event => setPasswordConfirmation(event.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/utils/AlertMessage.tsx import React from "react" import Snackbar from "@material-ui/core/Snackbar" import MuiAlert, { AlertProps } from "@material-ui/lab/Alert" const Alert = React.forwardRef<HTMLDivElement, AlertProps>(function Alert( props, ref, ) { return <MuiAlert elevation={6} ref={ref} variant="filled" {...props} /> }) interface AlertMessageProps { open: boolean setOpen: Function severity: "error" | "success" | "info" | "warning" message: string } // アラートメッセージ(何かアクションを行なった際の案内用に使い回す) const AlertMessage = ({ open, setOpen, severity, message}: AlertMessageProps) => { const handleCloseAlertMessage = (e?: React.SyntheticEvent, reason?: string) => { if (reason === "clickaway") return setOpen(false) } return ( <> <Snackbar open={open} autoHideDuration={6000} anchorOrigin={{ vertical: "top", horizontal: "center" }} onClose={handleCloseAlertMessage} > <Alert onClose={handleCloseAlertMessage} severity={severity}> {message} </Alert> </Snackbar> </> ) } export default AlertMessage 動作確認 サインアップ サインイン トップページ アラートメッセージ 特に問題が無さそうであれば認証機能は完成です。 マッチング機能を作成 だいぶそれっぽい雰囲気になってきたので、最後にマッチング機能を作成していきます。 Rails マッチング機能に関しては中間テーブルなどを活用する事で実現していきます。 テーブル設計 全体的なテーブルはこんな感じです。 ※雑ですみません...。 Userモデルにカラムを追加 deviseで作成したUserモデルに email name image といったカラムがデフォルトで入っていますが、マッチングアプリという観点からするとこれだけの情報ではやや物足りないため、 gender(性別) birthday(誕生日) prefecture(都道府県) profile(自己紹介) といったカラムを別途追加していきたいと思います。 $ docker-compose run api rails g migration AddColumnsToUsers ./db/migrate/20210609035043_add_columns_to_users.rb class AddColumnsToUsers < ActiveRecord::Migration[6.1] def change add_column :users, :gender, :integer, null: false, default: 0, after: :nickname add_column :users, :birthday, :date, after: :email add_column :users, :profile, :string, limit: 1000, after: :birthday add_column :users, :prefecture, :integer, null: false, default: 1, after: :profile remove_column :users, :nickname, :string # 特に使う予定の無いカラムなので削除 end end マイグレーションファイルを上記のように変更後、 $ docker-compose run api rails db:migrate を実行してデータベースに反映させてください。 画像アップロード機能を作成 そういえば、まだ画像アップロード機能が無いので実装していきましょう。今回は定番のgem「carrierwave」を使用します。 ./Gemfile gem 'carrierwave' Gemfileを更新したので再度ビルド。 $ docker-compose build アップローダーを作成。 $ docker-compose run api rails g uploader Image すると「./app/uploaders/image_uploader.rb」が自動生成されるので次のように変更します。 .app/uploaders/image_uploader.rb class ImageUploader < CarrierWave::Uploader::Base storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end # 受け付け可能なファイルの拡張子を指定 def extension_allowlist %w(jpg jpeg png) end end また、「./config/initializers/」配下にcarrierwave設定用のファイルを作成。 $ touch config/initializers/carrierwave.rb ./config/initializers/carrierwave.rb CarrierWave.configure do |config| config.asset_host = "http://localhost:3001" config.storage = :file config.cache_storage = :file end Userモデルにアップローダーをマウントします。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 追記 end あとは「./app/controllers/api/v1/auth/registrations_controller.rb」内のストロングパラメータに先ほど追加したカラムを記述しておきます。 ./app/controllers/api/v1/auth/registrations_controller.rb class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController private def sign_up_params params.permit(:email, :password, :password_confirmation, :name, :image, :gender, :prefecture, :birthday) end end これで準備は完了です。 あとは動作確認のためルートディレクトリに適当な画像を「sample.jpg」という名前配置し、次のcurlコマンドを実行しましょう。 $ curl -F "email=imagetest@example.com" -F "password=password" -F "password=password_confirmation" -F "name=imagetest" -F "gender=0" -F "birthday=2000-01-01" -F "prefecture=13" -F "profile=画像テストです" -F "image=@sample.jpg" http://localhost:3001/api/v1/auth { "status": "success", "data": { "email": "imagetest@example.com", "uid": "imagetest@example.com", "image": { "url": "http://localhost:3001/uploads/user/image/3/sample.jpg" }, "id": 3, "provider": "email", "allow_password_change": false, "name": "imagetest", "gender": 0, "birthday": "2000-01-01", "profile": null, "prefecture": 13, "created_at": "2021-06-09T08:53:03.944Z", "updated_at": "2021-06-09T08:53:04.116Z" } } こんな感じで画像のパスが保存されていればOKです。 Likeモデルを作成 今回、マッチングが成立するための条件を 双方のユーザーが相手に対して「いいね」を押す事 とするため、誰が誰に対して押したのかという情報を記録するためのLikeモデルを作成します。 $ docker-compose run api rails g model Like マイグレーションファイルを次のように変更。 ./db/migrate/20210609090711_create_likes.rb class CreateLikes < ActiveRecord::Migration[6.1] def change create_table :likes do |t| t.integer :from_user_id, null: false # 誰が t.integer :to_user_id, null: false # 誰に対して t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate Userモデルとのリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader # 以下を追記 has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね end ./app/models/like.rb class Like < ApplicationRecord belongs_to :to_user, class_name: "User", foreign_key: :to_user_id belongs_to :from_user, class_name: "User", foreign_key: :from_user_id end ChatRoomモデル・ChatRoomUserモデルを作成 マッチングが成立した際はメッセージのやりとりを行うための部屋が必要になるため、ChatRoomモデルおよびChatRoomUserモデルを作成します。 ChatRoom メッセージのやりとりを行う部屋 ChatRoomUser どの部屋にどのユーザーがいるのかという情報を記録 $ docker-compose run api rails g model ChatRoom $ docker-compose run api rails g model ChatRoomUser マイグレーションファイルをそれぞれ次のように変更。 ./db/migrate/20210609092254_create_chat_rooms.rb class CreateChatRooms < ActiveRecord::Migration[6.1] def change create_table :chat_rooms do |t| t.timestamps end end end ./db/migrate/20210609092312_create_chat_room_users.rb class CreateChatRoomUsers < ActiveRecord::Migration[6.1] def change create_table :chat_room_users do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoom、ChatRoomUser、それぞれのリレーションを作成しましょう。 ./app/models/user.eb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね # 以下を追記 has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 中間テーブルのchat_room_usersを介してusersを取得 end ./app/models/chat_room_user.rb class ChatRoomUser < ApplicationRecord belongs_to :chat_room belongs_to :user end Messageモデルを作成 あとはメッセージそのものとなるMessageモデルを作成します。なお、メッセージにはどの部屋のものなのか、誰が送信したものなのかといった情報も一緒に記録しておきたいところです。 $ docker-compose run api rails g model Message マイグレーションファイルを次のように変更。 ./db/migrate/20210609093540_create_messages.rb class CreateMessages < ActiveRecord::Migration[6.1] def change create_table :messages do |t| t.integer :chat_room_id, null: false t.integer :user_id, null: false t.string :content, null: false t.timestamps end end end データベースに反映します。 $ docker-compose run api rails db:migrate User、ChatRoomとそれぞれリレーションを作成しましょう。 ./app/models/user.rb # frozen_string_literal: true class User < ActiveRecord::Base # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable include DeviseTokenAuth::Concerns::User mount_uploader :image, ImageUploader has_many :likes_from, class_name: "Like", foreign_key: :from_user_id, dependent: :destroy has_many :likes_to, class_name: "Like", foreign_key: :to_user_id, dependent: :destroy has_many :active_likes, through: :likes_from, source: :to_user # 自分からのいいね has_many :passive_likes, through: :likes_to, source: :from_user # 相手からのいいね has_many :chat_room_users has_many :chat_rooms, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/chat_room.rb class ChatRoom < ApplicationRecord has_many :chat_room_users has_many :users, through: :chat_room_users # 以下を追記 has_many :messages end ./app/models/message.rb class Message < ApplicationRecord belongs_to :chat_room belongs_to :user end 各種コントローラーを作成 上記のモデルたちを操作するためのコントローラーを作成していきます。 $ docker-compose run api rails g controller api/v1/likes $ docker-compose run api rails g controller api/v1/chat_rooms $ docker-compose run api rails g controller api/v1/messages $ docker-compose run api rails g controller api/v1/users ./app/controllers/api/v1/likes_controller.rb class Api::V1::LikesController < ApplicationController def index render json: { status: 200, active_likes: current_api_v1_user.active_likes, # 自分からのいいね passive_likes: current_api_v1_user.passive_likes # 相手からのいいね } end def create is_matched = false # マッチングが成立したかどうかのフラグ active_like = Like.find_or_initialize_by(like_params) passsive_like = Like.find_by( from_user_id: active_like.to_user_id, to_user_id: active_like.from_user_id ) if passsive_like # いいねを押した際、相手からのいいねがすでに存在する場合はマッチング成立 chat_room = ChatRoom.create # メッセージ交換用の部屋を作成 # 自分 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: active_like.from_user_id ) # 相手 ChatRoomUser.find_or_create_by( chat_room_id: chat_room.id, user_id: passsive_like.from_user_id ) is_matched = true end if active_like.save render json: { status: 200, like: active_like, is_matched: is_matched } else render json: { status: 500, message: "作成に失敗しました" } end end private def like_params params.permit(:from_user_id, :to_user_id) end end ./app/controllers/api/v1/chat_rooms_controller.rb class Api::V1::ChatRoomsController < ApplicationController before_action :set_chat_room, only: %i[show] def index chat_rooms = [] current_api_v1_user.chat_rooms.order("created_at DESC").each do |chat_room| # 部屋の情報(相手のユーザーは誰か、最後に送信されたメッセージはどれか)をJSON形式で作成 chat_rooms << { chat_room: chat_room, other_user: chat_room.users.where.not(id: current_api_v1_user.id)[0], last_message: chat_room.messages[-1] } end render json: { status: 200, chat_rooms: chat_rooms } end def show other_user = @chat_room.users.where.not(id: current_api_v1_user.id)[0] messages = @chat_room.messages.order("created_at ASC") render json: { status: 200, other_user: other_user, messages: messages } end private def set_chat_room @chat_room = ChatRoom.find(params[:id]) end end ./app/controllers/api/v1/messages_controller.rb class Api::V1::MessagesController < ApplicationController def create message = Message.new(message_params) if message.save render json: { status: 200, message: message } else render json: { status: 500, message: "作成に失敗しました" } end end private def message_params params.permit(:chat_room_id, :user_id, :content) end end ./app/controllers/api/v1/users_controller.rb class Api::V1::UsersController < ApplicationController before_action :set_user, only: %i[show update] def index # 自分以外かつ性別の異なるユーザーを取得 users = User.where.not(id: current_api_v1_user.id, gender: current_api_v1_user.gender).order("created_at DESC") render json: { status: 200, users: users } end def show render json: { status: 200, user: @user } end def update @user.name = user_params[:name] @user.prefecture = user_params[:prefecture] @user.profile = user_params[:profile] @user.image = user_params[:image] if user_params[:image] != "" if @user.save render json: { status: 200, user: @user } else render json: { status: 500, message: "更新に失敗しました" } end end private def set_user @user = User.find(params[:id]) end def user_params params.permit(:name, :prefecture, :profile, :image) end end 各種ルーティングを設定 最後にルーティングの設定を行えばRails側の準備は全て完了です。 ./config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :test, only: %i[index] resources :likes, only: %i[index create] resources :chat_rooms, only: %i[index show] resources :messages, only: %i[create] resources :users, only: %i[index show update] mount_devise_token_auth_for 'User', at: 'auth', controllers: { registrations: 'api/v1/auth/registrations' } namespace :auth do resources :sessions, only: %i[index] end end end end React いよいよ仕上げです。Railsで準備したAPIを呼び出しつつ、ビューの部分を作り込んでいきます。 各種ディレクトリ・ファイルを準備 $ touch src/components/pages/Users.tsx $ touch src/components/pages/ChatRooms.tsx $ touch src/components/pages/ChatRoom.tsx $ touch src/components/pages/NotFound.tsx $ mkdir src/data $ touch src/data/genders.ts $ touch src/data/prefectures.ts $ touch src/lib/api/users.ts $ touch src/lib/api/likes.ts $ touch src/lib/api/chat_rooms.ts $ touch src/lib/api/messages.ts 最終的に次のような構成になっていればOK。 rails-react-matching-app ├── backend └── frontend ├── node_modules ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── components │   │   ├── layouts │   │   │   ├── CommonLayout.tsx │   │   │   └── Header.tsx │   │   ├── pages │   │   │   ├── ChatRoom.tsx │   │   │   ├── ChatRooms.tsx │   │   │   ├── Home.tsx │   │   │   ├── NotFound.tsx │   │   │   ├── SignIn.tsx │   │   │   ├── SignUp.tsx │   │   │   └── Users.tsx │   │   └── utils │   │   └── AlertMessage.tsx │   ├── data │   │   ├── genders.ts │   │   └── prefectures.ts │   ├── interfaces │   │   └── index.ts │   ├── lib │   │   └── api │   │   ├── auth.ts │   │   ├── chat_rooms.ts │   │   ├── client.ts │   │   ├── likes.ts │   │   ├── messages.ts │   │   ├── test.ts │   │   └── users.ts │   ├── App.tsx │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock 型定義 ./src/interfaces/index.ts // サインアップ export interface SignUpData { name: string email: string password: string passwordConfirmation: string gender: number prefecture: number birthday: Date image: string } export interface SignUpFormData extends FormData { append(name: keyof SignUpData, value: String | Blob, fileName?: string): any } // サインイン export interface SignInData { email: string password: string } // ユーザー export interface User { id: number uid: string provider: string email: string name: string image: { url: string } gender: number birthday: String | number | Date profile: string prefecture: number allowPasswordChange: boolean createdAt?: Date updatedAt?: Date } export interface UpdateUserData { id: number | undefined | null name?: string prefecture?: number profile?: string image?: string } export interface UpdateUserFormData extends FormData { append(name: keyof UpdateUserData, value: String | Blob, fileName?: string): any } // いいね export interface Like { id?: number fromUserId: number | undefined | null toUserId: number | undefined | null } // チャットルーム export interface ChatRoom { chatRoom: { id: number } otherUser: User, lastMessage: Message } // メッセージ export interface Message { chatRoomId: number userId: number | undefined content: string createdAt?: Date } マスターデータを作成 性別や都道府県といった不変的な情報はマスターデータとして保持しておきます。 ./src/data/genders.ts // 性別のマスターデータ export const genders: string[] = [ "男性", "女性", "その他" ] ./src/data/prefectures.ts // 都道府県のマスターデータ export const prefectures: string[] = [ "北海道", "青森県", "岩手県", "宮城県", "秋田県", "山形県", "福島県", "茨城県", "栃木県", "群馬県", "埼玉県", "千葉県", "東京都", "神奈川県", "新潟県", "富山県", "石川県", "福井県", "山梨県", "長野県", "岐阜県", "静岡県", "愛知県", "三重県", "滋賀県", "京都府", "大阪府", "兵庫県", "奈良県", "和歌山県", "鳥取県", "島根県", "岡山県", "広島県", "山口県", "徳島県", "香川県", "愛媛県", "高知県", "福岡県", "佐賀県", "長崎県", "熊本県", "大分県", "宮崎県", "鹿児島県", "沖縄県" ] API用の関数を作成 ./src/lib/api/auth.ts import client from "lib/api/client" import Cookies from "js-cookie" import { SignUpFormData, SignInData } from "interfaces/index" // サインアップ export const signUp = (data: SignUpFormData) => { return client.post("auth", data) } // サインイン export const signIn = (data: SignInData) => { return client.post("auth/sign_in", data) } // サインアウト export const signOut = () => { return client.delete("auth/sign_out", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // 認証中ユーザーの情報を取得 export const getCurrentUser = () => { if (!Cookies.get("_access_token") || !Cookies.get("_client") || !Cookies.get("_uid")) return return client.get("auth/sessions", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/users.ts import client from "lib/api/client" import { UpdateUserFormData} from "interfaces/index" import Cookies from "js-cookie" // 都道府県が同じで性別の異なるユーザー情報一覧を取得(自分以外) export const getUsers = () => { return client.get("users", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でユーザー情報を個別に取得 export const getUser = (id: number | undefined) => { return client.get(`users/${id}`) } // ユーザー情報を更新 export const updateUser = (id: number | undefined | null, data: UpdateUserFormData) => { return client.put(`users/${id}`, data) } ./src/lib/api/likes.ts import client from "lib/api/client" import { Like } from "interfaces/index" import Cookies from "js-cookie" // 全てのいいね情報(自分から、相手から両方)を取得 export const getLikes = () => { return client.get("likes", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // いいねを作成 export const createLike= (data: Like) => { return client.post("likes", data) } ./src/lib/api/chat_rooms.ts import client from "lib/api/client" import Cookies from "js-cookie" // マッチングしたユーザーとの全てのチャットルーム情報を取得 export const getChatRooms = () => { return client.get("chat_rooms", { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } // id指定でチャットルーム情報を個別に取得 export const getChatRoom = (id: number) => { return client.get(`chat_rooms/${id}`, { headers: { "access-token": Cookies.get("_access_token"), "client": Cookies.get("_client"), "uid": Cookies.get("_uid") }}) } ./src/lib/api/messages.ts import client from "lib/api/client" import { Message } from "interfaces/index" // メッセージを作成 export const createMessage = (data: Message) => { return client.post("messages", data) } 各種ビューを作成 各種ビューの部分を作成します。 ./src/components/layouts/CommonLayout.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "components/layouts/Header" const useStyles = makeStyles(() => ({ container: { marginTop: "3rem" } })) interface CommonLayoutProps { children: React.ReactElement } // 全てのページで共通となるレイアウト const CommonLayout = ({ children }: CommonLayoutProps) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container justify="center"> {children} </Grid> </Container> </main> </> ) } export default CommonLayout ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import Typography from "@material-ui/core/Typography" import IconButton from "@material-ui/core/IconButton" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import PersonIcon from "@material-ui/icons/Person" import SearchIcon from "@material-ui/icons/Search" import ChatBubbleIcon from "@material-ui/icons/ChatBubble" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ title: { flexGrow: 1, textDecoration: "none", color: "inherit" }, linkBtn: { textTransform: "none", marginLeft: theme.spacing(1) } })) const Header: React.FC = () => { const { loading, isSignedIn } = useContext(AuthContext) const classes = useStyles() // 認証済みかどうかで表示ボタンを変更 const AuthButtons = () => { if (!loading) { if (isSignedIn) { return ( <> <IconButton component={Link} to="/users" edge="start" className={classes.linkBtn} color="inherit" > <SearchIcon /> </IconButton> <IconButton component={Link} to="/chat_rooms" edge="start" className={classes.linkBtn} color="inherit" > <ChatBubbleIcon /> </IconButton> <IconButton component={Link} to="/home" edge="start" className={classes.linkBtn} color="inherit" > <PersonIcon /> </IconButton> </> ) } else { return ( <> <IconButton component={Link} to="/signin" edge="start" className={classes.linkBtn} color="inherit" > <ExitToAppIcon /> </IconButton> </> ) } } else { return <></> } } return ( <> <AppBar position="static"> <Toolbar> <Typography component={Link} to="/users" variant="h6" className={classes.title} > Sample </Typography> <AuthButtons /> </Toolbar> </AppBar> </> ) } export default Header ./src/components/pages/SignIn.tsx import React, { useState, useContext } from "react" import { useHistory, Link } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Typography } from "@material-ui/core" import TextField from "@material-ui/core/TextField" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signIn } from "lib/api/auth" import { SignInData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(2), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, box: { marginTop: "2rem" }, link: { textDecoration: "none" } })) // サインインページ const SignIn: React.FC = () => { const classes = useStyles() const history = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: SignInData = { email: email, password: password } try { const res = await signIn(data) console.log(res) if (res.status === 200) { // ログインに成功した場合はCookieに各情報を格納 Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) history.push("/home") setEmail("") setPassword("") console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインイン" /> <CardContent> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" placeholder="最低6文字以上" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!email || !password ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> <Box textAlign="center" className={classes.box}> <Typography variant="body2"> まだアカウントをお持ちでない方は <Link to="/signup" className={classes.link}> こちら </Link> から作成してください。 </Typography> </Box> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignIn ./src/components/pages/SignUp.tsx import React, { useState, useContext, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import "date-fns" import DateFnsUtils from "@date-io/date-fns" // バージョンに注意(https://stackoverflow.com/questions/59600125/cannot-get-material-ui-datepicker-to-work) import { makeStyles, Theme } from "@material-ui/core/styles" import Grid from "@material-ui/core/Grid" import TextField from "@material-ui/core/TextField" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import { MuiPickersUtilsProvider, KeyboardDatePicker } from "@material-ui/pickers" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import CardHeader from "@material-ui/core/CardHeader" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import { AuthContext } from "App" import AlertMessage from "components/utils/AlertMessage" import { signUp } from "lib/api/auth" import { SignUpFormData } from "interfaces/index" import { prefectures } from "data/prefectures" import { genders } from "data/genders" const useStyles = makeStyles((theme: Theme) => ({ container: { marginTop: theme.spacing(6) }, submitBtn: { marginTop: theme.spacing(1), flexGrow: 1, textTransform: "none" }, header: { textAlign: "center" }, card: { padding: theme.spacing(2), maxWidth: 340 }, inputFileButton: { textTransform: "none", color: theme.palette.primary.main }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // サインアップページ const SignUp: React.FC = () => { const classes = useStyles() const histroy = useHistory() const { setIsSignedIn, setCurrentUser } = useContext(AuthContext) const [name, setName] = useState<string>("") const [email, setEmail] = useState<string>("") const [password, setPassword] = useState<string>("") const [passwordConfirmation, setPasswordConfirmation] = useState<string>("") const [gender, setGender] = useState<number>() const [prefecture, setPrefecture] = useState<number>() const [birthday, setBirthday] = useState<Date | null>( new Date("2000-01-01T00:00:00"), ) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // アップロードした画像のデータを取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビューを表示 const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // フォームデータを作成 const createFormData = (): SignUpFormData => { const formData = new FormData() formData.append("name", name) formData.append("email", email) formData.append("password", password) formData.append("passwordConfirmation", passwordConfirmation) formData.append("gender", String(gender)) formData.append("prefecture", String(prefecture)) formData.append("birthday", String(birthday)) formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await signUp(data) console.log(res) if (res.status === 200) { Cookies.set("_access_token", res.headers["access-token"]) Cookies.set("_client", res.headers["client"]) Cookies.set("_uid", res.headers["uid"]) setIsSignedIn(true) setCurrentUser(res.data.data) histroy.push("/home") setName("") setEmail("") setPassword("") setPasswordConfirmation("") setGender(undefined) setPrefecture(undefined) setBirthday(null) console.log("Signed in successfully!") } else { setAlertMessageOpen(true) } } catch (err) { console.log(err) setAlertMessageOpen(true) } } return ( <> <form noValidate autoComplete="off"> <Card className={classes.card}> <CardHeader className={classes.header} title="サインアップ" /> <CardContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <TextField variant="outlined" required fullWidth label="メールアドレス" value={email} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード" type="password" value={password} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} /> <TextField variant="outlined" required fullWidth label="パスワード(確認用)" type="password" value={passwordConfirmation} margin="dense" autoComplete="current-password" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPasswordConfirmation(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">性別</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={gender} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setGender(e.target.value as number)} label="性別" > { genders.map((gender: string, index: number) => <MenuItem value={index}>{gender}</MenuItem> ) } </Select> </FormControl> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index +1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <MuiPickersUtilsProvider utils={DateFnsUtils}> <Grid container justify="space-around"> <KeyboardDatePicker fullWidth inputVariant="outlined" margin="dense" id="date-picker-dialog" label="誕生日" format="MM/dd/yyyy" value={birthday} onChange={(date: Date | null) => { setBirthday(date) }} KeyboardButtonProps={{ "aria-label": "change date", }} /> </Grid> </MuiPickersUtilsProvider> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } <div style={{ textAlign: "right"}} > <Button type="submit" variant="outlined" color="primary" disabled={!name || !email || !password || !passwordConfirmation ? true : false} // 空欄があった場合はボタンを押せないように className={classes.submitBtn} onClick={handleSubmit} > 送信 </Button> </div> </CardContent> </Card> </form> <AlertMessage // エラーが発生した場合はアラートを表示 open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="error" message="メールアドレスかパスワードが間違っています" /> </> ) } export default SignUp ./src/components/pages/Users.tsx import React, { useState, useEffect, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Dialog from "@material-ui/core/Dialog" import DialogContent from "@material-ui/core/DialogContent" import Avatar from "@material-ui/core/Avatar" import Button from "@material-ui/core/Button" import Divider from "@material-ui/core/Divider" import FavoriteIcon from "@material-ui/icons/Favorite" import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder" import AlertMessage from "components/utils/AlertMessage" import { prefectures } from "data/prefectures" import { getUsers } from "lib/api/users" import { getLikes, createLike } from "lib/api/likes" import { User, Like } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) } })) // ユーザー一覧ページ const Users: React.FC = () => { const { currentUser } = useContext(AuthContext) const classes = useStyles() const initialUserState: User = { id: 0, uid: "", provider: "", email: "", name: "", image: { url: "" }, gender: 0, birthday: "", profile: "", prefecture: 13, allowPasswordChange: true } const [loading, setLoading] = useState<boolean>(true) const [users, setUsers] = useState<User[]>([]) const [user, setUser] = useState<User>(initialUserState) const [userDetailOpen, setUserDetailOpen] = useState<boolean>(false) const [likedUsers, setLikedUsers] = useState<User[]>([]) const [likes, setLikes] = useState<Like[]>([]) const [alertMessageOpen, setAlertMessageOpen] = useState<boolean>(false) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const userAge = (): number | void => { const birthday = user.birthday.toString().replace(/-/g, "") if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const userPrefecture = (): string => { return prefectures[(user.prefecture) - 1] } // いいね作成 const handleCreateLike = async (user: User) => { const data: Like = { fromUserId: currentUser?.id, toUserId: user.id } try { const res = await createLike(data) console.log(res) if (res?.status === 200) { setLikes([res.data.like, ...likes]) setLikedUsers([user, ...likedUsers]) console.log(res?.data.like) } else { console.log("Failed") } if (res?.data.isMatched === true) { setAlertMessageOpen(true) setUserDetailOpen(false) } } catch (err) { console.log(err) } } // ユーザー一覧を取得 const handleGetUsers = async () => { try { const res = await getUsers() console.log(res) if (res?.status === 200) { setUsers(res?.data.users) } else { console.log("No users") } } catch (err) { console.log(err) } setLoading(false) } // いいね一覧を取得 const handleGetLikes = async () => { try { const res = await getLikes() console.log(res) if (res?.status === 200) { setLikedUsers(res?.data.activeLikes) } else { console.log("No likes") } } catch (err) { console.log(err) } } useEffect(() => { handleGetUsers() handleGetLikes() }, []) // すでにいいねを押されているユーザーかどうかの判定 const isLikedUser = (userId: number | undefined): boolean => { return likedUsers?.some((likedUser: User) => likedUser.id === userId) } return ( <> { !loading ? ( users?.length > 0 ? ( <Grid container justify="center"> { users?.map((user: User, index: number) => { return ( <div key={index} onClick={() => { setUser(user) setUserDetailOpen(true) }}> <Grid item style={{ margin: "0.5rem", cursor: "pointer" }}> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", textAlign: "center" }} > {user.name} </Typography> </Grid> </div> ) }) } </Grid> ) : ( <Typography component="p" variant="body2" color="textSecondary" > まだ1人もユーザーがいません。 </Typography> ) ) : ( <></> ) } <Dialog open={userDetailOpen} keepMounted onClose={() => setUserDetailOpen(false)} > <DialogContent> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={user?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1rem" }}> <Typography variant="body1" component="p" gutterBottom style={{ textAlign: "center" }}> {user.name} {userAge()}歳 ({userPrefecture()}) </Typography> <Divider /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> <Typography variant="body2" component="p" color="textSecondary" style={{ marginTop: "0.5rem" }}> {user.profile ? user.profile : "よろしくお願いします。" } </Typography> </Grid> </Grid> <Grid container justify="center"> <Button variant="outlined" onClick={() => isLikedUser(user.id) ? void(0) : handleCreateLike(user)} color="secondary" startIcon={isLikedUser(user.id) ? <FavoriteIcon /> : <FavoriteBorderIcon />} disabled={isLikedUser(user.id) ? true : false} style={{ marginTop: "1rem", marginBottom: "1rem" }} > {isLikedUser(user.id) ? "いいね済み" : "いいね"} </Button> </Grid> </DialogContent> </Dialog> <AlertMessage open={alertMessageOpen} setOpen={setAlertMessageOpen} severity="success" message="マッチングが成立しました!" /> </> ) } export default Users ./src/components/pages/ChatRooms.tsx import React, { useEffect, useState } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import Divider from '@material-ui/core/Divider'; import ListItemText from '@material-ui/core/ListItemText'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import { getChatRooms } from "lib/api/chat_rooms" import { ChatRoom } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ root: { flexGrow: 1, minWidth: 340, maxWidth: "100%" }, link: { textDecoration: "none", color: "inherit" } })) // チャットルーム一覧ページ const ChatRooms: React.FC = () => { const classes = useStyles() const [loading, setLoading] = useState<boolean>(true) const [chatRooms, setChatRooms] = useState<ChatRoom[]>([]) const handleGetChatRooms = async () => { try { const res = await getChatRooms() if (res.status === 200) { setChatRooms(res.data.chatRooms) } else { console.log("No chat rooms") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRooms() }, []) return ( <> { !loading ? ( chatRooms.length > 0 ? ( chatRooms.map((chatRoom: ChatRoom, index: number) => { return ( <Grid container key={index} justify="center"> <List> {/* 個別のチャットルームへ飛ばす */} <Link to={`/chatroom/${chatRoom.chatRoom.id}`} className={classes.link}> <div className={classes.root}> <ListItem alignItems="flex-start" style={{padding: 0 }}> <ListItemAvatar> <Avatar alt="avatar" src={chatRoom.otherUser.image.url} /> </ListItemAvatar> <ListItemText primary={chatRoom.otherUser.name} secondary={ <div style={{ marginTop: "0.5rem" }}> <Typography component="span" variant="body2" color="textSecondary" > {chatRoom.lastMessage === null ? "まだメッセージはありません。" : chatRoom.lastMessage.content.length > 30 ? chatRoom.lastMessage.content.substr(0, 30) + "..." : chatRoom.lastMessage.content} </Typography> </div> } /> </ListItem> </div> </Link> <Divider component="li" /> </List> </Grid> ) }) ) : ( <Typography component="p" variant="body2" color="textSecondary" > マッチング中の相手はいません。 </Typography> ) ) : ( <></> ) } </> ) } export default ChatRooms ./src/components/pages/ChatRoom.tsx import React, { useEffect, useState, useContext } from "react" import { RouteComponentProps } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Avatar from "@material-ui/core/Avatar" import TextField from "@material-ui/core/TextField" import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" import SendIcon from "@material-ui/icons/Send" import { getChatRoom } from "lib/api/chat_rooms" import { createMessage } from "lib/api/messages" import { User, Message } from "interfaces/index" import { AuthContext } from "App" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10), margin: "0 auto" }, formWrapper : { padding: "2px 4px", display: "flex", alignItems: "center", width: 340 }, textInputWrapper : { width: "100%" }, button: { marginLeft: theme.spacing(1) } })) type ChatRoomProps = RouteComponentProps<{ id: string }> // 個別のチャットルームページ const ChatRoom: React.FC<ChatRoomProps> = (props) => { const classes = useStyles() const { currentUser } = useContext(AuthContext) const id = parseInt(props.match.params.id) // URLからidを取得 const [loading, setLoading] = useState<boolean>(true) const [otherUser, setOtherUser] = useState<User>() const [messages, setMeesages] = useState<Message[]>([]) const [content, setContent] = useState<string>("") const handleGetChatRoom = async () => { try { const res = await getChatRoom(id) console.log(res) if (res?.status === 200) { setOtherUser(res?.data.otherUser) setMeesages(res?.data.messages) } else { console.log("No other user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetChatRoom() }, []) const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data: Message = { chatRoomId: id, userId: currentUser?.id, content: content } try { const res = await createMessage(data) console.log(res) if (res.status === 200) { setMeesages([...messages, res.data.message]) setContent("") } } catch (err) { console.log(err) } } // Railsから渡ってくるtimestamp(ISO8601)をdatetimeに変換 const iso8601ToDateTime = (iso8601: string) => { const date = new Date(Date.parse(iso8601)) const year = date.getFullYear() const month = date.getMonth() + 1 const day = date.getDate() const hour = date.getHours() const minute = date.getMinutes() return year + "年" + month + "月" + day + "日" + hour + "時" + minute + "分" } return ( <> { !loading ? ( <div style={{ maxWidth: 360 }}> <Grid container justify="center" style={{ marginBottom: "1rem" }}> <Grid item> <Avatar alt="avatar" src={otherUser?.image.url || ""} className={classes.avatar} /> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", marginBottom: "1rem", textAlign: "center" }} > {otherUser?.name} </Typography> </Grid> </Grid> { messages.map((message: Message, index: number) => { return ( <Grid key={index} container justify={message.userId === otherUser?.id ? "flex-start" : "flex-end"}> <Grid item> <Box borderRadius={message.userId === otherUser?.id ? "30px 30px 30px 0px" : "30px 30px 0px 30px"} bgcolor={message.userId === otherUser?.id ? "#d3d3d3" : "#ffb6c1"} color={message.userId === otherUser?.id ? "#000000" : "#ffffff"} m={1} border={0} style={{ padding: "1rem" }} > <Typography variant="body1" component="p"> {message.content} </Typography> </Box> <Typography variant="body2" component="p" color="textSecondary" style={{ textAlign: message.userId === otherUser?.id ? "left" : "right" }} > {iso8601ToDateTime(message.createdAt?.toString() || "100000000")} </Typography> </Grid> </Grid> ) }) } <Grid container justify="center" style={{ marginTop: "2rem" }}> <form className={classes.formWrapper} noValidate autoComplete="off"> <TextField required multiline value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setContent(e.target.value)} className={classes.textInputWrapper} /> <Button variant="contained" color="primary" disabled={!content ? true : false} onClick={handleSubmit} className={classes.button} > <SendIcon /> </Button> </form> </Grid> </div> ) : ( <></> ) } </> ) } export default ChatRoom ./src/components/pages/Home.tsx import React, { useContext, useEffect, useState, useCallback } from "react" import { useHistory } from "react-router-dom" import Cookies from "js-cookie" import { makeStyles, Theme } from "@material-ui/core/styles" import { Grid, Typography } from "@material-ui/core" import Card from "@material-ui/core/Card" import CardContent from "@material-ui/core/CardContent" import IconButton from "@material-ui/core/IconButton" import SettingsIcon from "@material-ui/icons/Settings" import Dialog from "@material-ui/core/Dialog" import TextField from "@material-ui/core/TextField" import DialogActions from "@material-ui/core/DialogActions" import DialogContent from "@material-ui/core/DialogContent" import DialogTitle from "@material-ui/core/DialogTitle" import InputLabel from "@material-ui/core/InputLabel" import MenuItem from "@material-ui/core/MenuItem" import FormControl from "@material-ui/core/FormControl" import Select from "@material-ui/core/Select" import PhotoCamera from "@material-ui/icons/PhotoCamera" import Box from "@material-ui/core/Box" import CancelIcon from "@material-ui/icons/Cancel" import ExitToAppIcon from "@material-ui/icons/ExitToApp" import Button from "@material-ui/core/Button" import Avatar from "@material-ui/core/Avatar" import Divider from "@material-ui/core/Divider" import { AuthContext } from "App" import { prefectures } from "data/prefectures" import { signOut } from "lib/api/auth" import { getUser, updateUser } from "lib/api/users" import { UpdateUserFormData } from "interfaces/index" const useStyles = makeStyles((theme: Theme) => ({ avatar: { width: theme.spacing(10), height: theme.spacing(10) }, card: { width: 340 }, imageUploadBtn: { textAlign: "right" }, input: { display: "none" }, box: { marginBottom: "1.5rem" }, preview: { width: "100%" } })) // ホーム(マイページ的な) const Home: React.FC = () => { const { isSignedIn, setIsSignedIn, currentUser, setCurrentUser } = useContext(AuthContext) const classes = useStyles() const histroy = useHistory() const [editFormOpen, setEditFormOpen] = useState<boolean>(false) const [name, setName] = useState<string | undefined>(currentUser?.name) const [prefecture, setPrefecture] = useState<number | undefined>(currentUser?.prefecture || 0) const [profile, setProfile] = useState<string | undefined>(currentUser?.profile) const [image, setImage] = useState<string>("") const [preview, setPreview] = useState<string>("") // アップロードした画像の情報を取得 const uploadImage = useCallback((e) => { const file = e.target.files[0] setImage(file) }, []) // 画像プレビュー const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) // 生年月日から年齢を計算する 年齢 = floor((今日 - 誕生日) / 10000) const currentUserAge = (): number | void => { const birthday = currentUser?.birthday.toString().replace(/-/g, "") || "" if (birthday.length !== 8) return const date = new Date() const today = date.getFullYear() + ("0" + (date.getMonth() + 1)).slice(-2) + ("0" + date.getDate()).slice(-2) return Math.floor((parseInt(today) - parseInt(birthday)) / 10000) } // 都道府県 const currentUserPrefecture = (): string => { return prefectures[(currentUser?.prefecture || 0) - 1] } const createFormData = (): UpdateUserFormData => { const formData = new FormData() formData.append("name", name || "") formData.append("prefecture", String(prefecture)) formData.append("profile", profile || "") formData.append("image", image) return formData } const handleSubmit = async (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault() const data = createFormData() try { const res = await updateUser(currentUser?.id, data) console.log(res) if (res.status === 200) { setEditFormOpen(false) setCurrentUser(res.data.user) console.log("Update user successfully!") } else { console.log(res.data.message) } } catch (err) { console.log(err) console.log("Failed in updating user!") } } // サインアウト用の処理 const handleSignOut = async (e: React.MouseEvent<HTMLButtonElement>) => { try { const res = await signOut() if (res.data.success === true) { // Cookieから各情報を削除 Cookies.remove("_access_token") Cookies.remove("_client") Cookies.remove("_uid") setIsSignedIn(false) histroy.push("/signin") console.log("Succeeded in sign out") } else { console.log("Failed in sign out") } } catch (err) { console.log(err) } } return ( <> { isSignedIn && currentUser ? ( <> <Card className={classes.card}> <CardContent> <Grid container justify="flex-end"> <Grid item> <IconButton onClick={() => setEditFormOpen(true)} > <SettingsIcon color="action" fontSize="small" /> </IconButton> </Grid> </Grid> <Grid container justify="center"> <Grid item> <Avatar alt="avatar" src={currentUser?.image.url} className={classes.avatar} /> </Grid> </Grid> <Grid container justify="center"> <Grid item style={{ marginTop: "1.5rem"}}> <Typography variant="body1" component="p" gutterBottom> {currentUser?.name} {currentUserAge()}歳 ({currentUserPrefecture()}) </Typography> <Divider style={{ marginTop: "0.5rem"}}/> <Typography variant="body2" component="p" gutterBottom style={{ marginTop: "0.5rem", fontWeight: "bold" }} > 自己紹介 </Typography> { currentUser.profile ? ( <Typography variant="body2" component="p" color="textSecondary"> {currentUser.profile} </Typography> ): ( <Typography variant="body2" component="p" color="textSecondary"> よろしくお願いいたします。 </Typography> ) } <Button variant="outlined" onClick={handleSignOut} color="primary" fullWidth startIcon={<ExitToAppIcon />} style={{ marginTop: "1rem"}} > サインアウト </Button> </Grid> </Grid> </CardContent> </Card> <form noValidate autoComplete="off"> <Dialog open={editFormOpen} keepMounted onClose={() => setEditFormOpen(false)} > <DialogTitle style={{ textAlign: "center"}}> プロフィールの変更 </DialogTitle> <DialogContent> <TextField variant="outlined" required fullWidth label="名前" value={name} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)} /> <FormControl variant="outlined" margin="dense" fullWidth > <InputLabel id="demo-simple-select-outlined-label">都道府県</InputLabel> <Select labelId="demo-simple-select-outlined-label" id="demo-simple-select-outlined" value={prefecture} onChange={(e: React.ChangeEvent<{ value: unknown }>) => setPrefecture(e.target.value as number)} label="都道府県" > { prefectures.map((prefecture, index) => <MenuItem key={index + 1} value={index + 1}>{prefecture}</MenuItem> ) } </Select> </FormControl> <TextField placeholder="1000文字以内で書いてください。" variant="outlined" multiline fullWidth label="自己紹介" rows="8" value={profile} margin="dense" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setProfile(e.target.value) }} /> <div className={classes.imageUploadBtn}> <input accept="image/*" className={classes.input} id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <label htmlFor="icon-button-file"> <IconButton color="primary" aria-label="upload picture" component="span" > <PhotoCamera /> </IconButton> </label> </div> { preview ? ( <Box className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview img" className={classes.preview} /> </Box> ) : null } </DialogContent> <DialogActions> <Button onClick={handleSubmit} color="primary" disabled={!name || !profile ? true : false} > 送信 </Button> </DialogActions> </Dialog> </form> </> ) : ( <></> ) } </> ) } export default Home ./src/components/pages/NotFound.tsx import React from "react" // 存在しないページにアクセスされた場合の表示 const NotFound: React.FC = () => { return ( <h2>404 Not Found</h2> ) } export default NotFound ./src/components/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route, Redirect } from "react-router-dom" import CommonLayout from "components/layouts/CommonLayout" import Home from "components/pages/Home" import ChatRooms from "components/pages/ChatRooms" import ChatRoom from "components/pages/ChatRoom" import Users from "components/pages/Users" import SignUp from "components/pages/SignUp" import SignIn from "components/pages/SignIn" import NotFound from "components/pages/NotFound" import { getCurrentUser } from "lib/api/auth" import { User } from "interfaces/index" // グローバルで扱う変数・関数(contextで管理) export const AuthContext = createContext({} as { loading: boolean isSignedIn: boolean setIsSignedIn: React.Dispatch<React.SetStateAction<boolean>> currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) const App: React.FC = () => { const [loading, setLoading] = useState<boolean>(true) const [isSignedIn, setIsSignedIn] = useState<boolean>(false) const [currentUser, setCurrentUser] = useState<User | undefined>() const handleGetCurrentUser = async () => { try { const res = await getCurrentUser() console.log(res) if (res?.status === 200) { setIsSignedIn(true) setCurrentUser(res?.data.currentUser) } else { console.log("No current user") } } catch (err) { console.log(err) } setLoading(false) } useEffect(() => { handleGetCurrentUser() }, [setCurrentUser]) // ユーザーが認証済みかどうかでルーティングを決定 // 未認証だった場合は「/signin」ページに促す const Private = ({ children }: { children: React.ReactElement }) => { if (!loading) { if (isSignedIn) { return children } else { return <Redirect to="/signin" /> } } else { return <></> } } return ( <Router> <AuthContext.Provider value={{ loading, isSignedIn, setIsSignedIn, currentUser, setCurrentUser }}> <CommonLayout> <Switch> <Route exact path="/signup" component={SignUp} /> <Route exact path="/signin" component={SignIn} /> <Private> <Switch> <Route exact path="/home" component={Home} /> <Route exact path="/users" component={Users} /> <Route exact path="/chat_rooms" component={ChatRooms} /> <Route path="/chatroom/:id" component={ChatRoom} /> <Route component={NotFound} /> </Switch> </Private> </Switch> </CommonLayout> </AuthContext.Provider> </Router> ) } export default App 動作確認 あとは全体的に問題が無いか動作確認しましょう。 サインアップ サインイン ホーム ユーザー一覧 マッチング一覧 チャットルーム ※ユーザーデータなどはそれぞれ適当に入れてください。 あとがき 以上、Rails APIモード + React + Material-UIでマッチングアプリを作ってみました。 だいぶコード量が多いので、所々で雑になってしまっているかもしれません。特にエラーハンドリングの部分とか全然できてないと思います...。 今回はあくまでサンプルという事で、細かい部分の訂正は各々でお願いします。 一応、GitHubのリンクも載せておくので、もし動かない部分などあったらそちらと照らし合わせてみてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rspecでボタンをクリック→へんじがない…ただのもじれつのようだ…

はじめに よくあるアコーディオンメニュー(※)を実装し、その挙動テストをRspecで書いていたときに困ったことについて、記事にしたいと思います。 ※ここでいうよくあるアコーディオンメニューとは、「+ボタン」を押すと、メニューが展開し、「+」が「ー」に変化するようなものであり、JavaScriptを使って実装しています。 発生した問題 最初に書いたテストは次のとおりです。 describe "アコーディオンメニューのテスト" do it "メニューが展開すること" do  #「+」、「ー」はFontAwesomeを使って実装 find(".fa-plus").click expect(page).to have_css ".fa-minus" end end 勘のいい方はこの段階でお気づきかと思いますが、Rspecを実行したところ、次のようなエラーが出力されました。 Failure/Error: expect(page).to have_css ".fa-minus" expected to find css ".fa-minus" but there were no matches 「ーボタン」が見つからないと言われているようです。 それなら、「+ボタン」がクリックされた後は、どんな状態になっているのか気になったため、試しにテストを次のように書き換えて、Rspecを実行したところ、テストが通りました。 describe "アコーディオンメニューのテスト" do it "メニューが展開すること" do find(".fa-plus").click expect(page).to have_css ".fa-plus" end end どうやら「+ボタン」をクリックした後も、「ーボタン」に変化するのではなく、「+ボタン」のままになっているようです。 要するに何も変化していないということです。 解決方法 「js: true」を追加しました。 describe "アコーディオンメニューのテスト" js: true do it "メニューが展開すること" do  #「+」、「ー」はFontAwesomeを使って実装 find(".fa-plus").click expect(page).to have_css ".fa-minus" end end さいごに 今回のミスは、JavaScriptを使っている箇所のテストに「js: true」を書いていなかったという、知っている人から見れば、当たり前のようなことですが、エラー文が出るわけでもないので、私は発見に時間がかかりました。 ちなみに、本当の闘いは、このあと「js: true」を使えるようにするために、Gemを入れたり、Docker環境だったのでDockerfileを変更したりしなければならないということだったのですが、この記事の趣旨から外れるので、そちらは割愛します。 Rspecを使い始めたばかりの方の、気づきになれば幸いです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む