Skip to content

Commit e6ae55c

Browse files
jmilljr24maebealeclaude
authored
Custom forms with roles (#1563)
* Add email confirmation, address type, and consent fields to registration forms - Add confirm email field with server-side match validation - Group email, confirm email, and email type in one row - Label as "Email" on short forms, "Primary Email" when secondary exists - Add address type (Home/Work) on same row as street address - Add consent and training interest questions (appear on all forms) - Skip storing confirmation field value in user form submissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract BaseRegistrationFormBuilder to share fields across form types Short, Extended, and Scholarship form builders now inherit from a common base class that provides shared helpers (add_header, add_field) and reusable field sections (basic contact, scholarship, consent). This eliminates duplication and ensures changes to shared fields propagate to all form types. Also fixes three bugs: - Email confirmation key mismatch (primary_email_confirmation → confirm_email) so validation now works for extended forms - workshop_settings → workshop_environments so professional tags get assigned - confirm_email responses no longer stored redundantly in PersonForm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Check all event forms for registration icon, not just current one (#1288) Previously the form submission icon on the manage registrations and event registrations index pages only checked the single form tagged as "registration" role. If an event's registration form was swapped, people who submitted the old form lost their green icon. Now both pages check across all forms linked to the event via event_forms. Also fixes N+1 queries on the event registrations index by batch- loading form submissions upfront. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Fix delete registration button and return_to routing (#1289) * Fix delete registration button and return_to routing The delete button on the registration edit form wasn't working because data-turbo="false" on the parent form disabled Turbo for the delete link which relies on turbo_method: :delete. Added turbo: true override. All CRUD actions (create, update, delete) now respect return_to param so admins are redirected back to the page they came from — manage registrants or registrations index — instead of always landing on the index or ticket. Also adds event registration seed data with various scenarios and a system spec for the delete button. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix request specs for return_to routing changes Update tests to pass return_to param explicitly since create no longer infers destination from event_id presence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Use rhino_description for registration confirmation email (#1291) * Use rhino_description for registration confirmation email details The email was showing the plain-text description column (lorem ipsum placeholder) instead of the actual event details from rhino_description, which is the same content used in calendar events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add EventMailer spec covering confirmation and cancellation emails Tests that rhino_description content appears in the confirmation email body, and verifies basic rendering for both mailer actions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove cancelled email tests from EventMailer spec Cancellation email doesn't reference description, so no test needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix rubocop empty line at block body end Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add direct organization associations to event registrations (#1292) * Add direct organization associations to event registrations Registrations now have their own many-to-many relationship with organizations via a join table, decoupled from the registrant's affiliations. Active orgs are snapshotted at registration time, and admins can add/remove orgs from the registration edit page using toggleable chips and a Stimulus controller. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Register people with multiple affiliations in seeds Ensures Mariana Johnson (3 affiliations), Samuel Smith, Lisa Williamson, Kim Davidson, and Sarah Davis (2 each) are registered to events so their organizations get snapshotted via the after_create callback. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Rescue RecordNotUnique in controllers with ActionText fields (#1294) A RecordNotUnique error (duplicate action_text_rich_texts entry) was surfacing as a 500 in production during workshop updates, likely from a race condition (e.g. double form submission). The transaction rescue blocks only caught RecordInvalid and RecordNotSaved, letting the uniqueness violation propagate uncaught. Added RecordNotUnique to rescue clauses in all 10 controllers that save models with has_rich_text fields, so duplicate-key errors roll back gracefully and re-render the form instead of crashing. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Prevent deletion of users with associated records (#1295) * Prevent deletion of users with associated records Users who have created reports, workshop logs, resources, workshops, stories, or ideas can no longer be deleted. The delete button is hidden in the UI and the controller rescues InvalidForeignKey as a safety net. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix report factory in deletable? test to include workshop The report factory doesn't include a workshop association, but the Report model requires one. Pass it explicitly in the test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Fix workshop form admin layout (#1298) * Remove dead assets/form partial reference from workshop edit The partial was removed in a previous revert but the render call was left behind, causing a MissingTemplate error on ?admin=true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix workshop form admin fields layout and conditional author name Stack admin fields vertically instead of horizontally to prevent label truncation and field overlap. Only show Author's name field when the value is present and ?admin=true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move workshop created date under Workshop Idea parent Relocate the date field from the bottom of the form into the admin grid column alongside Workshop Idea parent. Rename label from "Date created" to "Workshop created" with consistent sizing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove full_name fill from create workshop test The Author's name field is now conditionally shown only when the value is present and ?admin=true, so it won't appear on the new workshop form. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Allow workshop logs with external title and no workshop (#1299) * Allow workshop logs without workshop_id when external title present Workshop logs can now be submitted with an external_workshop_title instead of requiring a workshop_id. This supports users logging workshops that don't exist in the system. - Make workshop association optional on Report - Add migration to remove NOT NULL constraint on reports.workshop_id - Add custom validation requiring either workshop_id or external_workshop_title Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Show external workshop title on workshop log show page Display external_workshop_title in heading and Workshop field when no workshop record is associated. When both are present, show the external title inline next to the workshop chip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add tests for external workshop title on workshop logs - Model spec: validate workshop_id or external_workshop_title required - Request spec: create workshop log with external title and no workshop - System spec: display external title in heading and Workshop field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI failures in workshop log tests - Check workshop.present? instead of workshop_id.present? so validation works with both build (in-memory) and create (persisted) - Use specific Capybara selector to avoid ambiguous div matches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Use rhino rich text fields instead of native columns across the app (#1297) * Use OuterJoin in RichTextSearchable so records without rich text still appear InnerJoin was filtering out records that had no action_text_rich_texts entry, causing them to disappear from search results entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update SearchCop configs to search rich text content - Resource: enable rich text search (was commented out), remove native body - Story: add grouped default attributes so rich text + title both searched - Tutorial: add rich text search, split multi-word search terms for independent matching, search both native body and rhino_body - WorkshopVariation: add RichTextSearchable, replace native body search with rich text search, use rhino_body in description method Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use rhino rich text fields instead of native columns in decorators - StoryDecorator: use rhino_body.to_plain_text in detail - TutorialDecorator: use rhino_body in display_text and detail - ResourceDecorator: use rhino_body in detail, truncated_text, flex_text, display_text, and html - WorkshopDecorator: prepend rhino_ prefix in spanish_field_values, use rhino fields in html_content and html_objective Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use rhino rich text fields instead of native columns in views - Story shares: use rhino_body.to_plain_text instead of strip_tags(body) - Tutorial partial: display rhino_body instead of body.html_safe - Workshop variations index: use rhino_body.to_plain_text instead of body Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update tutorial specs to use rhino_body for test data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix tutorial view spec to include rhino_body for rendered output The show view now renders rhino_body instead of native body, so the test data needs to populate the rich text field. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Restrict person profile to read-only for non-admins (#1296) * Restrict person edit/update to admin only - Remove owner? from edit? and update? in PersonPolicy - Hide "My profile" menu link unless admin manage - Update policy spec to expect owner cannot edit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add admin-only styling and permission gates to person views - Gate People and Edit links with policy checks and admin-only styling - Show email with admin-only styling when profile_show_email is off - Remove comments section from person show (keep in edit form only) - Add admin-only styling to comments section on person edit form Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add request specs for person show authorization and profile flags - Test edit/update blocked for owners, allowed for admin - Test show page gated content (Edit link, email, Submitted content, Comments) - Test all 16 profile flags for visibility on own and admin-viewed profiles - Test email admin-only styling when profile_show_email is off Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix navbar avatar system test for admin-only person edit The test was using a regular user to edit a person record, which is now restricted to admins only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * Add admin CRUD for forms with question library picker Admins can now manage forms through a dedicated UI: - Index lists all forms with field counts, linked events, and type badges - New form flow: interstitial page to select builder type (short/extended registration, scholarship, or blank generic), then redirect to edit - Edit page supports inline editing of form name and question labels, toggling required/status, adding/removing fields via cocoon, and cloning questions from other forms via a searchable Turbo Frame picker - Show page displays fields grouped by section with linked events Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add email confirmation, address type, and consent fields to registration forms - Add confirm email field with server-side match validation - Group email, confirm email, and email type in one row - Label as "Email" on short forms, "Primary Email" when secondary exists - Add address type (Home/Work) on same row as street address - Add consent and training interest questions (appear on all forms) - Skip storing confirmation field value in user form submissions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Extract BaseRegistrationFormBuilder to share fields across form types Short, Extended, and Scholarship form builders now inherit from a common base class that provides shared helpers (add_header, add_field) and reusable field sections (basic contact, scholarship, consent). This eliminates duplication and ensures changes to shared fields propagate to all form types. Also fixes three bugs: - Email confirmation key mismatch (primary_email_confirmation → confirm_email) so validation now works for extended forms - workshop_settings → workshop_environments so professional tags get assigned - confirm_email responses no longer stored redundantly in PersonForm Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Consolidate form builders into configurable FormBuilderService with admin CRUD - Replace 4 hardcoded builders (Base, Short, Extended, Scholarship) with a single FormBuilderService that accepts selectable sections - Rename PersonForm → FormSubmission and PersonFormFormField → FormAnswer (tables, models, and all references) - Add admin FormsController with section interstitial (new) and field editor (edit) with drag-reorder via existing sortable_controller - Snapshot question_text on FormAnswer at submission time for answer preservation when fields are later deleted - Add form-level hide_answered_person_questions and hide_answered_form_questions booleans for conditional field visibility - Add sections JSON column to forms to record builder configuration - Keep form_fields.status column (still used by workshop logs/reports) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move registration form seeds into dummy dev seeds The standalone registration forms are dev/test data, not required for production bootstrapping. Placed before event seeds that depend on them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add preview form link to form editor Links to the public registration page of the first event using this form, opening in a new tab. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add form show page for previewing form fields Renders a disabled preview of the form as it would appear to a registrant. Works for all forms, including those not linked to events. Accessible from both the forms index and the form editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Match form page nav links to person show/edit styling Use the same text-sm text-gray-500 hover:text-gray-700 px-2 py-1 pattern in a right-aligned flex-wrap container. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Change consent field to checkbox with only "Yes" option Consent is an opt-in acknowledgment, not a yes/no choice. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add edit_sections page to add/remove form sections Allows changing which builder sections are included on an existing form. Unchecking a section removes its fields; checking adds default fields at the end. Preserves existing answers via question_text snapshots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix section removal to properly delete group headers Use explicit SECTION_HEADERS mapping to remove headers by question text instead of by field_group, which failed when sections shared a group (e.g. person_identifier and person_contact_info both use "contact"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve form editor: conditional visibility, group sorting, cocoon fields - Rename event_feedback section to marketing with updated field keys - Add three-category conditional visibility: Scholarship-only, Logged out only, Answers on file (covers professional + marketing groups) - Add slide toggle previews on form show page - Group-aware drag-and-drop: dragging a section header moves all its fields - Switch to cocoon for adding/removing fields (replaces server-side add_field) - Style section headers as bold text, indent child fields - Replace Delete checkbox with Remove link (matching affiliation pattern) - Fix nested form issue (button_to inside form_with) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add per-field visibility enum and fix cocoon new field saving - Add visibility column (always_ask, scholarship_only, logged_out_only, answers_on_file) to form_fields with migration - Update FormBuilderService with GROUP_VISIBILITY defaults per section - Replace visibility_select_controller with generic chip_select_controller that accepts styles via Stimulus values - Update public_registrations_controller to filter by visibility column instead of hardcoded field_group arrays - Update form show/edit views to derive toggle conditions from visibility - Fix new cocoon fields not saving: reject_if blank question on new records - Add validation error display to form edit page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Register chip-select and form-fields-sortable Stimulus controllers Both were created but never added to the controller index, so they weren't loading on the page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add one-time field hiding, flexbox layout, and cocoon insertion fix - Add `one_time` boolean to form_fields for cross-form answer hiding - Two-tier answer hiding: one-time checks all forms, regular checks within event - Switch field rows to flexbox with wrap for responsive layout - Make section header names editable text fields - New cocoon fields now append to bottom of form field list - Add ONE_TIME_GROUPS to FormBuilderService for professional/background sections Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rename form_answers.text to question_answer for clarity Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Consolidate form builder migrations and rename columns for clarity Collapse four separate migrations into a single consolidation migration. Rename columns across form_fields, form_answers, and form_submissions to use clearer, more consistent names (e.g. field_key → field_identifier, question → name, answer_datatype → input_type). Add NOT NULL constraints to match model associations. Rename tables person_forms → form_submissions and person_form_form_fields → form_answers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix legacy references and add missing specs for form builder - Fix FormField#html_input_type and #form_helper_type to use enum values instead of old human-readable strings - Update seeds to use FormSubmission/FormAnswer instead of PersonForm/PersonFormFormField - Update event specs to use :form_submission factory - Delete obsolete person_forms factory - Add FormSubmission and FormAnswer model specs - Add visibility enum, multiple_choice?, html_input_type, form_helper_type specs to FormField - Expand forms request spec with destroy, show preview, edit_sections, update_sections, and reorder_fields coverage - Regenerate schema.rb after running consolidation migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add specs for FormBuilderService.update_sections! Test adding sections, removing sections with field cleanup, header removal, field preservation, and position ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix schema/validation mismatches found in audit - Remove erroneous null: false on workshop_variations.workshop_id — PR #1078 intentionally made workshop optional per stakeholder request (variations can exist without a parent workshop, visible to admins only). The constraint was picked up by schema dump from local DB state, not from any migration. - Add required: true to form field name inputs so browser enforces the presence validation that exists at model (validates :name, presence: true) and DB (null: false) levels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update AGENTS.md with form builder models/services and fix minor issues - Add Form, FormField, FormSubmission, FormAnswer to model docs - Add FormBuilderService and new Stimulus controllers to AGENTS.md - Simplify logged_out_only field hiding to always apply for logged-in users (not gated on hide_answered_person_questions flag) - Use Stimulus shorthand action descriptors for chip-select - Remove trailing comma in forms/show.html.erb Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor separate forms with roles * filter form builder sections * add visitor param for admin * clean up * clean up seeds more * fix seeds * clean up * clean up * fix merge issues * change visibility * add logged in user form flow * fix test failure * rubocop * breakman * update puma --------- Co-authored-by: maebeale <maebeale@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2053f18 commit e6ae55c

88 files changed

Lines changed: 2346 additions & 1102 deletions

File tree

Some content is hidden

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

AGENTS.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ end
178178
- `TaggingSearchService` — Search and filter tagging data
179179
- `PersonFromUserService` — Create Person from User account
180180
- `BulkInviteService` — Bulk send welcome instructions and reset created_at for users
181+
- `FormBuilderService` — Builds configurable forms from composable sections with per-field visibility
181182
- `ModelDeduper` — Deduplication logic
182183
- `RichTextMigrator` — Rich text migration utility
183184
- `DisplayImagePresenter` — Image display logic
@@ -186,9 +187,6 @@ end
186187

187188
- `EventRegistrationServices::ProcessConfirmation` — Registration confirmation flow
188189
- `EventRegistrationServices::PublicRegistration` — Public registration handling
189-
- `ExtendedEventRegistrationFormBuilder` — Extended registration form builder
190-
- `ShortEventRegistrationFormBuilder` — Short registration form builder
191-
- `ScholarshipApplicationFormBuilder` — Scholarship form builder
192190

193191
### Notifications
194192

app/controllers/event_registrations_controller.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def process_confirm
129129
end
130130

131131
def link_organization
132-
@event_registration = EventRegistration.includes(:event, registrant: :person_forms).find(params[:id])
132+
@event_registration = EventRegistration.includes(:event, registrant: :form_submissions).find(params[:id])
133133
authorize! @event_registration, to: :link_organization?
134134
@person = @event_registration.registrant
135135
@submitted_org_name = find_submitted_agency_name(@event_registration)
@@ -234,14 +234,14 @@ def find_submitted_agency_name(registration)
234234
form = registration.event.registration_form
235235
return nil unless form
236236

237-
field = form.form_fields.find_by(field_key: "agency_name")
237+
field = form.form_fields.find_by(field_identifier: "agency_name")
238238
return nil unless field
239239

240-
PersonFormFormField
241-
.joins(person_form: :person)
240+
FormAnswer
241+
.joins(form_submission: :person)
242242
.find_by(
243-
person_forms: { person_id: registration.registrant_id, form_id: form.id },
243+
form_submissions: { person_id: registration.registrant_id, form_id: form.id },
244244
form_field_id: field.id
245-
)&.text
245+
)&.submitted_answer
246246
end
247247
end

app/controllers/events/public_registrations_controller.rb

Lines changed: 159 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def new
2020

2121
@form_fields = visible_form_fields
2222
@scholarship = scholarship_mode?
23+
@scholarship_form = @event.scholarship_form if @scholarship
2324
@event = @event.decorate
2425
end
2526

@@ -32,12 +33,15 @@ def create
3233
end
3334

3435
@form = registration_form
35-
form_params = params.dig(:public_registration, :form_fields)&.to_unsafe_h || {}
36+
@scholarship = scholarship_mode?
37+
@scholarship_form = @event.scholarship_form if @scholarship
38+
39+
all_params = params.dig(:public_registration, :form_fields)&.to_unsafe_h || {}
40+
registration_params, scholarship_params = split_form_params(all_params)
3641

37-
@field_errors = validate_required_fields(form_params)
42+
@field_errors = validate_required_fields(registration_params)
3843
if @field_errors.any?
3944
@form_fields = visible_form_fields
40-
@scholarship = scholarship_mode?
4145
@event = @event.decorate
4246
render :new, status: :unprocessable_content
4347
return
@@ -48,15 +52,16 @@ def create
4852
result = EventRegistrationServices::PublicRegistration.call(
4953
event: @event,
5054
form: @form,
51-
form_params: form_params,
52-
scholarship_requested: scholarship_mode?
55+
form_params: registration_params,
56+
scholarship_requested: @scholarship,
57+
person: current_user&.person
5358
)
5459

5560
if result.success?
5661
registration = result.event_registration
5762

58-
if !registration.scholarship_requested? && @event.cost_cents.to_i > 0 && credit_card_payment?(form_params)
59-
checkout_session = create_stripe_checkout_session(registration, form_params)
63+
if !registration.scholarship_requested? && @event.cost_cents.to_i > 0 && credit_card_payment?(registration_params)
64+
checkout_session = create_stripe_checkout_session(registration)
6065
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
6166
else
6267
redirect_to registration_ticket_path(registration.slug),
@@ -90,47 +95,31 @@ def show
9095
return
9196
end
9297

93-
@person_form = @form.person_forms.find_by(person: person)
94-
unless @person_form
98+
@form_submission = @form.form_submissions.find_by(person: person)
99+
unless @form_submission
95100
redirect_to event_path(@event), alert: "No registration form submission found."
96101
return
97102
end
98103

99-
@form_fields = if registration&.scholarship_requested?
100-
@form.form_fields.where(status: :active).where.not(field_group: "payment").reorder(position: :asc)
101-
else
102-
@form.form_fields.where(status: :active).where.not(field_group: "scholarship").reorder(position: :asc)
103-
end
104-
@responses = @person_form.person_form_form_fields.index_by(&:form_field_id)
104+
@form_fields = @form.form_fields.reorder(position: :asc)
105+
@responses = @form_submission.form_answers.index_by(&:form_field_id)
105106
@event = @event.decorate
106107
end
107108

108109
private
109110

110111
def credit_card_payment?(form_params)
111-
payment_method_field = @form.form_fields.find_by(field_key: "payment_method")
112+
payment_method_field = @form.form_fields.find_by(field_identifier: "payment_method")
112113
return false unless payment_method_field
113114

114115
form_params[payment_method_field.id.to_s] == "Credit Card"
115116
end
116117

117-
def number_of_attendees(form_params)
118-
attendees_field = @form.form_fields.find_by(field_key: "number_of_attendees")
119-
return 1 unless attendees_field
120-
121-
form_params[attendees_field.id.to_s].to_i
122-
end
123-
124-
def create_stripe_checkout_session(registration, form_params)
118+
def create_stripe_checkout_session(registration)
125119
person = registration.registrant
126-
attendees = number_of_attendees(form_params)
127-
amount = @event.cost_cents * attendees
120+
amount = @event.cost_cents
128121

129122
metadata = { event_registration_id: registration.id }
130-
comments_field = @form.form_fields.find_by(field_key: "payment_comments")
131-
if comments_field && form_params[comments_field.id.to_s].present?
132-
metadata[:payment_comments] = form_params[comments_field.id.to_s]
133-
end
134123

135124
person.set_payment_processor :stripe
136125

@@ -163,16 +152,146 @@ def scholarship_mode?
163152
params[:scholarship_requested] == "true"
164153
end
165154

155+
def split_form_params(all_params)
156+
reg_field_ids = @form.form_fields.pluck(:id).map(&:to_s)
157+
registration = all_params.slice(*reg_field_ids)
158+
159+
scholarship = {}
160+
if @scholarship_form
161+
scholarship_field_ids = @scholarship_form.form_fields.pluck(:id).map(&:to_s)
162+
scholarship = all_params.slice(*scholarship_field_ids)
163+
end
164+
165+
[ registration, scholarship ]
166+
end
167+
168+
def create_or_update_scholarship_submission(person, scholarship_params)
169+
scholarship_form = @event.scholarship_form
170+
return unless scholarship_form
171+
172+
submission = FormSubmission.find_or_create_by!(
173+
person: person, form: scholarship_form, role: "scholarship"
174+
)
175+
176+
scholarship_params.each do |field_id, raw_value|
177+
field = scholarship_form.form_fields.find_by(id: field_id)
178+
next unless field
179+
next if field.group_header?
180+
181+
text = raw_value.is_a?(Array) ? raw_value.reject(&:blank?).join(", ") : raw_value.to_s
182+
183+
record = submission.form_answers.find_or_initialize_by(form_field: field)
184+
record.update!(submitted_answer: text, question_name_when_answered: field.name)
185+
end
186+
end
187+
166188
def visible_form_fields
167-
scope = @form.form_fields.where(status: :active)
168-
if scholarship_mode?
169-
scope = scope.where.not(field_group: "payment")
170-
else
171-
scope = scope.where.not(field_group: "scholarship")
189+
scope = @form.form_fields
190+
191+
person = current_user&.person if params[:as_visitor] != "true"
192+
if person
193+
# Always hide logged_out_only fields for logged-in users with known data
194+
known_identifiers = person_known_identifiers(person)
195+
if known_identifiers.any?
196+
known_ids = @form.form_fields
197+
.where(visibility: :logged_out_only, field_identifier: known_identifiers)
198+
.ids
199+
scope = scope.where.not(id: known_ids) if known_ids.any?
200+
end
201+
202+
# Hide logged_out_only headers when all their non-header fields are hidden
203+
logged_out_sections = @form.form_fields.where(visibility: :logged_out_only)
204+
.where.not(answer_type: :group_header)
205+
.pluck(:section).uniq.compact
206+
logged_out_sections.each do |sect|
207+
section_field_ids = @form.form_fields.where(section: sect, visibility: :logged_out_only)
208+
.where.not(answer_type: :group_header).ids
209+
if section_field_ids.any? && known_identifiers.any? && (section_field_ids - scope.where(id: section_field_ids).ids).any?
210+
remaining = scope.where(id: section_field_ids).ids
211+
if remaining.empty?
212+
scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :logged_out_only)
213+
end
214+
end
215+
end
216+
217+
if @form.hide_answered_form_questions?
218+
answered_field_ids = []
219+
220+
# One-time fields: hide if answered on ANY form submission for this person
221+
one_time_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: true)
222+
.where.not(answer_type: :group_header).ids
223+
if one_time_field_ids.any?
224+
answered_one_time = FormAnswer.joins(:form_submission)
225+
.where(form_submissions: { person_id: person.id })
226+
.where(form_field_id: one_time_field_ids)
227+
.where.not(submitted_answer: [ nil, "" ])
228+
.pluck(:form_field_id)
229+
answered_field_ids.concat(answered_one_time)
230+
end
231+
232+
# Regular fields: hide if answered on forms within this event
233+
event_form_ids = @event.forms.ids
234+
event_submissions = FormSubmission.where(person: person, form_id: event_form_ids)
235+
if event_submissions.exists?
236+
regular_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: false)
237+
.where.not(answer_type: :group_header).ids
238+
if regular_field_ids.any?
239+
answered_regular = FormAnswer.where(form_submission: event_submissions)
240+
.where(form_field_id: regular_field_ids)
241+
.where.not(submitted_answer: [ nil, "" ])
242+
.pluck(:form_field_id)
243+
answered_field_ids.concat(answered_regular)
244+
end
245+
end
246+
247+
answered_field_ids.uniq!
248+
if answered_field_ids.any?
249+
scope = scope.where.not(id: answered_field_ids)
250+
251+
# Hide section headers when all their non-header fields are answered
252+
answered_sections = @form.form_fields.where(id: answered_field_ids)
253+
.pluck(:section).uniq.compact
254+
answered_sections.each do |sect|
255+
section_field_ids = @form.form_fields.where(section: sect, visibility: :answers_on_file)
256+
.where.not(answer_type: :group_header).ids
257+
if section_field_ids.any? && (section_field_ids - answered_field_ids).empty?
258+
scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :answers_on_file)
259+
end
260+
end
261+
end
262+
end
172263
end
264+
173265
scope.reorder(position: :asc)
174266
end
175267

