【问题标题】:Rails 7 Dynamic Nested Forms with hotwire/turbo frames?带有热线/涡轮框架的 Rails 7 动态嵌套表单?
【发布时间】:2022-07-13 15:46:28
【问题描述】:

我对 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 种成分中的一种并输入其数量。应该有添加/删除按钮来添加/删除成分。

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

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

【问题讨论】:

    标签: ruby-on-rails ruby-on-rails-7 hotwire-rails


    【解决方案1】:
    # TLDR: skip to the very end if you don't need a long explanation.
    

    首先,我们需要一个可以提交然后重新渲染而不创建新鸡尾酒的表单。

    使用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

    我们需要每个 cocktail_ingredient 一个单一的ingredient,如belongs_to :ingredient 所示。单select是一个明显的选择; collection_radio_buttons 也适用。

    fields_for 助手将输出一个隐藏字段,其 idcocktail_ingredient,如果该特定记录已保存在数据库中。这就是 rails 知道更新现有记录(带 id)和创建新记录(不带 id)的方式。

    因为我们使用accepts_nested_attributes_forfields_for 将“_attributes”附加到输入名称。换句话说,如果你的模型中有这个:

    accepts_nested_attributes_for :cocktail_ingredients
    

    意思是

    f.fields_for :cocktail_ingredients
    

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

    (WARN: source code incoming) 原因是accepts_nested_attributes_forCocktail模型中定义了一个新方法cocktail_ingredients_attributes=(params),它确实给你很多work。这是嵌套参数为handled 的地方,CocktailIngredient 对象被创建并分配给相应的 cocktail_ingredients 关联,如果 _destroy 参数也标记为销毁是 present 并且因为 autosavesettrue,你会得到自动的 validations。这只是一个 FYI,如果您想定义自己的 cocktail_ingredients_attributes= 方法并且您可以并且 f.fields_for 会选择它automatically

    CocktailsController 中,newcreate 动作需要一个微小的更新:

    # 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://stackoverflow.com/a/71762032/207090
    
          # 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
    

    就是这样,可以提交表单来制作鸡尾酒或添加其他成分。解释的很长,最后我只加了几行代码:

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

    希望这是有道理的。如果您了解所有这些,turbo-frame 部分会很容易,因为我们现在只使用一个框架,以后会有另一个框架。


    更新。添加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知道我们只想要提交页面的框架部分。一个普通的链接,不需要这个,我们只是把那个链接放在框架标签里面,但是一个 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
    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
    
      # 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 操作现在可以恢复为默认值。


    无说明短版。

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

    # config/routes.rb
    resources :cocktails do
      post :add_ingredient
    end
    
    # app/controllers/cocktails_controller.rb 
    # the other actions are the usual default scaffold
    def add_ingredient
      @cocktail = Cocktail.new cocktail_params
      @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: "/cocktails/add_ingredient", 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
    

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

    【讨论】:

    • 感谢您的详细解答!我尝试了您的解决方案和茧,并且都运行良好。 qq:你个人会怎么做?你在这里的思考过程是什么?这是严重的黑客攻击吗?
    • 不接受的答案不使用 hotwire/turboframes 吗?
    • 我已将答案更新为使用turbo-frames。这几乎是动态的,因为它可以用很少的代码获得它; @zdebyman 这次没有黑客攻击。
    猜你喜欢
    • 1970-01-01
    • 2023-03-29
    • 1970-01-01
    • 1970-01-01
    • 2010-11-10
    • 1970-01-01
    • 1970-01-01
    • 2015-08-26
    • 1970-01-01
    相关资源
    最近更新 更多