Nested Turbo Frames

这一章,我们为line items构建最后的增删改查代码,而line items是嵌入到line items dates中的,为此我们需要使用Turbo Frames去解决一些有趣的挑战。

What we will build in this chapter

本章中,我们会大致敲定quote编辑器,通过增加line itemline 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 dateline item

image-20230628124247522

Quotes#show页面中,我们应该能为每个quote中的line item date添加line item,当点击”Add item”链接时,应该跳转到LineItmes#new页面,这里我们就可以添加该Line item date专属的line item。

假如我们点击第二条line item date的”Add item”链接时,我们期待的页面应该是这样的:

image-20230628124716265

当我们正常提交表单时,我们会重定向到Quotes#show页面,而新创的数据应该也被添加进去了。

image-20230628124812981

如果我们决定更新刚刚创建的line item,我们点击对应的”Edit”链接,到达LineItems#edit页面。

image-20230628125022440

如果我们提交表单,将再被重定向到Quotes#show页面,并且数据被更新

image-20230628125111148

最后当想删除这条line item,点击”Delete”,则数据就被删除了。

需求已经理清楚了,开始敲代码吧。

Creating the model

让我们创建LineItme模型,这个模型有五个字段:

  • line item date的引用
  • 名称
  • 可选描述
  • 单价
  • 数量
bash
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可以在数据库层面进行控制,最终的迁移文件:

ruby
# 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

现在再执行迁移指令:

bash
bin/rails db:migrate

再到模型中添加对应的关系与校验

ruby
# 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方法,这样下面的两个代码是一致的。

Code
line_item.line_item_date.quote
line_item.quote

现在LineItem模型已经好了,再到LineItemDate中增加关联关系

ruby
# 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没啥意义。
ruby
# 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文件中添加这些项目:

yaml
# 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的链接

让我们添加到局部模板中:

ruby
<%# 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

ruby
<%# 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 %>
    &times;
    <%= 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_formathelper在渲染那些输入到文本框的文本时很有用。例如,让我们想象一下一个用户输入下面的文本到描述信息汇总。

Code
- Appetizer
- Main course
- Dessert
- A glass of wine

通过simple_formathelper将会生成下面的HTML代码

Code
<p>
  - Appetizers
  <br>
  - Main course
  <br>
  - Dessert
  <br>
  - A glass of wine
</p>

可以看到,这里很只能的加入了换行,如果不使用simple_formathelper,则信息就只展示到一行中了。

.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

css
// 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的创建和修改表单

这样不论的是在手机端,平板上,大屏上通过tabletAndUpbreakpoint都能正常响应,让我们写到代码里

css
.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;
    }
  }
}
css
// app/assets/stylesheets/application.sass.scss

// All the previous code
@import "components/line_item";

如果你去浏览器中试试,就能看到样式啦

在进入下一部分前,看看我们现在遇到的性能问题,尽管算是本教程的题外话,但在这里解释一下发生了什么也很重要,当我们访问Quotes#show页面时,如果你去看rails的日志,你会看到一个 N+1 查看问题

Code
...
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,因为这行代码:

ruby
<%# 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中修改吧:

ruby
# 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,我们会发现日志中,我们只查询了一次数据

Code
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

ruby
# 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,让我们加上吧

ruby
<%# app/views/line_items/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; 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的旧浏览器的人来说,它应该还是可以使用的。

ruby
<%# 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-itmeCSS样式,并结合.line-item--form

在浏览器中测试一下,但出问题了,line item date消失了,并且通过浏览器控制台看到了下面的异常

Code
Response has no matching <turbo-frame id="line_item_date_123456"> element

这是因为”Add item”链接已经嵌入到Turbo Frame中,就像下面的草图

image-20230629135429883

这是因为Turbo Frames拦截了所有的链接和表单提交,并且需要一个拥有相同id的响应。我们首先要使我们的CRUD工作没有Turbo Frames和Turbo Streams。

为了防止Turbo拦截提交,我们使用data-turbo-frame="_top",在第四章解释过,让我们添加这个参数到链接中

