Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,30 @@ import { Controller } from "@hotwired/stimulus"
// Swaps the colored icon inside the status select as the dropdown changes. The
// select looks like an ordinary form field (not the roster's autosaving chip),
// so an "Unsaved" hint is shown until the form is saved.
//
// The per-day attendance checkboxes drive the status the same way the Onboarding
// matrix does: toggling a day rolls the status forward/back (registered β†’
// incomplete_attendance β†’ attended), but only while the status is an active one β€”
// deliberate manual states (cancelled, no_show, transferred_out) are never
// overridden. Mirrors EventRegistration#sync_attendance_status_to_days!.
export default class extends Controller {
static targets = ["select", "icon", "dirty"]
static values = { colors: Object, icons: Object, initial: String }
static targets = ["select", "icon", "dirty", "day"]
static values = { colors: Object, icons: Object, initial: String, activeStatuses: Array, dayCount: Number }

// Recompute the status from how many day checkboxes are checked, then reflect it
// in the dropdown (which re-renders the icon and the unsaved hint via update()).
deriveFromDays() {
if (!this.activeStatusesValue.includes(this.selectTarget.value)) return

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Guards the daysβ†’status derivation to active statuses so a deliberate cancelled/no_show/transferred_out isn't silently rolled back by toggling a day β€” mirrors the server-side sync_attendance_status_to_days!.


const checked = this.dayTargets.filter((day) => day.checked).length
let derived = "incomplete_attendance"
if (checked === 0) derived = "registered"
else if (checked >= this.dayCountValue) derived = "attended"

if (this.selectTarget.value === derived) return
this.selectTarget.value = derived
this.update()
}
Comment thread
Copilot marked this conversation as resolved.

update() {
const status = this.selectTarget.value
Comment on lines 32 to 33
Expand Down
35 changes: 29 additions & 6 deletions app/views/event_registrations/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,15 @@
} %>
<% current_icon_color = status_icon_colors[f.object.status] || "text-gray-500" %>
<% current_icon = status_icons[f.object.status] || "fa-question" %>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
<%# The attendance-status controller spans the whole card so the per-day
checkmarks in the meta strip below can drive the status select above. %>
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm"
data-controller="attendance-status"
data-attendance-status-colors-value="<%= status_icon_colors.to_json %>"
data-attendance-status-icons-value="<%= status_icons.to_json %>"
data-attendance-status-initial-value="<%= f.object.status %>"
data-attendance-status-active-statuses-value="<%= EventRegistration::ACTIVE_STATUSES.to_json %>"
data-attendance-status-day-count-value="<%= f.object.event.day_count %>">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6">
<div class="min-w-0 flex-1">
<%= person_profile_button(f.object.registrant, subtitle: f.object.registrant.preferred_email) %>
Expand Down Expand Up @@ -77,11 +85,7 @@
</div>
<% end %>

<div class="sm:w-64"
data-controller="attendance-status"
data-attendance-status-colors-value="<%= status_icon_colors.to_json %>"
data-attendance-status-icons-value="<%= status_icons.to_json %>"
data-attendance-status-initial-value="<%= f.object.status %>">
<div class="sm:w-64">
<div class="mb-1 flex items-center justify-between gap-2">
<span class="text-xs font-medium text-gray-400">Registration status</span>
<span data-attendance-status-target="dirty"
Expand Down Expand Up @@ -134,6 +138,25 @@
<i class="fa-solid fa-arrow-up-right-from-square text-[0.55rem] text-gray-400"></i>
<% end %>
<% end %>

<%# ---- Per-day attendance β€” the same checkmarks as the Onboarding
matrix. Toggling a day rolls the status select above forward/back
(see the attendance-status controller), and the boxes save with the
form. ---- %>
<div class="ml-auto flex flex-col items-end gap-1">
<span class="text-gray-400">Days attended</span>
<div class="flex flex-wrap items-center justify-end gap-1.5">
<% (1..f.object.event.day_count).each do |day| %>
<label class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium cursor-pointer transition-colors border-gray-200 text-gray-500 has-[:checked]:border-green-300 has-[:checked]:bg-green-50 has-[:checked]:text-green-700">
<%= f.check_box "completed_day_#{day}",
"aria-label": "Day #{day}",
data: { "attendance-status-target": "day", action: "attendance-status#deriveFromDays" },
class: "h-3.5 w-3.5 rounded border-gray-300 text-green-600 focus:ring-green-200 cursor-pointer" %>
Day <%= day %>
</label>
<% end %>
</div>
</div>
</div>
</div>

Expand Down
57 changes: 57 additions & 0 deletions spec/system/event_registration_edit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,63 @@
end
end

describe "per-day attendance checkboxes" do
it "renders a checkbox per event day, reflecting stored state, and persists changes" do
registration.update!(completed_day_1: true)

sign_in(admin)
visit edit_event_registration_path(registration)

# The event spans 3 days, so Days 1-3 show (and no Day 4).
expect(page).to have_field("Day 1", checked: true)
expect(page).to have_field("Day 2", checked: false)
expect(page).to have_field("Day 3", checked: false)
expect(page).to have_no_field("Day 4")

check "Day 2", allow_label_click: true
click_on "Save changes"

expect(page).to have_current_path(registrants_event_path(event))
expect(registration.reload.completed_day_1).to be(true)
expect(registration.completed_day_2).to be(true)
end

it "derives the attendance status from the checked days and saves both together" do
sign_in(admin)
visit edit_event_registration_path(registration)

badge = "[data-attendance-status-target='dirty']"
expect(page).to have_no_css(badge, visible: true)

# Checking every day rolls the status forward to Attended (mirrors onboarding).
check "Day 1", allow_label_click: true
check "Day 2", allow_label_click: true
check "Day 3", allow_label_click: true

expect(page).to have_css(badge, visible: true, text: "Unsaved")
expect(page).to have_select("event_registration[status]", selected: "Attended")

click_on "Save changes"

# Wait for the save round-trip to land before reading the database.
expect(page).to have_current_path(registrants_event_path(event))
expect(registration.reload.status).to eq("attended")
expect(registration.completed_day_count).to eq(3)
end

it "leaves an inactive status untouched when days are toggled" do
registration.update!(status: "cancelled")

sign_in(admin)
visit edit_event_registration_path(registration)

check "Day 1", allow_label_click: true

# Cancelled is a deliberate manual state, so toggling a day never overrides it.
expect(page).to have_select("event_registration[status]", selected: "Cancelled")
end
end

describe "notifications box" do
it "lists notifications sent to the registrant" do
create(:notification,
Expand Down