Rails 7 动态嵌套表单与 hotwire/turbo frames

18

我对Rails非常陌生。我从Rails 7开始学习,所以关于我的问题还没有太多的信息。

这是我拥有的:

app/models/cocktail.rb

class Cocktail < ApplicationRecord
  has_many :cocktail_ingredients, dependent: :destroy
  has_many :ingredients, through: :cocktail_ingredients
  accepts_nested_attributes_for :cocktail_ingredients
end

app/models/ingredient.rb

class Ingredient < ApplicationRecord
  has_many :cocktail_ingredients
  has_many :cocktails, :through => :cocktail_ingredients
end

app/models/cocktail_ingredient.rb

class CocktailIngredient < ApplicationRecord
  belongs_to :cocktail
  belongs_to :ingredient
end

app/controllers/cocktails_controller.rb

def new
  @cocktail = Cocktail.new
  @cocktail.cocktail_ingredients.build
  @cocktail.ingredients.build
end


def create
  @cocktail = Cocktail.new(cocktail_params)

  respond_to do |format|
    if @cocktail.save
      format.html { redirect_to cocktail_url(@cocktail), notice: "Cocktail was successfully created." }
      format.json { render :show, status: :created, location: @cocktail }
    else
      format.html { render :new, status: :unprocessable_entity }
      format.json { render json: @cocktail.errors, status: :unprocessable_entity }
    end
  end
end


def cocktail_params
  params.require(:cocktail).permit(:name, :recipe, cocktail_ingredients_attributes: [:quantity, ingredient_id: []])
end

...

db/seeds.rb

Ingredient.create([ {name: "rum"}, {name: "gin"} ,{name: "coke"}])

来自模式的相关表格

create_table "cocktail_ingredients", force: :cascade do |t|
    t.float "quantity"
    t.bigint "ingredient_id", null: false
    t.bigint "cocktail_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["cocktail_id"], name: "index_cocktail_ingredients_on_cocktail_id"
    t.index ["ingredient_id"], name: "index_cocktail_ingredients_on_ingredient_id"
  end

create_table "cocktails", force: :cascade do |t|
  t.string "name"
  t.text "recipe"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "ingredients", force: :cascade do |t|
  t.string "name"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

...

add_foreign_key "cocktail_ingredients", "cocktails"
add_foreign_key "cocktail_ingredients", "ingredients"

app/views/cocktails/_form.html.erb

<%= form_for @cocktail do |form| %>
  <% if cocktail.errors.any? %>
    <% cocktail.errors.each do |error| %>
      <li><%= error.full_message %></li>
    <% end %>
  <% end %>

  <div>
    <%= form.label :name, style: "display: block" %>
    <%= form.text_field :name, value: "aa"%>
  </div>

  <div>
    <%= form.label :recipe, style: "display: block" %>
    <%= form.text_area :recipe, value: "nn" %>
  </div>

  <%= form.simple_fields_for :cocktail_ingredients do |ci| %>
    <%= ci.collection_check_boxes(:ingredient_id, Ingredient.all, :id, :name) %>
    <%= ci.text_field :quantity, value: "1"%>
  <% end %>

  <div>
    <%= form.submit %>
  </div>
<% end %>

当前错误:

鸡尾酒配料必须存在

我想要实现的目标:

我希望有一个局部视图,我可以选择其中一个3种配料,并输入其数量。应该有添加/删除按钮来添加/删除配料。

我应该使用什么?Turbo Frames?Hotwire?怎样去做?

1个回答

57
1. Controller & Form    - set it up as if you have no javascript,
2. Turbo Frame          - then wrap it in a frame.
3. TLDR                 - if you don't need a long explanation.
4. Turbo Stream         - you can skip Turbo Frame and do this instead.
5. Custom Form Field    - make a reusable form field
6. Frame + Stream       - stream from the frame
7. Stimulus             - it's much simpler than you think
8. Deeply Nested Fields - it's much harder than you think

控制器和表单

首先,我们需要一个可以提交并重新呈现而不创建新鸡尾酒的表单。

使用accepts_nested_attributes_for确实会改变表单的行为,这一点并不明显,当你不理解它时,它会让你发疯。

首先,让我们修复表单。我将使用默认的Rails表单构建器,但使用simple_form也是相同的设置:

<!-- form_for or form_tag: https://guides.rubyonrails.org/form_helpers.html#using-form-tag-and-form-for
     form_with does it all -->
