🦓

[Rails]ネストされたフォーム

2023/09/02に公開

はじめに

stimulus-rails-nested-formでネストされたフォームを実装していきます。
一つのTodoに複数のタスクをネストされるイメージです。Todoはすでに実装されています。

https://www.stimulus-components.com/docs/stimulus-rails-nested-form

環境

Rails 7.0.7
ruby 3.2.1

流れ

  1. scaffoldでTaskを作成する
  2. stimulus-rails-nested-formをインストールする
  3. stimulusコントローラーを作成する
  4. Todoフォームにstimulusコントローラーの設定を追加する
  5. TodoフォームにTaskフォームとそのテンプレを追加する
  6. Todoの一覧・詳細にTaskパーシャルを読み込む

Taskモデルを作成する

bin/rails g scaffold task descriptions:text status:integer todo:references

bin/rails db:migrate
== 20230901143525 CreateTasks: migrating ======================================
-- create_table(:tasks)
   -> 0.0050s
== 20230901143525 CreateTasks: migrated (0.0051s) =============================

モデルの関連付けを設定する

app/models/todo.rb
class Todo < ApplicationRecord
...
    has_many :tasks, dependent: :destroy
    accepts_nested_attributes_for :tasks, allow_destroy: true, reject_if: :all_blank
end

accepts_nested_attributes_forは、特定のモデル間の関連性に関するオプションの1つです。このオプションを使用することで、親モデルが子モデルの属性を一緒に保存できるようになります。

accepts_nested_attributes_forを使うと、親モデルが子モデルの属性を許可し、子モデルのレコードを親モデルと一緒に保存できます。

allow_destroy: trueオプションを使用して関連付けられたタスクを削除できるようにしています。

reject_if: :all_blankオプションを使用して、空白の属性を持つタスクを拒否する設定を行っています。親モデルにネストされた子モデルの属性がすべて空白の場合、その子モデルのレコードは作成されません。

app/models/task.rb
class Task < ApplicationRecord
  belongs_to :todo
  validates :descriptions, presence: true
  enum status: { planned: 0, started: 1, completed: 2 }
end

https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

ストロングパラメーターを追加する

app/controllers/todos_controller.rb
class TodosController < ApplicationController
...
  def new
    @todo = Todo.new
    @todo.tasks.build
  end
  
  private
    
  def todo_params
     params.require(:todo).permit(:title, :status, tasks_attributes: %i[id descriptions status _destroy])
  end
end

stimulus-rails-nested-formをインストールする

bin/importmap pin stimulus-rails-nested-form
Pinning "stimulus-rails-nested-form" to https://ga.jspm.io/npm:stimulus-rails-nested-form@4.1.0/dist/stimulus-rails-nested-form.mjs
Pinning "@hotwired/stimulus" to https://ga.jspm.io/npm:@hotwired/stimulus@3.2.2/dist/stimulus.js

stimulusコントローラーを作成する

bin/rails g stimulus nested_form
      create  app/javascript/controllers/nested_form_controller.js
app/javascript/controllers/nested_form_controller.js
import NestedForm from 'stimulus-rails-nested-form'

export default class extends NestedForm {
  connect() {
    super.connect()
  }
}

todoフォームにstimulusコントローラーの設定を追加する

DOMがネストされるフォーム(Task)を操作できるようにするためCSSのクラス名"nested-form-wrapper"を追加します。
Taskフォームにも同じクラス名を追加します。

app/views/todos/_form.html.erb
<%= form_with(model: todo, id: "#{dom_id(todo)}_form", 
 data: { controller: "nested-form",
 nested_form_wrapper_selector_value: '.nested-form-wrapper' }
do |form| %>

  # テンプレ
  <template data-nested-form-target="template">
    <%= form.fields_for :tasks, Task.new, child_index: 'NEW_RECORD' do |task_fields| %>
      <%= render "todos/task_form", form: task_fields %>
    <% end %>
  </template>
  
  # タスクフォーム
  <%= form.fields_for :tasks do |task_fields| %>
    <%= render "todos/task_form", form: task_fields %>
  <% end %>
  
  <%= form.submit %>
<% end %>

HTMLの<template>要素を使用して、Taskフォームのテンプレートを定義しています。
部分テンプレートを使用し新しいTaskオブジェクトを含むフォーム要素を生成します。

data-nested-form-target="template":JavaScriptからテンプレート要素を特定するための識別子として定義します。

child_indexは、フォーム内でネストされた属性(またはオブジェクト)を識別するために使用されます。ネストされたフォーム内で同じ属性を持つ複数のオブジェクトがある場合、child_index を使用してそれらを区別します。
child_index: 'NEW_RECORD' は、新しいTaskオブジェクトを識別するためのchild_index を設定しています。NEW_RECORDなどの特別な文字列を使用して、新しいオブジェクトを識別することがあります。

デベロッパーツールを開いて、実際に生成されるhtmlを見ると分かりやすいです。

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template

<div data-nested-form-target="target"></div>
<button type="button" data-action="nested-form#add">タスクを追加する</button>

タスクを追加するボタンがクリックされたときに実行されるアクションが指定されています。
nested_formコントローラーのaddアクションを実行し、テンプレによる新しいTaskフォームをdata-nested-form-target="target"の前に追加されることになります。

Taskフォーム

app/views/todos/task_form.html.erb
<div class="nested-form-wrapper" data-new-record="<%= form.object.new_record? %>">
  <%= form.label :descriptions %>
  <%= form.text_field :descriptions %>
  <%= form.label :status %>
  <%= form.select :status, Task.statuses.keys.map { |status| [I18n.t("enums.task.status.#{status}"), status] }, include_blank: true %>
  <button type="button" data-action="nested-form#remove">削除</button>
  <%= form.hidden_field :_destroy %>
</div>

新規タスクの場合data-new-record="true"を返します。
タスクの編集の場合data-new-record="false"になります。
削除ボタンをクリックしたらnested_formコントローラーのremoveアクションを実行しTaskをDOMから削除されます。

Taskパーシャル

app/views/todos/_tasks.html.erb
<% todo.tasks.each do |task| %>
        <%= task.descriptions %>
        <%= t("enums.task.status.#{task.status}") %>
<% end %>

終わりに

stimulus-componentsを使ってネストしたフォームを簡単に実装できましたが、使わないやり方も試してみたいと思います。

Discussion