記事一覧表示

Rails チュートリアル(機能拡張編)その2

問題文から予想した必要な作業

  • Messageモデルを作成
  • show_messageページ(ログインしているUserが参加しているMessageのリンク集)を作成
  • Message.dialog_idsにマッチした正規表現で文字列を入力する機能を作成
  • Messageのやり取りを表示するページを作成
  • UserプロフィールにMessageへのリンクを作成


作業履歴

- Messageモデルを作成

 以下のコマンドでMessageモデルを作成。

    % rails generate model Message content:text receiver_id:integer dialog_ids:string picture:string user:references

 % rails generate migration add_index_to_messages_dialog_ids でdialog_idsにindexを加える。

class AddIndexToMessagesDialogIds < ActiveRecord::Migration[5.2]
  def change
    add_index :messages, :dialog_ids
  end
end

 MessageモデルにバリデーションとUserモデルとのリレーション関係を書く。

class Message < ApplicationRecord
  belongs_to :user
  belongs_to :receiver, class_name: "User"
  scope :recent_messages, ->(user_id) { where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%").group(:dialog_ids).order(created_at: :desc) }
  mount_uploader :picture, PictureUploader
  validates :user_id, presence: true
  validates :dialog_ids, presence: true
  validates :receiver_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
  validate :picture_size


  private

    # アップロードされた画像のサイズをバリデーションする                                         
    def picture_size
      if picture.size > 5.megabytes
        errors.add(:picture, "should be less than 5MB")
      end
    end
end

 scope :recent_messages は、where("dialog_ids like ? or dialog_ids like ?", "%-#{user_id}", "#{user_id}-%")でuserが参加している全てのMessageを持って来る→group(:dialog_ids)で同じdialog_ids(特定のユーザ間)のMessageの内、最後の物を取ってくる→order(created_at: :desc)で特定のユーザ間の最後のMessageをcreated_atで並び替える→特定ユーザ間のMessageの最後のやり取りを最新順に並び替えている。
 Userモデルにもリレーションを書く。

class User < ApplicationRecord
  has_many :messages, dependent: :destroy
  has_many :receivers, -> { group(:receiver_id).order("messages.created_at DESC") },
            through: :messages
      ︙
  # 現在のユーザーがフォローしてたらtrueを返す                                                   
  def following?(other_user)
    following.include?(other_user)
  end

  # 現在のユーザーがフォローされていたらtrueを返す                                               
  def followers?(other_user)                                                                     
    followers.include?(other_user)
  end

  # 現在のユーザーとfollower,following関係のユーザか調べる                                       
  def inter_relationship?(other_user)                                                            
    followers?(other_user) && following?(other_user)
  end

  private
      ︙
end

 has_manyにscope書いて、取って来たレコードの重複を無くした(やってる事はMessageのscopeとほぼ同じ)。また inter_relationship?() は、ログインしているユーザと渡されたユーザが相互フォロー関係であるかを確認する為の関数である。
 Messageモデルのバリデーションのテストを書く。micropost_testと変わらなかったので、ソースは省略。


- show_messageページ(ログインしているUserが参加しているMessageのリンク集)を作成

 ルートを作成。

Rails.application.routes.draw do
    ︙
  resources :users do
    member do
      get :show_message
    end
  end
    ︙
  resources :messages, only: [:create, :destroy]
  resources :messages, only: :show, param: :dialog_ids
end

 show_messageページの他にmessagesコントローラのリソース(show[特定ユーザとのMessageのやり取りを表示するページ]、create[Messageの新規作成]、destroy[Messageの削除])も書いた。
 Usersコントローラにshow_messageアクションを作成。

class UsersController < ApplicationController
  before_action :logged_in_user, only: [..., :show_message]
  before_action :correct_user,   only: [..., :show_message]
           ︙
  def show_message
    @micropost = current_user.microposts.build
    @feed_items = Message.recent_messages(current_user.id).paginate(page: params[:page])
    render 'static_pages/home'
  end

  private
    # 正しいユーザーかどうか確認                                                                 
    def correct_user                                                                             
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
           ︙
end

 show_messageページはログインしているユーザ以外のアクセスをbefore_actionで弾く。動きとしては、トップページに表示されているfeed欄 : @feed_itemsをそれぞれのユーザとのMessageのやり取りの内、最新のMessage群 : Message.recent_messages(current_user.id)に変更して、トップページを再描画している。
 トップページのfeedパーシャル(app/views/shared/_feed.html.erb)にshow_messageページへのリンクを作成。

<section>
         ︙
  <a href="<%= show_message_user_path(current_user) %>" class="variable">
    <strong class="recent_message">
      <h3>Messages</h3>
    </strong>
  </a>
</section>
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

 show_messageへのリンクを踏むと、@feed_itemsがMessageオブジェクトになるので、render先がmessageパーシャル(app/views/messages/_message.html.erb)に変更される。
 messageパーシャルを作成。micropostパーシャルとほぼほぼ変わらないので、ソースは省略。将来的に、micropostのデザインとは変える予定。


- Message.dialog_idsに、正規表現にマッチした文字列を入力する機能を作成

 Messageモデルに機能を追加。

class Message < ApplicationRecord
          ︙
  def Message.dialog_ids(user_id, receiver_id)
    if user_id < receiver_id
      "#{user_id}-#{receiver_id}"
    else
      "#{receiver_id}-#{user_id}"
    end
  end

  def Message.include_user?(user_id, dialog_ids)
    dialog_ids.split("-").include?(user_id.to_s)
  end

  def Message.another_user(user_id, dialog_ids)
    dialog_ids.split("-").reject{|n| n == user_id.to_s}.first.to_i
  end

  private
          ︙
end

 機能説明は以下。

  • Message.dialog_ids() : 新規Messageのdialog_idsに入力する値を作成する。新規Messageのuser_idとreceiver_idを使って 小さい方のid-大きい方のid と言う文字列を返す。
  • Message.include_user?() : dialog_idsにuser_idが含まれているかを調べる。Messageのやり取りを見るページにアクセスする時、dialog_idsに含まれていないユーザを弾くための関数。
  • Message.another_user() : dialog_idsに参加している相手のuser_idを返す。


- Messageのやり取りを表示するページを作成

 Messagesコントローラを作成。

class MessagesController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy, :show]
  before_action :correct_user,   only: :destroy
  before_action :correct_dialog_ids, only: :show

  def show
    receiver_id = Message.another_user(current_user.id, params[:dialog_ids])
    if current_user.inter_relationship?(User.find(receiver_id))
      @feed_items = Message.where("dialog_ids = ?", params[:dialog_ids]).order(created_at: :desc).paginate(page: params[:page]).per_page(10)                                                     
      @message = current_user.messages.build(receiver_id: receiver_id, dialog_ids: params[:dialog_ids])
    else
      redirect_to root_url
    end
  end

  def create
    @message = current_user.messages.build(message_params)
    if current_user.inter_relationship?(User.find(@message.receiver_id))
      if @message.save
        flash[:success] = "Message created!"
        redirect_to message_url(@message.dialog_ids)
      else
        @feed_items = []
        render 'show'
      end
    else
      redirect_to root_url
    end
  end

  def destroy
    @message.destroy
    flash[:success] = "Message deleted"
    redirect_back(fallback_location: root_url)
    #redirect_to request.referrer || root_url                                                    
  end                                                                                            

  private

    def message_params
      params.require(:message).permit(:content, :picture, :receiver_id, :dialog_ids)
    end

    def correct_user
      @message = current_user.messages.find_by(id: params[:id])
      redirect_to root_url if @message.nil?
    end

    # ログインしているユーザーはdialogに参加しているユーザーであるか確認                         
    def correct_dialog_ids
      redirect_to root_url unless Message.include_user?(current_user.id, params[:dialog_ids])
    end                                                                                          