<%= form_with model: cocktail do |f| %>
  <%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>

  <%= f.text_field :name, placeholder: "Name" %>
  <%= f.text_area :recipe, placeholder: "Recipe" %>

  <%= f.fields_for :cocktail_ingredients do |ff| %>
    <div class="flex gap-2">
      <div class="text-sm text-right"> <%= ff.object.id || "New ingredient" %> </div>
      <%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
      <%= ff.text_field :quantity, placeholder: "Qty" %>
      <%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
    </div>
  <% end %>

  <!-- NOTE: Form has to be submitted, but with a different button,
             that way we can add different functionality in the controller
             see `CocktailsController#create` -->
  <%= f.submit "Add ingredient", name: :add_ingredient %>

  <div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>

<style type="text/css" media="screen">
  input[type], textarea, select { display: block; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; width: 100%; border: 1px solid rgba(0,0,0,0.15); border-radius: .375rem; box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px }
  input[type="checkbox"] { width: auto; padding: 0.75rem; }
  input[type="submit"] { width: auto; cursor: pointer; color: white; background-color: rgb(37, 99, 235); font-weight: 500; }
</style>

https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for

根据belongs_to :ingredient,每个cocktail_ingredient需要单独的ingredient。单选框select是一个明显的选择;collection_radio_buttons也适用。

fields_for助手将输出一个带有cocktail_ingredient ID的隐藏字段,如果该特定记录已在数据库中持久化。这就是rails知道如何更新现有记录(带有ID)和创建新记录(没有ID)的方式。

因为我们使用了accepts_nested_attributes_for,所以fields_for会将"_attributes"附加到输入名称。换句话说,如果您在模型中有这个:

accepts_nested_attributes_for :cocktail_ingredients

那意味着

f.fields_for :cocktail_ingredients

将输入名称前缀添加为cocktail [cocktail_ingredients_attributes]

(警告:源代码来了)原因是accepts_nested_attributes_forCocktail模型中定义了一个新方法cocktail_ingredients_attributes=(params),它为您完成了大量的工作。这就是嵌套参数处理的地方,CocktailIngredient对象被创建并分配给相应的cocktail_ingredients关联,并且如果_destroy参数存在,则标记为要销毁,因为autosave 设置true,所以您获得自动验证。这只是供参考,以防您想定义自己的cocktail_ingredients_attributes=方法,您可以,f.fields_for自动拾取它。

在`CocktailsController`中,`new`和`create`动作需要微小的更新:
# GET /cocktails/new
def new
  @cocktail = Cocktail.new
  # NOTE: Because we're using `accepts_nested_attributes_for`, nested fields
  #       are tied to the nested model now, a new object has to be added to
  #       `cocktail_ingredients` association, otherwise `fields_for` will not
  #       render anything; (zero nested objects = zero nested fields).
  @cocktail.cocktail_ingredients.build
end

# POST /cocktails
def create
  @cocktail = Cocktail.new(cocktail_params)
  respond_to do |format|
    # NOTE: Catch when form is submitted by "add_ingredient" button;
    #       `params` will have { add_ingredient: "Add ingredient" }.
    if params[:add_ingredient]
      # NOTE: Build another cocktail_ingredient to be rendered by
      #       `fields_for` helper.
      @cocktail.cocktail_ingredients.build

      # NOTE: Rails 7 submits as TURBO_STREAM format. It expects a form to
      #       redirect when valid, so we have to use some kind of invalid
      #       status. (this is temporary, for educational purposes only).
      #       https://dev59.com/asTra4cB1Zd3GeqPxBgg#71762032

      # NOTE: Render the form again. TADA! You're done.
      format.html { render :new, status: :unprocessable_entity }
    else
      if @cocktail.save
        format.html { redirect_to cocktail_url(@cocktail), notice: "Cocktail was successfully created." }
      else
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end
end

在“Cocktail”模型中,允许使用“_destroy”表单字段在保存时删除记录:
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true

就是这样,表单可以提交以创建鸡尾酒或提交以添加另一种成分。


Turbo Frame

当前,当添加新成分时,整个页面都会被turbo重新渲染。为了使表单更加动态化,我们可以添加turbo-frame标签来仅更新表单中的成分部分:

<!-- doesn't matter how you get the "id" attribute
     it just has to be unique and repeatable across page reloads -->
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">

  <%= f.fields_for :cocktail_ingredients do |ff| %>
    <div class="flex gap-2">
      <div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
      <%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
      <%= ff.text_field :quantity, placeholder: "Qty" %>
      <%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
    </div>
  <% end %>

