diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 020136fe1..d3994562e 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -40,12 +40,43 @@ $(function() { format: "HH:i", }); + // TomSelect for admin member lookup + if ($('#member_lookup_id').length) { + new TomSelect('#member_lookup_id', { + 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) + '
'; + } + } + }); + + $('#member_lookup_id').on('change', function() { + $('#view_profile').attr('href', '/admin/members/' + $(this).val()); + }); + } + + // Chosen for all other selects (exclude #member_lookup_id) // 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').on('chosen:ready', function () { + $('select').not('#member_lookup_id').on('chosen:ready', function () { var height = $(this).next('.chosen-container').height(); var width = $(this).next('.chosen-container').width(); @@ -57,14 +88,10 @@ $(function() { }).show(); }); - $('select').chosen({ + $('select').not('#member_lookup_id').chosen({ allow_single_deselect: true, no_results_text: 'No results matched' }); - $('#member_lookup_id').change(function(e) { - $('#view_profile').attr('href', '/admin/members/' + $(this).val()) - }); - $('[data-bs-toggle="tooltip"]').tooltip(); }); diff --git a/app/controllers/admin/members_controller.rb b/app/controllers/admin/members_controller.rb index ef6bcb09c..ad9799fca 100644 --- a/app/controllers/admin/members_controller.rb +++ b/app/controllers/admin/members_controller.rb @@ -2,7 +2,25 @@ class Admin::MembersController < Admin::ApplicationController before_action :set_member, only: %i[events update_subscriptions send_attendance_email send_eligibility_email] def index - @members = Member.all + # @members = Member.all removed - members loaded dynamically via search + end + + def search + query = params[:q].to_s.strip + + members = if query.length >= 3 + Member.where( + "CONCAT(name, ' ', surname) ILIKE :q OR email ILIKE :q", + q: "%#{query}%" + ).select(:id, :name, :surname, :email, :pronouns).limit(50) + else + [] + end + + render json: members.as_json( + only: %i[id name surname email], + methods: [:full_name] + ) end def show diff --git a/app/views/admin/members/index.html.haml b/app/views/admin/members/index.html.haml index 4401101dd..f6cb87bde 100644 --- a/app/views/admin/members/index.html.haml +++ b/app/views/admin/members/index.html.haml @@ -1,10 +1,14 @@ +- 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' } + .container.py-4.py-lg-5 .row.mb-4 .col %h1 Members Directory .row.mb-4 .col-12.col-md-6 - = select_tag 'member_lookup_id', options_for_select([['Select a member...', '']] + @members.collect{ |u| ["#{u.full_name} (#{u.email})", u.id] }), { class: 'chosen-select' } + = select_tag 'member_lookup_id', nil, class: 'form-control' .row .col = link_to 'View Profile', '#', { class: 'btn btn-primary', id: 'view_profile' } diff --git a/config/routes.rb b/config/routes.rb index b1610f5ae..37ee1c2aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -96,6 +96,7 @@ resources :announcements, only: %i[new index create edit update] resources :members, only: %i[show index] do + get :search, on: :collection get :events get :send_eligibility_email get :send_attendance_email diff --git a/docs/superpowers/plans/2025-04-11-tom-select-member-search.md b/docs/superpowers/plans/2025-04-11-tom-select-member-search.md new file mode 100644 index 000000000..a6a1dde69 --- /dev/null +++ b/docs/superpowers/plans/2025-04-11-tom-select-member-search.md @@ -0,0 +1,643 @@ +# TomSelect Member Search Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Chosen.js with TomSelect for member lookup on `/admin/members`, using remote data loading for on-demand search. + +**Architecture:** New JSON endpoint returns searched members. TomSelect fetches results dynamically via the `load` callback. Only affects `/admin/members` - other pages continue using Chosen.js. + +**Tech Stack:** Rails 8.1, TomSelect 2.4.3, Sprockets, jQuery, HAML + +--- + +## Files + +| File | Action | Purpose | +|------|--------|---------| +| `config/routes.rb` | Modify | Add `get :search` to admin members | +| `app/controllers/admin/members_controller.rb` | Modify | Add `search` action, simplify `index` | +| `app/views/admin/members/index.html.haml` | Modify | Empty select, remove `@members` usage | +| `app/assets/javascripts/application.js` | Modify | Add TomSelect init, exclude from Chosen | +| `app/assets/stylesheets/application.scss` | Modify | Require TomSelect CSS | +| `vendor/assets/javascripts/tom-select.complete.min.js` | Create | TomSelect JS library | +| `vendor/assets/stylesheets/tom-select.bootstrap5.min.css` | Create | TomSelect Bootstrap 5 theme | +| `spec/controllers/admin/members_controller_spec.rb` | Create | Controller tests for search action | +| `spec/features/admin/tom_select_member_lookup_spec.rb` | Create | Feature test for TomSelect behavior | + +--- + +## Task 1: Download TomSelect Assets + +**Files:** +- Create: `vendor/assets/javascripts/tom-select.complete.min.js` +- Create: `vendor/assets/stylesheets/tom-select.bootstrap5.min.css` + +- [ ] **Step 1: Download TomSelect JavaScript** + +```bash +curl -o vendor/assets/javascripts/tom-select.complete.min.js \ + https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js +``` + +- [ ] **Step 2: Download TomSelect Bootstrap 5 CSS** + +```bash +curl -o vendor/assets/stylesheets/tom-select.bootstrap5.min.css \ + https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css +``` + +- [ ] **Step 3: Verify downloads** + +```bash +ls -la vendor/assets/javascripts/tom-select.complete.min.js +ls -la vendor/assets/stylesheets/tom-select.bootstrap5.min.css +``` + +Expected: Both files exist with non-zero size. + +- [ ] **Step 4: Commit** + +```bash +git add vendor/assets/javascripts/tom-select.complete.min.js \ + vendor/assets/stylesheets/tom-select.bootstrap5.min.css +git commit -m "feat: add TomSelect 2.4.3 assets" +``` + +--- + +## Task 2: Add Search Route + +**Files:** +- Modify: `config/routes.rb` + +- [ ] **Step 1: Add search route to admin members** + +In `config/routes.rb`, find the `namespace :admin` block and the `resources :members` line. Add `search` as a collection route: + +```ruby +# config/routes.rb (within namespace :admin) +resources :members, only: %i[show index] do + get :search, on: :collection + get :events + # ... rest of existing routes +end +``` + +The route should create `GET /admin/members/search(.:format)`. + +- [ ] **Step 2: Verify route** + +```bash +bundle exec rails routes | grep "admin/members/search" +``` + +Expected: `search_admin_members GET /admin/members/search(.:format) admin/members#search` + +- [ ] **Step 3: Commit** + +```bash +git add config/routes.rb +git commit -m "feat: add search route for admin members" +``` + +--- + +## Task 3: Write Controller Tests + +**Files:** +- Create: `spec/controllers/admin/members_controller_spec.rb` + +- [ ] **Step 1: Create controller spec file** + +```ruby +# spec/controllers/admin/members_controller_spec.rb +require 'rails_helper' + +RSpec.describe Admin::MembersController, type: :controller do + describe 'GET #search' do + let(:admin) { Fabricate(:member) } + let!(:member_jane) { Fabricate(:member, name: 'Jane', surname: 'Doe', email: 'jane@example.com') } + let!(:member_john) { Fabricate(:member, name: 'John', surname: 'Smith', email: 'john@test.com') } + + before do + admin.add_role(:admin) + login_as_admin(admin) + end + + context 'with query less than 3 characters' do + it 'returns empty array' do + get :search, params: { q: 'ab' }, format: :json + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to eq([]) + end + end + + context 'with query 3 or more characters' do + it 'returns matching members by name' do + get :search, params: { q: 'Jan' }, format: :json + + expect(response).to have_http_status(:ok) + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first['id']).to eq(member_jane.id) + expect(results.first['full_name']).to eq('Jane Doe') + end + + it 'returns matching members by email' do + get :search, params: { q: 'john@tes' }, format: :json + + expect(response).to have_http_status(:ok) + results = JSON.parse(response.body) + expect(results.length).to eq(1) + expect(results.first['id']).to eq(member_john.id) + end + + it 'returns JSON with correct shape' do + get :search, params: { q: 'Jan' }, format: :json + + results = JSON.parse(response.body) + expect(results.first.keys).to contain_exactly('id', 'name', 'surname', 'email', 'full_name') + end + + it 'limits results to 50' do + 51.times { |i| Fabricate(:member, name: "Test#{i}", surname: 'User', email: "test#{i}@example.com") } + + get :search, params: { q: 'Test' }, format: :json + + results = JSON.parse(response.body) + expect(results.length).to be <= 50 + end + end + + context 'when not authenticated' do + before { login(Fabricate(:member)) } + + it 'redirects to login' do + get :search, params: { q: 'test' }, format: :json + + expect(response).to have_http_status(:found) + end + end + end +end +``` + +**Note:** Uses `login_as_admin(admin)` helper defined in `spec/support/helpers/login_helpers.rb`. + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +bundle exec rspec spec/controllers/admin/members_controller_spec.rb +``` + +Expected: Tests fail with "The action 'search' could not be found for Admin::MembersController" + +- [ ] **Step 3: Commit** + +```bash +git add spec/controllers/admin/members_controller_spec.rb +git commit -m "test: add specs for admin members search action" +``` + +--- + +## Task 4: Implement Search Action + +**Files:** +- Modify: `app/controllers/admin/members_controller.rb` + +- [ ] **Step 1: Add search action to controller** + +In `app/controllers/admin/members_controller.rb`, add the `search` action: + +```ruby +# app/controllers/admin/members_controller.rb +class Admin::MembersController < Admin::ApplicationController + def index + # @members = Member.all removed - members loaded dynamically via search + end + + def show + @member = Member.find(params[:id]) + end + + def events + @member = Member.find(params[:id]) + end + + def search + query = params[:q].to_s.strip + + if query.length >= 3 + members = Member.where( + "CONCAT(name, ' ', surname) ILIKE :q OR email ILIKE :q", + q: "%#{query}%" + ).select(:id, :name, :surname, :email).limit(50) + else + members = [] + end + + render json: members.as_json( + only: [:id, :name, :surname, :email], + methods: [:full_name] + ) + end + + private + + def set_member + @member = Member.find(params[:member_id]) + end +end +``` + +**Note:** Keep existing methods (show, events, etc.). Only add the `search` action and remove `@members = Member.all` from `index`. + +- [ ] **Step 2: Run tests to verify they pass** + +```bash +bundle exec rspec spec/controllers/admin/members_controller_spec.rb +``` + +Expected: All tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add app/controllers/admin/members_controller.rb +git commit -m "feat: add search action for member lookup" +``` + +--- + +## Task 5: Update View + +**Files:** +- Modify: `app/views/admin/members/index.html.haml` + +- [ ] **Step 1: Update view to use empty select** + +Replace the select_tag line. The full file should be: + +```haml +.container.py-4.py-lg-5 + .row.mb-4 + .col + %h1 Members Directory + .row.mb-4 + .col-12.col-md-6 + = select_tag 'member_lookup_id', nil, class: 'form-control' + .row + .col + = link_to 'View Profile', '#', class: 'btn btn-primary', id: 'view_profile' +``` + +Key changes: +- Removed `options_for_select([['Select a member...', '']] + @members.collect{ ... })` +- Select is now empty - TomSelect populates it dynamically +- Removed `chosen-select` class - TomSelect will handle this element + +- [ ] **Step 2: Commit** + +```bash +git add app/views/admin/members/index.html.haml +git commit -m "feat: update member lookup to use empty select for TomSelect" +``` + +--- + +## Task 6: Add TomSelect CSS toManifest + +**Files:** +- Modify: `app/assets/stylesheets/application.scss` + +- [ ] **Step 1: Add TomSelect CSS require** + +In `app/assets/stylesheets/application.scss`, add the require after `chosen` (around line 17): + +```scss +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, + * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the top of the + * compiled file, but it's generally better to create a new file per style scope. + * + *= require_self + *= require font_awesome5 + *= require main + *= require pickadate/classic + *= require pickadate/classic.date + *= require pickadate/classic.time + *= require chosen + *= require tom-select.bootstrap5.min + */ + +@import "partials/colors"; +@import "partials/layout"; +@import "partials/hero"; +@import "partials/social_media"; +@import "partials/star-rating"; + +$primary: $dark-codebar-blue !default; + +@import "bootstrap-custom"; + +/* Bootstrap's Reboot sets legends to float: left, which puts the first check box in a fieldset off to the + right instead of underneath the legend. This overrides that. */ +legend { + float: none !important; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/assets/stylesheets/application.scss +git commit -m "feat: add TomSelect CSS to asset manifest" +``` + +--- + +## Task 7: Add TomSelect JS to Manifest + +**Files:** +- Modify: `app/assets/javascripts/application.js` + +- [ ] **Step 1: Add TomSelect require** + +At the top of `app/assets/javascripts/application.js`, add the require for TomSelect after `chosen.jquery`: + +```javascript +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, +// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// compiled file. +// +// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details +// about supported directives. +// +//= require jquery +//= require popper +//= require bootstrap +//= require rails-ujs +//= require activestorage +//= require chosen.jquery +//= require tom-select.complete.min +//= require 'jsimple-star-rating.min.js' +//= require pickadate/picker +//= require pickadate/picker.date +//= require pickadate/picker.time +//= require subscriptions-toggle +//= require invitations +//= require dietary-restrictions +//= require cocoon +//= require font_awesome5 +//= require how-you-found-us +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/assets/javascripts/application.js +git commit -m "feat: add TomSelect JS to asset manifest" +``` + +--- + +## Task 8: Initialize TomSelect + +**Files:** +- Modify: `app/assets/javascripts/application.js` (same file as Task7) + +- [ ] **Step 1: Add TomSelect initialization and exclude from Chosen** + +Replace the Chosen initialization block (lines 43-67) with the updated version that: +1. Initializes TomSelect on `#member_lookup_id` first +2. Excludes `#member_lookup_id` from Chosen + +The updated `$(function() { ... })` block should be: + +```javascript +$(function() { + $("body").removeClass("no-js"); + + $('#event_local_date, #meeting_local_date, #workshop_local_date, #workshop_rsvp_open_local_date').pickadate({ + format: 'dd/mm/yyyy' + }); + $('#announcement_expires_at, #ban_expires_at').pickadate(); + $( + "#meeting_local_time, #meeting_local_end_time, #event_local_time, #event_local_end_time, #workshop_local_time, #workshop_local_end_time, #workshop_rsvp_open_local_time" + ).pickatime({ + format: "HH:i", + }); + + // TomSelect for admin member lookup + if ($('#member_lookup_id').length) { + new TomSelect('#member_lookup_id', { + 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) + '
'; + } + } + }); + + $('#member_lookup_id').on('change', function() { + $('#view_profile').attr('href', '/admin/members/' + $(this).val()); + }); + } + + // Chosen for all other selects (unchanged, but exclude #member_lookup_id) + $('select').not('#member_lookup_id').on('chosen:ready', function () { + var height = $(this).next('.chosen-container').height(); + var width = $(this).next('.chosen-container').width(); + + $(this).css({ + position: 'absolute', + height: height, + width: width, + opacity: 0 + }).show(); + }); + + $('select').not('#member_lookup_id').chosen({ + allow_single_deselect: true, + no_results_text: 'No results matched' + }); + + $('[data-bs-toggle="tooltip"]').tooltip(); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add app/assets/javascripts/application.js +git commit -m "feat: initialize TomSelect for member lookup, exclude from Chosen" +``` + +--- + +## Task 9: Feature Test for TomSelect + +**Files:** +- Create: `spec/features/admin/tom_select_member_lookup_spec.rb` + +**Note:** Named `tom_select_member_lookup_spec.rb` to avoid collision with existing `member_search_spec.rb`. + +- [ ] **Step 1: Create feature spec** + +```ruby +# spec/features/admin/tom_select_member_lookup_spec.rb +require 'rails_helper' + +RSpec.describe 'Admin TomSelect Member Lookup', type: :feature, js: true do + let(:admin) { Fabricate(:member) } + let!(:member_jane) { Fabricate(:member, name: 'Jane', surname: 'Doe', email: 'jane@example.com') } + let!(:member_john) { Fabricate(:member, name: 'John', surname: 'Smith', email: 'john@test.com') } + + before do + admin.add_role(:admin) + login_as_admin(admin) + end + + scenario 'searching for members with TomSelect' do + visit admin_members_path + + # TomSelect should be initialized + expect(page).to have_css('.ts-wrapper') + + # Type less than 3 characters - no search triggered + find('.ts-input').click + find('.ts-input').send_keys('Ja') + + # Wait a bit for potential (but shouldn't happen) search + sleep 0.5 + + # Type 3+ characters to trigger search + find('.ts-input').send_keys('ne') + + # Wait for results to load + expect(page).to have_css('.ts-dropdown .option', wait: 5) + + # Should show Jane Doe + expect(page).to have_content('Jane Doe') + expect(page).to have_content('jane@example.com') + + # Should not show John + expect(page).not_to have_content('John Smith') + end + + scenario 'selecting a member updates view profile link' do + visit admin_members_path + + find('.ts-input').click + find('.ts-input').send_keys('Jane Doe') + + # Wait for results + expect(page).to have_css('.ts-dropdown .option', wait: 5) + + # Click the option + find('.ts-dropdown .option', text: 'Jane Doe').click + + # View Profile link should update + expect(find('#view_profile')[:href]).to eq(admin_member_path(member_jane)) + end +end +``` + +**Note:** Uses `login_as_admin(admin)` helper from `spec/support/helpers/login_helpers.rb`. The helper mocks `current_user` on ApplicationController, which works with Playwright JS tests. + +- [ ] **Step 2: Run feature test to verify behavior** + +```bash +bundle exec rspec spec/features/admin/tom_select_member_lookup_spec.rb +``` + +Expected: Tests pass (may need debugging for browser timing). + +- [ ] **Step 3: Commit** + +```bash +git add spec/features/admin/tom_select_member_lookup_spec.rb +git commit -m "test: add feature test for TomSelect member lookup" +``` + +--- + +## Task 10: Integration Test + +**Files:** +- None (manual testing) + +- [ ] **Step 1: Start the server** + +```bash +bundle exec rails server +``` + +- [ ] **Step 2: Test the member search manually** + +1. Navigate to `/admin/members` +2. Verify the select field appears with placeholder "Type to search members..." +3. Type 2 characters - verify no search happens +4. Type 3+ characters - verify search triggers and shows results +5. Click a result - verify "View Profile" link updates +6. Click "View Profile" - verify it navigates to the member page + +- [ ] **Step 3: Verify Chosen still works elsewhere** + +1. Navigate to `/admin/workshops` or another page using Chosen +2. Verify Chosen dropdowns still work correctly + +--- + +## Task 11: Final Commit + +- [ ] **Step 1: Run all tests** + +```bash +bundle exec rspec +``` + +- [ ] **Step 2: Run linter** + +```bash +bundle exec rubocop +``` + +Expected: No offenses. + +- [ ] **Step 3: Final review of changes** + +```bash +git status +git log --oneline -10 +``` + +- [ ] **Step 4: Create feature branch summary (if needed)** + +If working on a feature branch, ensure all commits are squashed or organized appropriately before merge. \ No newline at end of file diff --git a/docs/superpowers/specs/2025-04-11-tom-select-member-search-design.md b/docs/superpowers/specs/2025-04-11-tom-select-member-search-design.md new file mode 100644 index 000000000..8bf9aa2e1 --- /dev/null +++ b/docs/superpowers/specs/2025-04-11-tom-select-member-search-design.md @@ -0,0 +1,236 @@ +# TomSelect Member Search Design + +**Date:** 2025-04-11 +**Status:** Draft +**Scope:** Replace Chosen.js with TomSelect on `/admin/members` for member lookup + +## Problem + +The member lookup dropdown on `/admin/members` currently uses Chosen.js and loads **all members** into the select dropdown on page load. As member count grows, this becomes: +- Slow to render +- Heavy on initial page load +- Difficult to find members in a large list + +## Solution + +Replace Chosen.js with TomSelect for the member lookup field, using remote data loading to search members on-demand instead of loading all members upfront. + +### Key Constraints +- Chosen.js remains in use elsewhere (feedback forms, workshop invitations, etc.) +- Only `/admin/members` uses TomSelect for now +- Search must cover both name and email fields +- Minimum 3 characters before triggering search +- Debouncing via TomSelect's `loadThrottle` (300ms) + +## Architecture + +### Data Flow + +``` +User types (≥3 chars) → TomSelect queries /admin/members/search?q=term + ↓ + Rails controller searches name+email + ↓ + Returns JSON: [{id, name, surname, email, full_name}] + ↓ + TomSelect displays results in dropdown + ↓ + User selects → "View Profile" link updates +``` + +### Components + +1. **Backend: `Admin::MembersController#search`** + - New action responding with JSON + - Searches `CONCAT(name, ' ', surname) ILIKE ? OR email ILIKE ?` + - Returns max 50 results + - Admin-only authentication (via `Admin::ApplicationController`) + +2. **Frontend: TomSelect initialization** + - Replaces Chosen.js for `#member_lookup_id` only + - Uses native `fetch` for API calls + - Custom renderer showing name + email inline + +3. **Assets: TomSelect library** + - Download JS: `https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js` + - Save to `vendor/assets/javascripts/tom-select.complete.min.js` + - Download CSS: `https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css` + - Save to `vendor/assets/stylesheets/tom-select.bootstrap5.min.css` + +## Implementation Details + +### Route + +```ruby +# config/routes.rb +namespace :admin do + resources :members, only: %i[show index] do + get :search, on: :collection + # ... existing routes + end +end +``` + +### Controller + +```ruby +# app/controllers/admin/members_controller.rb +def search + query = params[:q].to_s.strip + + if query.length >= 3 + members = Member.where( + "CONCAT(name, ' ', surname) ILIKE :q OR email ILIKE :q", + q: "%#{query}%" + ).select(:id, :name, :surname, :email).limit(50) + else + members = [] + end + + render json: members.as_json( + only: [:id, :name, :surname, :email], + methods: [:full_name] + ) +end +``` + +### View + +```haml +# app/views/admin/members/index.html.haml +.container.py-4.py-lg-5 + .row.mb-4 + .col + %h1 Members Directory + .row.mb-4 + .col-12.col-md-6 + = select_tag 'member_lookup_id', nil, class: 'form-control' + .row + .col + = link_to 'View Profile', '#', class: 'btn btn-primary', id: 'view_profile' +``` + +**Note:** Placeholder text is configured in TomSelect JS options, not as an HTML attribute on `