end

 showアクションでは、特定ユーザ間のMessageのやり取りを全て取得 : @feed_items し、新規Messageのフォーム作成の為に、Messageオブジェクト : @message をviewに渡している。また before_action :correct_dialog_ids は、showページにアクセスしようとしているユーザが、そのページのMessageのやり取りに参加しているかを確認している。 current_user.inter_relationship? はログインしているユーザとreceiverが相互フォロー関係であるかを確認し、違ったらrootページにリダイレクトしている。
 テスト用にMessageのfixturesを書く。

orange:
  content: "I just ate an orange!"
  receiver_id: <%= ActiveRecord::FixtureSet.identify(:lana) %>
  created_at: <%= 10.minutes.ago %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(:lana)) %>
  user: michael
          ︙
<% 30.times do |k| %>
message_<%= k %>:
  content: <%= Faker::Lorem.sentence(5) %>
  receiver_id: <%= ActiveRecord::FixtureSet.identify(":user_#{k}") %>
  created_at: <%= Time.zone.now %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(":user_#{k}")) %>
  user: michael
<% end %>

<% 30.times do |n| %>
message_lana<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  receiver_id: <%= ActiveRecord::FixtureSet.identify(:lana) %>
  created_at: <%= n.days.ago %>
  dialog_ids: <%= Message.dialog_ids(ActiveRecord::FixtureSet.identify(:michael), ActiveRecord::FixtureSet.identify(:lana)) %>
  user: michael
