Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/controllers/admin/workshops_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion app/models/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 1 addition & 11 deletions app/views/admin/workshops/_invitation_management.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions app/views/admin/workshops/rsvp.html.haml
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions app/views/admin/workshops/show.html.haml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
get 'attendees_checklist'
get 'attendees_emails'
get 'send_invites'
get 'rsvp'
get 'changes'

resource :invitations, only: [:update]
Expand Down
35 changes: 35 additions & 0 deletions spec/controllers/admin/invitations_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions spec/controllers/admin/workshops_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions spec/features/admin/manage_workshop_attendances_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions spec/models/member_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down