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