</turbo-frame>

将“添加配料”按钮更改为让 turbo 知道我们只需要提交页面的框架部分。普通链接不需要这个,我们只需将该链接放在 iframe 标签内,但是 input 按钮需要额外注意。
<!-- same `id` as <turbo-frame>; repeatable, remember. -->
<%= f.submit "Add ingredient", 
  data: { turbo_frame: f.field_id(:ingredients)},
  name: "add_ingredient" %>

Turbo 帧 id 必须匹配按钮的 data-turbo-frame 属性:
<turbo-frame id="has_to_match">
<input data-turbo-frame="has_to_match" ...>

现在,当点击“添加成分”按钮时,它仍然转到相同的控制器,仍然在服务器上呈现整个页面,但是不再重新呈现整个页面(框架#1),而只更新turbo-frame内部的内容(框架#2)。这意味着页面滚动保持不变,在turbo-frame标记外部的表单状态不变。就所有目的而言,这现在是一个动态表单。
可能的改进是停止干扰create操作,并通过不同的控制器操作添加成分,例如add_ingredient:
# config/routes.rb
resources :cocktails do
  post :add_ingredient, on: :collection
end

<%= f.submit "Add ingredient",
  formmethod: "post",
  formaction: add_ingredient_cocktails_path(id: f.object),
  data: { turbo_frame: f.field_id(:ingredients)} %>

CocktailsController中添加add_ingredient操作:
def add_ingredient
  @cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
  @cocktail.cocktail_ingredients.build # add another ingredient

  # NOTE: Even though we are submitting a form, there is no
  #       need for "status: :unprocessable_entity". 
  #       Turbo is not expecting a full page response that has
  #       to be compatible with the browser behavior
  #         (that's why all the status shenanigans; 422, 303)
  #       it is expecting to find the <turbo-frame> with `id`
  #       matching `data-turbo-frame` from the button we clicked.
  render :new
end

create操作现在可以恢复为默认设置。


您也可以重复使用new操作,而不是添加add_ingredient

resources :cocktails do
  post :new, on: :new # add POST /cocktails/new
end

完整的控制器设置:
https://dev59.com/8HYPtIcB2Jgan1znDuri#72890584

然后将表单调整为将内容发布到new而不是add_ingredient


简而言之 - 把所有东西都放在一起

我认为这是我能让它变得简单的程度了。以下是简短版(添加动态字段约10行左右,无需 JavaScript)。

# config/routes.rb
resources :cocktails do
  post :add_ingredient, on: :collection
end

# app/controllers/cocktails_controller.rb 
# the other actions are the usual default scaffold
def add_ingredient
  @cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
  @cocktail.cocktail_ingredients.build
  render :new
end

# app/views/cocktails/new.html.erb
<%= form_with model: cocktail do |f| %>
  <%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>
  <%= f.text_field :name, placeholder: "Name" %>
  <%= f.text_area :recipe, placeholder: "Recipe" %>

  <turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
    <%= f.fields_for :cocktail_ingredients do |ff| %>
      <div class="flex gap-2">
        <div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
        <%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
        <%= ff.text_field :quantity, placeholder: "Qty" %>
        <%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
      </div>
    <% end %>
  </turbo-frame>

  <%= f.button "Add ingredient", formmethod: "post", formaction: add_ingredient_cocktails_path(id: f.object), data: { turbo_frame: f.field_id(:ingredients)} %>
  <div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>

# app/models/*
class Cocktail < ApplicationRecord
  has_many :cocktail_ingredients, dependent: :destroy
  has_many :ingredients, through: :cocktail_ingredients
  accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
end
class Ingredient < ApplicationRecord
  has_many :cocktail_ingredients
  has_many :cocktails, through: :cocktail_ingredients
end
class CocktailIngredient < ApplicationRecord
  belongs_to :cocktail
  belongs_to :ingredient
end

Turbo Stream

Turbo Stream 是一种非常动态的表单,而且不需要任何 JavaScript 的干预。我们必须更改表单以便让我们渲染单个鸡尾酒配料:

# NOTE: remove `f.submit "Add ingredient"` button
#       and <turbo-frame> with nested fields

# NOTE: this `id` will be the target of the turbo stream
<%= tag.div id: :cocktail_ingredients do %>
  <%= f.fields_for :cocktail_ingredients do |ff| %>
    # put nested fields into a partial
    <%= render "ingredient_fields", f: ff %>
  <% end %>
<% end %>

# NOTE: `f.submit` is no longer needed, because there is no need to
#       submit the form anymore just to add an ingredient.
<%= link_to "Add ingredient",
    add_ingredient_cocktails_path,
    class: "text-blue-500 hover:underline",
    data: { turbo_method: :post } %>
#                          ^
# NOTE: still has to be a POST request. (not anymore)
# UPDATE: set `turbo_stream: true` to make it a GET request.

<!-- app/views/cocktails/_ingredient_fields.html.erb -->
<div class="flex gap-2">
  <div class="text-sm text-right"> <%= f.object&.id || "New" %> </div>
  <%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
  <%= f.text_field :quantity, placeholder: "Qty" %>
  <%= f.check_box :_destroy, title: "Check to delete ingredient" %>
</div>

更新add_ingredient操作以呈现turbo_stream响应:

# it should be in your routes, see previous section above.
def add_ingredient
  # NOTE: get a form builder but skip the <form> tag, `form_with` would work 
  #       here too. however, we'd have to use `fields` if we were in a template. 
  helpers.fields model: Cocktail.new do |f|
    # NOTE: instead of letting `fields_for` helper loop through `cocktail_ingredients`
    #        we can pass a new object explicitly.
    #                                   v
    f.fields_for :cocktail_ingredients, CocktailIngredient.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
      #                                                         ^            ^ Time.now.to_f also works
      # NOTE: one caveat is that we need a unique key when we render this
      #       partial otherwise it would always be 0, which would override
      #       previous inputs. just look at the generated input `name` attribute:
      #          cocktail[cocktail_ingredients_attributes][0][ingredient_id]
      #                                                    ^
      #       we need a different number for each set of fields

      render turbo_stream: turbo_stream.append(
        "cocktail_ingredients",
        partial: "ingredient_fields",
        locals: { f: ff }
      )
    end
  end
end
# NOTE: `fields_for` does output an `id` field for persisted records
#       which would be outside of the rendered html and turbo_stream.
#       not an issue here since we only render new records and there is no `id`.

自定义表单字段

创建一个表单字段助手将简化任务,只需一行代码即可完成:

# config/routes.rb
# NOTE: I'm not using `:id` for anything, but just in case you need it.
post "/fields/:model(/:id)/build/:association(/:partial)", to: "fields#build", as: :build_fields

# app/controllers/fields_controller.rb
class FieldsController < ApplicationController
  # POST /fields/:model(/:id)/build/:association(/:partial)
  def build
    resource_class      = params[:model].classify.constantize                                     # => Cocktail
    association_class   = resource_class.reflect_on_association(params[:association]).klass       # => CocktailIngredient
    fields_partial_path = params[:partial] || "#{association_class.model_name.collection}/fields" # => "cocktail_ingredients/fields"
    render locals: { resource_class:, association_class:, fields_partial_path: }
  end
end

# app/views/fields/build.turbo_stream.erb
<%=
  fields model: resource_class.new do |f|
    turbo_stream.append f.field_id(params[:association]) do
      f.fields_for params[:association], association_class.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
        render fields_partial_path, f: ff
      end
    end
  end
%>

# app/models/dynamic_form_builder.rb
class DynamicFormBuilder < ActionView::Helpers::FormBuilder
  def dynamic_fields_for association, name = nil, partial: nil, path: nil
    association_class   = object.class.reflect_on_association(association).klass
    partial           ||= "#{association_class.model_name.collection}/fields"
    name              ||= "Add #{association_class.model_name.human.downcase}"
    path              ||= @template.build_fields_path(object.model_name.name, association:, partial:)
    @template.tag.div id: field_id(association) do
      fields_for association do |ff|
        @template.render(partial, f: ff)
      end
    end.concat(
      @template.link_to(name, path, class: "text-blue-500 hover:underline", data: { turbo_method: :post })
    )
  end
end

这个新的辅助程序需要"#{association_name}/_fields"局部文件:

# app/views/cocktail_ingredients/_fields.html.erb
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>

覆盖默认的表单构建器,现在您应该拥有dynamic_fields_for输入:

# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail, builder: DynamicFormBuilder do |f| %>
  <%= f.dynamic_fields_for :cocktail_ingredients %>
  <%# f.dynamic_fields_for :other_things, "Add a thing", partial: "override/partial/path" %>

  # or without dynamic form builder, just using the new controller
  <%= tag.div id: f.field_id(:cocktail_ingredients) %>
  <%= link_to "Add ingredient", build_fields_path(:cocktail, :cocktail_ingredients), class: "text-blue-500 hover:underline", data: { turbo_method: :post } %>
<% end %>

框架 + 流

您可以在当前页面上呈现turbo_stream标签,它将正常工作。渲染某些内容仅为了将其移动到同一页的其他地方是相当无用的。但是,如果我们将其放置在turbo_frame内部,就可以将东西移出框架以进行安全保留,同时在turbo_frame内部获得更新。

# app/controllers/cocktails_controller.rb
# GET /cocktails/new
def new
  @cocktail = Cocktail.new
  @cocktail.cocktail_ingredients.build
  # turbo_frame_request?           # => true
  # request.headers["Turbo-Frame"] # => "add_ingredient"
  # skip `new.html.erb` rendering if you want
  render ("_form" if turbo_frame_request?), locals: { cocktail: @cocktail }
end

# app/views/cocktails/_form.html.erb
<%= tag.div id: :ingredients %>

<%= turbo_frame_tag :add_ingredient do %>
  # NOTE: render all ingredients and move them out of the frame.
  <%= turbo_stream.append :ingredients do %>
    # NOTE: just need to take extra care of that `:child_index` and pass it as a proc, so it would be different for each object
    <%= f.fields_for :cocktail_ingredients, child_index: -> { Process.clock_gettime(Process::CLOCK_REALTIME, :microsecond) } do |ff| %>
      <%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
      <%= ff.text_field :quantity, placeholder: "Qty" %>
      <%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
    <% end %>
  <% end %>
  # NOTE: this link is inside `turbo_frame`, so if we navigate to `new` action
  #       we get a single set of new ingredient fields and `turbo_stream`
  #       moves them out again.
  <%= link_to "Add ingredient", new_cocktail_path, class: "text-blue-500 hover:underline" %>
<% end %>

没有额外的操作、控制器、路由、部分或响应。只需使用GET请求和Html响应,只会附加一个字段集。


Stimulus

避免使用Javascript很有趣,但它可能会变得有点复杂。另一方面,使用Stimulus创建动态字段是如此简单:

bin/rails generate stimulus dynamic_fields

// app/javascript/controllers/dynamic_fields_controller.js

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["template"];

  add(event) {
    event.preventDefault();
    event.currentTarget.insertAdjacentHTML(
      "beforebegin",
      this.templateTarget.innerHTML.replace(
        /__CHILD_INDEX__/g,
        new Date().getTime().toString()
      )
    );
  }
}

