Just do IT

思うは招く

Rails ネストされたルートに対する form_with の書き方

やりたいこと

以下のように、ルートが親子関係を持つ場合のフォームの作り方を再現する。

  resources :articles do
    resources :logs
  end

前提

以下の2つのモデルが存在する。

  • Article モデル
    • 属性 title:string
  • Log モデル
    • 属性 title:string

Article モデルは「記事」を管理するモデルであり、Log モデルとは「1対多」の関係にある。

Article has_many Logs

Log モデルとは「ログ」を管理するモデルであり、ひとつの記事にたいして複数存在することができる。

結論

モデルのインスタンスを複数渡すと良い。

<%= form_with(model: [article, log], local: true) do |form| %>

環境

$ ruby -v
ruby 2.6.5
$ rails -v
Rails 6.0.3.2

前準備

DBはRailsデフォルトのSQLiteを使用。

$ rails new nested-routes
$ rails g scaffold Article title:string
$ rails g scaffold Log title:string article:references
$ rails db:migrate

routes.rb

Rails.application.routes.draw do
  root "articles#index"
  resources :articles do
    resources :logs
  end
end

モデルのリレーション

#app/models/article.rb
class Article < ApplicationRecord
  has_many :logs
end

#app/models/log.rb
class Log < ApplicationRecord
  belongs_to :article
end

f:id:K_Koh:20200903142540j:plain

とりあえず記事を作ってみる。

f:id:K_Koh:20200903142607j:plain

show をクリック。

f:id:K_Koh:20200903142633j:plain

記事の詳細が表示された。これで前準備は終了。

次はここにログを表示していく。

ログを表示する

以下を追記する。

app/views/articles/show.html.erb

<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @article.title %>
</p>

#追記
<% if @article.logs.present? %>
  <% @article.logs.each do |log| %>
  <li><%= log.title %></li>
  <% end %>
<% end %>

<%= link_to 'Edit', edit_article_path(@article) %> |
<%= link_to 'Back', articles_path %>

「もしこの記事にログが存在するなら、ログのタイトルを表示してね」と記述。

今はまだログを作っていないので、表示はされない。

ログを作成するページへのリンクをつくる。

開発環境でサーバーを起動していると、次のURLでルーティングを確認できる。

http://localhost:3000/rails/info/routes

以下コマンドでもルーティングは確認可能。

$ rails routes

すると以下のようになっている。

new_article_log_path
/articles/:article_id/logs/new(.:format)
logs#new

上記のルーティングにしたがい、app/views/articles/show.html.erb を修正する。

<%= link_to 'Create log', new_article_log_path(@article) %> |
<%= link_to 'Edit', edit_article_path(@article) %> |
<%= link_to 'Back', articles_path %>

これでサーバーを起動しアクセスしてみると・・・

f:id:K_Koh:20200903143924j:plain

こんなエラーメッセージが出る。

