现代 Rails 闪现消息(第一部分):ViewComponent、Stimulus 和 Tailwind CSS

2025-06-07

现代 Rails 闪现消息(第一部分):ViewComponent、Stimulus 和 Tailwind CSS

我一直觉得 Rails 的闪现消息功能可以做得更好。别误会,我真的很喜欢它的功能和易用性。

作为一个业余项目,我开始为桌面角色扮演游戏玩家开发一个简单的应用程序,然后发现我真的很需要一些操作。比如删除某些内容时典型的“撤消”操作,这样就可以跳过重复的“你确定吗?”这个烦人的问题。

当我看到Tailwind UI准备了一些非常好的通知时,我确认了我的需求

我希望它们出现在我的应用程序中!

TL;DR:向下滚动查看完整代码 ;-),这里是具有不同选项的最终版本的预览(如果您只看到下面的白色图像,请单击此处):

最终的 Flash 消息及其变体

第二部分之后的更新:您可以在modern-rails-flash-messages.herokuapp.com上找到基于本系列文章的运行演示,并在github.com/CiTroNaK/modern-rails-flash-messages上找到源代码

先决条件

创建组件

请按照ViewComponent Docs中的安装部分进行操作(如果您还没有)。

幸运的是,我们并不局限于只将字符串传递给flash对象。我们将使用传递 的可能性Hash(您也可以传递Array,请参阅文档)。

由于我们将使用key对象valueflash我将把它添加为新视图组件的参数。

bin/rails generate component Notification type data
Enter fullscreen mode Exit fullscreen mode

将会输出类似以下内容:

      create  app/components/notification_component.rb
      invoke  erb
      create    app/components/notification_component.html.erb
Enter fullscreen mode Exit fullscreen mode

一个文件用于逻辑,另一个文件用于 HTML 输出。让我们从组件的逻辑开始。

Ruby 部分

生成后,它将看起来像这样:

# app/components/notification_component.rb

class NotificationComponent < ViewComponent::Base
  def initialize(type:, data:)
    @type = type
    @data = data
  end
end
Enter fullscreen mode Exit fullscreen mode

我们将传递Hash作为我们的数据,但为了向后兼容,我们需要确保它适用于不受我们控制的地方。我们将使用String,而不是Hash

Hash我们可以轻松保证,我们每次都会与以下公司合作:

private 

def prepare_data(data)
  case data
  when Hash
    data
  else
    { title: data }
  end
end
Enter fullscreen mode Exit fullscreen mode

initialize以及方法上的相应变化

@data = prepare_data(data)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

正如我们所看到的,我们有以下部分:(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>
Enter fullscreen mode Exit fullscreen mode

我们已经处于可以展示它们来查看我们做得如何的状态。

您可以使用以下方式全部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>
Enter fullscreen mode Exit fullscreen mode

然后,在控制器中添加一些闪现消息,如下所示:

flash[:notice] = { 
  title: 'Discussion moved', 
  body: 'Lorem ipsum dolor sit amet consectetur adipisicing elit oluptatum tenetur.'
}
Enter fullscreen mode Exit fullscreen mode

或像以前一样:

flash[:notice] = 'Discussion moved'
Enter fullscreen mode Exit fullscreen mode

现在,我们应该能够看到闪现消息,但所有消息都具有相同的图标(我们将在一分钟内修复它)并且无法关闭(或自动隐藏)它,也没有一些不错的效果(我们将使用 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
Enter fullscreen mode Exit fullscreen mode

并将新的实例变量添加到初始化程序

@icon_class = icon_class
@icon_color_class = icon_color_class
Enter fullscreen mode Exit fullscreen mode

我们将在 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>
Enter fullscreen mode Exit fullscreen mode

这将改变每种闪光类型的颜色和图标。

使用 Stimulus 添加功能和效果

请按照Stimulus Docs中的安装部分进行操作(如果您还没有)。

让我们创建我们的通知控制器。

// app/javascript/controllers/notification_controller.js

import {Controller} from "stimulus"

export default class extends Controller {
}
Enter fullscreen mode Exit fullscreen mode

data-controller并使用根目录上的属性将其连接到 HTML div

<div class="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto" data-controller="notification">
...
</div>
Enter fullscreen mode Exit fullscreen mode

关闭和自动隐藏

要使用过渡效果,我们需要先隐藏通知,然后再触发过渡效果。为此,我将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>
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

现在,我们需要将该方法连接到我们的 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
Enter fullscreen mode Exit fullscreen mode

并将其添加到 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>
Enter fullscreen mode Exit fullscreen mode

现在,我们需要更新控制器。

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);
    }
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

动画notification-countdown很简单:

@keyframes notification-countdown {
  from {
    width: 100%;
  }
  to {
    width: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,最后一部分……

操作按钮

要从 JavaScript 发起请求,我们只需要知道两件事:urlmethod。当操作方法为 时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>
Enter fullscreen mode Exit fullscreen mode

您可以看到新的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
Enter fullscreen mode Exit fullscreen mode

调用操作的主要部分在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")
  }
}
Enter fullscreen mode Exit fullscreen mode

示例用法

一开始的 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')
Enter fullscreen mode Exit fullscreen mode

关键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>
Enter fullscreen mode Exit fullscreen mode

并在控制器中准备通知,例如:

# controller

@notification = NotificationComponent.new(type: 'success', data: { title: t('.success.title'), content: t('.success.content') })

respond_to do |format|
  format.js
end
Enter fullscreen mode Exit fullscreen mode

并在相应的视图中渲染它:

// view, eg. create.js.erb

document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render @notification %>");
Enter fullscreen mode Exit fullscreen mode

如果需要渲染经典flash对象,请将其更改为:

# controller

flash.now[:success] = { title: t('.success.title'), content: t('.success.content') }
Enter fullscreen mode Exit fullscreen mode
// view, eg. create.js.erb

<% flash.each do |type, data|  %>
  document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>
Enter fullscreen mode Exit fullscreen mode

下一个

在下一篇文章中,我将展示撤消功能的后端部分。

顺便提一句

如果你喜欢 Tailwind CSS,你一定要看看Tailwind UI。那里有很多很酷的东西。

如果您发现错误或更好的解决方法,请在评论中告诉我。谢谢!

文章来源:https://dev.to/citronak/modern-rails-flash-messages-part-1-viewcomponent-stimulus-tailwind-css-3alm
PREV
SOLID 时代,我最幸运
NEXT
REST 的十条戒律