JavaScript 就这些了,你甚至不需要离开主页就能学到这么多 https://stimulus.hotwired.dev/

它会更新模板中预定义的子索引,并将更新后的 HTML 放回表单中。

为了使这个 Stimulus 控制器工作,我们需要有一个 controller 元素,一个带有新字段的 template target,以及一个带有 add action 的按钮。我写了一个快速的辅助方法来完成所有这些:

# app/helpers/application_helper.rb

module ApplicationHelper
  def dynamic_fields_for f, association, name = "Add"
    # stimulus:      controller v
    tag.div data: {controller: "dynamic-fields"} do
      safe_join([
        # render existing fields
        f.fields_for(association) do |ff|
          yield ff
        end,

        # render "Add" button that will call `add()` function
        # stimulus:         `add(event)` v
        button_tag(name, data: {action: "dynamic-fields#add"}),

        # render "<template>"
        # stimulus:           `this.templateTarget` v
        tag.template(data: {dynamic_fields_target: "template"}) do
          f.fields_for association, association.to_s.classify.constantize.new,
            child_index: "__CHILD_INDEX__" do |ff|
              #           ^ make it easy to gsub from javascript
              yield ff
          end
        end
      ])
    end
  end
end

在你的表单中使用它:

# app/views/cocktails/_form.html.erb

