diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 915c7dc9b5..a0ff88d8af 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -30,8 +30,7 @@ def credited_author_edit_button(record)
person_edit_button(person,
display_name: record.author_credit,
compact: true,
- width_class: "inline-flex w-fit",
- data: { turbo_frame: "_top" })
+ width_class: "inline-flex w-fit")
end
# The person an author picker should show. Only the record's own author counts —
diff --git a/app/views/admin/ahoy_activities/_activity_row.html.erb b/app/views/admin/ahoy_activities/_activity_row.html.erb
index bdd5268456..5ac6f73cf5 100644
--- a/app/views/admin/ahoy_activities/_activity_row.html.erb
+++ b/app/views/admin/ahoy_activities/_activity_row.html.erb
@@ -15,7 +15,8 @@
<% if event.user %>
<%= link_to event.user.full_name,
user_path(event.user),
- class: "text-indigo-600 hover:underline" %>
+ class: "text-indigo-600 hover:underline",
+ data: { turbo_frame: "_top" } %>
<% else %>
Guest
<% end %>
diff --git a/app/views/admin/ahoy_activities/_event_details.html.erb b/app/views/admin/ahoy_activities/_event_details.html.erb
index 92a6e7d000..60963db6cf 100644
--- a/app/views/admin/ahoy_activities/_event_details.html.erb
+++ b/app/views/admin/ahoy_activities/_event_details.html.erb
@@ -30,7 +30,7 @@
<% if row[:action] %><%= row[:action] %><% end %>
<% if row[:link] %>
<% if row[:link][:path] %>
- <%= link_to row[:link][:text], row[:link][:path], class: "text-indigo-600 hover:underline" %>
+ <%= link_to row[:link][:text], row[:link][:path], class: "text-indigo-600 hover:underline", data: { turbo_frame: "_top" } %>
<% else %>
<%= row[:link][:text] %>
<% end %>
diff --git a/app/views/admin/ahoy_activities/visit_results.html.erb b/app/views/admin/ahoy_activities/visit_results.html.erb
index 538af51480..c518f6a97c 100644
--- a/app/views/admin/ahoy_activities/visit_results.html.erb
+++ b/app/views/admin/ahoy_activities/visit_results.html.erb
@@ -49,7 +49,8 @@
<% if visit.user %>
<%= link_to visit.user.full_name,
user_path(visit.user),
- class: "text-indigo-600 hover:underline" %>
+ class: "text-indigo-600 hover:underline",
+ data: { turbo_frame: "_top" } %>
<% else %>
Guest
<% end %>
@@ -61,7 +62,8 @@
<%= link_to visit.attributes["events_count"],
- admin_activities_events_path(visit_id: visit.id) %>
+ admin_activities_events_path(visit_id: visit.id),
+ data: { turbo_frame: "_top" } %>
|
diff --git a/app/views/comments/_aggregated_comment.html.erb b/app/views/comments/_aggregated_comment.html.erb
index b272783989..6a50b939e2 100644
--- a/app/views/comments/_aggregated_comment.html.erb
+++ b/app/views/comments/_aggregated_comment.html.erb
@@ -11,7 +11,8 @@
<%= link_to comment.source_label, comment.source_path,
- class: "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium #{DomainTheme.border_class_for(comment.source_theme)} #{DomainTheme.bg_class_for(comment.source_theme, intensity: 100)} #{DomainTheme.text_class_for(comment.source_theme, intensity: 800)}" %>
+ class: "inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium #{DomainTheme.border_class_for(comment.source_theme)} #{DomainTheme.bg_class_for(comment.source_theme, intensity: 100)} #{DomainTheme.text_class_for(comment.source_theme, intensity: 800)}",
+ data: { turbo_frame: "_top" } %>
<% if comment.topic.present? %>
<%= comment.topic %>
<% end %>
diff --git a/app/views/events/_person_profile_button.html.erb b/app/views/events/_person_profile_button.html.erb
index b7d6628050..35fb587e74 100644
--- a/app/views/events/_person_profile_button.html.erb
+++ b/app/views/events/_person_profile_button.html.erb
@@ -12,6 +12,7 @@
{ return_to: "bulk_payments", event_id: local_assigns[:event]&.id, expand: local_assigns[:expand] } : {} %>
<%= link_to person_path(person, person_link_params),
title: "#{profile_person.name} — #{person.preferred_email}",
+ data: { turbo_frame: "_top" },
class: "inline-flex items-center gap-2 shrink-0 rounded-lg border border-sky-300 bg-sky-100 hover:bg-sky-200 px-3 py-2 shadow-sm transition-colors" do %>
<% if profile_person.avatar.present? %>
<%= image_tag profile_person.avatar.variant(:thumbnail),
diff --git a/app/views/events/_registrant_roster.html.erb b/app/views/events/_registrant_roster.html.erb
index b7c7f5148d..1516ea31ba 100644
--- a/app/views/events/_registrant_roster.html.erb
+++ b/app/views/events/_registrant_roster.html.erb
@@ -90,7 +90,7 @@
- <%= link_to person.name, person_link, class: "font-bold text-gray-900 hover:underline text-base sm:text-sm" %>
+ <%= link_to person.name, person_link, class: "font-bold text-gray-900 hover:underline text-base sm:text-sm", data: { turbo_frame: "_top" } %>
|
<%# Primary sector + age group — one column, each independently
sortable (data-sort-sector / data-sort-age), mirroring the
@@ -116,7 +116,7 @@
<%= mobile_label.("Organization") %>
<% if person_orgs.any? %>
- <% person_orgs.each_with_index do |org, i| %><%= ", " if i.positive? %><%= org.decorate.high_profile_icon %><%= link_to org.name, organization_path(org), class: "hover:underline", onclick: "event.stopPropagation()" %><% end %>
+ <% person_orgs.each_with_index do |org, i| %><%= ", " if i.positive? %><%= org.decorate.high_profile_icon %><%= link_to org.name, organization_path(org), class: "hover:underline", onclick: "event.stopPropagation()", data: { turbo_frame: "_top" } %><% end %>
<% else %>
—
<% end %>
@@ -174,7 +174,8 @@
<%= link_to recipients_event_path(target_event, anchor: ("participant-#{participant_slug}" if participant_slug)),
class: "inline-flex items-center gap-1 #{declined ? "text-gray-400 hover:text-gray-600" : "#{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:#{DomainTheme.text_class_for(:scholarships)}"}",
title: declined ? "Scholarship declined — view application" : (show_event_column ? "Scholarship from #{target_event.decorate.compact_label} — view application" : "Scholarship recipient — view application"),
- onclick: "event.stopPropagation()" do %>
+ onclick: "event.stopPropagation()",
+ data: { turbo_frame: "_top" } do %>
<% if show_event_column %> <%= target_event.decorate.compact_label %><% end %>
<%= scholarship.decorate.agreement_status_badge %>
@@ -192,7 +193,8 @@
<%= link_to edit_continuing_education_registration_path(ce_registration),
class: "inline-flex items-center gap-1 #{DomainTheme.text_class_for(:continuing_education, intensity: 600)} hover:#{DomainTheme.text_class_for(:continuing_education)}",
title: ce_event ? "Continuing education from #{ce_event.decorate.compact_label} — edit" : "Edit continuing education",
- onclick: "event.stopPropagation()" do %>
+ onclick: "event.stopPropagation()",
+ data: { turbo_frame: "_top" } do %>
<% if ce_event %> <%= ce_event.decorate.compact_label %><% end %>
<% end %>
@@ -212,7 +214,8 @@
<%= link_to edit_event_registration_path(registration, return_to: row_return_to),
class: "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium hover:underline #{attendance.attendance_status_classes}",
title: "Edit registration",
- onclick: "event.stopPropagation()" do %>
+ onclick: "event.stopPropagation()",
+ data: { turbo_frame: "_top" } do %>
<%= registration.attendance_status_label %>
<% end %>
@@ -238,7 +241,7 @@
<% if first_registration %>
- <%= link_to edit_event_registration_path(first_registration, return_to: "attendees"), class: event_link_class do %>
+ <%= link_to edit_event_registration_path(first_registration, return_to: "attendees"), class: event_link_class, data: { turbo_frame: "_top" } do %>
<%= first_registration.event.decorate.compact_label %>
<%= first_registration.event.start_date&.strftime("%b %Y") %>
<% end %>
@@ -251,7 +254,7 @@
<% if registrations.size > 1 %>
<% registrations.drop(1).each do |registration| %>
- <%= link_to edit_event_registration_path(registration, return_to: "attendees"), class: "text-xs #{DomainTheme.text_class_for(:event_registrations, intensity: 700)} hover:underline" do %>
+ <%= link_to edit_event_registration_path(registration, return_to: "attendees"), class: "text-xs #{DomainTheme.text_class_for(:event_registrations, intensity: 700)} hover:underline", data: { turbo_frame: "_top" } do %>
<%= registration.event.decorate.compact_label %>
<%= registration.event.start_date&.strftime("%b %Y") %>
<% end %>
diff --git a/app/views/monthly_reports/_monthly_reports_results.html.erb b/app/views/monthly_reports/_monthly_reports_results.html.erb
index 983b228870..ea268eab6e 100644
--- a/app/views/monthly_reports/_monthly_reports_results.html.erb
+++ b/app/views/monthly_reports/_monthly_reports_results.html.erb
@@ -29,7 +29,7 @@
class: "font-bold text-gray-800 hover:text-indigo-800 hover:underline" %>
|
- <%= credited_author_link(report, class: eyebrow_link_class) %> |
+ <%= credited_author_link(report, class: eyebrow_link_class, data: { turbo_frame: "_top" }) %> |
<%= report.organization&.name || "—" %> |
diff --git a/app/views/workshop_variations/_workshop_variations_results.html.erb b/app/views/workshop_variations/_workshop_variations_results.html.erb
index 4ddfab4857..6a61dd80a7 100644
--- a/app/views/workshop_variations/_workshop_variations_results.html.erb
+++ b/app/views/workshop_variations/_workshop_variations_results.html.erb
@@ -62,7 +62,7 @@
<% end %>
- <%= credited_author_link(workshop_variation, class: "#{eyebrow_link_class}") %>
+ <%= credited_author_link(workshop_variation, class: "#{eyebrow_link_class}", data: { turbo_frame: "_top" }) %>
|
<% if workshop_variation.workshop_variation_idea %>
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 509b14d3d4..c4bf81d3c3 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -51,6 +51,17 @@
expect(result).to eq("AWBW Staff")
expect(result).not_to include(" "attendees_charts" } }
+ describe "roster away-links break out of the results frame" do
+ it "gives the registrant name link data-turbo-frame=_top so it doesn't Oopsie the frame" do
+ sign_in admin
+ get attendees_events_url, headers: frame_headers
+
+ expect(response).to have_http_status(:ok)
+ expect_frame_breakout(response.body, "/event_registrations/#{attendee_registration.id}/edit")
+ end
+ end
+
describe "GET /events/attendees" do
context "as a user who owns no events" do
it "redirects — there is nothing for them to report on" do
diff --git a/spec/requests/monthly_reports_spec.rb b/spec/requests/monthly_reports_spec.rb
index 79edcc38db..95b12c3d1a 100644
--- a/spec/requests/monthly_reports_spec.rb
+++ b/spec/requests/monthly_reports_spec.rb
@@ -23,6 +23,16 @@
expect(response.body).to include("Mar 2026")
end
+ it "breaks the credited-author byline out of the results frame" do
+ author = create(:person, first_name: "Lena", last_name: "Byline", profile_is_searchable: true)
+ create(:monthly_report, organization: organization, author: author,
+ author_credit_preference: "full_name", date: Date.new(2026, 4, 1))
+
+ get monthly_reports_url, headers: turbo_headers
+
+ expect_frame_breakout(response.body, person_path(author))
+ end
+
it "filters by organization_id when provided" do
create(:monthly_report, organization: organization, date: Date.new(2026, 4, 1))
create(:monthly_report, organization: other_organization, date: Date.new(2026, 3, 1))
diff --git a/spec/requests/payments_spec.rb b/spec/requests/payments_spec.rb
index d33a87ba37..958afeb0b7 100644
--- a/spec/requests/payments_spec.rb
+++ b/spec/requests/payments_spec.rb
@@ -6,6 +6,26 @@
before { sign_in admin }
+ describe "GET /payments index frame" do
+ it "renders the lazy results frame without erroring" do
+ create(:payment, person: person, amount_cents: 5000, amount_cents_remaining: 5000)
+
+ get payments_path, headers: { "Turbo-Frame" => "payments_results" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("payments_results")
+ expect(response.body).to include(person.name)
+ end
+
+ it "breaks the row's View link out of the results frame" do
+ payment = create(:payment, person: person, amount_cents: 5000, amount_cents_remaining: 5000)
+
+ get payments_path, headers: { "Turbo-Frame" => "payments_results" }
+
+ expect_frame_breakout(response.body, payment_path(payment))
+ end
+ end
+
describe "GET /payments/:id eyebrow" do
let(:event) { create(:event, cost_cents: 5000) }
let(:submission) { create(:form_submission, event: event, role: "bulk_payment") }
diff --git a/spec/requests/people_all_comments_spec.rb b/spec/requests/people_all_comments_spec.rb
index 7dfd4b24d6..0debaec497 100644
--- a/spec/requests/people_all_comments_spec.rb
+++ b/spec/requests/people_all_comments_spec.rb
@@ -36,6 +36,14 @@
expect(response.body).to include("Profile note", "Registration note", "Scholarship note", "CE note", "Account note")
end
+ it "breaks the source chip out of the results frame so it doesn't Oopsie" do
+ create(:comment, commentable: person, body: "Profile note")
+
+ get all_comments_person_path(person), headers: frame_headers
+
+ expect_frame_breakout(response.body, "/people/#{person.id}/edit")
+ end
+
it "filters to a single source" do
create(:comment, commentable: person, body: "Profile note")
registration = create(:event_registration, registrant: person)
diff --git a/spec/requests/people_notifications_spec.rb b/spec/requests/people_notifications_spec.rb
index 00a8af089b..59ca651865 100644
--- a/spec/requests/people_notifications_spec.rb
+++ b/spec/requests/people_notifications_spec.rb
@@ -139,7 +139,6 @@
get edit_person_path(person)
doc = Nokogiri::HTML(response.body)
- # Only admins reach the profile, so nothing inside the section carries the
# `admin-only` wash — buttons and cards alike render on the plain white card.
# (Per-author identity chips may independently be blue: chip_color hashes the
# author id onto a palette that includes bg-blue-100, so don't ban that class
diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb
index e1dff3e304..b3367cd98b 100644
--- a/spec/requests/people_search_spec.rb
+++ b/spec/requests/people_search_spec.rb
@@ -15,6 +15,11 @@
expect(response).to have_http_status(:ok)
end
+ it "breaks the person profile button out of the results frame" do
+ get people_path, headers: turbo_headers
+ expect_frame_breakout(response.body, person_path(person_alice))
+ end
+
it "returns all people when no filters are applied" do
get people_path, headers: turbo_headers
expect(response).to have_http_status(:ok)
diff --git a/spec/requests/professional_licenses_spec.rb b/spec/requests/professional_licenses_spec.rb
index 62621c73b1..95b3bed3d8 100644
--- a/spec/requests/professional_licenses_spec.rb
+++ b/spec/requests/professional_licenses_spec.rb
@@ -23,6 +23,13 @@
expect(response.body).to include("555")
end
+ it "breaks the person edit button out of the results frame" do
+ get professional_licenses_path,
+ headers: { "Turbo-Frame" => "professional_licenses_results" }
+
+ expect_frame_breakout(response.body, edit_person_path(person))
+ end
+
it "filters by type" do
other = create(:professional_license, kind: "LCSW", number: "999")
diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb
index ca8410f5fa..e32a7a426f 100644
--- a/spec/requests/users_spec.rb
+++ b/spec/requests/users_spec.rb
@@ -39,6 +39,15 @@
get users_url
expect(response).to be_successful
end
+
+ it "renders the lazy results frame, including an account with no linked person" do
+ create(:user, person: nil)
+
+ get users_url, headers: { "Turbo-Frame" => "users_results" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("users_results")
+ end
end
context "as regular_user" do
diff --git a/spec/requests/video_recordings_spec.rb b/spec/requests/video_recordings_spec.rb
index 9a95b11015..f54f63c27c 100644
--- a/spec/requests/video_recordings_spec.rb
+++ b/spec/requests/video_recordings_spec.rb
@@ -44,6 +44,15 @@
get video_recordings_url
expect(response).to be_successful
end
+
+ it "renders the lazy results frame without erroring" do
+ VideoRecording.create! valid_attributes
+
+ get video_recordings_url, headers: { "Turbo-Frame" => "video_recordings_results" }
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("video_recordings_results")
+ end
end
describe "GET /show" do
diff --git a/spec/requests/workshop_variations_spec.rb b/spec/requests/workshop_variations_spec.rb
index 92aa91ed34..4f34b377b6 100644
--- a/spec/requests/workshop_variations_spec.rb
+++ b/spec/requests/workshop_variations_spec.rb
@@ -221,6 +221,16 @@
expect(response.body).to include(authored.name)
expect(response.body).not_to include(other.name)
end
+
+ it "breaks the credited-author byline out of the results frame" do
+ author = create(:person, first_name: "Lena", last_name: "Byline", profile_is_searchable: true)
+ create(:workshop_variation, valid_attributes.merge(name: "Bylined variation", author: author))
+
+ get workshop_variations_path,
+ headers: { "Turbo-Frame" => "workshop_variations_results" }
+
+ expect_frame_breakout(response.body, person_path(author))
+ end
end
describe "GET /new" do
diff --git a/spec/support/turbo_frame_breakout_helpers.rb b/spec/support/turbo_frame_breakout_helpers.rb
new file mode 100644
index 0000000000..b5677a42dd
--- /dev/null
+++ b/spec/support/turbo_frame_breakout_helpers.rb
@@ -0,0 +1,17 @@
+module TurboFrameBreakoutHelpers
+ # Asserts that a link inside a lazy `*_results` turbo frame breaks out to the
+ # top browsing context (`data-turbo-frame="_top"`). Without it, clicking the
+ # link loads the destination *into* the results frame — which has no matching
+ # frame — and Turbo swaps in the "Oopsie!" frame-missing box. `href_includes`
+ # matches the destination path (a fragment is fine, e.g. an edit path).
+ def expect_frame_breakout(body, href_includes)
+ link = Nokogiri::HTML(body).at_css("a[href*='#{href_includes}']")
+ expect(link).to be_present, "expected a link to #{href_includes} in the frame"
+ expect(link["data-turbo-frame"]).to eq("_top"),
+ "link to #{href_includes} is missing data-turbo-frame=\"_top\" (would Oopsie the frame)"
+ end
+end
+
+RSpec.configure do |config|
+ config.include TurboFrameBreakoutHelpers, type: :request
+end
|