<% end %>
          ︙

 michaelから、同じユーザ(:lana)へのmessageを30件以上と、30人以上のユーザへ向けたmessageを作成。
 Messagesコントローラのテストを書く。micropostsコントローラのテストと変わらない所は省略。

class MessagesControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @message = messages(:orange)
    @no_following = users(:archer)
  end
        ︙
  test "should redirect show when wrong user logged in" do
    log_in_as(@user)
    get message_path(Message.dialog_ids(users(:lana).id, @receiver.id))
    assert_redirected_to root_url
  end

  test "should redirect show when be not following and followed from receiver" do
    log_in_as(@user)
    get message_path(Message.dialog_ids(@user.id, @no_following.id))
    assert_redirected_to root_url
  end

  test "should redirect create when be not following and followed from receiver" do
    log_in_as(@user)
    assert_no_difference 'Message.count' do
      post messages_path, params: { message: { content: "Lorem ipsum", receiver_id: @no_following.id, dialog_ids: Message.dialog_ids(@user.id, @no_following.id)} }
    end
    assert_redirected_to root_url
  end
end

 lanaとmalory : @receiver のMessageのやり取りを表示するページにmichael : @user でログインした状態で入れない事をテストしている。また、新規Message作成とMessageのやり取りを表示するページへのアクセスは相互フォロー関係に無いユーザ間(michaelとarcher : @no_following )では行えない事もテストしている。
 show_massageページのテストを書く(test/integration/messages_interface_test.rb)。

class MessagesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @another = users(:lana)
  end

  test "should put recent_message at the top in show_message" do
    log_in_as(@user)
    # 有効なメッセージ                                                                           
    content = "This message really ties the room together"
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png')
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 picture: picture,
                                                 receiver_id: @receiver.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @receiver.id) } }
    end
    get show_message_user_path(@user)
    assert_select 'div.pagination'
    assert_select 'input[type="file"]'
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert assigns(:feed_items).first.picture?
    # 同じ人への有効なメッセージ                                                                 
    content = "This message don't indlude pucture."
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 receiver_id: @receiver.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @receiver.id) } }
    end
    get show_message_user_path(@user)
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert_not assigns(:feed_items).first.picture?
    # 同じ人へのメッセージは最新の物が一つだけ表示されている事を確認                                       
    assert_not assigns(:feed_items).pluck(:id).include?(Message.last(2).first.id)
    # 違う人への有効なメッセージ                                                                 
    content = "This message is hello to lana."
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                               { content: content,                               
                                                 receiver_id: @another.id,
                                                 dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    get show_message_user_path(@user)
    # 自分のメッセージに削除リンクが付いているか確認                                             
    assert_select "a[href=?]", message_path(Message.last)
    # 先頭が最新のメッセージになっているか確認                                                   
    assert_equal assigns(:feed_items).first.content, content
    # 相手からのメッセージが最初に表示されている                                                 
    log_in_as(@another)
    get show_message_user_path(@another)
    # 相手のメッセージに削除リンクが付いていない事を確認                                         
    assert_select "a[href=?]", message_path(Message.last), count: 0
    assert_equal assigns(:feed_items).first.content, content
  end
end

 michaelでログイン→maloryへの新規Messageを作成→show_message(ログインしているUserが参加しているMessageのリンク集)へアクセス→自分のmessageに削除リンクがある事&feed欄 : :feed_items の先頭が新規Messageになっている事を確認→先ほどと同じ人(malory)へ新規Messageを作成→show_message(ログインしているUserが参加しているMessageのリンク集)へアクセス→同じ人へのmessageは、最新の物だけが表示される事を確認→違う人(lana)へのMessageも同じように確認→相手(lana)でログイン→michaelが作成したmessageが見えているかを確認。
 Messageのやり取りを表示するページ : show を作成(app/views/messages/show.html.erb)。

<section class="messages">
  <div class="user_name">
    <strong>
      <%= link_to @message.receiver.name, @message.receiver %>
    </strong>
    <%= link_to gravatar_for(@message.receiver, size: 50), @message.receiver %>
  </div>
</section>
<% if @feed_items.any? %>
  <ol class="messages">
    <%= render @feed_items.reverse %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>
<%= render 'shared/message_form' %>

 showアクションから渡された特定のユーザとのMessageのやり取り : @feed_items をmessageパーシャルに渡して表示している。reverseしているのは、画面の上から下に行くほど最近のMessageを置いて、会話っぽく見せたかったからである。messageフォームのパーシャル : app/views/shared/_message_form.erb は、micropost_formと変わらなかったので省略。
 Messageの一連の動作テストを書く(test/integration/messages_interface_test.rb)。

class MessagesInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
    @receiver = users(:malory)
    @another = users(:lana)
  end
       ︙
  test "message interface" do
    log_in_as(@user)
    # 無効なメッセージ                                                                           
    assert_no_difference 'Message.count' do
      post messages_path, params: { message:
                                            { content: '',
                                              receiver_id: @another.id,
                                              dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    assert_template 'messages/show'
    assert_select 'div#error_explanation'
    # 有効なメッセージ                                                                           
    content = "To #{@another.name} from #{@user.name}"
    assert_difference 'Message.count', 1 do
      post messages_path, params: { message:
                                            { content: content,
                                              receiver_id: @another.id,
                                              dialog_ids: Message.dialog_ids(@user.id, @another.id) } }
    end
    assert_redirected_to message_path(Message.dialog_ids(@user.id, @another.id))
    follow_redirect!
    assert_select 'div.pagination'
    assert_select 'input[type="file"]'
    # 最新のメッセージが会話の最後(一番下)に表示されている                                     
    assert_equal assigns(:feed_items).reverse.last.content, content
    # 自分のメッセージには削除リンクが付いている                                                 
    assigns(:feed_items).each do |message|
      if message.user_id == @user.id
        assert_select "a[href=?]", message_path(message)
      else
        assert_select "a[href=?]", message_path(message), count: 0
      end
    end
  end
end

 michaelでログイン→無効な新規Messageを作成→render先がshowページ&errorメッセージが表示されているかを確認→有効な新規Messageを作成→showページにリダイレクトされている&最新のMessageが一番下に表示されている&自分のMessageには削除リンクが表示されている事を確認。


- UserプロフィールにMessageへのリンクを作成

 Userプロフィール app/views/users/show.html.erb にMessageページへのリンクを作成。

<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      ︙
        <% if logged_in? %>
          <span><%= link_to "View Messages", message_path(Message.dialog_ids(current_user.id, @user.id)) if current_user.inter_relationship?(@user) %></span>
        <% end %>
    </section>
  </aside>
      ︙
</div>

 見ているUserプロフィールとログインしているユーザが相互フォロー関係だったら、Messageページへのリンクを作成している。
 上手く作れているかを確認するテストを書く(test/integration/users_profile_test.rb)。

class UsersProfileTest < ActionDispatch::IntegrationTest
  include ApplicationHelper

  def setup
    @user = users(:michael)
    @inter_relationship = users(:malory)
    @no_following = users(:archer)
  end

  test "profile display" do
    log_in_as(@user)
    get user_path(@user)
    #自分のプロフィールには、メッセージページへのリンクが無い事を確認。                                     
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@user.id, @user.id)), count: 0
    log_in_as(@inter_relationship)
    get user_path(@user)
    #相互フォローしているユーザのプロフィールには、メッセージページへのリンクがある事を確認。               
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@inter_relationship.id, @user.id)), count: 1
    log_in_as(@no_following)
    get user_path(@user)
    #相互フォロー関係に無いユーザのプロフィールには、には、メッセージページへのリンクが無い事を確認。       
    assert_select 'a[href=?]', message_path(Message.dialog_ids(@no_following.id, @user.id)), count: 0
               ︙