<%= dynamic_fields_for f, :cocktail_ingredients do |ff| %>
  # NOTE: this block will be rendered once for the <template> and
  #       once for every `cocktail_ingredient`
  <%= tag.div class: "flex gap-2" do %>
    <%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
    <%= ff.text_field :quantity, placeholder: "Qty" %>
    <%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
  <% end %>

  # NOTE: double nested dynamic fields also work
  <%# <%= dynamic_fields_for ff, :things do |fff| %>
  <%#   <%= fff.text_field :name %>
  <%#   <%= fff.text_field :value %>
  <%# <% end %>
<% end %>

深度嵌套字段

Stimulus的方式要简单得多 ^.

bin/rails g model Thing name cocktail_ingredient:references
bin/rails db:migrate

# config/routes.rb
resources :cocktails do
  post :add_fields, on: :collection
end

# app/models/*.rb
class Thing < ApplicationRecord
  belongs_to :cocktail_ingredient
end
class CocktailIngredient < ApplicationRecord
  belongs_to :ingredient
  belongs_to :cocktail
  has_many :things, dependent: :destroy
  accepts_nested_attributes_for :things
end

# app/views/cocktails/_form.html.erb

<%= form_with model: cocktail do |f| %>
  <%= tag.div id: f.field_id(:cocktail_ingredients) do %>
    <%= f.fields_for :cocktail_ingredients do |ff| %>
      <%= render "cocktail_ingredient_fields", f: ff %>
    <% end %>
  <% end %>

  # NOTE: we'll use `params[:name]` to build everything on the server
  <%= link_to "Add ingredient",
    add_fields_cocktails_path(name: f.field_name(:cocktail_ingredients)),
    data: { turbo_method: :post } %>
  <%= f.submit %>
