Skip to content

Commit 6eba191

Browse files
committed
Add person search functionality for new event invitations
- Implemented a new Stimulus controller for person search, allowing users to search and select people from a dropdown. - Updated the invitations controller to handle person invitations by ID and added a new action to fetch available people. - Created a new invitations.scss file for styling invitation-related components. - Enhanced the event invitations mailer to include the inviter's name in the invitation email. - Refactored event and event invitation models to support new invitation types and ensure uniqueness for event invitations. - Updated views to include tabs for inviting members and inviting by email, along with a new table for displaying invitations. - Improved localization for invitation-related texts in English, Spanish, and French. - Added routes for fetching available people for invitations.
1 parent 4ebd64b commit 6eba191

File tree

18 files changed

+574
-99
lines changed

18 files changed

+574
-99
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["select", "input"]
5+
static values = {
6+
searchUrl: String,
7+
searchDelay: { type: Number, default: 300 }
8+
}
9+
10+
connect() {
11+
this.setupPersonSearch()
12+
}
13+
14+
setupPersonSearch() {
15+
const select = this.selectTarget
16+
17+
// Convert select to a searchable input
18+
this.createSearchInput(select)
19+
20+
// Hide the original select
21+
select.style.display = 'none'
22+
}
23+
24+
createSearchInput(select) {
25+
const searchContainer = select.parentElement
26+
27+
// Create search input
28+
const searchInput = document.createElement('input')
29+
searchInput.type = 'text'
30+
searchInput.className = 'form-control person-search-input'
31+
searchInput.placeholder = select.options[0]?.text || 'Search for people...'
32+
searchInput.setAttribute('data-person-search-target', 'input')
33+
34+
// Create results dropdown
35+
const resultsDropdown = document.createElement('div')
36+
resultsDropdown.className = 'person-search-results'
37+
resultsDropdown.style.cssText = `
38+
position: absolute;
39+
top: 100%;
40+
left: 0;
41+
right: 0;
42+
background: white;
43+
border: 1px solid #ced4da;
44+
border-top: none;
45+
border-radius: 0 0 0.375rem 0.375rem;
46+
max-height: 200px;
47+
overflow-y: auto;
48+
z-index: 1000;
49+
display: none;
50+
`
51+
52+
// Insert elements
53+
searchContainer.style.position = 'relative'
54+
searchContainer.insertBefore(searchInput, select)
55+
searchContainer.appendChild(resultsDropdown)
56+
57+
// Setup event listeners
58+
let searchTimeout
59+
searchInput.addEventListener('input', (e) => {
60+
clearTimeout(searchTimeout)
61+
searchTimeout = setTimeout(() => {
62+
this.performSearch(e.target.value, resultsDropdown, select)
63+
}, this.searchDelayValue)
64+
})
65+
66+
searchInput.addEventListener('focus', () => {
67+
if (searchInput.value) {
68+
this.performSearch(searchInput.value, resultsDropdown, select)
69+
}
70+
})
71+
72+
// Hide dropdown when clicking outside
73+
document.addEventListener('click', (e) => {
74+
if (!searchContainer.contains(e.target)) {
75+
resultsDropdown.style.display = 'none'
76+
}
77+
})
78+
}
79+
80+
async performSearch(query, resultsDropdown, select) {
81+
if (query.length < 2) {
82+
resultsDropdown.style.display = 'none'
83+
return
84+
}
85+
86+
try {
87+
const response = await fetch(`${this.searchUrlValue}?search=${encodeURIComponent(query)}`, {
88+
headers: {
89+
'Accept': 'application/json',
90+
'X-Requested-With': 'XMLHttpRequest'
91+
}
92+
})
93+
94+
if (!response.ok) throw new Error('Search failed')
95+
96+
const people = await response.json()
97+
this.displayResults(people, resultsDropdown, select)
98+
} catch (error) {
99+
console.error('Person search error:', error)
100+
resultsDropdown.innerHTML = '<div class="p-2 text-danger">Search failed</div>'
101+
resultsDropdown.style.display = 'block'
102+
}
103+
}
104+
105+
displayResults(people, resultsDropdown, select) {
106+
if (people.length === 0) {
107+
resultsDropdown.innerHTML = '<div class="p-2 text-muted">No people found</div>'
108+
resultsDropdown.style.display = 'block'
109+
return
110+
}
111+
112+
const resultsHtml = people.map(person => `
113+
<div class="person-result p-2 border-bottom"
114+
style="cursor: pointer; display: flex; align-items: center;"
115+
data-person-id="${person.id}"
116+
data-person-name="${person.name}">
117+
<div class="me-2" style="width: 32px; height: 32px; background-color: #dee2e6; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
118+
<i class="fas fa-user text-muted"></i>
119+
</div>
120+
<div>
121+
<div class="fw-medium">${person.name}</div>
122+
<small class="text-muted">@${person.slug}</small>
123+
</div>
124+
</div>
125+
`).join('')
126+
127+
resultsDropdown.innerHTML = resultsHtml
128+
resultsDropdown.style.display = 'block'
129+
130+
// Add click handlers to results
131+
resultsDropdown.querySelectorAll('.person-result').forEach(result => {
132+
result.addEventListener('click', () => {
133+
this.selectPerson(result, select)
134+
resultsDropdown.style.display = 'none'
135+
})
136+
})
137+
}
138+
139+
selectPerson(resultElement, select) {
140+
const personId = resultElement.dataset.personId
141+
const personName = resultElement.dataset.personName
142+
143+
// Update the hidden select
144+
select.innerHTML = `<option value="${personId}" selected>${personName}</option>`
145+
select.value = personId
146+
147+
// Update the search input
148+
const searchInput = this.inputTarget
149+
searchInput.value = personName
150+
151+
// Trigger change event for form handling
152+
select.dispatchEvent(new Event('change', { bubbles: true }))
153+
}
154+
}