268+
def person_known_identifiers(person)
269+
keys = []
270+
keys << "first_name" if person.first_name.present?
271+
keys << "last_name" if person.last_name.present?
272+
keys << "primary_email" << "confirm_email" if person.email.present?
273+
keys << "primary_email_type" if person.email_type.present?
274+
keys << "nickname" if person.legal_first_name.present? || person.first_name.present?
275+
keys << "pronouns" if person.pronouns.present?
276+
keys << "secondary_email" if person.email_2.present?
277+
keys << "secondary_email_type" if person.email_2_type.present?
278+
279+
if person.addresses.exists?
280+
address = person.addresses.find_by(primary: true) || person.addresses.first
281+
keys << "mailing_street" if address.street_address.present?
282+
keys << "mailing_address_type" if address.address_type.present?
283+
keys << "mailing_city" if address.city.present?
284+
keys << "mailing_state" if address.state.present?
285+
keys << "mailing_zip" if address.zip_code.present?
286+
end
287+
288+
if person.contact_methods.where(kind: :phone).exists?
289+
keys << "phone" << "phone_type"
290+
end
291+
292+
keys
293+
end
294+
176295
def ensure_registerable
177296
unless @event.registerable?
178297
redirect_to event_path(@event), alert: "Registration is closed for this event."
@@ -182,14 +301,14 @@ def ensure_registerable
182301
def validate_required_fields(form_params)
183302
errors = {}
184303
fields = visible_form_fields
185-
fields_by_key = fields.select { |f| f.field_key.present? }.index_by(&:field_key)
304+
fields_by_identifier = fields.select { |f| f.field_identifier.present? }.index_by(&:field_identifier)
186305