<% end %>

# app/views/cocktails/_cocktail_ingredient_fields.html.erb

<%= tag.div class: "flex gap-2" do %>
  <%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
  <%= f.text_field :quantity, placeholder: "Qty" %>
  <%= f.check_box :_destroy, title: "Check to delete ingredient" %>
<% end %>

# nested nested fields
<%= tag.div id: f.field_id(:things, index: nil) do %>
  <%= f.fields_for :things do |ff| %>
    <%= render "thing_fields", f: ff %>
  <% end %>
<% end %>
<%= link_to "Add a thing",
  add_fields_cocktails_path(name: f.field_name(:things, index: nil)),
  data: { turbo_method: :post } %>

# app/views/cocktails/_thing_fields.html.erb

<%= f.text_field :name, placeholder: "Name" %>

# i imagine you could keep nesting

这是有趣的部分:

# app/controllers/cocktails_controller.rb

def add_fields
  form_model, *nested_attributes = params[:name].split(/\[|\]/).compact_blank
  helpers.fields form_model.classify.constantize.new do |form|
    nested_form_builder_for form, nested_attributes do |f|
      # NOTE: this block should run only once for the last association
      #       cocktail[cocktail_ingredients_attributes][0][things_attributes]
      #                vvvvvvv this vvvvvvv        or this ^^^^^^
      #       cocktail[cocktail_ingredients_attributes]
      #
      #       `f` is the last nested form builder, for example:
      #
      #         form_with model: Model.new do |f|
      #           f.fields_for :one do |ff|
      #             ff.fields_for :two do |fff|
      #               yield fff
      #               #     ^^^
      #               # NOTE: this is what you should get in this block
      #             end
      #           end
      #         end
      #
      render turbo_stream: turbo_stream.append(
        params[:name].parameterize(separator: "_"),
        partial: "#{f.object.class.name.underscore}_fields",
        locals: {f:}
      )
    end
  end
end

private

def nested_form_builder_for f, *nested_attributes, &block
  attribute, index = nested_attributes.flatten!.shift(2)
  if attribute.blank?
    # NOTE: yield the last form builder instance to render the response
    yield f
    return
  end
  association = attribute.chomp("_attributes")
  child_index = index || Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
  f.fields_for association, association.classify.constantize.new, child_index: do |ff|
    nested_form_builder_for(ff, nested_attributes, &block)
  end
end

这是第一次成功的设置。我尝试使用params [:name]作为前缀,并跳过重建整个表单堆栈,但结果更令人头疼。


4
我已经更新了答案,使用了 turbo-frames。这是使用非常少的代码实现的最动态的方式;@zdebyman 这次没有使用任何黑客技巧。 - Alex
3
这是一个非常好的答案。谢谢你。 - aidan
4
多年以来,我一直在使用Rails,但从未知道helpers.fields的技巧能够让FormBuilder像这样进入一个部分视图。 - aidan
2
这是我在SO上找到的最详细的答案之一 - 感谢您花费时间!如果在“Ingredient”下还有另一个嵌套模型,您会如何处理?下一个模型的控制器操作会是什么样子? - Daniel Friis
2
@DanielFriis 看到更新了吗?我不确定这是正确的做法,但对于第一和第二级字段来说它是有效的。尽管我没有尝试过,但应该可以适用于任何深度。 - Alex
显示剩余9条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接