app/assets/stylesheets/better_together/application.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
@use 'conversations';
3232
@use 'forms';
3333
@use 'image-galleries';
34+
@use 'invitations';
3435
@use 'maps';
3536
@use 'metrics';
3637
@use 'navigation';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
2+
.profile-image.invitee {
3+
width: 32px;
4+
height: 32px;
5+
}

app/controllers/better_together/events/invitations_controller.rb

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ module Events
55
class InvitationsController < ApplicationController # rubocop:todo Style/Documentation
66
before_action :set_event
77
before_action :set_invitation, only: %i[destroy resend]
8-
after_action :verify_authorized
8+
after_action :verify_authorized, except: %i[available_people]
9+
after_action :verify_policy_scoped, only: %i[available_people]
910

1011
def create # rubocop:todo Metrics/MethodLength
1112
@invitation = BetterTogether::EventInvitation.new(invitation_params)
@@ -14,6 +15,14 @@ def create # rubocop:todo Metrics/MethodLength
1415
@invitation.status = 'pending'
1516
@invitation.valid_from ||= Time.zone.now
1617

18+
# Handle person invitation by ID
19+
if params.dig(:invitation, :invitee_id).present?
20+
@invitation.invitee = BetterTogether::Person.find(params[:invitation][:invitee_id])
21+
# Use the person's email address and locale
22+
@invitation.invitee_email = @invitation.invitee.email
23+
@invitation.locale = @invitation.invitee.locale || I18n.default_locale
24+
end
25+
1726
authorize @invitation
1827

1928
if @invitation.save
@@ -26,8 +35,21 @@ def create # rubocop:todo Metrics/MethodLength
2635

2736
def destroy
2837
authorize @invitation
38+
invitation_dom_id = helpers.dom_id(@invitation)
2939
@invitation.destroy
30-
respond_success(@invitation, :ok)
40+
41+
respond_to do |format|
42+
format.html { redirect_to @event, notice: t('flash.generic.destroyed', resource: t('resources.invitation')) }
43+
format.turbo_stream do
44+
flash.now[:notice] = t('flash.generic.destroyed', resource: t('resources.invitation'))
45+
render turbo_stream: [
46+
turbo_stream.remove(invitation_dom_id),
47+
turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages',
48+
locals: { flash: })
49+
]
50+
end
51+
format.json { render json: { id: @invitation.id }, status: :ok }
52+
end
3153
end
3254

3355
def resend
@@ -36,6 +58,40 @@ def resend
3658
respond_success(@invitation, :ok)
3759
end
3860

