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..c65d06924 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].present? + 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 @@ -19,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) @@ -34,8 +39,10 @@ def update_attendance(attending:, attended:) def update_to_attended @invitation.update(attended: true) + end - "You have verified #{@invitation.member.full_name}’s attendace." + def update_to_unattended + @invitation.update(attended: nil) end def update_to_attending 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/app/views/admin/workshop/_attendance_row.html.haml b/app/views/admin/workshop/_attendance_row.html.haml new file mode 100644 index 000000000..49206684b --- /dev/null +++ b/app/views/admin/workshop/_attendance_row.html.haml @@ -0,0 +1,63 @@ +-# locals: (invitation:, workshop:) +.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 + = 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 + .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..2e50b5fd0 100644 --- a/spec/controllers/admin/invitations_controller_spec.rb +++ b/spec/controllers/admin/invitations_controller_spec.rb @@ -43,4 +43,34 @@ 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 + + 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/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 diff --git a/spec/features/admin/manage_workshop_attendances_spec.rb b/spec/features/admin/manage_workshop_attendances_spec.rb index 9e2192e14..e0e26f2ff 100644 --- a/spec/features/admin/manage_workshop_attendances_spec.rb +++ b/spec/features/admin/manage_workshop_attendances_spec.rb @@ -20,6 +20,27 @@ expect(page).to have_css('.fa-check-square') end + + 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) + + # 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 scenario 'can remove a member from the attendee list' do