现代 Rails 闪现消息(第一部分):ViewComponent、Stimulus 和 Tailwind CSS
我一直觉得 Rails 的闪现消息功能可以做得更好。别误会,我真的很喜欢它的功能和易用性。
作为一个业余项目,我开始为桌面角色扮演游戏玩家开发一个简单的应用程序,然后发现我真的很需要一些操作。比如删除某些内容时典型的“撤消”操作,这样就可以跳过重复的“你确定吗?”这个烦人的问题。
当我看到Tailwind UI准备了一些非常好的通知时,我确认了我的需求。
我希望它们出现在我的应用程序中!
TL;DR:向下滚动查看完整代码 ;-),这里是具有不同选项的最终版本的预览(如果您只看到下面的白色图像,请单击此处):
 
第二部分之后的更新:您可以在modern-rails-flash-messages.herokuapp.com上找到基于本系列文章的运行演示,并在github.com/CiTroNaK/modern-rails-flash-messages上找到源代码。
先决条件
- Ruby on Rails
- 视图组件
- 刺激
- 前端部分的Tailwind CSS (可选,因为您可以使用自己的 CSS/HTML)
- FontAwesome图标(可选)
创建组件
请按照ViewComponent Docs中的安装部分进行操作(如果您还没有)。
幸运的是,我们并不局限于只将字符串传递给flash对象。我们将使用传递 的可能性Hash(您也可以传递Array,请参阅文档)。
由于我们将使用key对象value,flash我将把它添加为新视图组件的参数。
bin/rails generate component Notification type data
将会输出类似以下内容:
      create  app/components/notification_component.rb
      invoke  erb
      create    app/components/notification_component.html.erb
一个文件用于逻辑,另一个文件用于 HTML 输出。让我们从组件的逻辑开始。
Ruby 部分
生成后,它将看起来像这样:
# app/components/notification_component.rb
class NotificationComponent < ViewComponent::Base
  def initialize(type:, data:)
    @type = type
    @data = data
  end
end
我们将传递Hash作为我们的数据,但为了向后兼容,我们需要确保它适用于不受我们控制的地方。我们将使用String,而不是Hash。
Hash我们可以轻松保证,我们每次都会与以下公司合作:
private 
def prepare_data(data)
  case data
  when Hash
    data
  else
    { title: data }
  end
end
initialize以及方法上的相应变化
@data = prepare_data(data)
HTML 部分
我使用了 Tailwind UI 的预处理通知,但您可以随意使用。此通知仅适用于 Tailwind CSS,因此您无需拥有 Tailwind UI(但如果您觉得它有用,则应该拥有)。
<!-- app/components/notification_component.html.erb -->
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
  <div class="rounded-lg shadow-xs overflow-hidden">
    <div class="p-4">
      <div class="flex items-start">
        <div class="flex-shrink-0">
          <div class="h-6 w-6 text-gray-400">
            <i class="far fa-info-square"></i>
          </div>
        </div>
        <div class="ml-3 w-0 flex-1 pt-0.5">
          <p class="text-sm leading-5 font-medium text-gray-900">
            Discussion moved
          </p>
          <p class="mt-1 text-sm leading-5 text-gray-500">
            Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.
          </p>
          <div class="mt-2">
            <button class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
              Undo
            </button>
            <button class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
              Dismiss
            </button>
          </div>
        </div>
        <div class="ml-4 flex-shrink-0 flex">
          <button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
            <i class="h-5 w-5 far fa-times"></i>
          </button>
        </div>
      </div>
    </div>
  </div>
