zde*_*man 20 ruby-on-rails hotwire-rails ruby-on-rails-7
我对 Rails 很陌生。我是从 Rails 7 开始的,所以关于我的问题的信息仍然很少。
应用程序/模型/cocktail.rb
class Cocktail < ApplicationRecord
has_many :cocktail_ingredients, dependent: :destroy
has_many :ingredients, through: :cocktail_ingredients
accepts_nested_attributes_for :cocktail_ingredients
end
Run Code Online (Sandbox Code Playgroud)
应用程序/模型/ingredient.rb
class Ingredient < ApplicationRecord
has_many :cocktail_ingredients
has_many :cocktails, :through => :cocktail_ingredients
end
Run Code Online (Sandbox Code Playgroud)
应用程序/模型/cocktail_ingredient.rb
class CocktailIngredient < ApplicationRecord
belongs_to :cocktail
belongs_to :ingredient
end
Run Code Online (Sandbox Code Playgroud)
应用程序/控制器/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
...
Run Code Online (Sandbox Code Playgroud)
db/seeds.rb
Ingredient.create([ {name: "rum"}, {name: "gin"} ,{name: "coke"}])
Run Code Online (Sandbox Code Playgroud)
架构中的相关表
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"
Run Code Online (Sandbox Code Playgroud)
应用程序/视图/鸡尾酒/_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 %>
Run Code Online (Sandbox Code Playgroud)
鸡尾酒成分 成分必须存在
我想要一个部分,我可以在其中选择 3 种成分中的一种并输入其数量。应该有添加/删除按钮来添加/删除成分。
我用什么?涡轮框架?热线?我怎么做?
Ale*_*lex 66
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
Run Code Online (Sandbox Code Playgroud)
首先,我们需要一个可以提交然后重新渲染的表单,而无需创建新的鸡尾酒。
使用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| %>
<%= 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 %>
# 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 %>
<%= f.submit %>
<% 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>
Run Code Online (Sandbox Code Playgroud)
https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for
我们需要每种cocktail_ingredient一种成分,如 所示。单身是一个显而易见的选择;也适用。belongs_to :ingredientselectcollection_radio_buttons
fields_for如果该特定记录已保存在数据库中,则helper 将输出一个id为cocktail_ingredient的隐藏字段。这就是Rails知道更新现有记录(带 id)和创建新记录(不带 id)的方式。
因为我们使用的是accepts_nested_attributes_for,fields_for所以将“_attributes”附加到输入名称。换句话说,如果你的模型中有这个:
accepts_nested_attributes_for :cocktail_ingredients
Run Code Online (Sandbox Code Playgroud)
这意味着
f.fields_for :cocktail_ingredients
Run Code Online (Sandbox Code Playgroud)
将为输入名称添加前缀cocktail[cocktail_ingredients_attributes].
(警告:源代码传入)原因是因为accepts_nested_attributes_forcocktail_ingredients_attributes=(params)在Cocktail模型中定义了一个新方法,它为您做了很多工作。这是处理嵌套参数的地方,创建CocktailIngredient对象并将其分配给相应的cocktail_ingredients关联,并且如果存在_destroy参数,则将其标记为销毁,并且由于autosave设置为true,因此您可以获得自动验证。这仅供参考,如果您想定义自己的cocktail_ingredients_attributes=方法,您可以并且f.fields_for会自动选择它。
在CocktailsController中,新建和创建操作需要进行微小的更新:
# 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
Run Code Online (Sandbox Code Playgroud)
在Cocktail模型中允许使用_destroy表单字段在保存时删除记录:
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
Run Code Online (Sandbox Code Playgroud)
就是这样,可以提交表单来创建鸡尾酒或提交表单来添加另一种成分。
现在,当添加新成分时,整个页面都会由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_tag f.field_id(:ingredients) do %>
<%= f.fields_for :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 %>
<% end %>
Run Code Online (Sandbox Code Playgroud)
更改“添加成分”按钮,让Turbo知道我们只需要提交页面的框架部分。常规链接不需要这个,我们只需将该链接放在框架标记内,但输入按钮需要额外注意,因为提交事件是从Turbo 框架外部的<form>元素触发的。
# same id as <turbo-frame>
<%= f.submit "Add ingredient",
data: {turbo_frame: f.field_id(:ingredients)},
name: "add_ingredient" %>
Run Code Online (Sandbox Code Playgroud)
Turbo 帧id必须与按钮的data-turbo-frame属性匹配:
<turbo-frame id="has_to_match">
<input data-turbo-frame="has_to_match" ...>
Run Code Online (Sandbox Code Playgroud)
现在,当单击“添加成分”按钮时,它仍然会转到同一个控制器,它仍然会在服务器上渲染整个页面,但不会重新渲染整个页面(第 1 帧),而是仅turbo-frame更新其中的内容(帧#2)。这意味着,页面滚动保持不变,turbo-frame标记之外的表单状态保持不变。出于所有意图和目的,这现在是一种动态形式。
可能的改进可能是停止扰乱创建操作并通过不同的控制器操作添加成分,例如add_ingredient:
# config/routes.rb
resources :cocktails do
post :add_ingredient, on: :collection
end
Run Code Online (Sandbox Code Playgroud)
<%= f.submit "Add ingredient",
formmethod: "post",
formaction: add_ingredient_cocktails_path(id: f.object),
data: {turbo_frame: f.field_id(:ingredients)} %>
Run Code Online (Sandbox Code Playgroud)
将add_ingredient操作添加到CocktailsController:
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
Run Code Online (Sandbox Code Playgroud)
create现在可以将操作恢复为默认值。
您还可以重用new操作而不是添加add_ingredient:
resources :cocktails do
post :new, on: :new # this adds POST /cocktails/new
end
Run Code Online (Sandbox Code Playgroud)
我认为这是我能做到的最简单的事情。这是简短的版本(大约 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_tag f.field_id(:ingredients) do %>
<%= f.fields_for :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 %>
<% end %>
<%= f.button "Add ingredient", formmethod: "post", formaction: add_ingredient_cocktails_path(id: f.object), data: {turbo_frame: f.field_id(:ingredients)} %>
<%= f.submit %>
<% 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
Run Code Online (Sandbox Code Playgroud)
Turbo 流是我们在不接触任何 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.
# UPDATE: set `turbo_stream: true` to make it a GET request.
Run Code Online (Sandbox Code Playgroud)
# app/views/cocktails/_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 %>
Run Code Online (Sandbox Code Playgroud)
更新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`.
Run Code Online (Sandbox Code Playgroud)
制作表单字段助手会将任务简化为一行:
# 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
Run Code Online (Sandbox Code Playgroud)
这个新助手需要"#{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" %>
Run Code Online (Sandbox Code Playgroud)
覆盖默认的表单生成器,现在您应该有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 %>
Run Code Online (Sandbox Code Playgroud)
您可以在当前页面上渲染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
<%= form_with model: cocktail do |f| %>
<%= 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 %>
<% end %>
| 归档时间: |
|
| 查看次数: |
10105 次 |
| 最近记录: |