Skip to content
Draft
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
65 changes: 62 additions & 3 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,72 @@ $(function() {
});
}

// Chosen for all other selects (exclude #member_lookup_id)
// TomSelect for meeting invitation member lookup
if ($('#meeting_invitations_member').length) {
new TomSelect('#meeting_invitations_member', {
placeholder: 'Type to search members...',
valueField: 'id',
labelField: 'full_name',
searchField: ['full_name', 'email'],
create: false,
loadThrottle: 300,
shouldLoad: function(query) {
return query.length >= 3;
},
load: function(query, callback) {
fetch('/admin/members/search?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(json => callback(json))
.catch(() => callback());
},
render: {
option: function(item, escape) {
return '<div>' + escape(item.full_name) + ' <small class="text-muted">' + escape(item.email) + '</small></div>';
},
no_results: function(data, escape) {
return '<div class="no-results">No members found</div>';
}
}
});
}

// TomSelect for meeting organisers (multi-select)
if ($('#meeting_organisers').length) {
new TomSelect('#meeting_organisers', {
plugins: ['remove_button'],
placeholder: 'Type to search members...',
valueField: 'id',
labelField: 'full_name',
searchField: ['full_name', 'email'],
create: false,
loadThrottle: 300,
shouldLoad: function(query) {
return query.length >= 3;
},
load: function(query, callback) {
fetch('/admin/members/search?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(json => callback(json))
.catch(() => callback());
},
render: {
option: function(item, escape) {
return '<div>' + escape(item.full_name) + ' <small class="text-muted">' + escape(item.email) + '</small></div>';
},
no_results: function(data, escape) {
return '<div class="no-results">No members found</div>';
}
}
});
}

// Chosen for all other selects (exclude TomSelect fields)
// Chosen hides inputs and selects, which becomes problematic when they are
// required: browser validation doesn't get shown to the user.
// This fix places "the original input behind the Chosen input, matching the
// height and width so that the warning appears in the correct position."
// https://github.com/harvesthq/chosen/issues/515#issuecomment-474588057
$('select').not('#member_lookup_id').on('chosen:ready', function () {
$('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers').on('chosen:ready', function () {
var height = $(this).next('.chosen-container').height();
var width = $(this).next('.chosen-container').width();

Expand All @@ -88,7 +147,7 @@ $(function() {
}).show();
});

$('select').not('#member_lookup_id').chosen({
$('select').not('#member_lookup_id, #meeting_invitations_member, #meeting_organisers').chosen({
allow_single_deselect: true,
no_results_text: 'No results matched'
});
Expand Down
12 changes: 6 additions & 6 deletions app/controllers/admin/meetings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ def new

def create
@meeting = Meeting.new(meeting_params)
set_organisers(organiser_ids)
set_chapters(chapter_ids)

if @meeting.save
set_organisers(organiser_ids)
set_chapters(chapter_ids)
redirect_to [:admin, @meeting], notice: t('admin.messages.meeting.created')
else
flash[:notice] = @meeting.errors.full_messages.join(', ')
Expand Down Expand Up @@ -62,10 +62,10 @@ def slug
end

def meeting_params
params.expect(meeting: [
:name, :description, :slug, :date_and_time, :local_date, :local_time, :local_end_time,
:invitable, :spaces, :venue_id, :sponsor_id, :chapters
])
params.expect(meeting: %i[
name description slug date_and_time local_date local_time local_end_time
invitable spaces venue_id sponsor_id chapters
])
end

def organiser_ids
Expand Down
10 changes: 9 additions & 1 deletion app/views/admin/meetings/_form.html.haml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
- content_for :head do
%link{ href: 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css', rel: 'stylesheet', type: 'text/css' }
%script{ src: 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js' }

= simple_form_for [:admin, @meeting] do |f|
.row
.col-12
Expand All @@ -17,7 +21,11 @@
.col-12
= f.association :venue, input_html: { data: { placeholder: 'Select venue' }}, required: true
.col-12
= f.input :organisers, collection: Member.all, label_method: :full_name, value_method: :id, selected: @meeting.organisers.map(&:id), input_html: { multiple: true }
= f.label :organisers
= f.select :organisers,
options_for_select(@meeting.organisers.map { |o| [o.full_name, o.id] }, @meeting.organisers.map(&:id)),
{ include_blank: false },
{ multiple: true, class: 'tom-select', data: { placeholder: 'Type to search members...' } }
.col-12
= f.input :chapters, collection: Chapter.all, label_method: :name, value_method: :id, selected: @meeting.chapters.map(&:id), input_html: { multiple: true }
.col-12
Expand Down
8 changes: 3 additions & 5 deletions app/views/admin/meetings/_invitation_management.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@
.card.bg-white.border-success
.card-body
%p.mb-0
<strong>#{@invitations.count}</strong> members have RSVP'd to this event.
%strong #{@invitations.count}
members have RSVP'd to this event.

= simple_form_for :meeting_invitations, url: admin_meeting_invitations_path do |f|
.row
.col-6
= f.select :member,
Member.all.map { |u| ["#{u.full_name}", u.id] },
{ include_blank: true }, { class: 'chosen-select', required: true,
data: { placeholder: t('messages.invitations.select_a_member_to_rsvp') } }
= f.select :member, [], { include_blank: true }, { class: 'tom-select', required: true, data: { placeholder: t('messages.invitations.select_a_member_to_rsvp') } }
= f.hidden_field :meeting_id, value: @meeting.slug
.col
= f.button :button, 'Add', class: 'btn btn-sm btn-primary mb-0 me-2'
Expand Down
3 changes: 3 additions & 0 deletions app/views/admin/meetings/show.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
= sanitize(@meeting.description)

- if @invitations.any?
- content_for :head do
%link{ href: 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css', rel: 'stylesheet', type: 'text/css' }
%script{ src: 'https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js' }
.py-4.py-lg-5.bg-light
.container#invitations
= render partial: 'invitation_management'
12 changes: 6 additions & 6 deletions spec/features/admin/managing_meeting_invitations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,27 @@
end

describe 'creating a new meeting invitation' do
scenario 'for a member that is not already attending' do
scenario 'for a member that is not already attending', :js do
Fabricate(:attending_meeting_invitation, meeting: meeting)
member = Fabricate(:member)

visit admin_meeting_path(meeting)
select member.name

select_from_tom_select(member.full_name, from: 'meeting_invitations_member')
click_on 'Add'

expect(page).to have_content("#{member.full_name} has been successfully added and notified via email")
end

scenario 'for a member that is already attending' do
scenario 'for a member that is already attending', :js do
meeting = Fabricate(:meeting)
attending_member = Fabricate(:member)
Fabricate(:attending_meeting_invitation, meeting: meeting)
Fabricate(:attending_meeting_invitation, meeting: meeting, member: attending_member)

visit admin_meeting_path(meeting)
select attending_member.name

select_from_tom_select(attending_member.full_name, from: 'meeting_invitations_member')
click_on 'Add'

expect(page).to have_content("#{attending_member.full_name} is already on the list!")
Expand All @@ -35,11 +37,9 @@

scenario 'Updating the attendance of an invitation' do
meeting = Fabricate(:meeting, date_and_time: 1.day.ago)
member = Fabricate(:member)
Fabricate(:attending_meeting_invitation, meeting: meeting)

visit admin_meeting_path(meeting)

find('.verify-attendance').click

expect(page).to have_content('Updated attendance')
Expand Down
28 changes: 20 additions & 8 deletions spec/features/admin/meeting_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
click_on 'Save'

expect(page).to have_content('Meeting successfully created')
expect(page.current_path)
.to eq(admin_meeting_path("#{I18n.l(today, format: :year_month).downcase}-august-meeting-1"))
expect(page)
.to have_current_path(admin_meeting_path("#{I18n.l(today, format: :year_month).downcase}-august-meeting-1"), ignore_query: true)
expect(page).to have_content 'Invite'
end

Expand All @@ -48,18 +48,30 @@
expect(page).to have_content('Slug has already been taken')
end

scenario 'successfully' do
scenario 'successfully', :js do
permissions = Fabricate(:permission, resource: meeting, name: 'organiser')

visit edit_admin_meeting_path(meeting)
fill_in 'Name', with: "March Meeting"
unselect permissions.members.first.full_name
fill_in 'Name', with: 'March Meeting'
remove_from_tom_select(permissions.members.first.full_name)

click_on 'Save'

expect(page).to have_content('You have successfully updated the details of this meeting')
expect(page).to have_css(%(span[title="#{permissions.members.last.full_name}"]))
expect(page).to_not have_css(%(span[title="#{permissions.members.first.full_name}"]))
expect(page).not_to have_css(%(span[title="#{permissions.members.first.full_name}"]))
end

scenario 'adding an organiser', :js do
meeting = Fabricate(:meeting)
new_organiser = Fabricate(:member)

visit edit_admin_meeting_path(meeting)
select_from_tom_select(new_organiser.full_name, from: 'meeting_organisers')

click_on 'Save'

expect(page).to have_css(%(span[title="#{new_organiser.full_name}"]))
end
end

Expand All @@ -78,7 +90,7 @@
scenario 'when no format is used then it redirects to the meeting page' do
visit attendees_emails_admin_meeting_path(meeting)

expect(page.current_path).to eq(admin_meeting_path(meeting))
expect(page).to have_current_path(admin_meeting_path(meeting), ignore_query: true)
end
end

Expand All @@ -88,7 +100,7 @@
meeting = Fabricate(:meeting, chapters: [chapter])

visit invite_admin_meeting_path(meeting)
expect(page).to have_content("Invitations are being sent out")
expect(page).to have_content('Invitations are being sent out')
end

scenario 'does not send the invitations to banned members' do
Expand Down
44 changes: 44 additions & 0 deletions spec/support/select_from_tom_select.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

# Helper for interacting with TomSelect dropdowns in Capybara feature tests
# Similar to select_from_chosen but for TomSelect remote data loading
module SelectFromTomSelect
# Select an item from a TomSelect dropdown
# @param item_text [String] The text to select
# @param from [String, Symbol] The field ID (for documentation purposes)
def select_from_tom_select(item_text, from: nil)
# Wait for TomSelect to initialize
expect(page).to have_css('.ts-wrapper', wait: 5)

# Open dropdown and type search query
find('.ts-control').click
input = find('.ts-control input')

# Type first 3 characters to trigger search (shouldLoad requires >= 3)
input.send_keys(item_text[0, 3])

# Wait for debounce (300ms) and network request
sleep 0.5

# Type the rest if item_text is longer than 3 characters
input.send_keys(item_text[3..]) if item_text.length > 3

# Wait for results (includes debounce + network)
expect(page).to have_css('.ts-dropdown .option', wait: 5)

# Click the matching option
find('.ts-dropdown .option', text: item_text, match: :prefer_exact).click
end

# Remove an item from a TomSelect multi-select
# @param item_text [String] The text of the item to remove (must match exactly)
def remove_from_tom_select(item_text)
within '.ts-wrapper' do
find('.item', text: item_text, match: :prefer_exact).find('.remove').click
end
end
end

RSpec.configure do |config|
config.include SelectFromTomSelect, type: :feature
end