</div>
正如我们所看到的,我们有以下部分:(title“讨论已移动”)、body(“Lorem ipsum dolor sat amet consectetur adipisicing elit oluptatum tenetur。”)和一个action(“撤消”)。
让我们将它们添加到 HTML 中(如果您有不同的内容,只需将实例变量放在 HTML 中的正确位置):
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
  <div class="rounded-lg shadow-xs overflow-hidden">
    <div class="p-4">
      <div class="flex items-start">
        <div class="flex-shrink-0">
          <div class="h-6 w-6 text-gray-400">
            <i class="far fa-info-square"></i>
          </div>
        </div>
        <div class="ml-3 w-0 flex-1 pt-0.5">
          <p class="text-sm leading-5 font-medium text-gray-900">
            <%= @data[:title] %>
          </p>
          <% if @data[:body].present? %>
            <p class="mt-1 text-sm leading-5 text-gray-500">
              <%= @data[:body] %>
            </p>
          <% end %>
          <% if @data[:action].present? %>
            <div class="mt-2">
              <button class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
                <%= @data.dig(:action, :name) %>
              </button>
              <button class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
                <%= t('.dismiss') %>
              </button>
            </div>
          <% end %>
        </div>
        <div class="ml-4 flex-shrink-0 flex">
          <button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150">
            <i class="h-5 w-5 far fa-times"></i>
          </button>
        </div>
      </div>
    </div>
  </div>
</div>
我们已经处于可以展示它们来查看我们做得如何的状态。
您可以使用以下方式全部app/views/layouts/application.html.erb或部分显示它们:
<div class="fixed inset-0 px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
  <div class="flex flex-col items-end justify-center">
    <% flash.each do |type, data| %>
      <%= render NotificationComponent.new(type: type, data: data) %>
    <% end %>
  </div>
</div>
然后,在控制器中添加一些闪现消息,如下所示:
flash[:notice] = { 
  title: 'Discussion moved', 
  body: 'Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.'
}
或像以前一样:
flash[:notice] = 'Discussion moved'
现在,我们应该能够看到闪现消息,但所有消息都具有相同的图标(我们将在一分钟内修复它)并且无法关闭(或自动隐藏)它,也没有一些不错的效果(我们将使用 Stimulus 来实现)。
为了更改图标(以及它们的颜色,以获得更美观的用户界面),我们需要更新notification_component.rb并在下方部分添加两个新方法private。您可能会注意到,我又添加了一种 Flash 类型。
def icon_class
  case @type
  when 'success'
    'fa-check-square'
  when 'error'
    'fa-exclamation-square'
  when 'alert'
    'fa-exclamation-square'
  else
    'fa-info-square'
  end
end
def icon_color_class
  case @type
  when 'success'
    'text-green-400'
  when 'error'
    'text-red-800'
  when 'alert'
    'text-red-400'
  else
    'text-gray-400'
  end
end
并将新的实例变量添加到初始化程序
@icon_class = icon_class
@icon_color_class = icon_color_class
我们将在 HTML 中使用它
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto">
  <div class="rounded-lg shadow-xs overflow-hidden">
    <div class="p-4">
      <div class="flex items-start">
        <div class="flex-shrink-0">
          <div class="h-6 w-6 <%= @icon_color_class %>">
            <i class="far <%= @icon_class %>"></i>
          </div>
        </div>
        ...
      </div>
    </div>
  </div>