187306
fields.find_each do |field|
188307
next if field.group_header?
189308

190309
value = form_params[field.id.to_s]
191310

192-
if field.is_required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?))
311+
if field.required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?))
193312
errors[field.id] = "can't be blank"
194313
next
195314
end
@@ -198,13 +317,13 @@ def validate_required_fields(form_params)
198317

199318
if field.number_integer? && value.to_s !~ /\A\d+\z/
200319
errors[field.id] = "must be a whole number"
201-
elsif field.field_key&.match?(/email(?!_type)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
320+
elsif field.field_identifier&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
202321
errors[field.id] = "must be a valid email address"
203322
end
204323
end
205324

206-
confirm_field = fields_by_key["confirm_email"]
207-
email_field = fields_by_key["primary_email"]
325+
confirm_field = fields_by_identifier["confirm_email"]
326+
email_field = fields_by_identifier["primary_email"]
208327
if confirm_field && email_field && errors[confirm_field.id].nil?
209328
confirm_value = form_params[confirm_field.id.to_s].to_s.strip
210329
email_value = form_params[email_field.id.to_s].to_s.strip

app/controllers/events_controller.rb

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -220,26 +220,26 @@ def event_registration_csv_row(registration, cost_required)
220220

221221
def assign_event_forms(event)
222222
form_id = params.dig(:event, :registration_form_id)
223-
return unless form_id
224-
225223
if form_id.blank?
226224
event.event_forms.registration.destroy_all
227225
else
228226
form = Form.standalone.find_by(id: form_id)
229-
return unless form
230-
231-
existing = event.event_forms.registration.first
232-
if existing
233-
existing.update!(form: form) unless existing.form_id == form.id.to_i
234-
else
235-
event.event_forms.create!(form: form, role: "registration")
227+
if form
228+
existing = event.event_forms.registration.first
229+
if existing
230+
existing.update!(form: form) unless existing.form_id == form.id.to_i
231+
else
232+
event.event_forms.create!(form: form, role: "registration")
233+
end
236234
end
237235
end
238236

239-
scholarship_form = Form.standalone.find_by(name: ScholarshipApplicationFormBuilder::FORM_NAME)
240-
if scholarship_form && event.cost_cents.to_i > 0
241-
event.event_forms.find_or_create_by!(form: scholarship_form, role: "scholarship")
242-
elsif event.cost_cents.to_i == 0
237+
if params.dig(:event, :scholarship_enabled) == "1"
238+
form = Form.standalone.find_by(role: "scholarship")
239+
if form && !event.event_forms.scholarship.exists?
240+
event.event_forms.create!(form: form, role: "scholarship")
241+
end
242+
else
243243
event.event_forms.scholarship.destroy_all
244244
end
245245
end
@@ -249,7 +249,7 @@ def set_form_variables
249249
@event.build_primary_asset if @event.primary_asset.blank?
250250
@event.gallery_assets.build
251251
@locations = Location.order(:city, :state)
252-
@registration_forms = Form.standalone.where(scholarship_application: false).order(:name)
252+
@registration_forms = Form.standalone.where(role: [ nil, "", "registration" ]).order(:name)
253253
@categories_grouped =
254254
Category
255255
.includes(:category_type)

0 commit comments

Comments
 (0)