Turbo Rails Tutorial-第十章
Nested Turbo Frames
这一章,我们为line items构建最后的增删改查代码,而line items是嵌入到line items dates中的,为此我们需要使用Turbo Frames去解决一些有趣的挑战。
What we will build in this chapter
本章中,我们会大致敲定quote编辑器,通过增加line item到line item date中,每一个line item都拥有名称,可选的描述,单价,数量。
这一章的的挑战是我们将会有大量的嵌套Turbo Frames,我们将讨论如何在操作LineItemDate and LineItem
增删改查时,并Quotes#show
页面的状态。
在敲代码前,我们在看一下线上的示例:final quote editor,让我们创建一个quote并且进入对应的Quotes#show
页面,让每位创建几个 line item dates and line items,来对我们最终的产品有一个确定的概念。
当我们清楚了最终产品的样子,我们就开始吧,我们会先不用Turbo Frames and Turbo Streams构建LineItem
模型的增删改查,在后面Controller都正常运转时,再加入Turbo Rails的特征。
首先,画几个在没有Turbo Frames and Turbo Streams时,系统的行为的草图,当我们访问Quotes#show
页面时,我们展示quote对应的line item date,而每个line item date都应有多个line item,每个line item date卡片都有一个”Add item”的链接,去创建专属这条Line item date的line item
在Quotes#show
页面中,我们应该能为每个quote中的line item date添加line item,当点击”Add item”链接时,应该跳转到LineItmes#new
页面,这里我们就可以添加该Line item date专属的line item。
假如我们点击第二条line item date的”Add item”链接时,我们期待的页面应该是这样的:
当我们正常提交表单时,我们会重定向到Quotes#show
页面,而新创的数据应该也被添加进去了。
如果我们决定更新刚刚创建的line item,我们点击对应的”Edit”链接,到达LineItems#edit
页面。
如果我们提交表单,将再被重定向到Quotes#show
页面,并且数据被更新
最后当想删除这条line item,点击”Delete”,则数据就被删除了。
需求已经理清楚了,开始敲代码吧。
Creating the model
让我们创建LineItme
模型,这个模型有五个字段:
- line item date的引用
- 名称
- 可选描述
- 单价
- 数量
bin/rails generate model LineItem \
line_item_date:references \
name:string \
description:text \
quantity:integer \
unit_price:decimal{10-2}
执行rails db:migrate
指令前,我们必须对name,quantity
添加约束,并且单价肯定非空,通过null: false
可以在数据库层面进行控制,最终的迁移文件:
# db/migrate/XXXXXXXXXXXXXX_create_line_items.rb
class CreateLineItems < ActiveRecord::Migration[7.0]
def change
create_table :line_items do |t|
t.references :line_item_date, null: false, foreign_key: true
t.string :name, null: false
t.text :description
t.integer :quantity, null: false
t.decimal :unit_price, precision: 10, scale: 2, null: false
t.timestamps
end
end
end
现在再执行迁移指令:
bin/rails db:migrate
再到模型中添加对应的关系与校验
# app/models/line_item.rb
class LineItem < ApplicationRecord
belongs_to :line_item_date
validates :name, presence: true
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :unit_price, presence: true, numericality: { greater_than: 0 }
delegate :quote, to: :line_item_date
end
这里的校验强制操作:
- 名称,数量,单价不为空
- 单价和数量必须大于0
- 数量必须是数字
我们将委托quote
方法给LineItem#line_item_date
方法,这样下面的两个代码是一致的。
line_item.line_item_date.quote
line_item.quote
现在LineItem
模型已经好了,再到LineItemDate
中增加关联关系
# app/models/line_item_date.rb
class LineItemDate < ApplicationRecord
has_many :line_items, dependent: :destroy
# All the previous code...
end
模型层就都可以了,下面开始搞路由部分。
Adding routes for line items
我们想要执行LineItem
模型中七个增删改查行为,除了下面两个
- 我们不需要
LineItem#index
,因为所有的line item都会出现在Quotes#show
页面中。 - 我们也不需要
LineItem#show
,因为查看单个line itme没啥意义。
# config/routes.rb
Rails.application.routes.draw do
# All the previous routes
resources :quotes do
resources :line_item_dates, except: [:index, :show] do
resources :line_items, except: [:index, :show]
end
end
end
路由就完成了,是时候加一点儿假数据了。
Designing line items
现在line item dates
都没有对应的line items
数据,我们将添加一些假数据到固件(fixtures)中。
让我们想象一下,我们正在构建的quote编辑器是一个企业活动软件。由于事件可以跨越多个日期,我们的quote将有多个日期,而每个日期下都有多个line item!在我们的fixture文件中,我们希望添加一个房间,供客人开会和用餐。让我们在fixture文件中添加这些项目:
# test/fixtures/line_items.yml
room_today:
line_item_date: today
name: Meeting room
description: A cosy meeting room for 10 people
quantity: 1
unit_price: 1000
catering_today:
line_item_date: today
name: Meal tray
description: Our delicious meal tray
quantity: 10
unit_price: 25
room_next_week:
line_item_date: next_week
name: Meeting room
description: A cosy meeting room for 10 people
quantity: 1
unit_price: 1000
catering_next_week:
line_item_date: next_week
name: Meal tray
description: Our delicious meal tray
quantity: 10
unit_price: 25
通过bin/rails db:seed
我们可以插入到数据库中,现在打开项目中”First quote”的Quotes#show
页面,我们给每个Line item date添加元素在页面中:
- line items集合
- 新建line items的链接
让我们添加到局部模板中:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<div class="line-item-date__header">
<!-- All the previous code -->
</div>
<div class="line-item-date__body">
<div class="line-item line-item--header">
<div class="line-item__name">Article</div>
<div class="line-item__quantity">Quantity</div>
<div class="line-item__price">Price</div>
<div class="line-item__actions"></div>
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
为了渲染每个line item,我们现在创建一个局部模板来展示单条line item
<%# app/views/line_items/_line_item.html.erb %>
<div class="line-item">
<div class="line-item__name">
<%= line_item.name %>
<div class="line-item__description">
<%= simple_format line_item.description %>
</div>
</div>
<div class="line-item__quantity-price">
<%= line_item.quantity %>
×
<%= number_to_currency line_item.unit_price %>
</div>
<div class="line-item__quantity">
<%= line_item.quantity %>
</div>
<div class="line-item__price">
<%= number_to_currency line_item.unit_price %>
</div>
<div class="line-item__actions">
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
class: "btn btn--light" %>
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
class: "btn btn--light" %>
</div>
</div>
simple_format
helper在渲染那些输入到文本框的文本时很有用。例如,让我们想象一下一个用户输入下面的文本到描述信息汇总。
- Appetizer
- Main course
- Dessert
- A glass of wine
通过simple_format
helper将会生成下面的HTML代码
<p>
- Appetizers
<br>
- Main course
<br>
- Dessert
<br>
- A glass of wine
</p>
可以看到,这里很只能的加入了换行,如果不使用simple_format
helper,则信息就只展示到一行中了。
.line-item__quantity
, .line-item__price
, and .line-item__quantity-price
CSS classes可能看起来有点儿多余,但是,只有当屏幕大小高于table And Up断点时,我们才会显示前两个CSS类,而在移动端时展示最后一个CSS类。
现在我们的HTML标签已经好了,让我们加点儿样式,首先,我们完善.line-item-date
组件,这是我们在上一个章节中使用的.line-item-date__body
and .line-item-date__footer
// app/assets/stylesheets/components/_line_item_date.scss
.line-item-date {
// All the previous code
&__body {
border-radius: var(--border-radius);
background-color: var(--color-white);
box-shadow: var(--shadow-small);
margin-top: var(--space-xs);
padding: var(--space-xxs);
padding-top: 0;
@include media(tabletAndUp) {
padding: var(--space-m);
}
}
&__footer {
border: dashed 2px var(--color-light);
border-radius: var(--border-radius);
text-align: center;
padding: var(--space-xxs);
@include media(tabletAndUp) {
padding: var(--space-m);
}
}
}
我们再花点儿时间设计独立的line item,这里会写很多CSS:
.line-item
基本组件用于单个line item.line-item--header
用于line items集合行上面.line-item--form
用于line item的创建和修改表单
这样不论的是在手机端,平板上,大屏上通过tabletAndUp
breakpoint都能正常响应,让我们写到代码里
.line-item {
display: flex;
align-items: start;
flex-wrap: wrap;
background-color: var(--color-white);
gap: var(--space-xs);
margin-bottom: var(--space-s);
padding: var(--space-xs);
border-radius: var(--border-radius);
> * {
margin-bottom: 0;
}
&__name {
flex: 1 1 100%;
font-weight: bold;
@include media(tabletAndUp) {
flex: 1 1 0;
}
}
&__description {
flex-basis: 100%;
max-width: 100%;
color: var(--color-text-muted);
font-weight: normal;
font-size: var(--font-size-s);
}
&__quantity-price {
flex: 0 0 auto;
align-self: flex-end;
justify-self: flex-end;
order: 3;
font-weight: bold;
@include media(tabletAndUp) {
display: none;
}
}
&__quantity {
flex: 1;
display: none;
@include media(tabletAndUp) {
display: revert;
flex: 0 0 7rem;
}
}
&__price {
flex: 1;
display: none;
@include media(tabletAndUp) {
display: revert;
flex: 0 0 9rem;
}
}
&__actions {
display: flex;
gap: var(--space-xs);
order: 2;
flex: 1 1 auto;
@include media(tabletAndUp) {
order: revert;
flex: 0 0 10rem;
}
}
&--form {
box-shadow: var(--shadow-small);
.line-item__quantity,
.line-item__price {
display: block;
}
.line-item__description {
order: 2;
}
}
&--header {
display: none;
background-color: var(--color-light);
margin-bottom: var(--space-s);
@include media(tabletAndUp) {
display: flex;
}
& > * {
font-size: var(--font-size-s);
font-weight: bold ;
letter-spacing: 1px;
text-transform: uppercase;
}
}
}
// app/assets/stylesheets/application.sass.scss
// All the previous code
@import "components/line_item";
如果你去浏览器中试试,就能看到样式啦
在进入下一部分前,看看我们现在遇到的性能问题,尽管算是本教程的题外话,但在这里解释一下发生了什么也很重要,当我们访问Quotes#show
页面时,如果你去看rails的日志,你会看到一个 N+1 查看问题
...
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" = $1
...
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" = $1
...
上面的日志中,我们查询line_items
表两次,因为我们有两条line item dates,而如果我们有n条line item dates,那我们就得查询n次了。这是因为我们每次渲染一个新的line item date时,我们都会执行一个请求去获取对应的line items,因为这行代码:
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
一个很好的性能经验法则是,我们应该在每个请求-响应周期中只查询一次数据库表。
为了避免N+1查询问题,我们需要提前加载每一个line item date的line items集合,让我们在QuotesController#show
中修改吧:
# app/controllers/quotes_controller.rb
class QuotesController < ApplicationController
# All the previous code...
def show
@line_item_dates = @quote.line_item_dates.includes(:line_items).ordered
end
# All the previous code...
end
通过加上includes
,我们会发现日志中,我们只查询了一次数据
SELECT "line_items".* FROM "line_items" WHERE "line_items"."line_item_date_id" IN ($1, $2)
性能问题已经解决了,是时候去创建LineItemsController
了。
Our standard CRUD controller
Creating line items without Turbo
现在数据库,模型,路由,标签,样式都好了,是时候开始Controller了,和介绍中的一样,我们先不用Turbo Frames and Turbo Streams,我们将在后面优化
我们的Controller使用除#index and #show
以外的actions,我们先来写#new and #create
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
before_action :set_quote
before_action :set_line_item_date
def new
@line_item = @line_item_date.line_items.build
end
def create
@line_item = @line_item_date.line_items.build(line_item_params)
if @line_item.save
redirect_to quote_path(@quote), notice: "Item was successfully created."
else
render :new, status: :unprocessable_entity
end
end
private
def line_item_params
params.require(:line_item).permit(:name, :description, :quantity, :unit_price)
end
def set_quote
@quote = current_company.quotes.find(params[:quote_id])
end
def set_line_item_date
@line_item_date = @quote.line_item_dates.find(params[:line_item_date_id])
end
end
还差个局部模板:line_items/new.html.erb and line_items/_form.html.erb
,让我们加上吧
<%# app/views/line_items/new.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>New item for <%= l(@line_item_date.date, format: :long) %></h1>
</div>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
</main>
我们不需要给LineItems#new
页面搞一个好样式,我们将后面用Turbo处理表单并插入到Quotes#show
页面中。不过,对于使用不支持Turbo的旧浏览器的人来说,它应该还是可以使用的。
<%# app/views/line_items/_form.html.erb %>
<%= simple_form_for [quote, line_item_date, line_item],
html: { class: "form line-item line-item--form" } do |f| %>
<%= form_error_notification(line_item) %>
<%= f.input :name,
wrapper_html: { class: "line-item__name" },
input_html: { autofocus: true } %>
<%= f.input :quantity,
wrapper_html: { class: "line-item__quantity" } %>
<%= f.input :unit_price,
wrapper_html: { class: "line-item__price" } %>
<%= f.input :description,
wrapper_html: { class: "line-item__description" } %>
<div class="line-item__actions">
<%= link_to "Cancel", quote_path(quote), class: "btn btn--light" %>
<%= f.submit class: "btn btn--secondary" %>
</div>
<% end %>
在表单中,我们再次使用了form_error_notification
helper,这是上一章创建的,我们还再次使用了.line-itme
CSS样式,并结合.line-item--form
在浏览器中测试一下,但出问题了,line item date消失了,并且通过浏览器控制台看到了下面的异常
Response has no matching <turbo-frame id="line_item_date_123456"> element
这是因为”Add item”链接已经嵌入到Turbo Frame中,就像下面的草图
这是因为Turbo Frames拦截了所有的链接和表单提交,并且需要一个拥有相同id的响应。我们首先要使我们的CRUD工作没有Turbo Frames和Turbo Streams。
为了防止Turbo拦截提交,我们使用data-turbo-frame="_top"
,在第四章解释过,让我们添加这个参数到链接中
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<!-- All the previous code -->
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: "_top" },
class: "btn btn--primary" %>
</div>
<!-- All the previous code -->
为了预防相同的问题,我们在”Edit”,”Delete”中也加上相同的参数
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<div class="line-item__actions">
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
form: { data: { turbo_frame: "_top" } },
class: "btn btn--light" %>
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
data: { turbo_frame: "_top" },
class: "btn btn--light" %>
</div>
<!-- All the previous code -->
现在再到浏览器中试试吧
我们花一点儿时间来写按钮,提示等内容
# config/locales/simple_form.en.yml
en:
simple_form:
placeholders:
quote:
name: Name of your quote
line_item:
name: Name of your item
description: Description (optional)
quantity: 1
unit_price: $100.00
labels:
quote:
name: Name
line_item:
name: Name
description: Description
quantity: Quantity
unit_price: Unit price
line_item_date:
date: Date
helpers:
submit:
quote:
create: Create quote
update: Update quote
line_item:
create: Create item
update: Update item
line_item_date:
create: Create date
update: Update date
Updating line items without Turbo
现在#new and #create
已经正常运转了,类似的,我们开始#edit and #update
class LineItemsController < ApplicationController
before_action :set_quote
before_action :set_line_item_date
before_action :set_line_item, only: [:edit, :update, :destroy]
# All the previous code
def edit
end
def update
if @line_item.update(line_item_params)
redirect_to quote_path(@quote), notice: "Item was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
private
# All the previous code
def set_line_item
@line_item = @line_item_date.line_items.find(params[:id])
end
end
我们知道#destroy
也需要set_line_item
的回调,所以我们提交加到回调列表中
现在#destroy and #update
已经实现了,我们再添加LineItems#edit
的视图,以便在浏览器中测试。
<%# app/views/line_items/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit item</h1>
</div>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
</main>
我们可以看到,LineItems#edit
页面和LineItems#new
页面十分相似,只有标题改动了。现在就可以去浏览器中测试了。在此之后我们还有一件事儿要做。
Deleting line items without Turbo
#destroy
是最简单的,因为不需要视图,我们只需删除数据,然后重定向到Quotes#show
页面。
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
# All the previous code
def destroy
@line_item.destroy
redirect_to quote_path(@quote), notice: "Item was successfully destroyed."
end
# All the previous code
end
在浏览器中测试吧。
我们的增删改查功能都已经完成了,不过我们希望所有的交互都放到相同的页面中,通过Turbo,只需要几行代码就可以切割页面,进行独立的操作。
Adding Turbo Frames and Turbo Streams
Creating line items with Turbo
为了理清楚需求,我们再画草图说明。
当用户访问Quotes#show
页面,并点击”Add item”按钮,我们希望表单出现在Quotes#show
页面上的”Add item”按钮的上面,这里我们使用Turbo Frames,为了生效,我们需要将”Add item”关联到一个空Turbo Frame中,通过data-turbo-frame
参数
我们发现这次的Turbo Frame ids
比之前的章节都长,**Turbo Frames
必须在页面中拥有独立的ids**,而如果页面中有多个日期数据时,我们的空Turbo Frame仅仅使用new_line_item
,或者line items列表的id只是line_items
,那就会导致多个Turbo Frame拥有了相同的id。
让我们解释一下为什么同一个页面的Turbo Frames必须要有不同的ids,如果我们像之前章节那样,我们的create.turbo_stream.erb
页面就会像下面的样子:
<%# app/views/line_items/create.turbo_stream.erb %>
<%= turbo_stream.update LineItem.new, "" %>
<%= turbo_stream.append "line_items", @line_item %>
如果我们的quote有多个line item dates
,则Quotes#show
页面就会有多个new_line_item and line_items
ids。Turbo怎么知道当有多个相同Id时怎么办?而我们新建的line item
就插入到错误的日期下面。
一种好的约定,即将我们通常拥有的id前缀设置为父资源的dom_id,这样就能确保ids唯一。
为了使Turbo正常工作,我们需要在LineItems#new
页面上添加一个相同id的Turbo Frame
这样当用户点击”New item”按钮时,Turbo将成功用表单替换掉空Turbo Frames
当用户提交表单时,我们想让创建的line item被添加到特定日期下的line items列表中。
现在需求已经清晰了,我们只需要通过Turbo Frames and Turbo Streams就能解决问题
先开始做第一部分,当用户点击”Add item”按钮时,表单出现在Quotes#show
页面,在每个line item date上,都加一个空的Turbo Frame去链接”Add date”按钮。
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<!-- All the previous code -->
<div class="line-item-date__body">
<div class="line-item line-item--header">
<!-- All the previous code -->
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<%= turbo_frame_tag dom_id(LineItem.new, dom_id(line_item_date)) %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: dom_id(LineItem.new, dom_id(line_item_date)) },
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
如上面提到的,对于嵌套资源,我们想用父dom_id
作为子dom_id
的前缀,dom_id helper
用第二个参数作为前缀,我们可以这么写:
line_item_date = LineItemDate.find(1)
dom_id(LineItem.new, dom_id(line_item_date))
# => line_item_date_1_new_line_item
这个方法可以奏效,但很难阅读,这里有一种迂回策略
dom_id("line_items", dom_id(line_item_date))
# This does not return "line_item_date_1_line_items"
# It raises an error as "line_items" does not respond to `#to_key`
# and so can't be transformed into a dom_id
不同于直接依赖dom_id helper
,我们创建一个helper去让我们的ids更容易生成或阅读,并确保团队能够使用统一的约定。
# app/helpers/application_helper.rb
module ApplicationHelper
# All the previous code
def nested_dom_id(*args)
args.map { |arg| arg.respond_to?(:to_key) ? dom_id(arg) : arg }.join("_")
end
end
通过这里的helper,我们就能更简单的生成和阅读我们的dom_ids
line_item_date = LineItemDate.find(1)
nested_dom_id(line_item_date, LineItem.new)
# => line_item_date_1_new_line_item
nested_dom_id(line_item_date, "line_items")
# => line_item_date_1_line_items
现在使用我们的新的约定来修改视图
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<!-- All the previous code -->
<div class="line-item-date__body">
<div class="line-item line-item--header">
<!-- All the previous code -->
</div>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<%= turbo_frame_tag nested_dom_id(line_item_date, LineItem.new) %>
<div class="line-item-date__footer">
<%= link_to "Add item",
[:new, quote, line_item_date, :line_item],
data: { turbo_frame: nested_dom_id(line_item_date, LineItem.new) },
class: "btn btn--primary" %>
</div>
</div>
</div>
<% end %>
在Quotes#show
页面,我们的Turbo Frames已经有了想要的ids,我们需要LineItems#new
页面去匹配Turbo Frames,为了能够进行替换,让我们把表单嵌套到Turbo Frame tag中吧。
<%# app/views/line_items/new.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>New item for <%= l(@line_item_date.date, format: :long) %></h1>
</div>
<%= turbo_frame_tag nested_dom_id(@line_item_date, LineItem.new) do %>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
<% end %>
</main>
在浏览器中试试吧,当点击”Add item”按钮时,表单会出现在指定日期的正确的位置上。
和前面的章节一样,当我们提交一个无效的表单时,错误会如预期的那样出现在页面上。
我们需要给Turbo更精确的指令当提交一个正常的表单, 通过Turbo Stream view,我们希望做到以下两点:
- 删除DOM中的表单
- 添加新建的Line item到具体日期下Line items列表中
让我们修改LineItemsController#create
去响应turbo_stream
format
# app/controllers/line_items_controller.rb
class LineItemsController < ApplicationController
# All the previous code...
def create
@line_item = @line_item_date.line_items.build(line_item_params)
if @line_item.save
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Item was successfully created." }
format.turbo_stream { flash.now[:notice] = "Item was successfully created." }
end
else
render :new, status: :unprocessable_entity
end
end
# All the previous code...
end
让我们创建我们的试图,来执行期望的行为
<%# app/views/line_items/create.turbo_stream.erb %>
<%# Step 1: empty the Turbo Frame containing the form %>
<%= turbo_stream.update nested_dom_id(@line_item_date, LineItem.new), "" %>
<%# Step 2: append the created line item to the list %>
<%= turbo_stream.append nested_dom_id(@line_item_date, "line_items") do %>
<%= render @line_item, quote: @quote, line_item_date: @line_item_date %>
<% end %>
<%= render_turbo_stream_flash_messages %>
最后一件事儿就是我们想要用Turbo Frame去嵌套line items列表,为每一个具体的日期。
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<!-- All the previous code -->
<%= turbo_frame_tag nested_dom_id(line_item_date, "line_items") do %>
<%= render line_item_date.line_items, quote: quote, line_item_date: line_item_date %>
<% end %>
<!-- All the previous code -->
在浏览器中试试看,所有工作都能正产运转,下面就剩#edit,#update,#destroy
Updating line items with Turbo
像刚刚#new and #create
一样,我们也希望#edit and #update
也在Quotes#show
页面操作,尽管我们已经有了需要的大部分的Turbo Frames,不过我们还需要将每一个line item嵌套到Turbo Frame,如下面草图所示:
当我们点击”Edit”到被嵌套到id为line_item_2
的Turbo Frame的第二条line item时,Turbo希望能在LineItems#edit
也页面中找到相同id的Turbo Frame,如下图所示
这样当点击一个line item时,Turbo就能用LineItem#edit
页面的表单去替换这条line item.
当提交表单时,我们希望最终的数据能再替换表单
现在需求已经明确了,该敲代码了,首先是让edit表单成功的替换Quotes#show
页面line items的HTML,为此,我们将每个item嵌套到Turbo Frame中。
<%# app/views/line_items/_line_item.html.erb %>
<%= turbo_frame_tag line_item do %>
<div class="line-item">
<!-- All the previous code -->
</div>
<% end %>
现在我们需要删掉”Edit”链接中的data-turbo-frame="_top"
参数
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<%= link_to "Edit",
[:edit, quote, line_item_date, line_item],
class: "btn btn--light" %>
<!-- All the previous code -->
现在我们需要把line items都嵌套到Turbo Frames,我们也需要把LineItems#edit
页面的表单嵌套到Turbo Frame中。
<%# app/views/line_items/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit item</h1>
</div>
<%= turbo_frame_tag @line_item do %>
<%= render "form",
quote: @quote,
line_item_date: @line_item_date,
line_item: @line_item %>
<% end %>
</main>
在浏览器中试试看,当点解line item的”Edit”按钮时,表单成功的替换到了Quotes#show
页面
如果我们提交一个异常数据,也能正常运转。
而提交一个正常数据时,数据被成功更新,但是缺少了flash message,为此,我们需要使用一个Turbo Stream view,首先让我们的Controller允许渲染Turbo Stream view:
# app/controllers/line_items_controller.rb
def update
if @line_item.update(line_item_params)
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Item was successfully updated." }
format.turbo_stream { flash.now[:notice] = "Item was successfully updated." }
end
else
render :edit, status: :unprocessable_entity
end
end
现在创建update.turbo_stream.erb
view去让line item的局部模版替换掉表单,并渲染flash message。
<%# app/views/line_items/update.turbo_stream.erb %>
<%= turbo_stream.replace @line_item do %>
<%= render @line_item, quote: @quote, line_item_date: @line_item_date %>
<% end %>
<%= render_turbo_stream_flash_messages %>
去浏览器中试试看,完美
Destroying line items with Turbo
最后一点,我们需要能够删除line Items,为此,我们需要让#destroy
支持Turbo Stream format
# app/controllers/line_items_controller.rb
def destroy
@line_item.destroy
respond_to do |format|
format.html { redirect_to quote_path(@quote), notice: "Date was successfully destroyed." }
format.turbo_stream { flash.now[:notice] = "Date was successfully destroyed." }
end
end
在这个view中,我们只需要删除对应的line item并渲染flash message即可。
<%# app/views/line_items/destroy.turbo_stream.erb %>
<%= turbo_stream.remove @line_item %>
<%= render_turbo_stream_flash_messages %>
记着不要忘了删除”Delete”按钮中的data-turbo-frame="_top"
参数
<%# app/views/line_items/_line_item.html.erb %>
<!-- All the previous code -->
<%= button_to "Delete",
[quote, line_item_date, line_item],
method: :delete,
class: "btn btn--light" %>
<!-- All the previous code -->
我们最终在浏览器中测试一下
Editing line item dates with Turbo
关于line items的所有操作都完成了,但有个小问题:当点击line item date的”Edit”链接时,整个line item date card都被edit表单替换掉了,我们只希望整个card的头部,包含日期的部分被替换带。
让每位将line item date的头部嵌套到Turbo Frame中,通过以”edit”为前缀的dom_id进行唯一标识
为了能让Turbo去替换这部分,我们要在LineItemDates#edit
页面中使用相同的id
这样当点击特定日期的”Edit”按钮时,Turbo只会替换line item date card的头部
现在需求明确了,开始敲代码吧,首先在line item date的局部模版中,添加以”edit”为前缀的Turbo Frame id。
<%# app/views/line_item_dates/_line_item_date.html.erb %>
<%= turbo_frame_tag line_item_date do %>
<div class="line-item-date">
<%= turbo_frame_tag dom_id(line_item_date, :edit) do %>
<div class="line-item-date__header">
<!-- All the previous code -->
</div>
<% end %>
<div class="line-item-date__body">
<!-- All the previous code -->
</div>
</div>
<% end %>
我们也需要修改LineItemDates#edit
页面。
<%# app/views/line_item_dates/edit.html.erb %>
<main class="container">
<%= link_to sanitize("← Back to quote"), quote_path(@quote) %>
<div class="header">
<h1>Edit date</h1>
</div>
<%= turbo_frame_tag dom_id(@line_item_date, :edit) do %>
<%= render "form", quote: @quote, line_item_date: @line_item_date %>
<% end %>
</main>
在浏览器中试试吧,当点击edit链接时,只有card头部被替换。
Preserving state with Turbo Rails
到目前为止,我们通过使页面的各个部分真正独立来始终保持应用程序的状态。但是,在我们的应用程序中有一个小故障。
为了演示这个问题,让我们来到第一个quote的Quotes#show
页面,并通过点击第一个line item date
多个line items的”Edit”按钮,来打开多个表单。然后当我们更新第一个line item date时,所有的表单都再次消失。
这是因为为了保证我们的日期能正向排序,我们完全删除了DOM中的line item date card,将其重新附加到列表中的正确位置,为此我们也就丢失了日期内line item的状态,因为默认情况下渲染的局部页面的表单都是关闭。
这里就是当我们只使用Turbo Rails,而不使用自定义JavaScript时遇到的瓶颈,如果你想在更新line item date
保留Quotes#show
页面的状态,我们有两种解决方案:
- 使用Turbo Stream format时不进行排序
- 使用Stimulus控制器重新排序前端中的项目
尽管这是一个小故障,但知道Turbo的局限性也是很重要的,在本教程中,我们将简单的忽略此故障。
Testing our code with system tests
如果不添加测试,我们的工作是不完整的。
让我们添加line items
的增删改查的系统测试
# test/system/line_items_test.rb
require "application_system_test_case"
class LineItemSystemTest < ApplicationSystemTestCase
include ActionView::Helpers::NumberHelper
setup do
login_as users(:accountant)
@quote = quotes(:first)
@line_item_date = line_item_dates(:today)
@line_item = line_items(:room_today)
visit quote_path(@quote)
end
test "Creating a new line item" do
assert_selector "h1", text: "First quote"
within "##{dom_id(@line_item_date)}" do
click_on "Add item", match: :first
end
assert_selector "h1", text: "First quote"
fill_in "Name", with: "Animation"
fill_in "Quantity", with: 1
fill_in "Unit price", with: 1234
click_on "Create item"
assert_selector "h1", text: "First quote"
assert_text "Animation"
assert_text number_to_currency(1234)
end
test "Updating a line item" do
assert_selector "h1", text: "First quote"
within "##{dom_id(@line_item)}" do
click_on "Edit"
end
assert_selector "h1", text: "First quote"
fill_in "Name", with: "Capybara article"
fill_in "Unit price", with: 1234
click_on "Update item"
assert_text "Capybara article"
assert_text number_to_currency(1234)
end
test "Destroying a line item" do
within "##{dom_id(@line_item_date)}" do
assert_text @line_item.name
end
within "##{dom_id(@line_item)}" do
click_on "Delete"
end
within "##{dom_id(@line_item_date)}" do
assert_no_text @line_item.name
end
end
end
如果执行rails test:all
指令,我们会有之前两个测试需要修复,我们有多个相同名称的”Edit”和”Delete”链接,Capybara不知道点击哪个,所以提出了一个Capybara::Ambiguous
异常。
为了修复这个问题,我们必须更具体地使用我们在within块中使用的id。
# test/system/line_item_dates_test.rb
# All the previous code
test "Updating a line item date" do
assert_selector "h1", text: "First quote"
within id: dom_id(@line_item_date, :edit) do
click_on "Edit"
end
assert_selector "h1", text: "First quote"
fill_in "Date", with: Date.current + 1.day
click_on "Update date"
assert_text I18n.l(Date.current + 1.day, format: :long)
end
test "Destroying a line item date" do
assert_text I18n.l(Date.current, format: :long)
accept_confirm do
within id: dom_id(@line_item_date, :edit) do
click_on "Delete"
end
end
assert_no_text I18n.l(Date.current, format: :long)
end
# All the previous code
现在我们再执行bin/rails test:all
,就全部变绿了。
Wrap up
本章节中,我们完善了我们的quote编辑器,我们学习了如何去管理嵌套的Turbo Frames并且保证我们的代码可读性,通过Turbo Frames的命名约定
下一章中,我们把所有内容敲定。