From a3d6a7aede7946135a4db1fd11e8c633adbb8db0 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Mon, 8 Jun 2026 21:44:11 +0200 Subject: [PATCH] feat(ds): DS::EmptyState primitive (#2137) (#2146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships DS::EmptyState — a centered icon / title / optional description / optional action-slot state — plus a preview, tests, and an exemplar migration of the recurring-transactions empty screen. Consolidates the repeated `text-center py-12 + icon + title + description + CTA` markup across the empty / no-data screens (recurring, imports, statements, exports, and several connector setups). The audit flagged the self-hosted feature-disabled pages rendering bare unstyled top-left text ("reads as a 500, not a state") — wiring those through this primitive is the follow-up. - app/components/DS/empty_state.rb: render DS::EmptyState.new(icon:, title:, description:) with an optional `with_action` slot for the CTA. - Migrate recurring_transactions/index empty branch. Tests + rubocop + erb_lint clean. Deferred: the remaining ~10 empty screens + the bare-text feature-disabled states — same primitive, one migration each. --- app/components/DS/empty_state.rb | 33 +++++++++++++++++++ .../recurring_transactions/index.html.erb | 29 ++++++++-------- test/components/DS/empty_state_test.rb | 32 ++++++++++++++++++ .../previews/empty_state_component_preview.rb | 23 +++++++++++++ 4 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 app/components/DS/empty_state.rb create mode 100644 test/components/DS/empty_state_test.rb create mode 100644 test/components/previews/empty_state_component_preview.rb diff --git a/app/components/DS/empty_state.rb b/app/components/DS/empty_state.rb new file mode 100644 index 000000000..937954d91 --- /dev/null +++ b/app/components/DS/empty_state.rb @@ -0,0 +1,33 @@ +class DS::EmptyState < DesignSystemComponent + # Centered empty / no-data / disabled state: icon, title, optional + # description, optional action slot. Replaces the repeated + # `text-center py-12 + icon + title + description + CTA` markup across the + # empty screens (#2137), and gives the bare-text feature-disabled pages a real + # state instead of unstyled top-left text. + # + # <%= render DS::EmptyState.new(icon: "repeat", title: "...", description: "...") do |es| %> + # <% es.with_action do %><%= render DS::Link.new(...) %><% end %> + # <% end %> + renders_one :action + + def initialize(icon:, title:, description: nil, icon_size: "xl", **opts) + @icon = icon + @title = title + @description = description + @icon_size = icon_size + @opts = opts + end + + erb_template <<~ERB + <%= content_tag :div, + class: class_names("flex flex-col items-center text-center px-4 py-12", @opts[:class]), + **@opts.except(:class) do %> +
<%= helpers.icon(@icon, size: @icon_size) %>
+

<%= @title %>

+ <% if @description.present? %> +

"><%= @description %>

+ <% end %> + <% if action? %>
<%= action %>
<% end %> + <% end %> + ERB +end diff --git a/app/views/recurring_transactions/index.html.erb b/app/views/recurring_transactions/index.html.erb index 2148b7bb3..03384a141 100644 --- a/app/views/recurring_transactions/index.html.erb +++ b/app/views/recurring_transactions/index.html.erb @@ -58,20 +58,21 @@
<% if @recurring_transactions.empty? %> -
-
- <%= icon "repeat", size: "xl" %> -
-

<%= t("recurring_transactions.empty.title") %>

-

<%= t("recurring_transactions.empty.description") %>

- <%= render DS::Link.new( - text: t("recurring_transactions.identify_patterns"), - icon: "search", - variant: "primary", - href: identify_recurring_transactions_path, - method: :post - ) %> -
+ <%= render DS::EmptyState.new( + icon: "repeat", + title: t("recurring_transactions.empty.title"), + description: t("recurring_transactions.empty.description") + ) do |empty| %> + <% empty.with_action do %> + <%= render DS::Link.new( + text: t("recurring_transactions.identify_patterns"), + icon: "search", + variant: "primary", + href: identify_recurring_transactions_path, + method: :post + ) %> + <% end %> + <% end %> <% else %>
diff --git a/test/components/DS/empty_state_test.rb b/test/components/DS/empty_state_test.rb new file mode 100644 index 000000000..82503341f --- /dev/null +++ b/test/components/DS/empty_state_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class DS::EmptyStateTest < ViewComponent::TestCase + test "renders a centered wrapper with title and description" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "No data yet", description: "Add something.")) + + assert_selector "div.text-center.items-center" + assert_selector "p.text-primary", text: "No data yet" + assert_selector "p.text-secondary", text: "Add something." + end + + test "description is optional" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty")) + + assert_selector "p.text-primary", text: "Empty" + assert_no_selector "p.text-secondary" + end + + test "renders the action slot" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty")) do |es| + es.with_action { "Go".html_safe } + end + + assert_selector "a[href='/go']", text: "Go" + end + + test "passthrough class merges onto the wrapper" do + render_inline(DS::EmptyState.new(icon: "repeat", title: "Empty", class: "custom-x")) + + assert_selector "div.custom-x.text-center" + end +end diff --git a/test/components/previews/empty_state_component_preview.rb b/test/components/previews/empty_state_component_preview.rb new file mode 100644 index 000000000..e50cc4f17 --- /dev/null +++ b/test/components/previews/empty_state_component_preview.rb @@ -0,0 +1,23 @@ +class EmptyStateComponentPreview < ViewComponent::Preview + # @display container_classes max-w-[480px] + def default + render DS::EmptyState.new( + icon: "inbox", + title: "No transactions yet", + description: "Imported and synced transactions will show up here." + ) + end + + # @display container_classes max-w-[480px] + def with_action + render DS::EmptyState.new( + icon: "repeat", + title: "No recurring transactions", + description: "We detect patterns automatically, or you can scan now." + ) do |es| + es.with_action do + render DS::Link.new(text: "Scan now", icon: "search", variant: "primary", href: "#") + end + end + end +end