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_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 %>
<%= 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_for在Cocktail模型中定义了一个新方法cocktail_ingredients_attributes=(params)
,它为您完成了大量的工作。这就是嵌套参数处理的地方,CocktailIngredient对象被创建并分配给相应的cocktail_ingredients关联,并且如果_destroy参数存在,则标记为要销毁,因为autosave 设置为true,所以您获得自动验证。这只是供参考,以防您想定义自己的cocktail_ingredients_attributes=方法,您可以,f.fields_for将自动拾取它。
在`CocktailsController`中,`new`和`create`动作需要微小的更新:
def new
@cocktail = Cocktail.new
@cocktail.cocktail_ingredients.build
end
def create
@cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
if params[:add_ingredient]
@cocktail.cocktail_ingredients.build
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
标签来仅更新表单中的成分部分:
<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 按钮需要额外注意。
<!
<%= 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
:
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
render :new
end
create
操作现在可以恢复为默认设置。
您也可以重复使用new
操作,而不是添加add_ingredient
:
resources :cocktails do
post :new, on: :new
end
完整的控制器设置:
https://dev59.com/8HYPtIcB2Jgan1znDuri#72890584
然后将表单调整为将内容发布到new
而不是add_ingredient
。
简而言之 - 把所有东西都放在一起
我认为这是我能让它变得简单的程度了。以下是简短版(添加动态字段约10行左右,无需 JavaScript)。
resources :cocktails do
post :add_ingredient, on: :collection
end
def add_ingredient
@cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
@cocktail.cocktail_ingredients.build
render :new
end
<%= 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 %>
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 的干预。我们必须更改表单以便让我们渲染单个鸡尾酒配料:
<%= tag.div id: :cocktail_ingredients do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<%= render "ingredient_fields", f: ff %>
<% end %>
<% end %>
<%= link_to "Add ingredient",
add_ingredient_cocktails_path,
class: "text-blue-500 hover:underline",
data: { turbo_method: :post } %>
<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
响应:
def add_ingredient
helpers.fields model: Cocktail.new do |f|
f.fields_for :cocktail_ingredients, CocktailIngredient.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
render turbo_stream: turbo_stream.append(
"cocktail_ingredients",
partial: "ingredient_fields",
locals: { f: ff }
)
end
end
end
自定义表单字段
创建一个表单字段助手将简化任务,只需一行代码即可完成:
post "/fields/:model(/:id)/build/:association(/:partial)", to: "fields#build", as: :build_fields
class FieldsController < ApplicationController
def build
resource_class = params[:model].classify.constantize
association_class = resource_class.reflect_on_association(params[:association]).klass
fields_partial_path = params[:partial] || "#{association_class.model_name.collection}/fields"
render locals: { resource_class:, association_class:, fields_partial_path: }
end
end
<%=
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
%>
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
输入:
<%= form_with model: cocktail, builder: DynamicFormBuilder do |f| %>
<%= f.dynamic_fields_for :cocktail_ingredients %>
<%
<%= 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内部获得更新。
def new
@cocktail = Cocktail.new
@cocktail.cocktail_ingredients.build
render ("_form" if turbo_frame_request?), locals: { cocktail: @cocktail }
end
<%= tag.div id: :ingredients %>
<%= turbo_frame_tag :add_ingredient do %>
<%= turbo_stream.append :ingredients do %>
<%= 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 %>
<%= 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
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
的按钮。我写了一个快速的辅助方法来完成所有这些:
module ApplicationHelper
def dynamic_fields_for f, association, name = "Add"
tag.div data: {controller: "dynamic-fields"} do
safe_join([
f.fields_for(association) do |ff|
yield ff
end,
button_tag(name, data: {action: "dynamic-fields#add"}),
tag.template(data: {dynamic_fields_target: "template"}) do
f.fields_for association, association.to_s.classify.constantize.new,
child_index: "__CHILD_INDEX__" do |ff|
yield ff
end
end
])
end
end
end
在你的表单中使用它:
<%= dynamic_fields_for f, :cocktail_ingredients do |ff| %>
<%= 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 %>
<%
<%
<%
<%
<% end %>
深度嵌套字段
Stimulus的方式要简单得多 ^.
bin/rails g model Thing name cocktail_ingredient:references
bin/rails db:migrate
resources :cocktails do
post :add_fields, on: :collection
end
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
<%= 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 %>
<%= link_to "Add ingredient",
add_fields_cocktails_path(name: f.field_name(:cocktail_ingredients)),
data: { turbo_method: :post } %>
<%= f.submit %>
<% end %>
<%= 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 %>
<%= 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 } %>
<%= f.text_field :name, placeholder: "Name" %>
这是有趣的部分:
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|
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?
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]
作为前缀,并跳过重建整个表单堆栈,但结果更令人头疼。
turbo-frames
。这是使用非常少的代码实现的最动态的方式;@zdebyman 这次没有使用任何黑客技巧。 - Alexhelpers.fields
的技巧能够让FormBuilder像这样进入一个部分视图。 - aidan