Rails 7 个具有 hotwire/turbo 框架的动态嵌套表单?

Rails 7 Dynamic Nested Forms with hotwire/turbo frames?

我是 rails 的新手。我是从 rails7 开始的,所以关于我的问题的信息仍然很少。

这是我的:

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 种成分中的一种并输入其数量。 add/remove 成分应该有 added/remove 个按钮。

我用什么?涡轮帧?热线?我该怎么做?

我仍然对 rails 中的所有内容感到非常困惑,因此非常感谢深入的回答。

我有一个简单但有点脏的解决方案。看到你是这一切的新手。我认为这可以帮助您在深入研究 Turbo 和所有其他内容之前了解表单本身发生了什么。 此外,使用 accepts_nested_attributes_for 既快捷又有趣;但是,当您不理解它时,它会让您发疯。

为了简单起见,首先让我们修改一下表格并放宽 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| %>
  <% if cocktail.errors.any? %>
    <% cocktail.errors.each do |error| %>
      <li><%= error.full_message %></li>
    <% end %>
  <% end %>
  <div>
    <%= f.label :name, style: "display: block" %>
    <%= f.text_field :name, value: "aa"%>
  </div>
  <div>
    <%= f.label :recipe, style: "display: block" %>
    <%= f.text_area :recipe, value: "nn" %>
  </div>

  <!-- https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for -->
  <%= f.fields_for "cocktail_ingredients" do |ff| %>
    <br>
    <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <%= ff.text_field :quantity, value: "1" %>

    <div>
      <%= ff.label 'Delete' %>
      <%= ff.check_box :_destroy %>
    </div>

  <% end %>

  <!-- Yes we're submitting our form, but with a different button -->
  <!-- see CocktailController#create -->
  <div> <%= f.submit "Add ingredient", name: 'add_ingredient' %> </div>

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

collection_check_boxes 不适用于这种情况,我们希望每个记录有一个成分,如 belongs_to :ingredient 所示。 single select 是一个明显的选择; collection_radio_buttons同样适用。

如果特定记录已保存在数据库中,

fields_for 助手还将输出一个隐藏字段,其中“id”为 cocktail_ingredient 记录。这就是 rails 知道更新现有记录(带 id)和创建新记录(不带 id)的方式。

fields_for 还将“_attributes”附加到 record_name 如果表单的模型,在本例中 Cocktail,为 record_name 定义了 accepts_nested_attributes_for

澄清一下,如果你的模型中有这个:

accepts_nested_attributes_for :cocktail_ingredients

这意味着

f.fields_for "cocktail_ingredients"

将在输入名称前加上 cocktail[cocktail_ingredients_attributes]

控制器

# GET /cocktails/new
def new
  @cocktail = Cocktail.new
  # build one new object for our new form; otherwise `fields_for` will not render anything
  # with this new form will always have one empty `cocktail_ingredient`
  # NOTE: this is necessary when using `accepts_nested_attributes_for`
  # NOTE: without `accepts_nested_attributes_for`, `fields_for` will render fields; see what a cluster it is.
  @cocktail.cocktail_ingredients.build 
end

# POST /cocktails
def create
  @cocktail = Cocktail.new(cocktail_params)
  respond_to do |format|
    # NOTE: catch when we submit our form with add_ingredient button
    # params will have "add_ingredient"=>"Add ingredient"
    if params[:add_ingredient]
      # build another cocktail_ingredient to be rendered by `fields_for` helper
      @cocktail.cocktail_ingredients.build
      
      # and just render the form again. tada! you're done.
      # NOTE: rails 7 submits as TURBO_STREAM format; hey we're actually using turbo.
      #       it expects a form to redirect when valid; so we have to use some kind of invalid status.
      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

解释的很长,最后我只加了四行代码:

if params[:add_ingredient]
  @cocktail.cocktail_ingredients.build
  format.html { render :new, status: :unprocessable_entity }
<%= f.submit "Add ingredient", name: 'add_ingredient' %>

希望这是有道理的。这里有很多改进的余地。我认为 cocoon gem 对于简单的用例仍然是一个可行的选择,如果你想研究一下的话。

更新。添加 turbo-frame

现在,当添加新成分时,整个页面都是 re-rendered。我们可以添加 turbo-frame 以仅更新表格的成分部分:

...

<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">

  <%= f.fields_for "cocktail_ingredients" do |ff| %>
    <br>
    <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <%= ff.text_field :quantity %> <%# NOTE: remove preset value %>

    <div>
      <%= ff.label 'Delete' %>
      <%= ff.check_box :_destroy %>
    </div>

  <% end %>

</turbo-frame>

...

更改 'Add ingredient' 按钮以了解我们要更新的 turbo-frame

...

<%= 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" ...>

现在,当单击“添加成分”按钮时,它仍然会转到同一个控制器,它仍然会在服务器上呈现整个表单,但是现在,re-rendering 整个页面,只有里面的内容turbo-frame 已更新。这意味着,页面滚动保持不变,turbo frame 标签外的表单状态不变。对于所有意图和目的,这现在是一个动态表单。

可能的改进可能是停止扰乱 create 操作并通过不同的控制器操作“添加成分”,例如 add_ingredient

# config/routes.rb

resources :cocktails do
  post :add_ingredient
end
...

<%= f.submit 'Add ingredient',
  formmethod: 'post',
  formaction: '/cocktails/add_ingredient',
  data: { turbo_frame: f.field_id(:ingredients)} %>

...

add_ingredient 操作添加到 CocktailsController

def add_ingredient
  @cocktail = Cocktail.new cocktail_params
  @cocktail.cocktail_ingredients.build # add another ingredient
  render :new
end

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

我觉得这已经很简单了。这是简短的版本(大约 10 行额外的代码来添加动态字段,并且没有 javascript)

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

# app/controllers/cocktails_controller.rb 
# the usual scaffold for other actions
def add_ingredient
  @cocktail = Cocktail.new cocktail_params
  @cocktail.cocktail_ingredients.build # add another ingredient
  render :new
end

# app/views/cocktails/new.html.erb
<%= form_with model: @cocktail do |f| %>

  <turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
    <%= f.fields_for :cocktail_ingredients do |ff| %>
      <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <% end %>
  </turbo-frame>

  <%= f.button "Add ingredient",
    formmethod: 'post',
    formaction: '/cocktails/add_ingredient',
    data: { turbo_frame: f.field_id(:ingredients)},%>

  <%= f.submit %>
<% end %>

https://thoughtbot.com/blog/dynamic-forms-with-turbo