undefined method `logs_path' for 
app/views/logs/_form.html.erb where line #1 raised:

logs_controller.rb を修正する。

  def new
    #追記
    @article = Article.find(params[:article_id])
    @log = Log.new
  end

~
~

  private
    ~

    def log_params
      params.require(:log).permit(:title, :article_id)
    end

次に app/views/logs/new.html.erb を修正する。

<h1>New Log</h1>

#削除する
<%= render 'form', log: @log %>

<%= link_to 'Back', logs_path %>

わかりやすくするため、今はパーシャルを使わない。よって<%= render 'form', log: @log %>を削除する。

app/views/logs/_form.html.erb からフォーム内容をコピペしてこよう。エラーメッセージ系の記述も今は削除する。

app/views/logs/new.html.erb

<h1>New Log</h1>

<%= form_with(model: log, local: true) do |form| %>
#ここらへんにあったエラーメッセージ系の記述は削除する

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  #これも削除
  <div class="field">
    <%= form.label :article_id %>
    <%= form.text_field :article_id %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

<%= link_to 'Back', logs_path %>

次のように変更する。

<h1>New Log</h1>

#変更点
<%= form_with(model: [@article, @log], local: true) do |form| %>
  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

#変更点
<%= link_to 'Back', article_path(@article) %>

以下のように変更している。

#変更前
<%= form_with(model: log, local: true) do |form| %>

#変更後
<%= form_with(model: [@article, @log], local: true) do |form| %>

こちらも変更。

#変更前
<%= link_to 'Back', logs_path %>

#変更後
<%= link_to 'Back', article_path(@article) %>

記事の詳細ページへ。

f:id:K_Koh:20200903144944j:plain

create log できるようになった。

f:id:K_Koh:20200903145308j:plain

ログ作成

ログを作成する、create アクションを修正する。

  def create
    @log = Log.new(log_params)
    #追記
    @log.article_id = params[:article_id]

    if @log.save
      #変更点
      redirect_to article_path(@log.article_id), notice: 'Log was successfully created.'
    else
      render :new
    end
  end

パラメーターから記事IDを取得し、ログのarticle_idに代入する。

@log.article_id = params[:article_id]

登録後のリダイレクト先は記事詳細ページにする。

redirect_to article_path(@log.article_id)

ためしにログを作成してみる。

f:id:K_Koh:20200903145540j:plain

作成できた。

f:id:K_Koh:20200903145601j:plain

ログを編集する

まずはログ編集ページへリンクを貼る。

app/views/articles/show.html.erb

<% if @article.logs.present? %>
  <% @article.logs.each do |log| %>
  <li><%= link_to log.title, article_log_path(log) %></li>
  <% end %>
<% end %>

app/views/logs/show.html.erb を変更。

<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @log.title %>
</p>

#削除↓
<p>
  <strong>Article:</strong>
  <%= @log.article_id %>
</p>

#変更点
<%= link_to 'Edit', edit_article_log_path(@log) %> |
<%= link_to 'Back', article_path(@log.article_id) %>

app/views/logs/edit.html.erb

わかりやすくするため、また render メソッドを削除してフォームをこのファイルに直書きする。

<h1>Editing Log</h1>

#削除
<%= render 'form', log: @log %>

<%= link_to 'Show', @log %> |
<%= link_to 'Back', logs_path %>

そして以下のように変更。

<h1>Editing Log</h1>

<%= form_with(model: [@log.article, @log], local: true) do |form| %>
  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

<%= link_to 'Back', article_path(@log.article_id) %>

logs_controller.rb の update アクションを修正。

  def update
    if @log.update(log_params)
      redirect_to article_path(@log.article_id), notice: 'Log was successfully updated.'
    else
      render :edit
    end
  end

編集してみる。

f:id:K_Koh:20200903151915j:plain

編集できた。

f:id:K_Koh:20200903151830j:plain

ログを削除する

作成、編集ができたので最後に削除機能を実装する。

logs_controller.rb

  def destroy
    @log.destroy
      redirect_to logs_url, notice: 'Log was successfully destroyed.'
  end

以下のように変更。リダイレクト先を変えただけ。

  def destroy
    @log.destroy
      redirect_to article_path(@log.article_id), notice: 'Log was successfully destroyed.'
  end

app/views/logs/show.html.erb に追記。

<%= link_to 'Edit', edit_article_log_path(@log) %> |
#追記
<%= link_to 'Delete', article_log_path(@log), method: :delete, data: { confirm: 'Are you sure?' } %> |
<%= link_to 'Back', article_path(@log.article_id) %>

削除してみる。

f:id:K_Koh:20200903152438j:plain

削除できた。

f:id:K_Koh:20200903152447j:plain

ルーティングを整理

やりたいことは実現できた。しかし、ルーティングが必要以上に複雑になっている。

とくに以下

article_log_path
/articles/:article_id/logs/:id(.:format)    
logs#show

ログ詳細を見る場合、すでにログオブジェクトには記事IDが保存されている。よって、パラメーターから:article_idをわざわざ渡す必要はない。

article_log_path
/articles/:article_id/logs/:id(.:format)    
logs#update

ログ詳細のときと同様に、すでにログオブジェクトに記事情報があるため、パラメーターから渡す必要はない。

article_log_path
/articles/:article_id/logs/:id(.:format)    
logs#destroy

削除時に:article_idを持っている必要はない。

よって以下のようにルーティングを変えてみる。

routes.rb

  resources :articles do
    resources :logs, only: %i(new create update)
  end
  resources :logs, only: %i(show destroy edit)

Ruby の機能で%iでシンボルの配列を簡単に表記できる。

以下の意味と同様になる。

resources :logs, only: [:new, :create, :update]

ルーティングを確認してみる。

log_path 
GET /logs/:id(.:format) 
logs#show

かなり短くなった。

app/views/articles/show.html.erb を修正。

<% if @article.logs.present? %>
  <% @article.logs.each do |log| %>
  <li><%= link_to log.title, log_path(log) %></li>
  <% end %>
<% end %>

app/views/logs/show.html.erb を修正。

<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @log.title %>
</p>

#変更点
<%= link_to 'Edit', edit_log_path(@log) %> |
<%= link_to 'Delete', article_log_path(@log), method: :delete, data: { confirm: 'Are you sure?' } %> |
<%= link_to 'Back', article_path(@log.article_id) %>

編集してみる。

f:id:K_Koh:20200903154309j:plain

f:id:K_Koh:20200903154343j:plain

編集できた。

f:id:K_Koh:20200903154445j:plain

URLも短くキレイになっている。

http://localhost:3000/logs/2/edit

削除

app/views/logs/show.html.erb を修正。

<%= link_to 'Edit', edit_log_path(@log) %> |
#変更点
<%= link_to 'Delete', @log, method: :delete, data: { confirm: 'Are you sure?' } %> |
<%= link_to 'Back', article_path(@log.article_id) %>

<%= link_to 'Delete', article_log_path(@log),だったのを<%= link_to 'Delete', log_path(@log)に変更。

省略して<%= link_to 'Delete', @log,と書くこともできる。これでルーティングの修正は完了。

フォームをパーシャル化

これまで、new と edit にはフォームを直書きで記述した。これらのフォームをパーシャル化して切り出す。

app/views/logs/new.html.erb

<h1>New Log</h1>

<%= render partial: "form", locals: { article: @article, log: @log} %>

app/views/logs/edit.html.erb

<h1>Editing Log</h1>

<%= render partial: "form", locals: { article: @log.article, log: @log} %>

app/views/logs/_form.html.erb

<%= form_with(model: [article, log], local: true) do |form| %>
  <% if log.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(log.errors.count, "error") %> prohibited this log from being saved:</h2>

      <ul>
        <% log.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

リポジトリ

よかったら参考にどうぞ。

https://github.com/kotakanazawa/nested-routes

感想

RESTむずかしいなぁ・・・。

関連

renderメソッドはちょっと注意ポイントがある。

k-koh.hatenablog.com

親と子モデルを複数オブジェクトを一括で登録したい場合のフォームの作り方。

k-koh.hatenablog.com