ruby
<%# 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”中也加上相同的参数

ruby
<%# 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 -->

现在再到浏览器中试试吧

我们花一点儿时间来写按钮,提示等内容

ruby
# 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

ruby
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的视图,以便在浏览器中测试。

ruby
<%# app/views/line_items/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; 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页面。

ruby
# 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参数

image-20230629212417936

我们发现这次的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页面就会像下面的样子:

ruby
<%# 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_itemsids。Turbo怎么知道当有多个相同Id时怎么办?而我们新建的line item就插入到错误的日期下面。

一种好的约定,即将我们通常拥有的id前缀设置为父资源的dom_id,这样就能确保ids唯一。

为了使Turbo正常工作,我们需要在LineItems#new页面上添加一个相同id的Turbo Frame

image-20230629213542656

这样当用户点击”New item”按钮时,Turbo将成功用表单替换掉空Turbo Frames

image-20230629213715380

当用户提交表单时,我们想让创建的line item被添加到特定日期下的line items列表中。

image-20230629213825492

现在需求已经清晰了,我们只需要通过Turbo Frames and Turbo Streams就能解决问题

先开始做第一部分,当用户点击”Add item”按钮时,表单出现在Quotes#show页面,在每个line item date上,都加一个空的Turbo Frame去链接”Add date”按钮。

ruby
<%# 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用第二个参数作为前缀,我们可以这么写:

Code
line_item_date = LineItemDate.find(1)

dom_id(LineItem.new, dom_id(line_item_date))
# => line_item_date_1_new_line_item

这个方法可以奏效,但很难阅读,这里有一种迂回策略

ruby
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更容易生成或阅读,并确保团队能够使用统一的约定。

ruby
# 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

ruby
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

现在使用我们的新的约定来修改视图

ruby
<%# 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中吧。

ruby
<%# app/views/line_items/new.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; 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,我们希望做到以下两点:

  1. 删除DOM中的表单
  2. 添加新建的Line item到具体日期下Line items列表中

让我们修改LineItemsController#create去响应turbo_stream format

ruby
# 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

让我们创建我们的试图,来执行期望的行为

ruby
<%# 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列表,为每一个具体的日期。

ruby
<%# 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,如下面草图所示:

image-20230701101156669

当我们点击”Edit”到被嵌套到id为line_item_2的Turbo Frame的第二条line item时,Turbo希望能在LineItems#edit也页面中找到相同id的Turbo Frame,如下图所示

image-20230701101429630

这样当点击一个line item时,Turbo就能用LineItem#edit页面的表单去替换这条line item.

image-20230701101632162

当提交表单时,我们希望最终的数据能再替换表单

image-20230701101714652

现在需求已经明确了,该敲代码了,首先是让edit表单成功的替换Quotes#show页面line items的HTML,为此,我们将每个item嵌套到Turbo Frame中。

ruby
<%# 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"参数

ruby
<%# 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中。

ruby
<%# app/views/line_items/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; 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:

ruby
# 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.erbview去让line item的局部模版替换掉表单,并渲染flash message。

ruby
<%# 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

ruby
# 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即可。

ruby
<%# app/views/line_items/destroy.turbo_stream.erb %>

<%= turbo_stream.remove @line_item %>
<%= render_turbo_stream_flash_messages %>

记着不要忘了删除”Delete”按钮中的data-turbo-frame="_top"参数

ruby
<%# 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进行唯一标识

image-20230701103957240

为了能让Turbo去替换这部分,我们要在LineItemDates#edit页面中使用相同的id

image-20230701104106437

这样当点击特定日期的”Edit”按钮时,Turbo只会替换line item date card的头部

image-20230701104201719

现在需求明确了,开始敲代码吧,首先在line item date的局部模版中,添加以”edit”为前缀的Turbo Frame id。

ruby
<%# 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页面。

ruby
<%# app/views/line_item_dates/edit.html.erb %>

<main class="container">
  <%= link_to sanitize("&larr; 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的增删改查的系统测试

ruby
# 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。

ruby
# 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的命名约定

下一章中,我们把所有内容敲定。