diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index d3994562e..344f13a2b 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -70,13 +70,42 @@ $(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 '
' + escape(item.full_name) + ' ' + escape(item.email) + '
';
+ },
+ no_results: function(data, escape) {
+ return 'No members found
';
+ }
+ }
+ });
+ }
+
+ // 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').on('chosen:ready', function () {
var height = $(this).next('.chosen-container').height();
var width = $(this).next('.chosen-container').width();
@@ -88,7 +117,7 @@ $(function() {
}).show();
});
- $('select').not('#member_lookup_id').chosen({
+ $('select').not('#member_lookup_id, #meeting_invitations_member').chosen({
allow_single_deselect: true,
no_results_text: 'No results matched'
});
diff --git a/app/views/admin/meetings/_invitation_management.html.haml b/app/views/admin/meetings/_invitation_management.html.haml
index 5b2bd6255..6ff0bb528 100644
--- a/app/views/admin/meetings/_invitation_management.html.haml
+++ b/app/views/admin/meetings/_invitation_management.html.haml
@@ -3,15 +3,13 @@
.card.bg-white.border-success
.card-body
%p.mb-0
- #{@invitations.count} 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'
diff --git a/app/views/admin/meetings/show.html.haml b/app/views/admin/meetings/show.html.haml
index 48d66e9cb..73a7f2c62 100644
--- a/app/views/admin/meetings/show.html.haml
+++ b/app/views/admin/meetings/show.html.haml
@@ -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'
diff --git a/spec/features/admin/managing_meeting_invitations_spec.rb b/spec/features/admin/managing_meeting_invitations_spec.rb
index a12ba7348..9c6342f62 100644
--- a/spec/features/admin/managing_meeting_invitations_spec.rb
+++ b/spec/features/admin/managing_meeting_invitations_spec.rb
@@ -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!")
@@ -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')
diff --git a/spec/support/select_from_tom_select.rb b/spec/support/select_from_tom_select.rb
new file mode 100644
index 000000000..228c74ac9
--- /dev/null
+++ b/spec/support/select_from_tom_select.rb
@@ -0,0 +1,36 @@
+# 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
+end
+
+RSpec.configure do |config|
+ config.include SelectFromTomSelect, type: :feature
+end