</div>
这将改变每种闪光类型的颜色和图标。
使用 Stimulus 添加功能和效果
请按照Stimulus Docs中的安装部分进行操作(如果您还没有)。
让我们创建我们的通知控制器。
// app/javascript/controllers/notification_controller.js
import {Controller} from "stimulus"
export default class extends Controller {
}
data-controller并使用根目录上的属性将其连接到 HTML div。
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto" data-controller="notification">
...
</div>
关闭和自动隐藏
要使用过渡效果,我们需要先隐藏通知,然后再触发过渡效果。为此,我将hidden在 root 类中添加一个 class div。
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto hidden" data-controller="notification">
...
</div>
connect在Stimulus 控制器的一个方法中,我将移除它并添加一些类来实现流畅的过渡效果。connect当控制器连接到 DOM 时,该方法就会被触发(参见生命周期回调)。我还会对 Turbolinks 使用一个巧妙的技巧,避免在返回时渲染它。
connect() {
  if (!this.isPreview) {
    // Display with transition
    setTimeout(() => {
      this.element.classList.remove('hidden');
      this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
      // Trigger transition
      setTimeout(() => {
        this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
      }, 100);
    }, 500);
    // Auto-hide
    setTimeout(() => {
      this.close();
    }, 5500);
  }
}
close() {
  // Remove with transition
  this.element.classList.remove('transform', 'ease-out', 'duration-300', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2', 'translate-y-0', 'sm:translate-x-0');
  this.element.classList.add('ease-in', 'duration-100')
  // Trigger transition
  setTimeout(() => {
    this.element.classList.add('opacity-0');
  }, 100);
  // Remove element after transition
  setTimeout(() => {
    this.element.remove();
  }, 300);
}
get isPreview() {
  return document.documentElement.hasAttribute('data-turbolinks-preview')
}
现在,我们需要将该方法连接到我们的 HTML,我们可以使用元素上的属性close轻松地做到这一点。data-action="notification#close"button
这一切都是为了顺利进入和(自动)离开,并带有一个功能关闭按钮。
倒计时
对于倒计时,我们将在通知中添加一个选项来控制超时。我们还需要依靠添加默认值来实现向后兼容性。
# app/components/notification_component.rb
def initialize(type:, data:)
  @type = type
  @data = prepare_data(data)
  @icon_class = icon_class
  @icon_color_class = icon_color_class
  @data[:timeout] ||= 3
end
并将其添加到 HTML 中作为data-notification-timeout="<%= @data[:timeout] %>"我们的根div,这样我们就能够在 Stimulus 控制器中使用它。
我们还在 Stimulus 控制器中使用的 HTML 代码底部添加了一个带有特殊data-target属性的倒计时行。我们只会在需要时显示它。在某些操作中,倒计时可能会显得突兀,例如“打开”、“查看”等。
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto hidden" data-controller="notification" data-notification-timeout="<%= @data[:timeout] %>">
  <div class="rounded-lg shadow-xs overflow-hidden">
    ...
    <% if @data[:countdown] %>
      <div class="bg-indigo-600 rounded-lg h-1 w-0" data-target="notification.countdown"></div>
    <% end %>
  </div>
</div>
现在,我们需要更新控制器。
import {Controller} from "stimulus"
export default class extends Controller {
  static targets = ["countdown"]
  connect() {
    const timeoutSeconds = parseInt(this.data.get("timeout"));
    if (!this.isPreview) {
      setTimeout(() => {
        this.element.classList.remove('hidden');
        this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
        // Trigger transition
        setTimeout(() => {
          this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
        }, 100);
        // Trigger countdown
        if (this.hasCountdownTarget) {
          this.countdownTarget.style.animation = 'notification-countdown linear ' + timeoutSeconds + 's';
        }
      }, 500);
      setTimeout(() => {
        this.close();
      }, timeoutSeconds * 1000 + 500);
    }
  }
  ...
}
动画notification-countdown很简单:
@keyframes notification-countdown {
  from {
    width: 100%;
  }
  to {
    width: 0;
  }
}
现在,最后一部分……
操作按钮
要从 JavaScript 发起请求,我们只需要知道两件事:url和method。当操作方法为 时GET,我们不应该发起请求并让用户打开页面。例如,当用户创建新文章时,我们可以显示Open操作,并附带指向文章页面的链接。
最终的 HTML:
<!-- app/components/notification_component.html.erb -->
<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto mt-4 hidden" data-notification-action-url="<%= @data.dig(:action, :url) %>" data-notification-action-method="<%= @data.dig(:action, :method) %>" data-notification-timeout="<%= @data[:timeout] %>" data-controller="notification">
  <div class="rounded-lg shadow-xs overflow-hidden">
    <div class="p-4">
      <div class="flex items-start">
        <div class="flex-shrink-0">
          <div class="h-6 w-6 <%= @icon_color_class %>">
            <i class="far <%= @icon_class %>"></i>
          </div>
        </div>
        <div class="ml-3 w-0 flex-1 pt-0.5">
          <p class="text-sm leading-5 font-medium text-gray-900">
            <%= @data[:title] %>
          </p>
          <% if @data[:body].present? %>
            <p class="mt-1 text-sm leading-5 text-gray-500">
              <%= @data[:body] %>
            </p>
          <% end %>
          <% if @data[:action].present? %>
            <div class="mt-2" data-target="notification.buttons">
              <a <% if @data.dig(:action, :method) == 'get' %> href="<%= @data.dig(:action, :url) %>" <% else %> href="#" data-action="notification#run" <% end %> class="text-sm leading-5 font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150">
                <%= @data.dig(:action, :name) %>
              </a>
              <button data-action="notification#close" class="ml-6 text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:underline transition ease-in-out duration-150">
                <%= t('.dismiss') %>
              </button>
            </div>
          <% end %>
        </div>
        <div class="ml-4 flex-shrink-0 flex">
          <button class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 transition ease-in-out duration-150" data-action="notification#close">
            <i class="h-5 w-5 far fa-times"></i>
          </button>
        </div>
      </div>
    </div>
    <% if @data[:countdown] %>
      <div class="bg-indigo-600 rounded-lg h-1 w-0" data-target="notification.countdown"></div>
    <% end %>
  </div>
</div>
您可以看到新的data-target="notification.buttons"和新的data-notification属性。
最终的NotificationComponent:
# app/components/notification_component.rb
# frozen_string_literal: true
# @param type [String] Classic notification type `error`, `alert` and `info` + custom `success`
# @param data [String, Hash] `String` for backward compatibility,
#   `Hash` for the new functionality `{title: '', body: '', timeout: 5, countdown: false, action: { url: '', method: '', name: ''}}`.
#   The `title` attribute for `Hash` is mandatory.
class NotificationComponent < ViewComponent::Base
  def initialize(type:, data:)
    @type = type
    @data = prepare_data(data)
    @icon_class = icon_class
    @icon_color_class = icon_color_class
    @data[:timeout] ||= 3
  end
  private
  def icon_class
    case @type
    when 'success'
      'fa-check-square'
    when 'error'
      'fa-exclamation-square'
    when 'alert'
      'fa-exclamation-square'
    else
      'fa-info-square'
    end
  end
  def icon_color_class
    case @type
    when 'success'
      'text-green-400'
    when 'error'
      'text-red-800'
    when 'alert'
      'text-red-400'
    else
      'text-gray-400'
    end
  end
  def prepare_data(data)
    case data
    when Hash
      data
    else
      { title: data }
    end
  end
end
调用操作的主要部分在run方法中。我还保存了关闭通知的超时时间,这样我就可以停止它并确保它会显示返回的内容(查找this.timeoutId方法stop)。
为了发出有效的请求,我们需要从 HTML 标头中获取 CSRF 令牌。具体csrfToken方法如下。
最终的刺激控制器:
// app/javascript/controllers/notification_controller.js
import {Controller} from "stimulus"
export default class extends Controller {
  static targets = ["buttons", "countdown"]
  connect() {
    const timeoutSeconds = parseInt(this.data.get("timeout"));
    if (!this.isPreview) {
      setTimeout(() => {
        this.element.classList.remove('hidden');
        this.element.classList.add('transform', 'ease-out', 'duration-300', 'transition', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2');
        // Trigger transition
        setTimeout(() => {
          this.element.classList.add('translate-y-0', 'opacity-100', 'sm:translate-x-0');
        }, 100);
        // Trigger countdown
        if (this.hasCountdownTarget) {
          this.countdownTarget.style.animation = 'notification-countdown linear ' + timeoutSeconds + 's';
        }
      }, 500);
      this.timeoutId = setTimeout(() => {
        this.close();
      }, timeoutSeconds * 1000 + 500);
    }
  }
  run(e) {
    e.preventDefault();
    this.stop();
    let _this = this;
    this.buttonsTarget.innerHTML = '<span class="text-sm leading-5 font-medium text-grey-700">Processing...</span>';
    // Call the action
    fetch(this.data.get("action-url"), {
      method: this.data.get("action-method").toUpperCase(),
      dataType: 'script',
      credentials: "include",
      headers: {
        "X-CSRF-Token": this.csrfToken
      },
    })
      .then(function (response) {
        let content;
        // Example of the response, content should be provided from the controller
        if (response.status === 200) {
          content = '<span class="text-sm leading-5 font-medium text-green-700">Done!</span>'
        } else {
          content = '<span class="text-sm leading-5 font-medium text-red-700">Error!</span>'
        }
        // Set new content
        _this.buttonsTarget.innerHTML = content;
        // Close
        setTimeout(() => {
          _this.close();
        }, 1000);
      });
  }
  stop() {
    clearTimeout(this.timeoutId)
    this.timeoutId = null
  }
  close() {
    // Remove with transition
    this.element.classList.remove('transform', 'ease-out', 'duration-300', 'translate-y-2', 'opacity-0', 'sm:translate-y-0', 'sm:translate-x-2', 'translate-y-0', 'sm:translate-x-0');
    this.element.classList.add('ease-in', 'duration-100')
    // Trigger transition
    setTimeout(() => {
      this.element.classList.add('opacity-0');
    }, 100);
    // Remove element after transition
    setTimeout(() => {
      this.element.remove();
    }, 300);
  }
  get isPreview() {
    return document.documentElement.hasAttribute('data-turbolinks-preview')
  }
  get csrfToken() {
    const element = document.head.querySelector('meta[name="csrf-token"]')
    return element.getAttribute("content")
  }
}
示例用法
一开始的 demo 就是由这些例子创建的:
NotificationComponent.new(type: 'notice', data: { timeout: 8, title: 'Entry was deleted', body: 'You can still recover the deleted item using Undo below.', countdown: true, action: { url: 'http://localhost:3000/undo', method: 'patch', name: 'Undo' } })
NotificationComponent.new(type: 'error', data: { timeout: 8, title: 'Access denied', body: "You don't have sufficient rights to the action." })
NotificationComponent.new(type: 'success', data: 'Successfully logged in')
NotificationComponent.new(type: 'alert', data: 'You need to log in to access the page')
关键data基本上是您将传递给对象的内容flash。
最后一件事:如何从控制器的 js 响应触发它们?
很简单。添加 ID,例如#notifications:
<div class="fixed inset-0 px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
  <div id="notifications" class="flex flex-col items-end justify-center">
    <% flash.each do |type, data| %>
      <%= render NotificationComponent.new(type: type, data: data) %>
    <% end %>
  </div>
</div>
并在控制器中准备通知,例如:
# controller
@notification = NotificationComponent.new(type: 'success', data: { title: t('.success.title'), content: t('.success.content') })
respond_to do |format|
  format.js
end
并在相应的视图中渲染它:
// view, eg. create.js.erb
document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render @notification %>");
如果需要渲染经典flash对象,请将其更改为:
# controller
flash.now[:success] = { title: t('.success.title'), content: t('.success.content') }
// view, eg. create.js.erb
<% flash.each do |type, data|  %>
  document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>
下一个
在下一篇文章中,我将展示撤消功能的后端部分。
顺便提一句
如果你喜欢 Tailwind CSS,你一定要看看Tailwind UI。那里有很多很酷的东西。
如果您发现错误或更好的解决方法,请在评论中告诉我。谢谢!
文章来源:https://dev.to/citronak/modern-rails-flash-messages-part-1-viewcomponent-stimulus-tailwind-css-3alm 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com