【Ruby on Rails チュートリアル】サンプルアプリケーションにメッセージ機能を追加する
Ruby on Rails チュートリアル (Rails 5.1 第4版) の14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回はメッセージ機能を実装しました。
チュートリアルには以下のようなヒントが記載されています。
- Messageモデルを追加する
- 新規マイクロポストにマッチする正規表現を追加する
2つ目のヒントについてはチュートリアル側が想定している仕様が思いつかなかったので無視します。
仕様
Twitterのダイレクトメッセージ機能を参考に以下のように決定しました。
Message
モデルを追加する。- トークへの参加を表す
Membership
モデルを作成する。 - メッセージのやり取りを表す
Talk
モデルを作成する。 - トークから退出することができる。
- 最後の参加者の退出と同時にトークを削除する。
- メッセージのやり取りは1対1とする。
- メッセージは140文字まで。
- 自分のメッセージを削除することができる。
- ユーザーページにメッセージボタンを配置する。
Home
ページに作成したトークの一覧を表示するタブを追加する。
実装
今回の仕様ではメッセージのやり取りは1対1としていますが、今後の拡張を考えて複数人でのやり取りを考慮した実装を行っていきます。
例によってテスト等は記載していません。
ソースコードの全体を見たい方はこちら→GitLab
テーブルの作成
テーブル作成のためにマイグレーションファイルを作成していきます。
最初にtalks
テーブルを作成します。
特にフィールドはありません。
# db/migrate/20190118132605_create_talks.rb class CreateTalks < ActiveRecord::Migration[5.1] def change create_table :talks do |t| t.timestamps end add_index :talks, :updated_at end end
次にトークの参加を表すmemberships
テーブルを作成します。
user_id
とtalk_id
の組み合わせは一意にしたいのでインデックスにunique
オプションを付与しています。
# db/migrate/20190118132703_create_memberships.rb class CreateMemberships < ActiveRecord::Migration[5.1] def change create_table :memberships do |t| t.references :talk, foreign_key: true t.references :user, foreign_key: true t.timestamps end add_index :memberships, [:user_id, :talk_id], unique: true end end
最後にmessages
テーブルを作成します。
時系列順にメッセージの一覧を表示したいのでupdated_at
とtalk_id
にインデックスを追加しています。
# db/migrate/20190118132746_create_messages.rb class CreateMessages < ActiveRecord::Migration[5.1] def change create_table :messages do |t| t.references :talk, foreign_key: true t.references :user, foreign_key: true t.string :content t.timestamps end add_index :messages, [:updated_at, :talk_id] end end
user
モデルとtalk
モデルの関連
モデル作成の前にuser
モデルとtalk
モデルの関連を追加していきます。
user
は複数のメッセージを発信でき、複数のトークに参加できるようにしたいので、messages
、memberships
をhas_many
メソッドで定義しています。
また、参加しているトークをtalks
で取得できるように、has_many
メソッドで定義し、through
オプションにmemberships
を指定しました。
このあたりはチュートリアルのrelationship
モデルの関連付けを参考にしています。
# app/models/user.rb class User < ApplicationRecord ... has_many :memberships, dependent: :destroy has_many :talks, class_name: "Talk", through: :memberships has_many :messages, dependent: :destroy ... end
モデルの関連
テーブルができたので、モデルの関連を追加していきます。
先にuser
モデルとtalk
モデルの関連を追加したので、talk
モデルも同じように追加していきます。
talk
には複数の参加(membership
)と、メッセージを持たせたいので1対多の関係を表すhas_many
メソッドで定義しています。
また、talk
には複数の参加者が存在するので、members
をhas_many
メソッドで定義し、through
オプションにmemberships
を指定しています。
# app/models/talk.rb class Talk < ApplicationRecord has_many :memberships, dependent: :destroy has_many :members, class_name: "User", through: :memberships, source: :user has_many :messages, dependent: :destroy end
membership
モデルとmessage
モデルの関連は同じです。
それぞれtalk
とuser
をbelongs_to
メソッドで定義しています。
membership
# app/models/membership.rb class Membership < ApplicationRecord belongs_to :talk belongs_to :user end
# app/models/message.rb class Message < ApplicationRecord belongs_to :talk belongs_to :user end
モデルのバリデーション
続いてモデルのバリデーションを追加します。
talk
モデルにはフィールドが無いのでバリデーションもありません。
membership
モデルにはテーブル作成時に行った、ユーザーとトークの一意性のバリデーションを追加しています。
# app/models/membership.rb class Membership < ApplicationRecord ... validates :user_id, uniqueness: { scope: :talk_id } end
message
モデルではそれぞれのフィールドの存在性と、メッセージの文字数制限のバリデーションを追加しています。
# app/models/message.rb class Message < ApplicationRecord ... validates :talk_id, presence: true validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
membership
モデルのafter_destroy
コールバック
仕様で定めた、最後の参加者の退出と同時にトークを削除する
を実装するために、memberships
が空であればtalk
を削除するメソッド(destroy_empty_talk
)を追加し、after_destroy
コールバックに指定しています。
# app/models/membership.rb class Membership < ApplicationRecord after_destroy :destroy_empty_talk ... private def destroy_empty_talk talk.destroy if talk.reload.memberships.empty? end end
ルーティングの追加
モデルを作成したのでルーティングを追加していきます。
membership
とmessage
はtalk
ありきで作成するので、talks
のmember
として追加しています。
# config/routes.rb Rails.application.routes.draw do ... resources :talks, only: [:show, :create] do member do post :memberships, :messages end end resources :memberships, only: :destroy resources :messages, only: :destroy end
コントローラーの作成
コントローラーを作成し、ルーティングで追加したアクションを実装していきます。
# app/controllers/talks_controller.rb class TalksController < ApplicationController before_action :logged_in_user before_action :correct_member, only: [:show, :messages] def show @messages = @talk.messages @message = Message.new end def create @talk = Talk.new @talk.memberships.build(user_id: current_user.id) @talk.memberships.build(user_id: params[:member_id]) @talk.save redirect_to @talk end def messages @message = Message.new(message_params) # トークの更新日時を更新 @talk.touch if @message.save redirect_to @talk else @messages = @talk.messages render "show" end end private def message_params params[:message].merge!({ user_id: current_user.id, talk_id: @talk.id }) params.require(:message).permit(:user_id, :talk_id, :content) end def correct_member @talk = current_user.talks.find_by(id: params[:id]) redirect_to root_url if @talk.nil? end def correct_user @message = current_user.messages.find_by(id: params[:id]) redirect_to root_url if @message.nil? end end
# app/controllers/memberships_controller.rb class MembershipsController < ApplicationController before_action :logged_in_user before_action :correct_user, only: :destroy def create @talk = Talk.find(params[:talk_id]) params[:guest_users].each do |guest_user| if @talk.member?(guest_user) else @talk.add_member(guest_user) end end end def destroy @membership.destroy flash[:success] = "Left the talk" redirect_back(fallback_location: root_url) end private def correct_user @membership = current_user.memberships.find_by(id: params[:id]) redirect_to root_url if @membership.nil? end end
# app/controllers/messages_controller.rb class MessagesController < ApplicationController before_action :logged_in_user before_action :correct_user, only: :destroy def destroy @talk = @message.talk @message.destroy flash[:success] = "Message deleted" redirect_to @talk end private def correct_user @message = current_user.messages.find_by(id: params[:id]) redirect_to root_url if @message.nil? end end
ビューの作成
home
ページにタブを追加し、フィードタブ、メッセージタブを追加しています。
# app/views/logged_in_home.html.erb ... <ul class="nav nav-tabs"> <li class="active"><a href="#feed-tab" data-toggle="tab">Micropost Feed</a></li> <li><a href="#talks-tab" data-toggle="tab">Messages</a></li> </ul> <div class="tab-content"> <div class="tab-pane active" id="feed-tab"> <%= render 'shared/feed' %> </div> <div class="tab-pane" id="talks-tab"> <%= render 'shared/talks' %> </div> </div> ...
チュートリアルのフィードページを参考に、トークの一覧をパーシャルとして作成します。
# app/views/shared/_talks.html.erb <% if @talks.any? %> <ol class="talks"> <%= render @talks %> </ol> <%= paginate @talks, param_name: :talks_page %> <% end %>
トーク本体です。
# app/views/talks/_talk.html.erb <li id="talk-<%=talk.id%>"> <% message = talk.latest_message %> <% membership = talk.memberships.find_by(user: current_user) %> <%= gravatar_for(message.nil? ? talk.memberships.first.user : message.user, size: 50) %> <%= link_to 'leave', membership_path(membership), class: "delete", method: :delete, data: { confirm: "You sure?" } %> <%= link_to(talk) do %> <span class="title"><%= talk.title(current_user, 50) %></span> <span class="content"><%= message.content unless message.nil? %></span> <span class="timestamp"> Posted <%= time_ago_in_words(talk.updated_at) %> ago. </span> <% end %> </li>
トークページです。 メッセージの一覧と、メッセージフォームを作成します。
# app/views/talks/show.html.erb <% title = @talk.title(current_user, 50) %> <% provide(:title, title) %> <h1><%= title %></h1> <div class="row" > <div class="col-md-8 col-md-offset-2"> <ul class="messages"> <%= render @messages %> </ul> <%= form_for @message, url: messages_talk_path(@talk), html: { class: 'message-form'} do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="field"> <%= f.text_area :content, class: "content" %> </div> <%= f.submit "Send", class: "btn btn-primary" %> <% end %> </div> </div>
メッセージ本体です。
# app/views/messages/_message.html.erb <li id="message-<%= message.id %>"> <% user = message.user %> <% class_suffix = current_user?(user) ? 'right' : 'left' %> <%= link_to gravatar_for(user, size: 50, additional_class: "gravatar-#{class_suffix}"), user %> <div class="balloon balloon-<%= class_suffix %>"> <% unless current_user?(user) %> <span class="user"><%= link_to user.name, user %></span> <% end %> <span class="content"><%= message.content %></span> <span class="timestamp"> Posted <%= time_ago_in_words(message.created_at) %> ago. <% if current_user?(user) %> <%= link_to "delete", message_path(message), class: "delete", method: :delete, data: { confirm: "You sure?" } %> <% end %> </span> </div> </li>
おわりに
前回の投稿からだいぶ時間が空いてしまいましたが、自分の仕様通りに実装できました。
次回は今まで作成したテストをRspec
で書き直していこうと思います。
まずは以下の書籍でRspecについて学習していきます。