Skip to content

Commit f6db92f

Browse files
authored
Merge branch 'main' into dependabot/bundler/puma-7.0.0
2 parents fdec1bc + 7a0392b commit f6db92f

File tree

99 files changed

+2372
-367
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+2372
-367
lines changed

.github/instructions/view-helpers.instructions.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ end
8080
- `truncate`, `pluralize`, `excerpt`, `word_wrap`, `simple_format`.
8181
- For rich text (Action Text), use `record.body` and `to_plain_text` for indexing/search.
8282

83+
### Privacy Display
84+
- Use `privacy_display_value(entity)` for consistent, translated privacy level display across the application.
85+
- This helper automatically looks up the proper translation from `attributes.privacy_list` and falls back to humanized values.
86+
- Supports all privacy levels: `public`, `private`, `community`, `unlisted`.
87+
88+
```ruby
89+
# Instead of: entity.privacy.humanize or entity.privacy.capitalize
90+
<%= privacy_display_value(@event) %> # "Public" / "Público" / "Public"
91+
<%= privacy_display_value(@community) %> # "Private" / "Privado" / "Privé"
92+
93+
# Works in badges too (automatically used)
94+
<%= privacy_badge(@entity) %> # Uses privacy_display_value internally
95+
```
96+
8397
---
8498
8599
## 4. Navigation & Link Helpers

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ plugins:
1313
- rubocop-rspec_rails
1414
- rubocop-capybara
1515
- rubocop-factory_bot
16+
RSpec/MultipleExpectations:
17+
Enabled: false
1618
Style/StringLiterals:
1719
Exclude:
1820
- 'db/migrate/*'

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ GEM
668668
rswag-ui (2.16.0)
669669
actionpack (>= 5.2, < 8.1)
670670
railties (>= 5.2, < 8.1)
671-
rubocop (1.80.0)
671+
rubocop (1.80.1)
672672
json (~> 2.3)
673673
language_server-protocol (~> 3.17.0.2)
674674
lint_roller (~> 1.1.0)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Event DateTime Controller
2+
// Handles dynamic synchronization between start time, end time, and duration fields
3+
//
4+
// Behavior:
5+
// - When start time changes: Updates end time based on current duration
6+
// - When end time changes: Updates duration based on start/end time difference
7+
// - When duration changes: Updates end time based on start time + duration
8+
// - Validates minimum duration (5 minutes)
9+
// - Defaults duration to 30 minutes when not set
10+
11+
import { Controller } from "@hotwired/stimulus"
12+
13+
export default class extends Controller {
14+
static targets = ["startTime", "endTime", "duration"]
15+
16+
connect() {
17+
// Set default duration if not already set
18+
if (!this.durationTarget.value || this.durationTarget.value === "0") {
19+
this.durationTarget.value = "30"
20+
}
21+
22+
// Initialize end time if start time is set but end time is not
23+
if (this.startTimeTarget.value && !this.endTimeTarget.value) {
24+
this.updateEndTimeFromDuration()
25+
}
26+
}
27+
28+
// Called when start time changes
29+
updateEndTime() {
30+
if (!this.startTimeTarget.value) {
31+
this.endTimeTarget.value = ""
32+
return
33+
}
34+
35+
// Use current duration or default to 30 minutes
36+
const duration = this.getDurationInMinutes()
37+
this.calculateEndTime(duration)
38+
}
39+
40+
// Called when end time changes
41+
updateDuration() {
42+
if (!this.startTimeTarget.value || !this.endTimeTarget.value) {
43+
return
44+
}
45+
46+
const startTime = new Date(this.startTimeTarget.value)
47+
const endTime = new Date(this.endTimeTarget.value)
48+
49+
// Validate end time is after start time
50+
if (endTime <= startTime) {
51+
this.showTemporaryError("End time must be after start time")
52+
return
53+
}
54+
55+
// Calculate duration in minutes
56+
const diffInMs = endTime.getTime() - startTime.getTime()
57+
const diffInMinutes = Math.round(diffInMs / (1000 * 60))
58+
59+
// Enforce minimum duration
60+
if (diffInMinutes < 5) {
61+
this.durationTarget.value = "5"
62+
this.calculateEndTime(5)
63+
} else {
64+
this.durationTarget.value = diffInMinutes.toString()
65+
}
66+
}
67+
68+
// Called when duration changes
69+
updateEndTimeFromDuration() {
70+
if (!this.startTimeTarget.value) {
71+
return
72+
}
73+
74+
const duration = this.getDurationInMinutes()
75+
76+
// Enforce minimum duration
77+
if (duration < 5) {
78+
this.durationTarget.value = "5"
79+
this.calculateEndTime(5)
80+
} else {
81+
this.calculateEndTime(duration)
82+
}
83+
}
84+
85+
// Helper methods
86+
getDurationInMinutes() {
87+
const duration = parseInt(this.durationTarget.value) || 30
88+
return Math.max(duration, 5) // Minimum 5 minutes
89+
}
90+
91+
calculateEndTime(durationMinutes) {
92+
if (!this.startTimeTarget.value) return
93+
94+
const startTime = new Date(this.startTimeTarget.value)
95+
const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000))
96+
97+
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
98+
const year = endTime.getFullYear()
99+
const month = String(endTime.getMonth() + 1).padStart(2, '0')
100+
const day = String(endTime.getDate()).padStart(2, '0')
101+
const hours = String(endTime.getHours()).padStart(2, '0')
102+
const minutes = String(endTime.getMinutes()).padStart(2, '0')
103+
104+
this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`
105+
}
106+
107+
showTemporaryError(message) {
108+
// Create or update error message
109+
let errorElement = this.element.querySelector('.datetime-sync-error')
110+
111+
if (!errorElement) {
112+
errorElement = document.createElement('div')
113+
errorElement.className = 'alert alert-warning datetime-sync-error mt-2'
114+
errorElement.setAttribute('role', 'alert')
115+
this.element.appendChild(errorElement)
116+
}
117+
118+
errorElement.textContent = message
119+
120+
// Remove error after 3 seconds
121+
setTimeout(() => {
122+
if (errorElement && errorElement.parentNode) {
123+
errorElement.parentNode.removeChild(errorElement)
124+
}
125+
}, 3000)
126+
}
127+
}

app/controllers/better_together/agreements_controller.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@ class AgreementsController < FriendlyResourceController
99
# return only the fragment wrapped in the expected <turbo-frame id="agreement_modal_frame">...</turbo-frame>
1010
# so Turbo can swap it into the frame. For normal requests, fall back to the
1111
# default rendering (with layout).
12-
def show
12+
def show # rubocop:todo Metrics/MethodLength
1313
if @agreement.page
1414
@page = @agreement.page
1515
@content_blocks = @page.content_blocks
1616
@layout = 'layouts/better_together/page'
1717
@layout = @page.layout if @page.layout.present?
1818
end
1919

20-
return unless turbo_frame_request?
21-
22-
content = render_to_string(action: :show, layout: false)
23-
render html: view_context.turbo_frame_tag('agreement_modal_frame', content)
20+
# Check if this is a Turbo Frame request
21+
if request.headers['Turbo-Frame'].present?
22+
Rails.logger.debug 'Rendering turbo frame response'
23+
render partial: 'modal_content', layout: false
24+
else
25+
Rails.logger.debug 'Rendering normal response'
26+
# Normal full-page rendering continues with the view
27+
end
2428
end
2529

2630
protected

app/controllers/better_together/events_controller.rb

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
module BetterTogether
44
# CRUD for BetterTogether::Event
5-
class EventsController < FriendlyResourceController
5+
class EventsController < FriendlyResourceController # rubocop:todo Metrics/ClassLength
66
before_action if: -> { Rails.env.development? } do
77
# Make sure that all subclasses are loaded in dev to generate type selector
88
Rails.application.eager_load!
@@ -17,6 +17,14 @@ def index
1717
end
1818

1919
def show
20+
# Handle AJAX requests for card format - only our specific hover card requests
21+
card_request = request.headers['X-Card-Request'] == 'true' || request.headers['HTTP_X_CARD_REQUEST'] == 'true'
22+
23+
if request.xhr? && card_request
24+
render partial: 'better_together/events/event', locals: { event: @event }, layout: false
25+
return
26+
end
27+
2028
super
2129
end
2230

@@ -35,10 +43,21 @@ def rsvp_going
3543
rsvp_update('going')
3644
end
3745

38-
def rsvp_cancel
39-
@event = set_resource_instance
46+
def rsvp_cancel # rubocop:disable Metrics/MethodLength
47+
set_resource_instance
48+
return if performed? # Exit early if 404 was already rendered
49+
50+
@event = @resource
4051
authorize @event, :show?
41-
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: helpers.current_person)
52+
53+
# Ensure current_person exists
54+
current_person = helpers.current_person
55+
unless current_person
56+
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to manage RSVPs.')
57+
return
58+
end
59+
60+
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person)
4261
attendance&.destroy
4362
redirect_to @event, notice: t('better_together.events.rsvp_cancelled', default: 'RSVP cancelled')
4463
end
@@ -74,10 +93,30 @@ def resource_class
7493

7594
private
7695

77-
def rsvp_update(status)
78-
@event = set_resource_instance
96+
# rubocop:todo Metrics/MethodLength
97+
def rsvp_update(status) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
98+
set_resource_instance
99+
return if performed? # Exit early if 404 was already rendered
100+
101+
@event = @resource
79102
authorize @event, :show?
80-
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: helpers.current_person)
103+
104+
# Check if event allows RSVP
105+
unless @event.scheduled?
106+
redirect_to @event,
107+
alert: t('better_together.events.rsvp_not_available',
108+
default: 'RSVP is not available for this event.')
109+
return
110+
end
111+
112+
# Ensure current_person exists before creating attendance
113+
current_person = helpers.current_person
114+
unless current_person
115+
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to RSVP.')
116+
return
117+
end
118+
119+
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: current_person)
81120
attendance.status = status
82121
authorize attendance
83122
if attendance.save
@@ -86,5 +125,54 @@ def rsvp_update(status)
86125
redirect_to @event, alert: attendance.errors.full_messages.to_sentence
87126
end
88127
end
128+
# rubocop:enable Metrics/MethodLength
129+
130+
# Override base controller method to add performance optimizations
131+
def set_resource_instance
132+
super
133+
134+
# Preload associations needed for event show page to avoid N+1 queries
135+
preload_event_associations! unless json_request?
136+
end
137+
138+
def json_request?
139+
request.format.json?
140+
end
141+
142+
# rubocop:todo Metrics/AbcSize
143+
# rubocop:todo Metrics/MethodLength
144+
def preload_event_associations! # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize
145+
return unless @event
146+
147+
# Preload categories and their translations to avoid N+1 queries
148+
@event.categories.includes(:string_translations).load
149+
150+
# Preload event hosts and their associated models
151+
@event.event_hosts.includes(:host).load
152+
153+
# Preload event attendances to avoid count queries in view
154+
@event.event_attendances.includes(:person).load
155+
156+
# Preload current person's attendance for RSVP buttons
157+
if current_person
158+
@current_attendance = @event.event_attendances.find do |a|
159+
a.person_id == current_person.id
160+
end
161+
end
162+
163+
# Preload translations for the event itself
164+
@event.string_translations.load
165+
@event.text_translations.load
166+
167+
# Preload cover image attachment to avoid attachment queries
168+
@event.cover_image_attachment&.blob&.load if @event.cover_image.attached?
169+
170+
# Preload location if present
171+
@event.location&.reload
172+
173+
self
174+
end
175+
# rubocop:enable Metrics/MethodLength
176+
# rubocop:enable Metrics/AbcSize
89177
end
90178
end

app/controllers/better_together/people_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ def show
1717
:string_translations,
1818
blocks: { background_image_file_attachment: :blob }
1919
)
20+
21+
# Preload calendar associations to avoid N+1 queries
22+
@person.preload_calendar_associations!
2023
end
2124

2225
# GET /people/new

app/helpers/better_together/application_helper.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,25 @@ def main_app_url_helper?(method)
204204
def better_together_url_helper?(method)
205205
method.to_s.end_with?('_path', '_url') && BetterTogether::Engine.routes.url_helpers.respond_to?(method)
206206
end
207+
208+
# Returns the appropriate icon and color for an event based on the person's relationship to it
209+
def event_relationship_icon(person, event) # rubocop:todo Metrics/MethodLength
210+
relationship = person.event_relationship_for(event)
211+
212+
case relationship
213+
when :created
214+
{ icon: 'fas fa-user-edit', color: '#28a745',
215+
tooltip: t('better_together.events.relationship.created', default: 'Created by you') }
216+
when :going
217+
{ icon: 'fas fa-check-circle', color: '#007bff',
218+
tooltip: t('better_together.events.relationship.going', default: 'You\'re going') }
219+
when :interested
220+
{ icon: 'fas fa-heart', color: '#e91e63',
221+
tooltip: t('better_together.events.relationship.interested', default: 'You\'re interested') }
222+
else
223+
{ icon: 'fas fa-circle', color: '#6c757d',
224+
tooltip: t('better_together.events.relationship.calendar', default: 'Calendar event') }
225+
end
226+
end
207227
end
208228
end

app/helpers/better_together/badges_helper.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ def categories_badge(entity, rounded: true, style: 'info')
1212
)
1313
end
1414

15+
# Get the translated display value for a privacy setting
16+
def privacy_display_value(entity)
17+
return '' unless entity.respond_to?(:privacy) && entity.privacy.present?
18+
19+
privacy_key = entity.privacy.to_s.downcase
20+
t("attributes.privacy_list.#{privacy_key}", default: entity.privacy.humanize.capitalize)
21+
end
22+
1523
# Render a privacy badge for an entity.
1624
# By default, map known privacy values to sensible Bootstrap context classes.
1725
# Pass an explicit `style:` to force a fixed Bootstrap style instead of using the mapping.
@@ -29,7 +37,7 @@ def privacy_badge(entity, rounded: true, style: nil)
2937

3038
chosen_style = style || privacy_style_map[privacy_key] || 'primary'
3139

32-
create_badge(entity.privacy.humanize.capitalize, rounded: rounded, style: chosen_style)
40+
create_badge(privacy_display_value(entity), rounded: rounded, style: chosen_style)
3341
end
3442

3543
# Return the mapped bootstrap-style for an entity's privacy. Useful for wiring

0 commit comments

Comments
 (0)