diff --git a/reporting-app/app/components/alert_component.html.erb b/reporting-app/app/components/alert_component.html.erb
new file mode 100644
index 00000000..67e850c7
--- /dev/null
+++ b/reporting-app/app/components/alert_component.html.erb
@@ -0,0 +1,12 @@
+<%= tag.div class: alert_classes, role: resolved_role, style: style do %>
+
+ <% if heading.present? %>
+ <%= content_tag(:"h#{heading_level}", heading, class: "usa-alert__heading") %>
+ <% end %>
+ <% if body %>
+ <%= body %>
+ <% elsif message.present? %>
+
<%= message %>
+ <% end %>
+
+<% end %>
diff --git a/reporting-app/app/components/alert_component.rb b/reporting-app/app/components/alert_component.rb
new file mode 100644
index 00000000..8c2e47b1
--- /dev/null
+++ b/reporting-app/app/components/alert_component.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# USWDS alert (usa-alert). Simple mode: message and optional heading, or use the body slot
+# for lists, buttons, accordions.
+class AlertComponent < ViewComponent::Base
+ module TYPES
+ INFO = "info"
+ SUCCESS = "success"
+ WARNING = "warning"
+ ERROR = "error"
+
+ ALL = [ INFO, SUCCESS, WARNING, ERROR ].freeze
+ end
+
+ module ROLES
+ ALERT = "alert"
+ STATUS = "status"
+
+ ALL = [ ALERT, STATUS ].freeze
+ end
+
+ ROLE_DEFAULT = Object.new.freeze
+
+ renders_one :body
+
+ def initialize(type:, heading: nil, message: nil, heading_level: 2, classes: nil, style: nil, role: ROLE_DEFAULT)
+ @type = type.to_s
+ raise ArgumentError, "Invalid alert type: #{type.inspect}" unless TYPES::ALL.include?(@type)
+
+ @heading = heading
+ @message = message
+ @heading_level = Integer(heading_level)
+ raise ArgumentError, "heading_level must be between 1 and 6" unless (1..6).cover?(@heading_level)
+
+ @classes = classes
+ @style = style
+ @role = role
+ end
+
+ attr_reader :type, :heading, :message, :heading_level, :classes, :style
+
+ def alert_classes
+ [ "usa-alert", "usa-alert--#{type}", classes ].compact.join(" ")
+ end
+
+ def error?
+ type == TYPES::ERROR
+ end
+
+ def resolved_role
+ if @role != ROLE_DEFAULT
+ @role.presence
+ else
+ error? ? ROLES::ALERT : ROLES::STATUS
+ end
+ end
+end
diff --git a/reporting-app/app/helpers/certification_batch_uploads_helper.rb b/reporting-app/app/helpers/certification_batch_uploads_helper.rb
index a6a9a15b..7ab34a1f 100644
--- a/reporting-app/app/helpers/certification_batch_uploads_helper.rb
+++ b/reporting-app/app/helpers/certification_batch_uploads_helper.rb
@@ -33,12 +33,12 @@ def status_alert_options(batch_upload)
case batch_upload.status
when "pending"
{
- type: "info",
+ type: AlertComponent::TYPES::INFO,
message: t("queued_message", scope: scope)
}
when "processing"
{
- type: "info",
+ type: AlertComponent::TYPES::INFO,
message: t(
"processing_message",
scope: scope,
@@ -48,7 +48,7 @@ def status_alert_options(batch_upload)
}
when "failed"
{
- type: "error",
+ type: AlertComponent::TYPES::ERROR,
heading: t("failed_heading", scope: scope),
message: batch_upload.results&.dig("error") || t("failed_message", scope: scope)
}
diff --git a/reporting-app/app/views/application/_case_documents.html.erb b/reporting-app/app/views/application/_case_documents.html.erb
index 3b9fed71..2cd1bd2e 100644
--- a/reporting-app/app/views/application/_case_documents.html.erb
+++ b/reporting-app/app/views/application/_case_documents.html.erb
@@ -10,10 +10,11 @@
<% end %>
<% else %>
-
-
-
-
<%= t(".heading") %>
-
- <%= t(".intro") %>
-
-
-
+ <%= render AlertComponent.new(type: AlertComponent::TYPES::ERROR, heading: t(".heading"), message: t(".intro"), heading_level: 3) %>
<%= link_to t(".view_activity_report_button"), activity_report_application_form_path(activity_report), class: "usa-button" %>
diff --git a/reporting-app/app/views/dashboard/_exemption_denied.html.erb b/reporting-app/app/views/dashboard/_exemption_denied.html.erb
index d67a789f..81c9c0b5 100644
--- a/reporting-app/app/views/dashboard/_exemption_denied.html.erb
+++ b/reporting-app/app/views/dashboard/_exemption_denied.html.erb
@@ -1,12 +1,5 @@
-
-
-
<%= t(".heading") %>
-
- <%= t(".intro") %>
-
-
-
+ <%= render AlertComponent.new(type: AlertComponent::TYPES::ERROR, heading: t(".heading"), message: t(".intro"), heading_level: 3) %>
<%= link_to t(".view_exemption_button"), exemption_application_form_path(exemption_application), class: "usa-button" %>
diff --git a/reporting-app/app/views/dashboard/_request_for_information.html.erb b/reporting-app/app/views/dashboard/_request_for_information.html.erb
index 77403034..ae16b453 100644
--- a/reporting-app/app/views/dashboard/_request_for_information.html.erb
+++ b/reporting-app/app/views/dashboard/_request_for_information.html.erb
@@ -1,7 +1,6 @@
-
-
-
<%= t('.heading') %>
+ <%= render AlertComponent.new(type: AlertComponent::TYPES::WARNING, heading: t('.heading'), heading_level: 3, classes: "margin-bottom-3") do |c| %>
+ <% c.with_body do %>
<%= t('.intro_html', due_date: information_request.due_date&.strftime('%B %d, %Y') || '') %>
@@ -12,6 +11,6 @@
<%= link_to t('.cta_button'), edit_exemption_information_request_path(information_request), class: 'usa-button display-inline-block' %>
<% end %>
-
-
+ <% end %>
+ <% end %>
diff --git a/reporting-app/app/views/document_staging/create.html.erb b/reporting-app/app/views/document_staging/create.html.erb
index 5424b8cf..aa2e34eb 100644
--- a/reporting-app/app/views/document_staging/create.html.erb
+++ b/reporting-app/app/views/document_staging/create.html.erb
@@ -3,11 +3,7 @@
<%= t(".heading") %>
<% if @error %>
-
+ <%= render AlertComponent.new(type: AlertComponent::TYPES::ERROR, message: @error, classes: "margin-bottom-3") %>
<% end %>
diff --git a/reporting-app/app/views/information_requests/_edit.html.erb b/reporting-app/app/views/information_requests/_edit.html.erb
index 34e8a9e8..af14243b 100644
--- a/reporting-app/app/views/information_requests/_edit.html.erb
+++ b/reporting-app/app/views/information_requests/_edit.html.erb
@@ -3,15 +3,15 @@
<%= t('information_requests.edit.instructions_heading') %>
<%= t('information_requests.edit.instructions_html') %>
-
-
+<%= render AlertComponent.new(type: AlertComponent::TYPES::INFO, classes: "margin-top-3") do |c| %>
+ <% c.with_body do %>
<%= @information_request.staff_comment %>
<%= t('information_requests.edit.due_by', due_date: @information_request.due_date.strftime('%B %d, %Y')) %>
-
-
+ <% end %>
+<% end %>
<%= strata_form_with(model: @information_request) do |f| %>
<%= f.file_field :supporting_documents, label: t('information_requests.edit.upload_documents'), multiple: true %>
diff --git a/reporting-app/app/views/staff/certification_batch_uploads/_completion_alert.html.erb b/reporting-app/app/views/staff/certification_batch_uploads/_completion_alert.html.erb
index 5d68ae2a..8d6a3040 100644
--- a/reporting-app/app/views/staff/certification_batch_uploads/_completion_alert.html.erb
+++ b/reporting-app/app/views/staff/certification_batch_uploads/_completion_alert.html.erb
@@ -3,11 +3,11 @@
<% scope = "staff.certification_batch_uploads.show" %>
<% if batch_upload.num_rows_errored.zero? %>
<%= render "staff/certification_batch_uploads/status_alert",
- type: "success", heading: t("success_heading", scope: scope),
+ type: AlertComponent::TYPES::SUCCESS, heading: t("success_heading", scope: scope),
message: success_message %>
<% else %>
<%= render "staff/certification_batch_uploads/status_alert",
- type: "warning", heading: t("partial_success_heading", scope: scope),
+ type: AlertComponent::TYPES::WARNING, heading: t("partial_success_heading", scope: scope),
message: t("partial_success_message", scope: scope,
num_rows_succeeded: batch_upload.num_rows_succeeded,
num_rows_errored: batch_upload.num_rows_errored,
diff --git a/reporting-app/app/views/staff/certification_batch_uploads/_status_alert.html.erb b/reporting-app/app/views/staff/certification_batch_uploads/_status_alert.html.erb
index 95ddedcc..479dc342 100644
--- a/reporting-app/app/views/staff/certification_batch_uploads/_status_alert.html.erb
+++ b/reporting-app/app/views/staff/certification_batch_uploads/_status_alert.html.erb
@@ -1,10 +1,8 @@
<%# Shared USWDS alert banner for batch upload status messages %>
<%# Locals: type (info/error/success/warning), heading (optional), message %>
-
>
-
- <% if local_assigns[:heading] %>
-
<%= heading %>
- <% end %>
-
<%= message %>
-
-
+<%= render AlertComponent.new(
+ type: type,
+ heading: local_assigns[:heading],
+ message: message,
+ heading_level: 2
+) %>
diff --git a/reporting-app/app/views/staff/certification_batch_uploads/index.html.erb b/reporting-app/app/views/staff/certification_batch_uploads/index.html.erb
index b19d2ae5..913806b7 100644
--- a/reporting-app/app/views/staff/certification_batch_uploads/index.html.erb
+++ b/reporting-app/app/views/staff/certification_batch_uploads/index.html.erb
@@ -28,10 +28,6 @@
<% else %>
-
-
-
<%= t(".no_uploads") %>
-
-
+ <%= render AlertComponent.new(type: AlertComponent::TYPES::INFO, message: t(".no_uploads"), classes: "margin-top-4") %>
<% end %>
<% end %>
diff --git a/reporting-app/app/views/staff/certification_batch_uploads/new.html.erb b/reporting-app/app/views/staff/certification_batch_uploads/new.html.erb
index 98aeda4b..44669bfe 100644
--- a/reporting-app/app/views/staff/certification_batch_uploads/new.html.erb
+++ b/reporting-app/app/views/staff/certification_batch_uploads/new.html.erb
@@ -4,8 +4,8 @@
<%= link_to t(".download_template"), "/certification_batch_upload_template.csv", class: "usa-link", download: true %>
-
-
+<%= render AlertComponent.new(type: AlertComponent::TYPES::INFO) do |c| %>
+ <% c.with_body do %>
<%= t(".csv_format.heading") %>
<%= t(".csv_format.description") %>
@@ -43,8 +43,8 @@
-
-
+ <% end %>
+<% end %>
<%= form_with(url: certification_batch_uploads_path, multipart: true, class: "margin-top-4") do |f| %>
<%= f.label :csv_file, t(".csv_file_label"), class: "usa-label" %>
diff --git a/reporting-app/app/views/users/passwords/reset.html.erb b/reporting-app/app/views/users/passwords/reset.html.erb
index 8cf30037..75b6f6b3 100644
--- a/reporting-app/app/views/users/passwords/reset.html.erb
+++ b/reporting-app/app/views/users/passwords/reset.html.erb
@@ -1,13 +1,11 @@
<% content_for :title, t(".title") %>
-
-
-
<%= t(".instructions_heading") %>
-
- <%= t(".instructions_html", verify_account_url: url_for(users_verify_account_path)) %>
-
-
-
+<%= render AlertComponent.new(
+ type: AlertComponent::TYPES::INFO,
+ heading: t(".instructions_heading"),
+ message: t(".instructions_html", verify_account_url: url_for(users_verify_account_path)),
+ heading_level: 2
+) %>
<%= t(".title") %>
@@ -29,4 +27,4 @@
<%= f.submit t(".submit") %>
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/reporting-app/spec/components/alert_component_spec.rb b/reporting-app/spec/components/alert_component_spec.rb
new file mode 100644
index 00000000..87251eb6
--- /dev/null
+++ b/reporting-app/spec/components/alert_component_spec.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe AlertComponent, type: :component do
+ describe "types" do
+ it "renders each type with matching modifier class" do
+ AlertComponent::TYPES::ALL.each do |alert_type|
+ render_inline(described_class.new(type: alert_type, message: "x"))
+ expect(page).to have_css(".usa-alert.usa-alert--#{alert_type}")
+ end
+ end
+
+ it "raises for invalid type" do
+ expect do
+ described_class.new(type: "bogus", message: "x")
+ end.to raise_error(ArgumentError, /Invalid alert type/)
+ end
+ end
+
+ describe "ARIA role" do
+ it "sets role=alert only for error" do
+ render_inline(described_class.new(type: "error", message: "e"))
+ expect(page).to have_css('.usa-alert[role="alert"]')
+ end
+
+ it "defaults to role=status for non-error types" do
+ %w[info success warning].each do |alert_type|
+ render_inline(described_class.new(type: alert_type, message: "m"))
+ expect(page).to have_css(".usa-alert.usa-alert--#{alert_type}")
+ expect(page).to have_css('.usa-alert[role="status"]')
+ end
+ end
+
+ it "honors explicit role (e.g. status on info)" do
+ render_inline(described_class.new(type: "info", message: "m", role: "status"))
+ expect(page).to have_css('.usa-alert[role="status"]')
+ end
+
+ it "honors explicit role=alert on error" do
+ render_inline(described_class.new(type: "error", message: "e", role: "alert"))
+ expect(page).to have_css('.usa-alert[role="alert"]')
+ end
+
+ it "allows role: nil to omit the attribute" do
+ render_inline(described_class.new(type: "info", message: "m", role: nil))
+ expect(page).to have_no_css('.usa-alert[role]')
+ end
+ end
+
+ describe "simple mode" do
+ it "renders message in usa-alert__text" do
+ render_inline(described_class.new(type: "success", message: "Saved"))
+ expect(page).to have_css("p.usa-alert__text", text: "Saved")
+ end
+
+ it "renders optional heading at the given level" do
+ render_inline(described_class.new(type: "info", heading: "Title", message: "Body", heading_level: 4))
+ expect(page).to have_css("h4.usa-alert__heading", text: "Title")
+ expect(page).to have_css("p.usa-alert__text", text: "Body")
+ end
+ end
+
+ describe "block mode (body slot)" do
+ it "renders slot content instead of message" do
+ render_inline(described_class.new(type: "error", heading: "Problems")) do |c|
+ c.with_body { "
".html_safe }
+ end
+ expect(page).to have_css("h2.usa-alert__heading", text: "Problems")
+ expect(page).to have_css("ul.usa-list li", text: "a")
+ expect(page).to have_no_css("p.usa-alert__text")
+ end
+
+ it "renders heading before body when both heading and slot are used (e.g. warning + actions)" do
+ render_inline(described_class.new(type: "warning", heading: "Notice", heading_level: 3)) do |c|
+ c.with_body { "
Details
".html_safe }
+ end
+ expect(page).to have_css("h3.usa-alert__heading", text: "Notice")
+ expect(page).to have_css("p.usa-alert__text", text: "Details")
+ expect(page).to have_css('.usa-alert[role="status"]')
+ end
+ end
+
+ describe "extra attributes" do
+ it "merges classes onto the root" do
+ render_inline(described_class.new(type: "info", message: "m", classes: "margin-top-4 usa-alert--slim"))
+ expect(page).to have_css(".usa-alert.margin-top-4.usa-alert--slim")
+ end
+
+ it "passes style to the root element" do
+ render_inline(described_class.new(type: "info", message: "m", style: "padding: unset;"))
+ expect(page).to have_css('.usa-alert[style="padding: unset;"]')
+ end
+ end
+
+ describe "heading_level validation" do
+ it "rejects invalid heading levels" do
+ expect do
+ described_class.new(type: "info", message: "m", heading_level: 7)
+ end.to raise_error(ArgumentError, /heading_level/)
+ end
+ end
+end
diff --git a/reporting-app/spec/rails_helper.rb b/reporting-app/spec/rails_helper.rb
index bc9c0e0d..d322504c 100644
--- a/reporting-app/spec/rails_helper.rb
+++ b/reporting-app/spec/rails_helper.rb
@@ -35,6 +35,8 @@
# Prevent database truncation if the environment is production
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'
+require 'view_component/test_helpers'
+require 'capybara/rspec'
require 'webmock/rspec'
# Add additional requires below this line. Rails is not loaded until this point!
require_relative 'support/factory_bot'
@@ -72,6 +74,7 @@
config.include Strata::Testing::ApiAuthHelpers
config.include Devise::Test::ControllerHelpers, type: :controller
config.include PunditSpecViewHelper, type: :view
+ config.include ViewComponent::TestHelpers, type: :component
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_paths = [