From e47055bd169b7328e331c9b6da417b8cd40b680b Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 2 Jul 2026 19:55:44 +0200 Subject: [PATCH 1/5] perf: eager-load attendance_warnings and overrider in workshop scopes Eliminates N+1 queries on the admin workshop attendance page. attendance_warnings is needed for flag_to_organisers? checks. overrider is needed for the hat-wizard tooltip on admin-added RSVPs. --- app/models/waiting_list.rb | 2 +- app/models/workshop_invitation.rb | 2 +- spec/controllers/admin/workshops_controller_spec.rb | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/models/waiting_list.rb b/app/models/waiting_list.rb index 92fe00d6e..7c3ee7abe 100644 --- a/app/models/waiting_list.rb +++ b/app/models/waiting_list.rb @@ -6,7 +6,7 @@ class WaitingList < ApplicationRecord scope :by_workshop, ->(workshop) { joins(:invitation).where('workshop_invitations.workshop_id = ?', workshop.id) } scope :where_role, ->(role) { where('workshop_invitations.role = ?', role) } - scope :with_notes_and_their_authors, -> { includes(member: { member_notes: :author }) } + scope :with_notes_and_their_authors, -> { includes(member: [{ member_notes: :author }, :attendance_warnings]) } def self.add(invitation, auto_rsvp = true) find_or_create_by(invitation: invitation) do |waiting_list| diff --git a/app/models/workshop_invitation.rb b/app/models/workshop_invitation.rb index 59d20959b..d4dd6596d 100644 --- a/app/models/workshop_invitation.rb +++ b/app/models/workshop_invitation.rb @@ -23,7 +23,7 @@ class WorkshopInvitation < ApplicationRecord scope :last_six_months, -> { joins(:workshop).where(workshops: { date_and_time: 6.months.ago...Time.zone.now }) } scope :not_reminded, -> { where(reminded_at: nil) } scope :on_waiting_list, -> { joins(:waiting_list) } - scope :with_notes_and_their_authors, -> { includes(member: { member_notes: :author }) } + scope :with_notes_and_their_authors, -> { includes(member: [{ member_notes: :author }, :attendance_warnings]).includes(:overrider) } after_save :clear_member_cache, if: :saved_change_to_attending? diff --git a/spec/controllers/admin/workshops_controller_spec.rb b/spec/controllers/admin/workshops_controller_spec.rb index 66c338cbe..f580b75d6 100644 --- a/spec/controllers/admin/workshops_controller_spec.rb +++ b/spec/controllers/admin/workshops_controller_spec.rb @@ -6,6 +6,15 @@ login_as_organiser(admin, workshop.chapter) end + describe 'GET #show' do + it 'loads the workshop attendance page with attendees' do + Fabricate(:workshop_invitation, workshop: workshop, attending: true) + get :show, params: { id: workshop.id } + + expect(response).to have_http_status(:success) + end + end + describe 'DELETE #destroy' do context 'workshop invitations have been sent' do before do From c09f0935dfe73c3626de3d4419ec3f918ea3002e Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 2 Jul 2026 19:57:21 +0200 Subject: [PATCH 2/5] perf: targeted row replacement for verify-attendance clicks Instead of re-rendering the full attendance panel, the controller now returns only the single attendee row when params[:attended] is 'true'. The UJS handler replaces just that row in the DOM via .closest('.row.attendee'). Also skips set_admin_workshop_data for single attendance clicks, avoiding loading all students, coaches, and waiting lists. --- app/assets/javascripts/invitations.js | 20 +++++-- .../admin/invitations_controller.rb | 8 ++- .../admin/workshop/_attendance_row.html.haml | 59 ++++++++++++++++++ .../admin/workshop/_attendances.html.haml | 60 +------------------ .../admin/invitations_controller_spec.rb | 20 +++++++ .../admin/manage_workshop_attendances_spec.rb | 16 +++++ 6 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 app/views/admin/workshop/_attendance_row.html.haml diff --git a/app/assets/javascripts/invitations.js b/app/assets/javascripts/invitations.js index 7a496b8f3..fd57836ee 100644 --- a/app/assets/javascripts/invitations.js +++ b/app/assets/javascripts/invitations.js @@ -1,14 +1,22 @@ $(document).ready(function() { $(document).on("ajax:success", "#invitations [data-remote]", function(e) { var xhr = e.detail[2]; - var $invitations = $("#invitations"); + var $link = $(e.currentTarget); - $invitations.html(xhr.responseText); + if ($link.hasClass('verify_attendance')) { + var $row = $link.closest('.row.attendee'); + var rowId = $row.attr('id'); + $row.replaceWith(xhr.responseText); + $(".row.attendee[id='" + rowId + "']").find('[data-bs-toggle="tooltip"]').tooltip(); + } else { + var $invitations = $("#invitations"); + $invitations.html(xhr.responseText); - $invitations.find('select').chosen(function () { - allow_single_deselect: true - no_results_text: 'No results matched' - }); + $invitations.find('select').chosen({ + allow_single_deselect: true, + no_results_text: 'No results matched' + }); + } }); $(document).on('change','#workshop_invitations ',function() { diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 28143b15b..4d2e8dfbd 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -9,8 +9,12 @@ def update message = update_attendance(attending: params[:attending], attended: params[:attended]) if request.xhr? - set_admin_workshop_data - render partial: 'admin/workshops/invitation_management' + if params[:attended] == 'true' + render partial: 'admin/workshop/attendance_row', locals: { invitation: InvitationPresenter.new(@invitation), workshop: @workshop } + else + set_admin_workshop_data + render partial: 'admin/workshops/invitation_management' + end else redirect_back fallback_location: root_path, notice: message end diff --git a/app/views/admin/workshop/_attendance_row.html.haml b/app/views/admin/workshop/_attendance_row.html.haml new file mode 100644 index 000000000..08478a091 --- /dev/null +++ b/app/views/admin/workshop/_attendance_row.html.haml @@ -0,0 +1,59 @@ +.row.attendee.mt-3{ id: "attendee-row-#{invitation.id}" } + .col-1 + - if (workshop.date_and_time - 30.minutes).past? + - if invitation.attended.nil? + = link_to admin_workshop_invitation_path(workshop, invitation, attended: true), + class: 'verify_attendance', title: 'Verify attendance', method: :put, remote: true, + data: { disable_with: "" } do + %i.far.fa-square + - else + %i.far.fa-check-square + - else + = link_to admin_workshop_invitation_path(workshop, invitation, attending: false), class: 'cancel_attendance float-right', title: 'Cancel RSVP', data: { confirm: "Are you sure you want to remove #{invitation.member.full_name} from the list of attendees?" }, method: :put, remote: true do + %i.far.fa-minus-square + .col-5 + - if invitation.member.newbie? + %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'New attendee.' } + %i.fas.fa-paw + - elsif invitation.member.flag_to_organisers? + %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Multiple no shows and attendance warnings in the last six months' } + %i.fas.fa-exclamation-circle + + - if invitation.member.recent_notes.any? + %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Note recently added'} + %i.fas.fa-comment + + = link_to admin_member_path(invitation.member) do + - if invitation.member.full_name.blank? + = invitation.member.email + - else + = invitation.member.full_name + - if invitation.member.dietary_restrictions.present? + %p + - invitation.member.displayed_dietary_restrictions.each do |dr| + %span.badge.bg-secondary.text-wrap.text-break.mb-1.text-start= dr + .col-6 + - if invitation.tutorial? + %p.mb-1 Tutorial: #{invitation.tutorial} + - if invitation.note? + %p.mb-1 Note: #{invitation.note} + - if invitation.rsvp_time.present? + %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Time of RSVP'} + %p.mb-1.small + %i.fas.fa-history + = l(invitation.rsvp_time) + - if invitation.automated_rsvp? + - tooltip_args = { 'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom' } + - if invitation.last_overridden_by_id? + %span{ tooltip_args, title: "Addition by #{invitation.overrider.full_name}" } + %p.mb-1.small + %i.fas.fa-hat-wizard + - else + %span{ tooltip_args, title: 'Waiting list or admin addition' } + %p.mb-1.small + %i.fas.fa-magic + - if invitation.reminded_at.present? + %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Reminder emailed at'} + %p.mb-1.small + %i.far.fa-clock + = l(invitation.reminded_at) diff --git a/app/views/admin/workshop/_attendances.html.haml b/app/views/admin/workshop/_attendances.html.haml index 13e79fe6e..d43ce1d53 100644 --- a/app/views/admin/workshop/_attendances.html.haml +++ b/app/views/admin/workshop/_attendances.html.haml @@ -1,62 +1,4 @@ .row .col.attendances - invitations.each do |invitation| - .row.attendee.mt-3 - .col-1 - - if (invitation.parent.date_and_time - 30.minutes).past? - - if invitation.attended.nil? - = link_to admin_workshop_invitation_path(@workshop, invitation, attended: true), - class: 'verify_attendance', title: 'Verify attendance', method: :put, remote: true, - data: { disable_with: "" } do - %i.far.fa-square - - else - %i.far.fa-check-square - - else - = link_to admin_workshop_invitation_path(@workshop, invitation, attending: false), class: 'cancel_attendance float-right', title: 'Cancel RSVP', data: { confirm: "Are you sure you want to remove #{invitation.member.full_name} from the list of attendees?" }, method: :put, remote: true do - %i.far.fa-minus-square - .col-5 - - if invitation.member.newbie? - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'New attendee.' } - %i.fas.fa-paw - - elsif invitation.member.flag_to_organisers? - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Multiple no shows and attendance warnings in the last six months' } - %i.fas.fa-exclamation-circle - - - if invitation.member.recent_notes.any? - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Note recently added'} - %i.fas.fa-comment - - = link_to admin_member_path(invitation.member) do - - if invitation.member.full_name.blank? - = invitation.member.email - - else - = invitation.member.full_name - - if invitation.member.dietary_restrictions.present? - %p - - invitation.member.displayed_dietary_restrictions.each do |dr| - %span.badge.bg-secondary.text-wrap.text-break.mb-1.text-start= dr - .col-6 - - if invitation.tutorial? - %p.mb-1 Tutorial: #{invitation.tutorial} - - if invitation.note? - %p.mb-1 Note: #{invitation.note} - - if invitation.rsvp_time.present? - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Time of RSVP'} - %p.mb-1.small - %i.fas.fa-history - = l(invitation.rsvp_time) - - if invitation.automated_rsvp? - - tooltip_args = { 'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom' } - - if invitation.last_overridden_by_id? - %span{ tooltip_args, title: "Addition by #{invitation.overrider.full_name}" } - %p.mb-1.small - %i.fas.fa-hat-wizard - - else - %span{ tooltip_args, title: 'Waiting list or admin addition' } - %p.mb-1.small - %i.fas.fa-magic - - if invitation.reminded_at.present? - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: 'Reminder emailed at'} - %p.mb-1.small - %i.far.fa-clock - = l(invitation.reminded_at) + = render partial: 'admin/workshop/attendance_row', locals: { invitation: invitation, workshop: @workshop } diff --git a/spec/controllers/admin/invitations_controller_spec.rb b/spec/controllers/admin/invitations_controller_spec.rb index efd1c88d5..c0f47a119 100644 --- a/spec/controllers/admin/invitations_controller_spec.rb +++ b/spec/controllers/admin/invitations_controller_spec.rb @@ -43,4 +43,24 @@ expect(invitation.last_overridden_by_id).to be admin.id end end + + describe "PUT #update with attended param" do + let(:workshop) { Fabricate(:workshop, date_and_time: Time.zone.now - 1.day) } + let(:invitation) { Fabricate(:workshop_invitation, workshop: workshop, attending: true) } + let(:admin) { Fabricate(:chapter_organiser) } + + before do + admin.add_role(:organiser, workshop.chapter) + login admin + request.env["HTTP_REFERER"] = "/admin/workshop/#{workshop.id}" + end + + it "renders the attendance row partial via XHR when verifying" do + put :update, params: { id: invitation.token, workshop_id: workshop.id, attended: true }, xhr: true + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq('text/html') + expect(invitation.reload.attended).to be true + end + end end diff --git a/spec/features/admin/manage_workshop_attendances_spec.rb b/spec/features/admin/manage_workshop_attendances_spec.rb index 9e2192e14..6ef26ed71 100644 --- a/spec/features/admin/manage_workshop_attendances_spec.rb +++ b/spec/features/admin/manage_workshop_attendances_spec.rb @@ -20,6 +20,22 @@ expect(page).to have_css('.fa-check-square') end + + scenario 'verifies attendance with targeted row replacement', js: true do + second_invitation = Fabricate(:workshop_invitation, workshop: workshop, attending: true) + + visit admin_workshop_path(workshop) + + second_row_id = "attendee-row-#{second_invitation.id}" + expect(page).to have_selector("##{second_row_id}") + + first('.verify_attendance').click + + expect(page).to have_css('.fa-check-square', count: 1, wait: 5) + expect(page).to have_css('.verify_attendance', count: 1) + + expect(page).to have_selector("##{second_row_id}") + end end scenario 'can remove a member from the attendee list' do From 56dbbc67c6ee4eab6c00670691cd5fe32d72020c Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Thu, 2 Jul 2026 19:58:08 +0200 Subject: [PATCH 3/5] feat: unverify attendance with targeted row replacement Clicking a checked attendance square now un-verifies attendance (sets attended back to nil). Uses the same targeted row replacement as verify, avoiding full-panel re-render. --- app/controllers/admin/invitations_controller.rb | 11 +++++++++-- app/views/admin/workshop/_attendance_row.html.haml | 5 ++++- spec/controllers/admin/invitations_controller_spec.rb | 10 ++++++++++ .../admin/manage_workshop_attendances_spec.rb | 11 ++++++++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 4d2e8dfbd..00a6d1120 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -9,7 +9,7 @@ def update message = update_attendance(attending: params[:attending], attended: params[:attended]) if request.xhr? - if params[:attended] == 'true' + if params[:attended].present? render partial: 'admin/workshop/attendance_row', locals: { invitation: InvitationPresenter.new(@invitation), workshop: @workshop } else set_admin_workshop_data @@ -23,7 +23,8 @@ def update private def update_attendance(attending:, attended:) - return update_to_attended if attended + return update_to_attended if attended.to_s == 'true' + return update_to_unattended if attended.to_s == 'false' result = attending.eql?('true') ? update_to_attending : update_to_not_attending message, error = result.values_at(:message, :error) @@ -42,6 +43,12 @@ def update_to_attended "You have verified #{@invitation.member.full_name}’s attendace." end + def update_to_unattended + @invitation.update(attended: nil) + + "You have unverified #{@invitation.member.full_name}’s attendace." + end + def update_to_attending update_successful = @invitation.update( attending: true, diff --git a/app/views/admin/workshop/_attendance_row.html.haml b/app/views/admin/workshop/_attendance_row.html.haml index 08478a091..77e58c7d9 100644 --- a/app/views/admin/workshop/_attendance_row.html.haml +++ b/app/views/admin/workshop/_attendance_row.html.haml @@ -7,7 +7,10 @@ data: { disable_with: "" } do %i.far.fa-square - else - %i.far.fa-check-square + = link_to admin_workshop_invitation_path(workshop, invitation, attended: false), + class: 'verify_attendance', title: 'Unverify attendance', method: :put, remote: true, + data: { disable_with: "" } do + %i.far.fa-check-square - else = link_to admin_workshop_invitation_path(workshop, invitation, attending: false), class: 'cancel_attendance float-right', title: 'Cancel RSVP', data: { confirm: "Are you sure you want to remove #{invitation.member.full_name} from the list of attendees?" }, method: :put, remote: true do %i.far.fa-minus-square diff --git a/spec/controllers/admin/invitations_controller_spec.rb b/spec/controllers/admin/invitations_controller_spec.rb index c0f47a119..2e50b5fd0 100644 --- a/spec/controllers/admin/invitations_controller_spec.rb +++ b/spec/controllers/admin/invitations_controller_spec.rb @@ -62,5 +62,15 @@ expect(response.media_type).to eq('text/html') expect(invitation.reload.attended).to be true end + + it "renders the attendance row partial via XHR when unverifying" do + invitation.update!(attended: true) + + put :update, params: { id: invitation.token, workshop_id: workshop.id, attended: false }, xhr: true + + expect(response).to have_http_status(:success) + expect(response.media_type).to eq('text/html') + expect(invitation.reload.attended).to be_nil + end end end diff --git a/spec/features/admin/manage_workshop_attendances_spec.rb b/spec/features/admin/manage_workshop_attendances_spec.rb index 6ef26ed71..e0e26f2ff 100644 --- a/spec/features/admin/manage_workshop_attendances_spec.rb +++ b/spec/features/admin/manage_workshop_attendances_spec.rb @@ -21,19 +21,24 @@ expect(page).to have_css('.fa-check-square') end - scenario 'verifies attendance with targeted row replacement', js: true do + scenario 'verifies and unverifies attendance with targeted row replacement', js: true do second_invitation = Fabricate(:workshop_invitation, workshop: workshop, attending: true) visit admin_workshop_path(workshop) + # Capture the ID of the second row before clicking second_row_id = "attendee-row-#{second_invitation.id}" expect(page).to have_selector("##{second_row_id}") + # Verify first attendee first('.verify_attendance').click - expect(page).to have_css('.fa-check-square', count: 1, wait: 5) - expect(page).to have_css('.verify_attendance', count: 1) + # Unverify the same attendee + first('.verify_attendance').click + expect(page).to have_css('.fa-check-square', count: 0, wait: 5) + + # The unclicked row should still exist with its original ID, proving targeted replacement expect(page).to have_selector("##{second_row_id}") end end From c76e2ea790ca0386c1424bbefeec6ffbf4855375 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 06:41:36 +0200 Subject: [PATCH 4/5] refactor: remove unused flash messages from attended update helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_to_attended and update_to_unattended returned strings that were never displayed. For XHR requests (all verify/unverify clicks), the controller renders the row partial directly and the message is discarded. For the non-XHR fallback path, no flash is now shown — matching the behaviour that users already experience. --- app/controllers/admin/invitations_controller.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb index 00a6d1120..c65d06924 100644 --- a/app/controllers/admin/invitations_controller.rb +++ b/app/controllers/admin/invitations_controller.rb @@ -39,14 +39,10 @@ def update_attendance(attending:, attended:) def update_to_attended @invitation.update(attended: true) - - "You have verified #{@invitation.member.full_name}’s attendace." end def update_to_unattended @invitation.update(attended: nil) - - "You have unverified #{@invitation.member.full_name}’s attendace." end def update_to_attending From 6264d41ad3c941b18de342f23e73ba18714d72dc Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Fri, 3 Jul 2026 08:58:56 +0200 Subject: [PATCH 5/5] refactor: declare strict locals on attendance_row partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Rails strict locals declaration (introduced in 7.1) to _attendance_row.html.haml, making the partial's interface explicit. This means: - Calling the partial with a missing or misspelled local raises immediately at render time instead of producing a confusing nil error - The partial is self-documenting — its function signature is right at the top of the file Suggested by @olleolleolle in review. --- app/views/admin/workshop/_attendance_row.html.haml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/admin/workshop/_attendance_row.html.haml b/app/views/admin/workshop/_attendance_row.html.haml index 77e58c7d9..49206684b 100644 --- a/app/views/admin/workshop/_attendance_row.html.haml +++ b/app/views/admin/workshop/_attendance_row.html.haml @@ -1,3 +1,4 @@ +-# locals: (invitation:, workshop:) .row.attendee.mt-3{ id: "attendee-row-#{invitation.id}" } .col-1 - if (workshop.date_and_time - 30.minutes).past?