Two ways to handle empty states with Hotwire

本章节中,我们将会学习两种方式去使用Turbo控制空状态页,第一种使用Turbo Frames and Turbo Streams,第二种使用 the only-child CSS pseudo-class.

Adding empty states to our Ruby on Rails applications

空状态页是我们系统中最重要的一部分,当我们第一次访问页面时,页面上没有任何提示让我们知道这个页面能干嘛。所以如果当一个新用户访问我们系统时,显示一点儿图片或者几句话可以更好的表达页面可以操作什么。

如果我们删除了所有的quotes数据,我们的页面也就只剩下标题和按钮了,所以当数据为空时,使用空状态页也会是一个好的选择。让我们开始吧

Empty states with Turbo Frames and Turbo Streams

在敲代码之前,让我们花点儿时间,用草图描述一下将要干嘛。当一个用户没有数据时,我们想展示包含提示信息的空状态

image-20230617223354649

  • 如果用户点击header中的New quote按钮,Turbo会使用Quotes#new页面中的frame替换Quotes#index页面中,id为new_quote的frame。
  • 当用户点击空状态中的Add quote按钮时,Turbo一样会使用Quotes#new页面中的数据替换Quotes#index的frame

如上面所讲,不管用户点击哪个链接,空状态都会被new quote form替换掉,页面的状态如下图展示:

image-20230618084405067

当用户提交表单数据,行为也不会发生改变

  1. 新创建的数据放到表单最前面
  2. 嵌套id为new_quote的frame的HTML被删除

如下图所示

image-20230618084619435

如果你刷新页面,只要至少有一条数据,空状态页就不会再显示

现在我们需求明确了,开始敲代码吧,第一件事儿就是在页面中没有数据时,展示空状态页,为此,我们创建一个空状态的局部视图,并用在Quotes#index页面

<%# app/views/quotes/_empty_state.html.erb %>

<div class="empty-state">
  <p class="empty-state__text">
    You don't have any quotes yet!
  </p>

  <%= link_to "Add quote", new_quote_path, class: "btn btn--primary" %>
</div>

现在我们可以在用户没有数据时,渲染空状态页到Quotes#index页面中

<%# app/views/quotes/index.html.erb %>

<%= turbo_stream_from current_company, "quotes" %>

<div class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new do %>
    <% if @quotes.none? %>
      <%= render "quotes/empty_state" %>
    <% end %>
  <% end %>

  <%= turbo_frame_tag "quotes" do %>
    <%= render @quotes %>
  <% end %>
</div>

在浏览器测试之前,我们来给空状态页增加点儿样式,让更美观一点儿

// app/assets/stylesheets/components/_empty_state.scss

.empty-state {
  padding: var(--space-m);
  border: var(--border);
  border-style: dashed;
  text-align: center;

  &__text {
    font-size: var(--font-size-l);
    color: var(--color-text-header);
    margin-bottom: var(--space-l);
    font-weight: bold;
  }
}

别忘了导入到 manifest 文件中

// app/assets/stylesheets/application.sass.scss

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

现在就可以测试了。

Quotes#index页面中,我们先删除所有的数据,当我们点击New quote or Add quote按钮时,我们可以看到创建表单替换了空状态页,如果我们提交正常数据,新数据就会被放到最上面,空状态页也不再显示。

然后我们仍然需要做一些改进,如果我们删除了刚刚创建的数据,空状态页并没有回到屏幕上,我们希望只要没有数据了,就显示空状态页,为此我们需要修改destory.turbo_stream.erb去更改id为new_quote的frame的内容。

<%# app/views/quotes/destroy.turbo_stream.erb %>

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

<% unless current_company.quotes.exists? %>
  <%= turbo_stream.update Quote.new do %>
    <%= render "quotes/empty_state" %>
  <% end %>
<% end %>

现在,一切如期运转。

然而,我们当前的实现方案,有一个很容易被忽略的问题,在第五章,第六章,我们使用Turbo Stream去订阅:创建,修改,删除的信息通道在Quotes#index页面,因此,当我还是空状态页时,如果这时候有人创建了一条数据,我这里是可以看到,但是空状态页仍然保留在页面上。

让我们讨论一下问题为什么发生,并如何去解决

