fix(insights): drop the dismiss toast and fix both empty states (#2965)

* fix(insights): drop the dismiss toast and fix both empty states

Three problems with acknowledging an insight, all in the turbo-stream path.

Every dismissal appended an undo toast to the notification tray. Acknowledging
already means "hidden until these numbers change" — GenerateInsightsJob
resurfaces a row whose metadata moves materially, and 6 of 8 generators scope
`dedup_key` to a month — so a toast interrupting the flow bought little.
Removed, along with the now-orphaned `_undo_toast` partial and its two locale
keys.

Dismissing the last insight left the dashboard widget on screen: the stream
re-rendered the well unconditionally, so the section shell stayed with its
header above an empty box until a reload. A full render already drops it
(PagesController#insights_feed_section sets `visible: @feed_insights.any?`);
the stream now removes the whole section to match, targeted by
`[data-section-key='insights_feed']` because the shared dashboard loop emits no
id on the section element.

Dismissing the last insight on /insights left a blank page. The card left via
`turbo_stream.remove`, which emptied #insights-list without re-rendering the
partial that owns the empty state, so "No insights yet" only appeared after a
reload. The list is now replaced rather than the card removed — the same thing
unacknowledge already did.

InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept
and still work; only the toast that reached them is gone, so undo can be
re-wired to a different surface without resurrecting them.

* fix(insights): announce the dismissal now the toast is gone

Removing the undo toast took the only `role=status` element with it, and the
stream also replaces the list containing the "Got it" control the user just
activated — so a screen-reader or keyboard user was left with no confirmation
that anything happened.

Add a shared, visually hidden live region to the notification tray and update
it from the acknowledge stream. It sits outside every stream target and is
rendered with the page, which matters: a live region that arrives together
with its own content is not announced. Updated rather than appended, so
messages replace instead of piling up, and it stays empty (and free) until
something uses it.

This is a general primitive, not an insights one — the tray already holds
`#sync-toast` and `#cta` as stable stream targets, and any flow that changes
the page without leaving something on screen to read can use it.

Verified in a browser: after dismissing, the region reads "Insight dismissed"
at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible.
This commit is contained in:
Guillem Arias Fauste
2026-08-11 06:15:02 +02:00
committed by GitHub
parent 914547ffdb
commit 2bcef99441
10 changed files with 103 additions and 57 deletions
+52 -2
View File
@@ -49,15 +49,65 @@ class InsightsControllerTest < ActionDispatch::IntegrationTest
"insights_feed should be prepended, not appended, for saved orders that predate it"
end
test "acknowledge removes the insight from the feed and offers undo via turbo stream" do
# Acknowledging is a quiet action — no undo toast. Acknowledgement only covers
# the numbers the user saw (see Insight's class comment), so a dismissed
# insight resurfaces on its own when those numbers move; a toast interrupting
# every dismissal bought little.
test "acknowledge clears the insight without an undo toast" do
patch acknowledge_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_match "turbo-stream", response.body
assert_match unacknowledge_insight_path(@insight), response.body
assert_no_match(/#{Regexp.escape(unacknowledge_insight_path(@insight))}/, response.body)
assert @insight.reload.acknowledged?
end
# The card leaves via a stream, which is silent to a screen reader, and takes
# the control the user just activated with it. With the toast gone, the shared
# live region carries that confirmation instead.
test "acknowledge announces the dismissal in the shared live region" do
patch acknowledge_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_select "turbo-stream[action=update][target=?]", "aria-announcer" do
assert_match CGI.escapeHTML(I18n.t("insights.card.acknowledged")), response.body
end
end
test "the live region is present before any stream updates it" do
get insights_url
assert_response :success
assert_select "#aria-announcer[role=status][aria-live=polite]", 1,
"a live region that arrives with its content is not announced"
end
# The card used to leave via `turbo_stream.remove`, which emptied
# #insights-list without re-rendering the partial that owns the empty state —
# so dismissing the last insight showed a blank page until reload.
test "acknowledging the last insight renders the empty state" do
@user.family.insights.where.not(id: @insight.id).destroy_all
patch acknowledge_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_match "insights-list", response.body
assert_match CGI.escapeHTML(I18n.t("insights.index.empty.title")), response.body
end
# A full dashboard render already drops the section (insights_feed_section sets
# `visible: @feed_insights.any?`), so the turbo path has to as well — otherwise
# the section shell lingered with its header above an empty well.
test "acknowledging the last insight removes the dashboard section" do
@user.family.insights.where.not(id: @insight.id).destroy_all
patch acknowledge_insight_url(@insight), as: :turbo_stream
assert_response :success
assert_select "turbo-stream[action=remove][targets=?]", "[data-section-key='insights_feed']"
assert_select "turbo-stream[action=replace][target=?]", "insights-feed", count: 0
end
test "unacknowledge restores the insight as read and re-renders the list" do
@insight.acknowledge!