diff --git a/app/controllers/admin/workshops_controller.rb b/app/controllers/admin/workshops_controller.rb index c50b523ed..38306b7c9 100644 --- a/app/controllers/admin/workshops_controller.rb +++ b/app/controllers/admin/workshops_controller.rb @@ -94,6 +94,20 @@ def invite redirect_to admin_workshop_path(@workshop), notice: "Invitations to #{audience} are being emailed out." end + def rsvp + @workshop = Workshop.find(params[:workshop_id]) + authorize @workshop, :update? + + @eligible_count = @workshop.invitations + .joins(:member) + .merge(Member.not_banned) + .count + + return if params[:q].blank? + + @pagy, @invitations = paginate_matching_invitations(params[:q]) + end + def destroy authorize(@workshop) @@ -147,6 +161,16 @@ def destroy_host private + def paginate_matching_invitations(query) + eligible = @workshop.invitations + .joins(:member) + .merge(Member.not_banned) + invitations = eligible.merge(Member.find_members_by_name(query)) + .includes(:member) + .order('members.name, members.surname') + pagy(invitations, items: 20) + end + def workshop_params params.expect(workshop: [ :local_date, :local_time, :local_end_time, :chapter_id, diff --git a/app/models/member.rb b/app/models/member.rb index b44ea7276..1ad3c1b9f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -201,7 +201,13 @@ def other_dietary_restrictions? def self.find_members_by_name(name) name.strip! - name.eql?('') ? none : where("CONCAT(name, ' ', surname) ILIKE ?", "%#{name}%") + return none if name.eql?('') + + # ILIKE metacharacters (% _ \) are escaped so search terms are treated literally: + # a bare '%' matches everything and a trailing backslash raises 'LIKE pattern must + # not end with escape character'. + escaped = name.gsub(/[%_\\]/) { |char| "\\#{char}" } + where("CONCAT(name, ' ', surname) ILIKE ?", "%#{escaped}%") end private diff --git a/app/views/admin/workshops/_invitation_management.html.haml b/app/views/admin/workshops/_invitation_management.html.haml index af2985322..602f8a562 100644 --- a/app/views/admin/workshops/_invitation_management.html.haml +++ b/app/views/admin/workshops/_invitation_management.html.haml @@ -12,17 +12,7 @@ %br = link_to 'See all invitations’ statuses', admin_workshop_changes_path(@workshop) -= simple_form_for :workshop, url: admin_workshop_invitations_path(@workshop, attending: true), remote: true, method: :put do |f| - .row.mb-4 - .col-auto - = f.select :invitations, - @workshop.invitations.includes(:member).not_accepted.all.map { |u| [ "#{u.member.full_name} (#{u.role})", u.token] }, - { include_blank: true }, - { class: 'chosen-select', required: true, - data: { placeholder: t('messages.invitations.select_a_member_to_rsvp') } } - .col-auto - %span{'data-bs-toggle': 'tooltip', 'data-bs-placement': 'bottom', title: t('admin.workshop.manage_rsvps.text')} - %i.fas.fa-info-circle += link_to 'RSVP a member', admin_workshop_rsvp_path(@workshop), class: 'btn btn-primary mb-4' .row .col-12.col-md-6 diff --git a/app/views/admin/workshops/rsvp.html.haml b/app/views/admin/workshops/rsvp.html.haml new file mode 100644 index 000000000..ba6c5b1b0 --- /dev/null +++ b/app/views/admin/workshops/rsvp.html.haml @@ -0,0 +1,78 @@ +- content_for :title, "RSVP members - Workshop ##{@workshop.id}" +.container.py-4.py-lg-5 + .row.mb-1 + .col + = link_to "Back to workshop ##{@workshop.id}", admin_workshop_path(@workshop), class: 'text-muted' + %h2.mt-2 RSVP members + %p.text-muted + #{number_with_delimiter(@eligible_count)} invited members (total) + + .row.mb-4 + .col.col-md-10.col-lg-8 + %dl.row.mb-0.small + %dt.col-sm-2 Workshop + %dd.col-sm-10 ##{@workshop.id} + %dt.col-sm-2 Date & time + %dd.col-sm-10= humanize_date(@workshop.date_and_time, @workshop.ends_at, with_time: true) + %dt.col-sm-2 Venue + %dd.col-sm-10 + - if @workshop.host + - venue = @workshop.host.name + - venue = "#{venue}, #{@workshop.host.address.city}" if @workshop.host.address.present? + = venue + - elsif @workshop.virtual? + Virtual + - else + TBC + %dt.col-sm-2 Spots + %dd.col-sm-10 #{@workshop.student_spaces} students / #{@workshop.coach_spaces} coaches + + .row.mb-4 + .col.col-md-10.col-lg-8 + %p.text-muted.mb-0 + Use this page to manage individual RSVPs for this workshop. Search for an invited member, then use the button in their row to mark them as attending or not attending. + + .row.mb-4 + .col.col-md-10.col-lg-8 + = form_tag admin_workshop_rsvp_path(@workshop), method: :get, class: 'row g-3 align-items-end' do + .col-auto.col-md-6 + = label_tag :q, 'Member name', class: 'form-label' + = text_field_tag :q, params[:q], placeholder: 'Enter member name', class: 'form-control' + .col-auto + = submit_tag 'Search', class: 'btn btn-primary' + + - if params[:q].present? + .row + .col.col-md-10.col-lg-8 + - if @invitations.empty? + %p.text-muted No members found for "#{params[:q]}". + - else + %table.table.table-hover + %thead + %tr + %th Member + %th Role + %th Status + %th + %tbody + - @invitations.each do |invitation| + %tr + %td + = link_to invitation.member.full_name, admin_member_path(invitation.member) + %br + %small.text-muted= invitation.member.email + %td= invitation.role + %td + - if invitation.attending? + %span.badge.bg-success Attending + - elsif invitation.attending == false + %span.badge.bg-secondary Not attending + - else + %span.badge.bg-warning No response + %td + = form_tag admin_workshop_invitation_path(@workshop, invitation), method: :put, class: 'd-inline' do + = hidden_field_tag :attending, invitation.attending? ? 'false' : 'true' + = submit_tag(invitation.attending? ? 'Mark as not attending' : 'RSVP', class: 'btn btn-sm btn-outline-primary', aria: { label: "#{invitation.member.full_name} — #{invitation.attending? ? 'mark as not attending' : 'RSVP'}" }) + = render partial: 'shared/pagination', locals: { pagy: @pagy, model: 'invitation' } if @pagy&.pages&.positive? + - else + %p.text-muted Search for a member to manage their RSVP. diff --git a/app/views/admin/workshops/show.html.haml b/app/views/admin/workshops/show.html.haml index 017207f6e..115ccd657 100644 --- a/app/views/admin/workshops/show.html.haml +++ b/app/views/admin/workshops/show.html.haml @@ -1,3 +1,4 @@ +- content_for :title, "Workshop ##{@workshop.id}" .container-fluid.btn-group.p-0{ role: 'group' } = link_to edit_admin_workshop_path(@workshop), class: 'btn btn-primary py-3 rounded-0' do %i.fas.fa-pencil-alt diff --git a/config/routes.rb b/config/routes.rb index bc8239af1..a7f8ded7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -147,6 +147,7 @@ get 'attendees_checklist' get 'attendees_emails' get 'send_invites' + get 'rsvp' get 'changes' resource :invitations, only: [:update] diff --git a/spec/controllers/admin/invitations_controller_spec.rb b/spec/controllers/admin/invitations_controller_spec.rb index 4858afba5..b7833920f 100644 --- a/spec/controllers/admin/invitations_controller_spec.rb +++ b/spec/controllers/admin/invitations_controller_spec.rb @@ -73,4 +73,39 @@ expect(invitation.reload.attended).to be_nil end end + + describe 'PUT #update (RSVP page toggle)' do + before do + admin.add_role(:organiser, workshop.chapter) + login admin + end + + it 'toggles attending on and redirects back to the rsvp page' do + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'true' } + + expect(invitation.reload.attending).to be(true) + expect(response).to redirect_to(admin_workshop_rsvp_url(workshop)) + end + + it 'toggles an attending invitation back to not attending' do + invitation.update!(attending: true) + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'false' } + + expect(invitation.reload.attending).to be(false) + expect(response).to redirect_to(admin_workshop_rsvp_url(workshop)) + end + + it 'redirects back preserving the search term and page' do + request.env['HTTP_REFERER'] = admin_workshop_rsvp_url(workshop, q: 'Zoe', page: 2) + + put :update, params: { workshop_id: workshop.id, id: invitation.token, attending: 'true' } + + expect(invitation.reload.attending).to be(true) + expect(response).to redirect_to(admin_workshop_rsvp_path(workshop, q: 'Zoe', page: 2)) + end + end end diff --git a/spec/controllers/admin/workshops_controller_spec.rb b/spec/controllers/admin/workshops_controller_spec.rb index f87e3333b..431788556 100644 --- a/spec/controllers/admin/workshops_controller_spec.rb +++ b/spec/controllers/admin/workshops_controller_spec.rb @@ -13,6 +13,10 @@ def count_queries(&block) n end + def assigns(symbol) + controller.instance_variable_get("@#{symbol}") + end + describe 'GET #show' do it 'loads the workshop attendance page with attendees' do Fabricate(:workshop_invitation, workshop: workshop, attending: true) @@ -37,6 +41,122 @@ def count_queries(&block) expect(response).to have_http_status(:success) expect(count).to be < 50 end + + context 'when rendering the page' do + render_views + + it 'links to the RSVP members page instead of rendering an invitations select' do + Fabricate(:workshop_invitation, workshop: workshop, attending: nil) + get :show, params: { id: workshop.id } + + expect(response.body).to include(admin_workshop_rsvp_path(workshop)) + expect(response.body).not_to include('chosen-select') + expect(response.body).not_to include('outstanding invitations') + end + end + end + + describe 'GET #rsvp' do + render_views + + let(:member) { Fabricate(:member, name: 'Zoe', surname: 'Searchable') } + let!(:matching) { Fabricate(:workshop_invitation, workshop: workshop, member: member, attending: nil) } + + before do + Fabricate(:ban, member: Fabricate(:member, name: 'Bob', surname: 'Banned')) + Fabricate(:workshop_invitation, workshop: workshop, attending: true) # an already-attending member (counts toward eligible) + Fabricate(:workshop_invitation, member: member) # an invite for a DIFFERENT workshop + end + + it 'is not accessible without organiser rights' do + allow(controller).to receive(:manager?).and_return(false) + get :rsvp, params: { workshop_id: workshop.id } + + expect(response).to redirect_to(root_path) + end + + it 'assigns the eligible count and no invitations when no search term is given' do + get :rsvp, params: { workshop_id: workshop.id } + + expect(assigns(:eligible_count)).to eq(2) # matching + other; banned is excluded from the count + expect(assigns(:invitations)).to be_nil + expect(response).to have_http_status(:success) + end + + it 'returns only matching invited members for the workshop, excluding banned members' do + get :rsvp, params: { workshop_id: workshop.id, q: 'Zoe' } + + expect(assigns(:invitations).map(&:id)).to eq([matching.id]) + end + + it 'excludes banned members from search results' do + get :rsvp, params: { workshop_id: workshop.id, q: 'Banned' } + + expect(assigns(:invitations)).to be_empty + expect(response.body).to include('No members found') + end + + it 'filters by member name case-insensitively across first and surname' do + get :rsvp, params: { workshop_id: workshop.id, q: 'SEARCHA' } + + expect(assigns(:invitations).map(&:id)).to eq([matching.id]) + end + + it 'eager loads member so rendering does not query per row' do + 3.times { Fabricate(:workshop_invitation, workshop: workshop, member: Fabricate(:member, name: 'Eager', surname: 'Load'), attending: nil) } + + get :rsvp, params: { workshop_id: workshop.id, q: 'Eager' } + + expect(assigns(:invitations).all? { |i| i.association(:member).loaded? }).to be(true) + end + + it 'paginates results at 20 per page and preserves the search term across pages' do + 21.times do |i| + Fabricate(:workshop_invitation, workshop: workshop, member: Fabricate(:member, name: "Page#{i}", surname: 'User'), attending: nil) + end + + get :rsvp, params: { workshop_id: workshop.id, q: 'Page' } + + expect(assigns(:pagy).pages).to eq(2) + expect(assigns(:invitations).size).to eq(20) + expect(response.body).to include('page=2') + + get :rsvp, params: { workshop_id: workshop.id, q: 'Page', page: 2 } + + expect(assigns(:invitations).size).to eq(1) + expect(assigns(:invitations).first.member.name).to start_with('Page') + end + + it 'renders the not-attending badge and RSVP toggle for a declined member' do + declined = Fabricate(:member, name: 'Declined', surname: 'Member') + Fabricate(:workshop_invitation, workshop: workshop, member: declined, attending: false) + + get :rsvp, params: { workshop_id: workshop.id, q: 'Declined' } + + expect(response.body).to include('Not attending') + expect(response.body).to include('RSVP') + expect(response.body).not_to include('Mark as not attending') + end + + it 'renders the eligible count, search box, back link and toggle forms' do + get :rsvp, params: { workshop_id: workshop.id, q: 'Zoe' } + + expect(response.body).to include('invited members') + expect(response.body).to include('Search') + expect(response.body).to include('Back to') + # q: 'Zoe' only returns `matching` (attending: nil) -> its button says 'RSVP' + expect(response.body).to include('RSVP') + expect(response.body).not_to include('Mark as not attending') + end + + it 'renders the not-attending toggle for an already-attending result' do + attending_member = Fabricate(:member, name: 'Aaron', surname: 'Other') + Fabricate(:workshop_invitation, workshop: workshop, member: attending_member, attending: true) + + get :rsvp, params: { workshop_id: workshop.id, q: 'Aaron' } + + expect(response.body).to include('Mark as not attending') + end end describe 'POST #create' do diff --git a/spec/features/admin/manage_workshop_attendances_spec.rb b/spec/features/admin/manage_workshop_attendances_spec.rb index 317478598..274880eda 100644 --- a/spec/features/admin/manage_workshop_attendances_spec.rb +++ b/spec/features/admin/manage_workshop_attendances_spec.rb @@ -66,16 +66,22 @@ login_as_admin(member) other_invitation = Fabricate(:workshop_invitation, workshop: workshop, attending: nil) + student = other_invitation.member visit admin_workshop_path(workshop) expect(page).to have_text('1 are attending as students') expect(page).to have_no_css('i.fa-magic') - # Use the select_from_chosen helper to select the member - select_from_chosen("#{other_invitation.member.full_name} (#{other_invitation.role})", from: 'workshop_invitations') + click_link 'RSVP a member' + fill_in 'q', with: "#{student.name} #{student.surname}" + click_button 'Search' - expect(page).to have_text('2 are attending as students', wait: 5) + expect(page).to have_text('No response') + click_button 'RSVP' + expect(page).to have_text('Attending', wait: 5) + visit admin_workshop_path(workshop) + expect(page).to have_text('2 are attending as students', wait: 5) expect(page).to have_text(I18n.l(other_invitation.reload.rsvp_time)) expect(page).to have_css('.fa-hat-wizard') end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 7813b51b4..e9375ee09 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -232,6 +232,19 @@ expect(described_class.find_members_by_name('').size).to eq(0) end end + + describe 'wildcard characters are treated literally' do + it 'does not treat % as a wildcard' do + Fabricate(:member, name: 'Per', surname: 'Cent') + expect(described_class.find_members_by_name('%').size).to eq(0) + end + + it 'accepts a search term ending in a backslash without raising' do + Fabricate(:member, name: 'Slash', surname: 'Back\\') + result = described_class.find_members_by_name('back\\') + expect(result.map(&:surname)).to include('Back\\') + end + end end describe '.admin' do