61+
def available_people
62+
# Get IDs of people who are already invited to this event
63+
# We use EventInvitation directly to avoid the default scope with includes(:invitee)
64+
invited_person_ids = BetterTogether::EventInvitation
65+
.where(invitable: @event, invitee_type: 'BetterTogether::Person')
66+
.pluck(:invitee_id)
67+
68+
# Search for people excluding those already invited and those without email
69+
# People have email through either user.email or contact_detail.email_addresses (association)
70+
people = policy_scope(BetterTogether::Person)
71+
.left_joins(:user, contact_detail: :email_addresses)
72+
.where.not(id: invited_person_ids)
73+
.where(
74+
'better_together_users.email IS NOT NULL OR ' \
75+
'better_together_email_addresses.email IS NOT NULL'
76+
)
77+
78+
# Apply search filter if provided
79+
if params[:search].present?
80+
search_term = "%#{params[:search]}%"
81+
people = people.joins(:string_translations)
82+
.where('mobility_string_translations.value ILIKE ? AND mobility_string_translations.key = ?',
83+
search_term, 'name')
84+
.distinct
85+
end
86+
87+
# Format for SlimSelect
88+
formatted_people = people.limit(20).map do |person|
89+
{ value: person.id, text: person.name }
90+
end
91+
92+
render json: formatted_people
93+
end
94+
3995
private
4096

4197
def set_event
@@ -49,7 +105,7 @@ def set_invitation
49105
end
50106

51107
def invitation_params
52-
params.require(:invitation).permit(:invitee_email, :valid_from, :valid_until, :locale, :role_id)
108+
params.require(:invitation).permit(:invitee_id, :invitee_email, :valid_from, :valid_until, :locale, :role_id)
53109
end
54110

55111
def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
@@ -59,10 +115,12 @@ def notify_invitee(invitation) # rubocop:todo Metrics/AbcSize, Metrics/MethodLen
59115
return
60116
end
61117

62-
if invitation.invitee.present?
118+
if invitation.for_existing_user? && invitation.invitee.present?
119+
# Send notification to existing user through the notification system
63120
BetterTogether::EventInvitationNotifier.with(invitation:).deliver_later(invitation.invitee)
64121
invitation.update_column(:last_sent, Time.zone.now)
65-
elsif invitation.respond_to?(:invitee_email) && invitation[:invitee_email].present?
122+
elsif invitation.for_email?
123+
# Send email directly to external email address (bypassing notification system)
66124
BetterTogether::EventInvitationsMailer.invite(invitation).deliver_later
67125
invitation.update_column(:last_sent, Time.zone.now)
68126
end
@@ -72,11 +130,13 @@ def respond_success(invitation, status) # rubocop:todo Metrics/MethodLength
72130
respond_to do |format|
73131
format.html { redirect_to @event, notice: t('flash.generic.queued', resource: t('resources.invitation')) }
74132
format.turbo_stream do
133+
flash.now[:notice] = t('flash.generic.queued', resource: t('resources.invitation'))
75134
render turbo_stream: [
76135
turbo_stream.replace('flash_messages', partial: 'layouts/better_together/flash_messages',
77136
locals: { flash: }),
78137
turbo_stream.replace('event_invitations_table_body',
79-
partial: 'better_together/events/pending_invitation_rows', locals: { event: @event })
138+
partial: 'better_together/events/invitation_row',
139+
collection: @event.invitations.order(:status, :created_at))
80140
], status:
81141
end
82142
format.json { render json: { id: invitation.id }, status: }

app/mailers/better_together/event_invitations_mailer.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ def invite(invitation)
77
@event = invitation.invitable
88
@invitation_url = invitation.url_for_review
99

10-
to_email = invitation[:invitee_email].to_s
10+
to_email = invitation.invitee_email.to_s
1111
return if to_email.blank?
1212

13-
mail(to: to_email,
14-
subject: I18n.t('better_together.event_invitations_mailer.invite.subject',
15-
default: 'You are invited to an event'))
13+
# Use the invitation's locale for proper internationalization
14+
I18n.with_locale(invitation.locale) do
15+
mail(to: to_email,
16+
subject: I18n.t('better_together.event_invitations_mailer.invite.subject',
17+
event_name: @event&.name,
18+
default: 'You are invited to %<event_name>s'))
19+
end
1620
end
1721
end
1822
end

app/models/better_together/event.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ class Event < ApplicationRecord
1717

1818
attachable_cover_image
1919

20-
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy
20+
has_many :event_attendances, class_name: 'BetterTogether::EventAttendance',
21+
foreign_key: :event_id, inverse_of: :event, dependent: :destroy
22+
has_many :invitations, -> { includes(:invitee, :inviter) }, class_name: 'BetterTogether::EventInvitation',
23+
foreign_key: :invitable_id, inverse_of: :invitable, dependent: :destroy
2124
has_many :attendees, through: :event_attendances, source: :person
2225

2326
has_many :calendar_entries, class_name: 'BetterTogether::CalendarEntry', dependent: :destroy

0 commit comments

Comments
 (0)