end

 ログインしているユーザ自身のプロフィール、ログインしているユーザと相互フォロー関係にあるユーザのプロフィール、ログインしているユーザと相互フォロー関係に無いユーザのプロフィールページで、Messageへのリンクがそれぞれ、一つも無い、一つ有る、一つも無い事を確認している。


分かった知識一覧

  • has_many :シンボル名, -> { SQL文 }, through: :テーブル名 : has_manyにscopeを書ける。
  • resources :コントローラ名, param: :シンボル名 : resourceのparamのシンボル名 :id を別のシンボル名に変更する事が出来る。
  • Array.delete(要素) : 返り値は削除した要素。
  • Array.reject{|n| 条件文} : 返り値は削除した後のArray。
  • sqlでのエスケープはバックスラッシュ \
  • レコードオブジェクト.pluck(:カラム名) : レコードのカラムの値を配列として取得。レコードオブジェクトのままで検索: exists?(検索レコード) を行うと、最終的に取ってくるレコードだけでなく、途中式で取得したレコードも検索範囲に含まれてしまうので、pluckで配列化してから検索: include?(検索する値) しよう。


改善したい所

  • feedとreply_feedとshow_messageのどちらのページを見ているかを分かりやすくしたい
     前と同じ事を言っている。

  • messageページはページへの遷移じゃなく、ポップアップを出して表示したい
     ページを遷移させるのではなく、ウィンドウの手前に小さめのポップアップを表示して、その中でmessageのやり取りページを表示したい。messageページの機能としては、messageの相手のアイコン&名前、messageのやり取り数件表示、新規messageフォームだけで良いので、ウィンドウでやらせるには広すぎると思うので、ポップアップを出してコンパクトにまとめたい。

  • messageページの一番下を新規messageフォームにし、真ん中のmessageの表示にスクロールを付けて、常に最新メッセージがフォームのすぐ上に来るように設定したい
     ウィンドウの真ん中にスクロールページを設けて、そこにmessageのやり取りを表示したい。また、messageはpagingで取得せず、読み込んだ上限までスクロールした時に、その都度読み込むようにしたい。

  • messageの横に既読機能を付けたい
     Messageモデルにread_tagカラムを作って、初期値をfalseにする。それで、そのmessageを作ったユーザ以外(相手)がページ上で、レコードを読み込んだ時に値をtrueに変える。レコードのread_tagがtrueになった時に、ページ上でmessageの横に ‘既読’ を表示。もしかしたら、javascriptでページ内処理が必要かも?

  • 相手と自分のMessageは分かりやすく振り分けたい
     自分が作成したmessageは左側、相手が作成したmessageは右側に表示したい。吹き出しっぽいアイコンを用意して、その中に入れたい(向きも調節したい)。

  • show_messageページに相互フォローの関係にあるユーザ全員へのMessageページへのリンクを表示したい
     今の仕様だと、新しいユーザへのmessageページは、そのユーザのプロフィールからしかリンクが無い。これを、相互フォロー関係が出来た時に、show_messageページにリンクを貼れるようにしたい。相互フォロー状態になったユーザ情報を何処かに保存しないと行けないので、Dialogモデルを作って、Dialog.idsカラムにMessage.dialog_idsを入れる。そして、誰かがフォローを行った時に、その相手と相互フォロー関係になっていたら、レコードを作る。Messageモデルは、dialog_idsを削除し、Dialogモデルとhas_many関係を構築し、Dialogモデルのレコードidから紐付けたMessageを取得するようにするとか?どうせなら、最初からサンプルデータを消すより、既存のサンプルデータを自動に書き換えて、上手く新しいバージョンに適用させる演習もやってみたい。

  • show_messageページのUserへのMessageを表示するリンクには常に相手のプロフィール画像を使いたい
     dialog_idsから、相手のidを取ってきて、そのままアイコンと名前持ってくれば良いだけ。すぐ出来そう^_^;

  • show_messageのページのUserへのMessageを表示するリンクを相互フォローの関係が切れた場合、消したい(レコードは残す)
     show_messageのページ上で、取得したレコード(recent_messages)のdialog_idsから、ユーザ間の現在のフォロー関係を調べても良いけど、どうせなら、Dialogモデル作った後に、inter_relationshipカラムを作って、そこにtrueかfalseを付けた方が速いのでは?とか思ってる。