Empty states with the only-child CSS pseudo-class

在讨论使用Turbo第二种控制空状态页的方式前,我们再来把问题复现一下,我们来到Quotes#index页面,并删除所有数据,然后我们在控制台中创建一个新的数据,然后新数据会被广播到浏览器页面中,然后我们就会发现空状态页和数据会同时显示在页面上。


注意:我们这里的例子可能比较繁琐,但让我们花点儿时间想象一下数据被通知,就像Github里的通知一样。

  1. 当我们页面中没有数据通知时,我们希望展示空状态页
  2. 当我们通知到达页面时,我们想让空状态页消失
  3. 当我们删除通知数据时,我们希望空状态页再次返回

这就是这部分我们要完成的任务,并且通知是一个很好的例子


让我们通过第五章,第六章来分析一下这里的问题,在Quote模型中使用broadcasts_to方法

  • 当数据创建时,quotes/_quote.html.erb视图中的内容被渲染到列表前面
  • 当数据删除时,这条数据从列表中删除

默认的,并没有对空状态页提及,如果我们修改broadcasts_to方法的默认选项,使用回调和重写,就有一些巧妙的方法来实现我们想要的功能,归功于CSS中的:only-child pseudo-class,我们在下面陈列想要实现的行为

  • 当空状态是the only child of the quotes list,我们想让它展示出来
  • 当空状态不是the only child of the quotes list,我们想让它消失

这次行为与之前第一种实现方式有一些小小的不同,这次我们不会使用创建表单替换空状态页

让我们开始敲代码吧,首先我们需要把quotes/empty_state局部视图内容放到数据列表中

<%# app/views/quotes/index.html.erb %>

<%= turbo_stream_from current_company, "quotes" %>

<div class="container">
  <div class="header">
    <h1>Quotes</h1>
    <%= link_to "New quote",
                new_quote_path,
                class: "btn btn--primary",
                data: { turbo_frame: dom_id(Quote.new) } %>
  </div>

  <%= turbo_frame_tag Quote.new %>

  <%= turbo_frame_tag "quotes" do %>
    <%= render "quotes/empty_state" %>
    <%= render @quotes %>
  <% end %>
</div>

然后我们在CSS中使用``:only-child pseudo-class,当空状态页是id为quotes的Turbo Frame中唯一子节点时显示,如果不是,则再隐藏

// app/assets/stylesheets/components/_empty_state.scss

.empty-state {
  padding: var(--space-m);
  border: var(--border);
  border-style: dashed;
  text-align: center;

  &__text {
    font-size: var(--font-size-l);
    color: var(--color-text-header);
    margin-bottom: var(--space-l);
    font-weight: bold;
  }

  &--only-child {
    display: none;

    &:only-child {
      display: revert;
    }
  }
}

在这里,我们使用了一个修饰符来支持本章介绍的两种方法,以便使用相同的.empty-state类。

在我们的空状态页视图中,我们需要明确的指定”Add quote”链接到id为new_quote的Turbo Frame,我们使用data-turbo-frame="new_quote"

<%# app/views/quotes/_empty_state.html.erb %>

<div class="empty-state empty-state--only-child">
  <p class="empty-state__text">
    You don't have any quotes yet!
  </p>

  <%= link_to "Add quote",
              new_quote_path,
              class: "btn btn--primary",
              data: { turbo_frame: dom_id(Quote.new) } %>
</div>

然后我们就可以删除destroy.turbo_stream.erb中的修改内容了,我们不再需要任何自定义行为

<%# app/views/quotes/destroy.turbo_stream.erb %>

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

现在再在浏览器中试试吧

  • 当我们列表中拥有数据时,空状态页消失
  • 当没有数据时,空状态页展示

这一部分我们只是用了CSS就完成了任务

Wrap up

本章中,我们使用两种方式去控制空状态页

第一种方式,使用Turbo Frames 和 Turbo Stream去提前添加/删除空状态页在Quotes#index页面,虽然这个方案适合于大多数情况,但在HTML被广播到页面中的情况并不适和

第二种方式,我们使用了``:only-child CSS pseudo-class的魔力来完成所有的工作,而不需要写自定义的相关代码。

下面的章节,我们将在Quotes#show页面中完善我们的quote编辑器,再见