现代 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