From ac0d07f2c2fd546fc40a5a7dd8b99f0986640319 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 1 Mar 2026 14:45:20 -0500
Subject: [PATCH 01/14] 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
---
.../events/public_registrations_controller.rb | 2 +-
.../public_registration.rb | 3 ++-
...xtended_event_registration_form_builder.rb | 23 +++++++++++++++----
.../public_registrations/_form_field.html.erb | 4 ++--
.../events/public_registrations/new.html.erb | 13 +++++++++--
.../events/public_registrations/show.html.erb | 2 ++
6 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index ade7bfcec..d9dd62012 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -142,7 +142,7 @@ def validate_required_fields(form_params)
if field.number_integer? && value.to_s !~ /\A\d+\z/
errors[field.id] = "must be a whole number"
- elsif field.field_key&.match?(/email(?!_type)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
+ elsif field.field_key&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
errors[field.id] = "must be a valid email address"
end
end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index af5b734e7..209a5264a 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -123,7 +123,7 @@ def create_mailing_address(person)
state: new_state,
zip_code: field_value("mailing_zip"),
locality: "Unknown",
- address_type: "mailing",
+ address_type: field_value("mailing_address_type")&.downcase || "mailing",
primary: true
)
end
@@ -252,6 +252,7 @@ def update_person_form(person)
def save_form_fields(person_form)
@form.form_fields.where(status: :active).find_each do |field|
next if field.group_header?
+ next if field.field_key == "primary_email_confirmation"
raw_value = @form_params[field.id.to_s]
text = if raw_value.is_a?(Array)
diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb
index 9a7e2678b..e65a9fc29 100644
--- a/app/services/extended_event_registration_form_builder.rb
+++ b/app/services/extended_event_registration_form_builder.rb
@@ -58,7 +58,8 @@ def build_fields!(form)
position = build_professional_fields(form, position)
position = build_qualitative_fields(form, position)
position = build_scholarship_fields(form, position)
- build_payment_fields(form, position)
+ position = build_payment_fields(form, position)
+ build_consent_fields(form, position)
form
end
@@ -78,6 +79,8 @@ def build_contact_fields(form, position)
key: "pronouns", group: "contact", required: false)
position = add_field(form, position, "Primary Email", :free_form_input_one_line,
key: "primary_email", group: "contact", required: true)
+ position = add_field(form, position, "Confirm Primary Email", :free_form_input_one_line,
+ key: "primary_email_confirmation", group: "contact", required: true)
position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
key: "primary_email_type", group: "contact", required: true,
options: %w[Personal Work])
@@ -90,15 +93,15 @@ def build_contact_fields(form, position)
position = add_header(form, position, "Mailing Address", group: "contact")
position = add_field(form, position, "Street Address", :free_form_input_one_line,
key: "mailing_street", group: "contact", required: true)
+ position = add_field(form, position, "Address Type", :multiple_choice_radio,
+ key: "mailing_address_type", group: "contact", required: true,
+ options: %w[Home Work])
position = add_field(form, position, "City", :free_form_input_one_line,
key: "mailing_city", group: "contact", required: true)
position = add_field(form, position, "State / Province", :free_form_input_one_line,
key: "mailing_state", group: "contact", required: true)
position = add_field(form, position, "Zip / Postal Code", :free_form_input_one_line,
key: "mailing_zip", group: "contact", required: true)
- position = add_field(form, position, "Mailing Address Type", :multiple_choice_radio,
- key: "mailing_address_type", group: "contact", required: true,
- options: %w[Work Personal])
position = add_field(form, position, "Phone", :free_form_input_one_line,
key: "phone", group: "contact", required: true)
@@ -220,6 +223,18 @@ def build_payment_fields(form, position)
position
end
+ def build_consent_fields(form, position)
+ position = add_header(form, position, "Consent", group: "consent")
+ position = add_field(form, position,
+ "I agree to receive email communications from A Window Between Worlds.",
+ :multiple_choice_radio,
+ key: "communication_consent", group: "consent", required: true,
+ hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, including information about this event as well as upcoming events, training opportunities, resources, impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
+ options: %w[Yes No])
+
+ position
+ end
+
# --- helpers ---
def add_header(form, position, title, group:)
diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb
index d5fe9730f..9cf64881d 100644
--- a/app/views/events/public_registrations/_form_field.html.erb
+++ b/app/views/events/public_registrations/_form_field.html.erb
@@ -1,4 +1,4 @@
-<%# locals: (field:, value: nil) %>
+<%# locals: (field:, value: nil, label: nil) %>
<% return if field.group_header? %>
<% error = @field_errors&.dig(field.id) %>
@@ -6,7 +6,7 @@
- <%= field.question %>
+ <%= label || field.question %>
<% if field.is_required %>
*
<% end %>
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb
index 8d802542b..5804c94d7 100644
--- a/app/views/events/public_registrations/new.html.erb
+++ b/app/views/events/public_registrations/new.html.erb
@@ -74,6 +74,7 @@
{ keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
email_row,
{ keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } },
+ { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
{ keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
@@ -86,6 +87,13 @@
fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key)
rendered_keys = {}
+
+ has_secondary_email = fields_by_key["secondary_email"].present?
+ email_label_overrides = if has_secondary_email
+ {}
+ else
+ { "primary_email" => "Email", "primary_email_confirmation" => "Confirm Email", "primary_email_type" => "Email Type" }
+ end
%>
<% @form_fields.each do |field| %>
@@ -103,12 +111,13 @@
<% rendered_keys[key] = true %>
<% span = group.dig(:spans, key) %>
<% submitted_value = params.dig(:public_registration, :form_fields, row_field.id.to_s) %>
+ <% label_override = email_label_overrides[key] %>
<% if span %>
- <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value, label: label_override %>
<% else %>
- <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value, label: label_override %>
<% end %>
<% end %>
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb
index 94373ae41..b4550c811 100644
--- a/app/views/events/public_registrations/show.html.erb
+++ b/app/views/events/public_registrations/show.html.erb
@@ -32,6 +32,7 @@
{ keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } },
{ keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } },
+ { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
{ keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
@@ -47,6 +48,7 @@
%>
<% @form_fields.each do |field| %>
+ <% next if field.field_key == "primary_email_confirmation" %>
<% next if field.field_key.present? && rendered_keys[field.field_key] %>
<% if field.group_header? %>
From 34b0511cc8e50be8331a807ee2b2786f98eb2d15 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 1 Mar 2026 16:49:58 -0500
Subject: [PATCH 02/14] Extract BaseRegistrationFormBuilder to share fields
across form types
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../base_registration_form_builder.rb | 94 +++++++++++++++
.../public_registration.rb | 2 +-
...xtended_event_registration_form_builder.rb | 98 ++--------------
.../scholarship_application_form_builder.rb | 64 +---------
.../short_event_registration_form_builder.rb | 90 ++------------
.../events/public_registrations/new.html.erb | 8 +-
.../events/public_registrations/show.html.erb | 2 +-
...ed_event_registration_form_builder_spec.rb | 111 ++++++++++++++++++
...rt_event_registration_form_builder_spec.rb | 61 ++++++++++
9 files changed, 292 insertions(+), 238 deletions(-)
create mode 100644 app/services/base_registration_form_builder.rb
create mode 100644 spec/services/extended_event_registration_form_builder_spec.rb
create mode 100644 spec/services/short_event_registration_form_builder_spec.rb
diff --git a/app/services/base_registration_form_builder.rb b/app/services/base_registration_form_builder.rb
new file mode 100644
index 000000000..64c2b9ad8
--- /dev/null
+++ b/app/services/base_registration_form_builder.rb
@@ -0,0 +1,94 @@
+class BaseRegistrationFormBuilder
+ protected
+
+ def build_basic_contact_fields(form, position)
+ position = add_field(form, position, "First Name", :free_form_input_one_line,
+ key: "first_name", group: "contact", required: true)
+ position = add_field(form, position, "Last Name", :free_form_input_one_line,
+ key: "last_name", group: "contact", required: true)
+ position = add_field(form, position, "Email", :free_form_input_one_line,
+ key: "primary_email", group: "contact", required: true)
+ position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
+ key: "confirm_email", group: "contact", required: true)
+
+ position
+ end
+
+ def build_scholarship_fields(form, position)
+ position = add_header(form, position, "Scholarship Application", group: "scholarship")
+
+ position = add_field(form, position,
+ "I / my agency cannot afford the full training cost and need a scholarship to attend.",
+ :multiple_choice_checkbox,
+ key: "scholarship_eligibility", group: "scholarship", required: true,
+ options: [ "Yes" ])
+ position = add_field(form, position,
+ "How will what you gain from this training directly impact the people you serve?",
+ :free_form_input_paragraph,
+ key: "impact_description", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position,
+ "Please describe one way in which you plan to use art workshops and how you envision it will help.",
+ :free_form_input_paragraph,
+ key: "implementation_plan", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
+ key: "additional_comments", group: "scholarship", required: false)
+
+ position
+ end
+
+ def build_consent_fields(form, position)
+ position = add_header(form, position, "Consent", group: "consent")
+ position = add_field(form, position,
+ "I agree to receive email communications from A Window Between Worlds.",
+ :multiple_choice_radio,
+ key: "communication_consent", group: "consent", required: true,
+ hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
+ "including information about this event as well as upcoming events, training opportunities, resources, " \
+ "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
+ options: %w[Yes No])
+
+ position
+ end
+
+ def add_header(form, position, title, group:)
+ position += 1
+ form.form_fields.create!(
+ question: title,
+ answer_type: :group_header,
+ status: :active,
+ position: position,
+ is_required: false,
+ field_key: nil,
+ field_group: group
+ )
+ position
+ end
+
+ def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil)
+ position += 1
+ field = form.form_fields.create!(
+ question: question,
+ answer_type: answer_type,
+ answer_datatype: datatype,
+ status: :active,
+ position: position,
+ is_required: required,
+ instructional_hint: hint,
+ field_key: key,
+ field_group: group
+ )
+
+ if options.present?
+ options.each_with_index do |opt, idx|
+ ao = AnswerOption.find_or_create_by!(name: opt) do |a|
+ a.position = idx
+ end
+ field.form_field_answer_options.create!(answer_option: ao)
+ end
+ end
+
+ position
+ end
+end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index 209a5264a..c681a75dd 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -252,7 +252,7 @@ def update_person_form(person)
def save_form_fields(person_form)
@form.form_fields.where(status: :active).find_each do |field|
next if field.group_header?
- next if field.field_key == "primary_email_confirmation"
+ next if field.field_key == "confirm_email"
raw_value = @form_params[field.id.to_s]
text = if raw_value.is_a?(Array)
diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb
index e65a9fc29..f6e093eac 100644
--- a/app/services/extended_event_registration_form_builder.rb
+++ b/app/services/extended_event_registration_form_builder.rb
@@ -1,4 +1,4 @@
-class ExtendedEventRegistrationFormBuilder
+class ExtendedEventRegistrationFormBuilder < BaseRegistrationFormBuilder
FORM_NAME = "Extended Event Registration"
def self.build_standalone!(include_contact_fields: true)
@@ -69,21 +69,15 @@ def build_fields!(form)
def build_contact_fields(form, position)
position = add_header(form, position, "Contact Information", group: "contact")
- position = add_field(form, position, "First Name", :free_form_input_one_line,
- key: "first_name", group: "contact", required: true)
- position = add_field(form, position, "Last Name", :free_form_input_one_line,
- key: "last_name", group: "contact", required: true)
+ position = build_basic_contact_fields(form, position)
+
+ position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
+ key: "primary_email_type", group: "contact", required: true,
+ options: %w[Personal Work])
position = add_field(form, position, "Preferred Nickname", :free_form_input_one_line,
key: "nickname", group: "contact", required: false)
position = add_field(form, position, "Pronouns", :free_form_input_one_line,
key: "pronouns", group: "contact", required: false)
- position = add_field(form, position, "Primary Email", :free_form_input_one_line,
- key: "primary_email", group: "contact", required: true)
- position = add_field(form, position, "Confirm Primary Email", :free_form_input_one_line,
- key: "primary_email_confirmation", group: "contact", required: true)
- position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
- key: "primary_email_type", group: "contact", required: true,
- options: %w[Personal Work])
position = add_field(form, position, "Secondary Email", :free_form_input_one_line,
key: "secondary_email", group: "contact", required: false)
position = add_field(form, position, "Secondary Email Type", :multiple_choice_radio,
@@ -152,7 +146,7 @@ def build_professional_fields(form, position)
key: "primary_service_area", group: "professional", required: false,
hint: "Select all that apply. These represent the sectors you primarily serve.")
position = add_field(form, position, "Workshop Settings", :multiple_choice_checkbox,
- key: "workshop_settings", group: "professional", required: false,
+ key: "workshop_environments", group: "professional", required: false,
hint: "Select all settings where you facilitate or plan to facilitate workshops.",
options: [
"Clinical", "Educational", "Events / conferences",
@@ -185,30 +179,6 @@ def build_qualitative_fields(form, position)
position
end
- def build_scholarship_fields(form, position)
- position = add_header(form, position, "Scholarship Application", group: "scholarship")
-
- position = add_field(form, position,
- "I / my agency cannot afford the full training cost and need a scholarship to attend.",
- :multiple_choice_checkbox,
- key: "scholarship_eligibility", group: "scholarship", required: true,
- options: [ "Yes" ])
- position = add_field(form, position,
- "How will what you gain from this training directly impact the people you serve?",
- :free_form_input_paragraph,
- key: "impact_description", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position,
- "Please describe one way in which you plan to use art workshops and how you envision it will help.",
- :free_form_input_paragraph,
- key: "implementation_plan", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
- key: "additional_comments", group: "scholarship", required: false)
-
- position
- end
-
def build_payment_fields(form, position)
position = add_header(form, position, "Payment Information", group: "payment")
@@ -222,58 +192,4 @@ def build_payment_fields(form, position)
position
end
-
- def build_consent_fields(form, position)
- position = add_header(form, position, "Consent", group: "consent")
- position = add_field(form, position,
- "I agree to receive email communications from A Window Between Worlds.",
- :multiple_choice_radio,
- key: "communication_consent", group: "consent", required: true,
- hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, including information about this event as well as upcoming events, training opportunities, resources, impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
- options: %w[Yes No])
-
- position
- end
-
- # --- helpers ---
-
- def add_header(form, position, title, group:)
- position += 1
- form.form_fields.create!(
- question: title,
- answer_type: :group_header,
- status: :active,
- position: position,
- is_required: false,
- field_key: nil,
- field_group: group
- )
- position
- end
-
- def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil)
- position += 1
- field = form.form_fields.create!(
- question: question,
- answer_type: answer_type,
- answer_datatype: datatype,
- status: :active,
- position: position,
- is_required: required,
- instructional_hint: hint,
- field_key: key,
- field_group: group
- )
-
- if options.present?
- options.each_with_index do |opt, idx|
- ao = AnswerOption.find_or_create_by!(name: opt) do |a|
- a.position = idx
- end
- field.form_field_answer_options.create!(answer_option: ao)
- end
- end
-
- position
- end
end
diff --git a/app/services/scholarship_application_form_builder.rb b/app/services/scholarship_application_form_builder.rb
index 10331fc2a..b0e42c382 100644
--- a/app/services/scholarship_application_form_builder.rb
+++ b/app/services/scholarship_application_form_builder.rb
@@ -1,4 +1,4 @@
-class ScholarshipApplicationFormBuilder
+class ScholarshipApplicationFormBuilder < BaseRegistrationFormBuilder
FORM_NAME = "Scholarship Application"
def self.build_standalone!
@@ -15,67 +15,7 @@ def self.build!(event)
end
def build_fields!(form)
- position = 0
-
- position = add_header(form, position, "Scholarship Application", group: "scholarship")
-
- position = add_field(form, position,
- "I / my agency cannot afford the full training cost and need a scholarship to attend.",
- :multiple_choice_checkbox,
- key: "scholarship_eligibility", group: "scholarship", required: true,
- options: [ "Yes" ])
- position = add_field(form, position,
- "How will what you gain from this training directly impact the people you serve?",
- :free_form_input_paragraph,
- key: "impact_description", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position,
- "Please describe one way in which you plan to use art workshops and how you envision it will help.",
- :free_form_input_paragraph,
- key: "implementation_plan", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
- key: "additional_comments", group: "scholarship", required: false)
-
+ build_scholarship_fields(form, 0)
form
end
-
- private
-
- def add_header(form, position, title, group:)
- position += 1
- form.form_fields.create!(
- question: title,
- answer_type: :group_header,
- status: :active,
- position: position,
- is_required: false,
- field_key: nil,
- field_group: group
- )
- position
- end
-
- def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil)
- position += 1
- field = form.form_fields.create!(
- question: question,
- answer_type: answer_type,
- status: :active,
- position: position,
- is_required: required,
- instructional_hint: hint,
- field_key: key,
- field_group: group
- )
-
- if options.present?
- options.each_with_index do |opt, idx|
- ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx }
- field.form_field_answer_options.create!(answer_option: ao)
- end
- end
-
- position
- end
end
diff --git a/app/services/short_event_registration_form_builder.rb b/app/services/short_event_registration_form_builder.rb
index 7d5ec291f..900d4c6f9 100644
--- a/app/services/short_event_registration_form_builder.rb
+++ b/app/services/short_event_registration_form_builder.rb
@@ -1,4 +1,4 @@
-class ShortEventRegistrationFormBuilder
+class ShortEventRegistrationFormBuilder < BaseRegistrationFormBuilder
FORM_NAME = "Short Event Registration"
def self.build_standalone!
@@ -17,22 +17,17 @@ def self.build!(event)
def build_fields!(form)
position = 0
- position = add_field(form, position, "First Name", :free_form_input_one_line,
- key: "first_name", group: "contact", required: true)
- position = add_field(form, position, "Last Name", :free_form_input_one_line,
- key: "last_name", group: "contact", required: true)
- position = add_field(form, position, "Enter Email", :free_form_input_one_line,
- key: "primary_email", group: "contact", required: true)
- position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
- key: "confirm_email", group: "contact", required: true)
+ position = build_basic_contact_fields(form, position)
+ position = build_consent_fields(form, position)
+ position = build_qualitative_fields(form, position)
+ position = build_scholarship_fields(form, position)
+
+ position
+ end
- position = add_field(form, position, "Consent", :multiple_choice_checkbox,
- key: "consent", group: "consent", required: true,
- hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
- "including information about this event as well as upcoming events, training opportunities, resources, " \
- "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
- options: [ "I agree to receive email communications from A Window Between Worlds." ])
+ private
+ def build_qualitative_fields(form, position)
position = add_field(form, position, "How did you hear about this event?", :multiple_choice_checkbox,
key: "referral_source", group: "qualitative", required: true,
options: [ "AWBW Email", "Facebook", "Instagram", "LinkedIn", "Online Search", "Word of Mouth", "Other" ])
@@ -42,71 +37,6 @@ def build_fields!(form)
key: "training_interest", group: "qualitative", required: true,
options: [ "Yes", "Not right now" ])
- position = build_scholarship_fields(form, position)
-
- position
- end
-
- private
-
- def build_scholarship_fields(form, position)
- position = add_header(form, position, "Scholarship Application", group: "scholarship")
-
- position = add_field(form, position,
- "I / my agency cannot afford the full training cost and need a scholarship to attend.",
- :multiple_choice_checkbox,
- key: "scholarship_eligibility", group: "scholarship", required: true,
- options: [ "Yes" ])
- position = add_field(form, position,
- "How will what you gain from this training directly impact the people you serve?",
- :free_form_input_paragraph,
- key: "impact_description", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position,
- "Please describe one way in which you plan to use art workshops and how you envision it will help.",
- :free_form_input_paragraph,
- key: "implementation_plan", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
- key: "additional_comments", group: "scholarship", required: false)
-
- position
- end
-
- def add_header(form, position, title, group:)
- position += 1
- form.form_fields.create!(
- question: title,
- answer_type: :group_header,
- status: :active,
- position: position,
- is_required: false,
- field_key: nil,
- field_group: group
- )
- position
- end
-
- def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil)
- position += 1
- field = form.form_fields.create!(
- question: question,
- answer_type: answer_type,
- status: :active,
- position: position,
- is_required: required,
- instructional_hint: hint,
- field_key: key,
- field_group: group
- )
-
- if options.present?
- options.each_with_index do |opt, idx|
- ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx }
- field.form_field_answer_options.create!(answer_option: ao)
- end
- end
-
position
end
end
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb
index 5804c94d7..1b2750fe4 100644
--- a/app/views/events/public_registrations/new.html.erb
+++ b/app/views/events/public_registrations/new.html.erb
@@ -63,7 +63,9 @@
<%# Row groupings: related fields share a grid row on md+ screens %>
<%
fields_by_key_check = @form_fields.select(&:field_key).index_by(&:field_key)
- email_row = if fields_by_key_check["confirm_email"]
+ email_row = if fields_by_key_check["confirm_email"] && fields_by_key_check["primary_email_type"]
+ { keys: %w[primary_email confirm_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" }
+ elsif fields_by_key_check["confirm_email"]
{ keys: %w[primary_email confirm_email], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }
else
{ keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } }
@@ -90,9 +92,9 @@
has_secondary_email = fields_by_key["secondary_email"].present?
email_label_overrides = if has_secondary_email
- {}
+ { "primary_email" => "Primary Email", "confirm_email" => "Confirm Primary Email", "primary_email_type" => "Primary Email Type" }
else
- { "primary_email" => "Email", "primary_email_confirmation" => "Confirm Email", "primary_email_type" => "Email Type" }
+ {}
end
%>
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb
index b4550c811..b94b25005 100644
--- a/app/views/events/public_registrations/show.html.erb
+++ b/app/views/events/public_registrations/show.html.erb
@@ -48,7 +48,7 @@
%>
<% @form_fields.each do |field| %>
- <% next if field.field_key == "primary_email_confirmation" %>
+ <% next if field.field_key == "confirm_email" %>
<% next if field.field_key.present? && rendered_keys[field.field_key] %>
<% if field.group_header? %>
diff --git a/spec/services/extended_event_registration_form_builder_spec.rb b/spec/services/extended_event_registration_form_builder_spec.rb
new file mode 100644
index 000000000..9fd7f7e21
--- /dev/null
+++ b/spec/services/extended_event_registration_form_builder_spec.rb
@@ -0,0 +1,111 @@
+require "rails_helper"
+
+RSpec.describe ExtendedEventRegistrationFormBuilder do
+ let(:event) { create(:event) }
+
+ describe ".build!" do
+ subject(:form) { described_class.build!(event) }
+
+ it "creates a registration form linked to the event" do
+ expect(form.name).to eq("Extended Event Registration")
+ expect(event.registration_form).to eq(form)
+ end
+
+ it "assigns sequential positions starting at 1" do
+ field_count = form.form_fields.count
+ positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
+ expect(positions).to eq((1..field_count).to_a)
+ end
+
+ it "inherits basic contact fields from BaseRegistrationFormBuilder" do
+ %w[first_name last_name primary_email confirm_email].each do |key|
+ field = form.form_fields.find_by(field_key: key)
+ expect(field).to be_present, "expected field_key '#{key}' to exist"
+ expect(field.field_group).to eq("contact")
+ end
+ end
+
+ it "adds extended contact fields beyond the basic set" do
+ extended_keys = %w[nickname pronouns primary_email_type secondary_email secondary_email_type
+ mailing_street mailing_address_type mailing_city mailing_state mailing_zip
+ phone phone_type agency_name agency_position]
+ extended_keys.each do |key|
+ field = form.form_fields.find_by(field_key: key)
+ expect(field).to be_present, "expected field_key '#{key}' to exist"
+ end
+ end
+
+ it "creates workshop_environments field with correct key" do
+ field = form.form_fields.find_by(field_key: "workshop_environments")
+ expect(field).to be_present
+ expect(field.answer_type).to eq("multiple_choice_checkbox")
+ end
+
+ it "inherits consent fields from BaseRegistrationFormBuilder" do
+ field = form.form_fields.find_by(field_key: "communication_consent")
+ expect(field).to be_present
+ expect(field.answer_type).to eq("multiple_choice_radio")
+ expect(field.is_required).to be true
+ expect(field.field_group).to eq("consent")
+ end
+
+ it "inherits scholarship fields from BaseRegistrationFormBuilder" do
+ field = form.form_fields.find_by(field_key: "scholarship_eligibility")
+ expect(field).to be_present
+ expect(field.field_group).to eq("scholarship")
+ end
+
+ it "creates payment fields" do
+ attendees = form.form_fields.find_by(field_key: "number_of_attendees")
+ expect(attendees.answer_datatype).to eq("number_integer")
+ expect(attendees.is_required).to be true
+
+ payment = form.form_fields.find_by(field_key: "payment_method")
+ expect(payment.answer_type).to eq("multiple_choice_radio")
+ end
+
+ it "creates all expected field groups" do
+ groups = form.form_fields.pluck(:field_group).uniq.sort
+ expect(groups).to contain_exactly("background", "consent", "contact", "payment", "professional", "qualitative", "scholarship")
+ end
+ end
+
+ describe ".build! without contact fields" do
+ subject(:form) { described_class.build!(event, include_contact_fields: false) }
+
+ it "omits contact fields" do
+ expect(form.form_fields.find_by(field_key: "first_name")).to be_nil
+ expect(form.form_fields.find_by(field_key: "primary_email")).to be_nil
+ end
+
+ it "still includes consent fields" do
+ expect(form.form_fields.find_by(field_key: "communication_consent")).to be_present
+ end
+
+ it "still includes scholarship fields" do
+ expect(form.form_fields.find_by(field_key: "scholarship_eligibility")).to be_present
+ end
+ end
+
+ describe ".build_standalone!" do
+ it "creates a form without linking to an event" do
+ form = described_class.build_standalone!
+
+ expect(form.name).to eq("Extended Event Registration")
+ expect(form.event_forms).to be_empty
+ end
+ end
+
+ describe ".copy!" do
+ it "duplicates a form for a new event" do
+ source_form = described_class.build!(event)
+ new_event = create(:event)
+
+ copied = described_class.copy!(from_form: source_form, to_event: new_event)
+
+ expect(copied.id).not_to eq(source_form.id)
+ expect(copied.form_fields.count).to eq(source_form.form_fields.count)
+ expect(new_event.registration_form).to eq(copied)
+ end
+ end
+end
diff --git a/spec/services/short_event_registration_form_builder_spec.rb b/spec/services/short_event_registration_form_builder_spec.rb
new file mode 100644
index 000000000..dda53dbee
--- /dev/null
+++ b/spec/services/short_event_registration_form_builder_spec.rb
@@ -0,0 +1,61 @@
+require "rails_helper"
+
+RSpec.describe ShortEventRegistrationFormBuilder do
+ let(:event) { create(:event) }
+
+ describe ".build!" do
+ subject(:form) { described_class.build!(event) }
+
+ it "creates a registration form linked to the event" do
+ expect(form.name).to eq("Short Event Registration")
+ expect(event.registration_form).to eq(form)
+ end
+
+ it "assigns sequential positions starting at 1" do
+ field_count = form.form_fields.count
+ positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
+ expect(positions).to eq((1..field_count).to_a)
+ end
+
+ it "inherits basic contact fields from BaseRegistrationFormBuilder" do
+ %w[first_name last_name primary_email confirm_email].each do |key|
+ field = form.form_fields.find_by(field_key: key)
+ expect(field).to be_present, "expected field_key '#{key}' to exist"
+ expect(field.field_group).to eq("contact")
+ end
+ end
+
+ it "inherits consent fields from BaseRegistrationFormBuilder" do
+ field = form.form_fields.find_by(field_key: "communication_consent")
+ expect(field).to be_present
+ expect(field.answer_type).to eq("multiple_choice_radio")
+ expect(field.is_required).to be true
+ expect(field.field_group).to eq("consent")
+ end
+
+ it "inherits scholarship fields from BaseRegistrationFormBuilder" do
+ field = form.form_fields.find_by(field_key: "scholarship_eligibility")
+ expect(field).to be_present
+ expect(field.field_group).to eq("scholarship")
+ end
+
+ it "creates short-specific qualitative fields" do
+ referral = form.form_fields.find_by(field_key: "referral_source")
+ expect(referral.answer_type).to eq("multiple_choice_checkbox")
+ expect(referral.is_required).to be true
+
+ interest = form.form_fields.find_by(field_key: "training_interest")
+ expect(interest.answer_type).to eq("multiple_choice_checkbox")
+ expect(interest.is_required).to be true
+ end
+ end
+
+ describe ".build_standalone!" do
+ it "creates a form without linking to an event" do
+ form = described_class.build_standalone!
+
+ expect(form.name).to eq("Short Event Registration")
+ expect(form.event_forms).to be_empty
+ end
+ end
+end
From d7fef0fa569a4bfe995b352a25d2c00e4053ef6f Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 15:41:01 -0400
Subject: [PATCH 03/14] Consolidate form builders into configurable
FormBuilderService with admin CRUD
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
.../events/public_registrations_controller.rb | 30 ++-
app/controllers/events_controller.rb | 2 +-
app/controllers/forms_controller.rb | 76 +++++++
app/models/event.rb | 2 +-
app/models/form.rb | 2 +-
app/models/form_answer.rb | 8 +
app/models/form_submission.rb | 7 +
app/models/person.rb | 2 +-
app/models/person_form.rb | 7 -
app/models/person_form_form_field.rb | 8 -
app/policies/form_policy.rb | 3 +
.../base_registration_form_builder.rb | 94 ---------
.../public_registration.rb | 28 +--
...orm_builder.rb => form_builder_service.rb} | 196 ++++++++++++------
.../scholarship_application_form_builder.rb | 21 --
.../short_event_registration_form_builder.rb | 42 ----
.../event_registrations/_ticket.html.erb | 2 +-
app/views/event_registrations/index.html.erb | 2 +-
app/views/events/_form.html.erb | 17 +-
app/views/events/_manage_results.html.erb | 2 +-
.../events/public_registrations/show.html.erb | 4 +-
app/views/forms/_form_field_row.html.erb | 33 +++
app/views/forms/edit.html.erb | 46 ++++
app/views/forms/index.html.erb | 33 +++
app/views/forms/new.html.erb | 39 ++++
config/routes.rb | 5 +
...20260308120000_consolidate_form_builder.rb | 25 +++
db/seeds.rb | 22 +-
db/seeds/dummy_dev_seeds.rb | 6 +-
spec/factories/form_answers.rb | 8 +
spec/factories/form_submissions.rb | 6 +
spec/models/event_spec.rb | 4 +-
spec/requests/events/registrations_spec.rb | 8 +-
spec/requests/forms_spec.rb | 81 ++++++++
...ed_event_registration_form_builder_spec.rb | 111 ----------
spec/services/form_builder_service_spec.rb | 126 +++++++++++
...holarship_application_form_builder_spec.rb | 69 ------
...rt_event_registration_form_builder_spec.rb | 61 ------
spec/system/event_registration_show_spec.rb | 7 +-
spec/system/events_show_spec.rb | 2 +-
spec/system/public_registration_new_spec.rb | 6 +-
41 files changed, 723 insertions(+), 530 deletions(-)
create mode 100644 app/controllers/forms_controller.rb
create mode 100644 app/models/form_answer.rb
create mode 100644 app/models/form_submission.rb
delete mode 100644 app/models/person_form.rb
delete mode 100644 app/models/person_form_form_field.rb
create mode 100644 app/policies/form_policy.rb
delete mode 100644 app/services/base_registration_form_builder.rb
rename app/services/{extended_event_registration_form_builder.rb => form_builder_service.rb} (52%)
delete mode 100644 app/services/scholarship_application_form_builder.rb
delete mode 100644 app/services/short_event_registration_form_builder.rb
create mode 100644 app/views/forms/_form_field_row.html.erb
create mode 100644 app/views/forms/edit.html.erb
create mode 100644 app/views/forms/index.html.erb
create mode 100644 app/views/forms/new.html.erb
create mode 100644 db/migrate/20260308120000_consolidate_form_builder.rb
create mode 100644 spec/factories/form_answers.rb
create mode 100644 spec/factories/form_submissions.rb
create mode 100644 spec/requests/forms_spec.rb
delete mode 100644 spec/services/extended_event_registration_form_builder_spec.rb
create mode 100644 spec/services/form_builder_service_spec.rb
delete mode 100644 spec/services/scholarship_application_form_builder_spec.rb
delete mode 100644 spec/services/short_event_registration_form_builder_spec.rb
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index d9dd62012..3a44e48c0 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -82,14 +82,14 @@ def show
return
end
- @person_form = @form.person_forms.find_by(person: person)
- unless @person_form
+ @form_submission = @form.form_submissions.find_by(person: person)
+ unless @form_submission
redirect_to event_path(@event), alert: "No registration form submission found."
return
end
- @form_fields = @form.form_fields.where(status: :active).reorder(position: :asc)
- @responses = @person_form.person_form_form_fields.index_by(&:form_field_id)
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ @responses = @form_submission.form_answers.index_by(&:form_field_id)
@event = @event.decorate
end
@@ -108,12 +108,32 @@ def scholarship_mode?
end
def visible_form_fields
- scope = @form.form_fields.where(status: :active)
+ scope = @form.form_fields
if scholarship_mode?
scope = scope.where.not(field_group: "payment")
else
scope = scope.where.not(field_group: "scholarship")
end
+
+ person = current_user&.person
+ if person
+ existing_submission = @form.form_submissions.find_by(person: person)
+ if existing_submission
+ answered_field_ids = existing_submission.form_answers
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
+
+ if @form.hide_answered_person_questions?
+ person_ids = answered_field_ids & @form.form_fields.where(field_group: "contact").ids
+ scope = scope.where.not(id: person_ids) if person_ids.any?
+ end
+
+ if @form.hide_answered_form_questions?
+ scope = scope.where.not(id: answered_field_ids) if answered_field_ids.any?
+ end
+ end
+ end
+
scope.reorder(position: :asc)
end
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 4188bc8dc..72a3c3d86 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -199,7 +199,7 @@ def assign_event_forms(event)
end
end
- scholarship_form = Form.standalone.find_by(name: ScholarshipApplicationFormBuilder::FORM_NAME)
+ scholarship_form = Form.standalone.scholarship_application.first
if scholarship_form && event.cost_cents.to_i > 0
event.event_forms.find_or_create_by!(form: scholarship_form, role: "scholarship")
elsif event.cost_cents.to_i == 0
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
new file mode 100644
index 000000000..f4b4a2bb5
--- /dev/null
+++ b/app/controllers/forms_controller.rb
@@ -0,0 +1,76 @@
+class FormsController < ApplicationController
+ before_action :set_form, only: %i[edit update destroy reorder_field]
+
+ def index
+ authorize!
+ @forms = Form.standalone.order(:name)
+ end
+
+ def new
+ authorize!
+ end
+
+ def create
+ authorize!
+
+ sections = (params[:sections] || []).reject(&:blank?).map(&:to_sym)
+ if sections.empty?
+ flash.now[:alert] = "Please select at least one section."
+ render :new, status: :unprocessable_content
+ return
+ end
+
+ form = FormBuilderService.new(
+ name: params[:name].presence || "New Form",
+ sections: sections,
+ scholarship_application: params[:scholarship_application] == "1"
+ ).call
+
+ redirect_to edit_form_path(form), notice: "Form created with #{form.form_fields.size} fields."
+ end
+
+ def edit
+ authorize! @form
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ end
+
+ def update
+ authorize! @form
+
+ if @form.update(form_params)
+ redirect_to edit_form_path(@form), notice: "Form updated."
+ else
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ authorize! @form
+ @form.destroy!
+ redirect_to forms_path, notice: "Form deleted."
+ end
+
+ def reorder_field
+ authorize! @form
+ field = @form.form_fields.find(params[:field_id])
+ field.update!(position: params[:position].to_i)
+ head :ok
+ end
+
+ private
+
+ def set_form
+ @form = Form.find(params[:id])
+ end
+
+ def form_params
+ params.require(:form).permit(
+ :name, :hide_answered_person_questions, :hide_answered_form_questions,
+ form_fields_attributes: [
+ :id, :question, :answer_type, :is_required, :instructional_hint,
+ :field_key, :field_group, :position, :_destroy
+ ]
+ )
+ end
+end
diff --git a/app/models/event.rb b/app/models/event.rb
index 8f67557f0..bed4328f4 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -149,7 +149,7 @@ def public_registration_just_enabled?
def build_public_registration_form
return if event_forms.registration.exists?
- form_name = title&.match?(/training/i) ? ExtendedEventRegistrationFormBuilder::FORM_NAME : ShortEventRegistrationFormBuilder::FORM_NAME
+ form_name = title&.match?(/training/i) ? "Extended Event Registration" : "Short Event Registration"
form = Form.standalone.find_by(name: form_name)
return unless form
diff --git a/app/models/form.rb b/app/models/form.rb
index 6c238178b..f8c9b02d7 100644
--- a/app/models/form.rb
+++ b/app/models/form.rb
@@ -3,7 +3,7 @@ class Form < ApplicationRecord
has_many :form_fields, dependent: :destroy, inverse_of: :form
has_many :event_forms, dependent: :destroy
has_many :user_forms
- has_many :person_forms
+ has_many :form_submissions
has_many :reports, as: :owner
# has_many through
has_many :events, through: :event_forms
diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb
new file mode 100644
index 000000000..07e1eeefe
--- /dev/null
+++ b/app/models/form_answer.rb
@@ -0,0 +1,8 @@
+class FormAnswer < ApplicationRecord
+ belongs_to :form_field, optional: true
+ belongs_to :form_submission
+
+ def name
+ "#{question_text.presence || form_field&.question}: #{text}"
+ end
+end
diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb
new file mode 100644
index 000000000..d54a48bef
--- /dev/null
+++ b/app/models/form_submission.rb
@@ -0,0 +1,7 @@
+class FormSubmission < ApplicationRecord
+ belongs_to :person
+ belongs_to :form
+ has_many :form_answers, dependent: :destroy
+
+ accepts_nested_attributes_for :form_answers
+end
diff --git a/app/models/person.rb b/app/models/person.rb
index 304bbd5c5..e5563af3f 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -23,7 +23,7 @@ class Person < ApplicationRecord
has_many :events, through: :event_registrations
has_many :categories, through: :categorizable_items
has_many :sectors, through: :sectorable_items
- has_many :person_forms, dependent: :destroy
+ has_many :form_submissions, dependent: :destroy
# Asset associations
has_one_attached :avatar, dependent: :purge do |attachable|
diff --git a/app/models/person_form.rb b/app/models/person_form.rb
deleted file mode 100644
index 2c4a4cfcd..000000000
--- a/app/models/person_form.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class PersonForm < ApplicationRecord
- belongs_to :person
- belongs_to :form
- has_many :person_form_form_fields, dependent: :destroy
-
- accepts_nested_attributes_for :person_form_form_fields
-end
diff --git a/app/models/person_form_form_field.rb b/app/models/person_form_form_field.rb
deleted file mode 100644
index 2ef92ebf0..000000000
--- a/app/models/person_form_form_field.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-class PersonFormFormField < ApplicationRecord
- belongs_to :form_field
- belongs_to :person_form
-
- def name
- "#{form_field.question}: #{text}"
- end
-end
diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb
new file mode 100644
index 000000000..f2514e20c
--- /dev/null
+++ b/app/policies/form_policy.rb
@@ -0,0 +1,3 @@
+class FormPolicy < ApplicationPolicy
+ # Admin-only — all CRUD actions inherit manage? from ApplicationPolicy
+end
diff --git a/app/services/base_registration_form_builder.rb b/app/services/base_registration_form_builder.rb
deleted file mode 100644
index 64c2b9ad8..000000000
--- a/app/services/base_registration_form_builder.rb
+++ /dev/null
@@ -1,94 +0,0 @@
-class BaseRegistrationFormBuilder
- protected
-
- def build_basic_contact_fields(form, position)
- position = add_field(form, position, "First Name", :free_form_input_one_line,
- key: "first_name", group: "contact", required: true)
- position = add_field(form, position, "Last Name", :free_form_input_one_line,
- key: "last_name", group: "contact", required: true)
- position = add_field(form, position, "Email", :free_form_input_one_line,
- key: "primary_email", group: "contact", required: true)
- position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
- key: "confirm_email", group: "contact", required: true)
-
- position
- end
-
- def build_scholarship_fields(form, position)
- position = add_header(form, position, "Scholarship Application", group: "scholarship")
-
- position = add_field(form, position,
- "I / my agency cannot afford the full training cost and need a scholarship to attend.",
- :multiple_choice_checkbox,
- key: "scholarship_eligibility", group: "scholarship", required: true,
- options: [ "Yes" ])
- position = add_field(form, position,
- "How will what you gain from this training directly impact the people you serve?",
- :free_form_input_paragraph,
- key: "impact_description", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position,
- "Please describe one way in which you plan to use art workshops and how you envision it will help.",
- :free_form_input_paragraph,
- key: "implementation_plan", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
- key: "additional_comments", group: "scholarship", required: false)
-
- position
- end
-
- def build_consent_fields(form, position)
- position = add_header(form, position, "Consent", group: "consent")
- position = add_field(form, position,
- "I agree to receive email communications from A Window Between Worlds.",
- :multiple_choice_radio,
- key: "communication_consent", group: "consent", required: true,
- hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
- "including information about this event as well as upcoming events, training opportunities, resources, " \
- "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
- options: %w[Yes No])
-
- position
- end
-
- def add_header(form, position, title, group:)
- position += 1
- form.form_fields.create!(
- question: title,
- answer_type: :group_header,
- status: :active,
- position: position,
- is_required: false,
- field_key: nil,
- field_group: group
- )
- position
- end
-
- def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil)
- position += 1
- field = form.form_fields.create!(
- question: question,
- answer_type: answer_type,
- answer_datatype: datatype,
- status: :active,
- position: position,
- is_required: required,
- instructional_hint: hint,
- field_key: key,
- field_group: group
- )
-
- if options.present?
- options.each_with_index do |opt, idx|
- ao = AnswerOption.find_or_create_by!(name: opt) do |a|
- a.position = idx
- end
- field.form_field_answer_options.create!(answer_option: ao)
- end
- end
-
- position
- end
-end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index c681a75dd..11495b2c7 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -30,12 +30,12 @@ def call
existing = @event.event_registrations.find_by(registrant: person)
if existing
existing.update!(scholarship_requested: true) if @scholarship_requested
- update_person_form(person)
+ update_form_submission(person)
return Result.new(success?: true, event_registration: existing, errors: [])
end
event_registration = create_event_registration(person)
- create_person_form(person)
+ create_form_submission(person)
send_notifications(event_registration)
@@ -237,20 +237,20 @@ def create_event_registration(person)
)
end
- def create_person_form(person)
- person_form = PersonForm.create!(person: person, form: @form)
- save_form_fields(person_form)
- person_form
+ def create_form_submission(person)
+ submission = FormSubmission.create!(person: person, form: @form)
+ save_form_answers(submission)
+ submission
end
- def update_person_form(person)
- person_form = PersonForm.find_or_create_by!(person: person, form: @form)
- save_form_fields(person_form)
- person_form
+ def update_form_submission(person)
+ submission = FormSubmission.find_or_create_by!(person: person, form: @form)
+ save_form_answers(submission)
+ submission
end
- def save_form_fields(person_form)
- @form.form_fields.where(status: :active).find_each do |field|
+ def save_form_answers(submission)
+ @form.form_fields.find_each do |field|
next if field.group_header?
next if field.field_key == "confirm_email"
@@ -261,8 +261,8 @@ def save_form_fields(person_form)
raw_value.to_s
end
- record = person_form.person_form_form_fields.find_or_initialize_by(form_field: field)
- record.update!(text: text)
+ record = submission.form_answers.find_or_initialize_by(form_field: field)
+ record.update!(text: text, question_text: field.question)
end
end
diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/form_builder_service.rb
similarity index 52%
rename from app/services/extended_event_registration_form_builder.rb
rename to app/services/form_builder_service.rb
index f6e093eac..439a6f7fe 100644
--- a/app/services/extended_event_registration_form_builder.rb
+++ b/app/services/form_builder_service.rb
@@ -1,75 +1,96 @@
-class ExtendedEventRegistrationFormBuilder < BaseRegistrationFormBuilder
- FORM_NAME = "Extended Event Registration"
+class FormBuilderService
+ SECTIONS = {
+ person_identifier: { label: "Person identifier", method: :build_person_identifier_fields },
+ person_contact_info: { label: "Person contact info", method: :build_person_contact_info_fields },
+ person_background: { label: "Person background", method: :build_person_background_fields },
+ professional_info: { label: "Professional info", method: :build_professional_info_fields },
+ event_feedback: { label: "Event feedback", method: :build_event_feedback_fields },
+ scholarship: { label: "Scholarship", method: :build_scholarship_fields },
+ payment: { label: "Payment", method: :build_payment_fields },
+ consent: { label: "Consent", method: :build_consent_fields },
+ post_event_feedback: { label: "Post-event feedback", method: :build_post_event_feedback_fields }
+ }.freeze
- def self.build_standalone!(include_contact_fields: true)
- form = Form.create!(name: FORM_NAME)
- new(nil, include_contact_fields:).build_fields!(form)
- form
+ def initialize(name:, sections:, scholarship_application: false)
+ @name = name
+ @sections = sections.map(&:to_sym)
+ @scholarship_application = scholarship_application
end
- def self.build!(event, include_contact_fields: true)
- form = Form.create!(name: FORM_NAME)
- new(event, include_contact_fields:).build_fields!(form)
- EventForm.create!(event: event, form: form, role: "registration") if event
- form
- end
-
- def self.copy!(from_form:, to_event:)
- new_form = Form.create!(
- name: from_form.name,
- form_builder_id: from_form.form_builder_id
+ def call
+ form = Form.create!(
+ name: @name,
+ sections: @sections.map(&:to_s),
+ scholarship_application: @scholarship_application
)
- from_form.form_fields.unscoped.where(form_id: from_form.id).order(:position).each do |source_field|
- new_field = new_form.form_fields.create!(
- question: source_field.question,
- answer_type: source_field.answer_type,
- answer_datatype: source_field.answer_datatype,
- status: source_field.status,
- position: source_field.position,
- is_required: source_field.is_required,
- instructional_hint: source_field.instructional_hint,
- field_key: source_field.field_key,
- field_group: source_field.field_group
- )
-
- source_field.form_field_answer_options.each do |ffao|
- new_field.form_field_answer_options.create!(answer_option: ffao.answer_option)
- end
+ position = 0
+ @sections.each do |key|
+ section = SECTIONS.fetch(key)
+ position = send(section[:method], form, position)
end
- EventForm.create!(event: to_event, form: new_form, role: "registration")
- new_form
+ form
end
- def initialize(event = nil, include_contact_fields: true)
- @event = event
- @include_contact_fields = include_contact_fields
+ private
+
+ def add_header(form, position, title, group:)
+ position += 1
+ form.form_fields.create!(
+ question: title,
+ answer_type: :group_header,
+ status: :active,
+ position: position,
+ is_required: false,
+ field_key: nil,
+ field_group: group
+ )
+ position
end
- def build_fields!(form)
- position = 0
+ def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil)
+ position += 1
+ field = form.form_fields.create!(
+ question: question,
+ answer_type: answer_type,
+ answer_datatype: datatype,
+ status: :active,
+ position: position,
+ is_required: required,
+ instructional_hint: hint,
+ field_key: key,
+ field_group: group
+ )
- if @include_contact_fields
- position = build_contact_fields(form, position)
+ if options.present?
+ options.each_with_index do |opt, idx|
+ ao = AnswerOption.find_or_create_by!(name: opt) do |a|
+ a.position = idx
+ end
+ field.form_field_answer_options.create!(answer_option: ao)
+ end
end
- position = build_background_fields(form, position)
- position = build_professional_fields(form, position)
- position = build_qualitative_fields(form, position)
- position = build_scholarship_fields(form, position)
- position = build_payment_fields(form, position)
- build_consent_fields(form, position)
-
- form
+ position
end
- private
+ # ---- Section builders ----
- def build_contact_fields(form, position)
- position = add_header(form, position, "Contact Information", group: "contact")
+ def build_person_identifier_fields(form, position)
+ position = add_field(form, position, "First Name", :free_form_input_one_line,
+ key: "first_name", group: "contact", required: true)
+ position = add_field(form, position, "Last Name", :free_form_input_one_line,
+ key: "last_name", group: "contact", required: true)
+ position = add_field(form, position, "Email", :free_form_input_one_line,
+ key: "primary_email", group: "contact", required: true)
+ position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
+ key: "confirm_email", group: "contact", required: true)
+ position
+ end
- position = build_basic_contact_fields(form, position)
+ def build_person_contact_info_fields(form, position)
+ position = add_header(form, position, "Contact Information", group: "contact")
position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
key: "primary_email_type", group: "contact", required: true,
@@ -125,21 +146,19 @@ def build_contact_fields(form, position)
])
position = add_field(form, position, "Agency Website", :free_form_input_one_line,
key: "agency_website", group: "contact", required: false)
-
position
end
- def build_background_fields(form, position)
+ def build_person_background_fields(form, position)
position = add_header(form, position, "Background Information", group: "background")
position = add_field(form, position, "Racial / Ethnic Identity", :free_form_input_one_line,
key: "racial_ethnic_identity", group: "background", required: false,
hint: "This information helps us understand the diversity of our community.")
-
position
end
- def build_professional_fields(form, position)
+ def build_professional_info_fields(form, position)
position = add_header(form, position, "Professional Information", group: "professional")
position = add_field(form, position, "Primary Service Area(s)", :multiple_choice_checkbox,
@@ -161,21 +180,43 @@ def build_professional_fields(form, position)
position = add_field(form, position, "Primary Age Group(s) Served", :multiple_choice_checkbox,
key: "primary_age_group", group: "professional", required: false,
hint: "Select all age groups you primarily serve.")
-
position
end
- def build_qualitative_fields(form, position)
- position = add_header(form, position, "About You", group: "qualitative")
+ def build_event_feedback_fields(form, position)
+ position = add_header(form, position, "About You", group: "event_feedback")
position = add_field(form, position, "How did you hear about this training?", :free_form_input_paragraph,
- key: "referral_source", group: "qualitative", required: false)
+ key: "referral_source", group: "event_feedback", required: false)
position = add_field(form, position, "What motivates you to attend this training?", :free_form_input_paragraph,
- key: "training_motivation", group: "qualitative", required: false)
- position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?", :multiple_choice_radio,
- key: "interested_in_more", group: "qualitative", required: true,
+ key: "training_motivation", group: "event_feedback", required: false)
+ position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?",
+ :multiple_choice_radio,
+ key: "interested_in_more", group: "event_feedback", required: true,
options: %w[Yes No])
+ position
+ end
+ def build_scholarship_fields(form, position)
+ position = add_header(form, position, "Scholarship Application", group: "scholarship")
+
+ position = add_field(form, position,
+ "I / my agency cannot afford the full training cost and need a scholarship to attend.",
+ :multiple_choice_checkbox,
+ key: "scholarship_eligibility", group: "scholarship", required: true,
+ options: [ "Yes" ])
+ position = add_field(form, position,
+ "How will what you gain from this training directly impact the people you serve?",
+ :free_form_input_paragraph,
+ key: "impact_description", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position,
+ "Please describe one way in which you plan to use art workshops and how you envision it will help.",
+ :free_form_input_paragraph,
+ key: "implementation_plan", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
+ key: "additional_comments", group: "scholarship", required: false)
position
end
@@ -189,7 +230,32 @@ def build_payment_fields(form, position)
position = add_field(form, position, "Payment Method", :multiple_choice_radio,
key: "payment_method", group: "payment", required: true,
options: [ "Credit Card", "Check", "Purchase Order", "Other" ])
+ position
+ end
+
+ def build_consent_fields(form, position)
+ position = add_header(form, position, "Consent", group: "consent")
+ position = add_field(form, position,
+ "I agree to receive email communications from A Window Between Worlds.",
+ :multiple_choice_radio,
+ key: "communication_consent", group: "consent", required: true,
+ hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
+ "including information about this event as well as upcoming events, training opportunities, resources, " \
+ "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
+ options: %w[Yes No])
+ position
+ end
+
+ def build_post_event_feedback_fields(form, position)
+ position = add_header(form, position, "Post-Event Feedback", group: "post_event_feedback")
+ position = add_field(form, position, "How would you rate this event?", :multiple_choice_radio,
+ key: "event_rating", group: "post_event_feedback", required: true,
+ options: [ "Excellent", "Good", "Fair", "Poor" ])
+ position = add_field(form, position, "What did you find most valuable?", :free_form_input_paragraph,
+ key: "most_valuable", group: "post_event_feedback", required: false)
+ position = add_field(form, position, "Any suggestions for improvement?", :free_form_input_paragraph,
+ key: "improvement_suggestions", group: "post_event_feedback", required: false)
position
end
end
diff --git a/app/services/scholarship_application_form_builder.rb b/app/services/scholarship_application_form_builder.rb
deleted file mode 100644
index b0e42c382..000000000
--- a/app/services/scholarship_application_form_builder.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-class ScholarshipApplicationFormBuilder < BaseRegistrationFormBuilder
- FORM_NAME = "Scholarship Application"
-
- def self.build_standalone!
- form = Form.create!(name: FORM_NAME, scholarship_application: true)
- new.build_fields!(form)
- form
- end
-
- def self.build!(event)
- form = Form.create!(name: FORM_NAME, scholarship_application: true)
- new.build_fields!(form)
- EventForm.create!(event: event, form: form, role: "scholarship") if event
- form
- end
-
- def build_fields!(form)
- build_scholarship_fields(form, 0)
- form
- end
-end
diff --git a/app/services/short_event_registration_form_builder.rb b/app/services/short_event_registration_form_builder.rb
deleted file mode 100644
index 900d4c6f9..000000000
--- a/app/services/short_event_registration_form_builder.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-class ShortEventRegistrationFormBuilder < BaseRegistrationFormBuilder
- FORM_NAME = "Short Event Registration"
-
- def self.build_standalone!
- form = Form.create!(name: FORM_NAME)
- new.build_fields!(form)
- form
- end
-
- def self.build!(event)
- form = Form.create!(name: FORM_NAME)
- new.build_fields!(form)
- EventForm.create!(event: event, form: form, role: "registration") if event
- form
- end
-
- def build_fields!(form)
- position = 0
-
- position = build_basic_contact_fields(form, position)
- position = build_consent_fields(form, position)
- position = build_qualitative_fields(form, position)
- position = build_scholarship_fields(form, position)
-
- position
- end
-
- private
-
- def build_qualitative_fields(form, position)
- position = add_field(form, position, "How did you hear about this event?", :multiple_choice_checkbox,
- key: "referral_source", group: "qualitative", required: true,
- options: [ "AWBW Email", "Facebook", "Instagram", "LinkedIn", "Online Search", "Word of Mouth", "Other" ])
-
- position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?",
- :multiple_choice_checkbox,
- key: "training_interest", group: "qualitative", required: true,
- options: [ "Yes", "Not right now" ])
-
- position
- end
-end
diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index bb81e7db8..fab37ce76 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -141,7 +141,7 @@
<% if event_registration.active? %>
<% registration_form = event_registration.event.registration_form %>
- <% if registration_form && registration_form.person_forms.exists?(person: event_registration.registrant) %>
+ <% if registration_form && registration_form.form_submissions.exists?(person: event_registration.registrant) %>
<%= link_to "View registration details",
event_public_registration_path(event_registration.event, reg: event_registration.slug),
class: "text-xs text-gray-400 hover:text-blue-600 underline" %>
diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb
index 71bb042d4..64e62ec45 100644
--- a/app/views/event_registrations/index.html.erb
+++ b/app/views/event_registrations/index.html.erb
@@ -49,7 +49,7 @@
<%= person_profile_button(event_registration.registrant) %>
<% reg_form = event_registration.event.registration_form %>
- <% if reg_form && reg_form.person_forms.exists?(person: event_registration.registrant) %>
+ <% if reg_form && reg_form.form_submissions.exists?(person: event_registration.registrant) %>
<% elsif reg_form %>
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index fe725c838..e503de836 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -386,24 +386,27 @@
<% default_form_id = if current_reg_form
current_reg_form.id
elsif @event.title&.match?(/training/i)
- @registration_forms.find { |rf| rf.name == ExtendedEventRegistrationFormBuilder::FORM_NAME }&.id
+ @registration_forms.find { |rf| rf.name == "Extended Event Registration" }&.id
else
- @registration_forms.find { |rf| rf.name == ShortEventRegistrationFormBuilder::FORM_NAME }&.id
+ @registration_forms.find { |rf| rf.name == "Short Event Registration" }&.id
end %>
No registration form
<% @registration_forms.each do |reg_form| %>
<% label = case reg_form.name
- when ExtendedEventRegistrationFormBuilder::FORM_NAME, "Public Registration" then "Extended registration form"
- when ShortEventRegistrationFormBuilder::FORM_NAME, "Short Registration" then "Short registration form"
+ when "Extended Event Registration", "Public Registration" then "Extended registration form"
+ when "Short Event Registration", "Short Registration" then "Short registration form"
else reg_form.name
end %>
><%= label %> (<%= reg_form.form_fields.size %> fields)
<% end %>
- <% if @event.cost_cents.to_i > 0 %>
-
Scholarship application form will be linked automatically for paid events.
- <% end %>
+
+ <%= link_to "Create new form", new_form_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %>
+ <% if @event.cost_cents.to_i > 0 %>
+ · Scholarship application form will be linked automatically for paid events.
+ <% end %>
+
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb
index 15fccb9a4..9f5be81a6 100644
--- a/app/views/events/_manage_results.html.erb
+++ b/app/views/events/_manage_results.html.erb
@@ -4,7 +4,7 @@
<%= @event_registrations.size %> registrant<%= "s" if @event_registrations.size != 1 %>
<% reg_form = @event.registration_form %>
- <% form_submissions = reg_form ? reg_form.person_forms.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %>
+ <% form_submissions = reg_form ? reg_form.form_submissions.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %>
<% if @event_registrations.any? %>
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb
index b94b25005..cd64aa177 100644
--- a/app/views/events/public_registrations/show.html.erb
+++ b/app/views/events/public_registrations/show.html.erb
@@ -1,6 +1,6 @@
<% content_for(:page_bg_class, "public") %>
-<% reg = @person_form.person.event_registrations.find_by(event: @event) %>
+<% reg = @form_submission.person.event_registrations.find_by(event: @event) %>
<% back_path = reg&.slug.present? ? registration_ticket_path(reg.slug) : event_path(@event) %>
<%= link_to "← Back to Registration", back_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
@@ -84,7 +84,7 @@
- Submitted on <%= @person_form.created_at.strftime("%B %d, %Y at %l:%M %P") %>
+ Submitted on <%= @form_submission.created_at.strftime("%B %d, %Y at %l:%M %P") %>
diff --git a/app/views/forms/_form_field_row.html.erb b/app/views/forms/_form_field_row.html.erb
new file mode 100644
index 000000000..7a8b4cfc4
--- /dev/null
+++ b/app/views/forms/_form_field_row.html.erb
@@ -0,0 +1,33 @@
+
+
+
+
+
+ <%= ff.hidden_field :id %>
+ <%= ff.hidden_field :position, value: field.position %>
+
+
+
+ <%= ff.text_field :question, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+
+
+ <%= ff.select :answer_type,
+ FormField.answer_types.keys.map { |t| [ t.titleize.gsub("_", " "), t ] },
+ {},
+ class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+
+
+
+ <%= ff.check_box :is_required, class: "rounded border-gray-300 text-blue-600" %>
+ Required
+
+
+
+
+ <%= ff.check_box :_destroy, class: "rounded border-gray-300 text-red-600" %>
+ Delete
+
+
+
+
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
new file mode 100644
index 000000000..ec9757b4f
--- /dev/null
+++ b/app/views/forms/edit.html.erb
@@ -0,0 +1,46 @@
+
+
+
Edit: <%= @form.display_name %>
+ <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
+
+
+ <%= form_with model: @form, class: "space-y-6" do |f| %>
+
+
+ Form Name
+ <%= f.text_field :name, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
+
+
+
+
+ <%= f.check_box :hide_answered_person_questions, class: "rounded border-gray-300 text-blue-600" %>
+ Hide already-answered person questions
+
+
+ <%= f.check_box :hide_answered_form_questions, class: "rounded border-gray-300 text-blue-600" %>
+ Hide already-answered form questions
+
+
+
+
+
+
+
Fields
+ <%= @form_fields.size %> fields
+
+
+
">
+ <% @form_fields.each_with_index do |field, index| %>
+ <%= f.fields_for :form_fields, field do |ff| %>
+ <%= render "form_field_row", ff: ff, field: field, index: index %>
+ <% end %>
+ <% end %>
+
+
+
+
+ <%= f.submit "Save Changes", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb
new file mode 100644
index 000000000..c6a26eece
--- /dev/null
+++ b/app/views/forms/index.html.erb
@@ -0,0 +1,33 @@
+
+
+
Forms
+ <%= link_to "New Form", new_form_path, class: "btn btn-primary" %>
+
+
+
+
+
+
+ Name
+ Fields
+ Events
+ Submissions
+
+
+
+
+ <% @forms.each do |form| %>
+
+ <%= form.display_name %>
+ <%= form.form_fields.size %>
+ <%= form.events.size %>
+ <%= form.form_submissions.size %>
+
+ <%= link_to "Edit", edit_form_path(form), class: "text-blue-600 hover:text-blue-800 underline" %>
+
+
+ <% end %>
+
+
+
+
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb
new file mode 100644
index 000000000..eb123abfe
--- /dev/null
+++ b/app/views/forms/new.html.erb
@@ -0,0 +1,39 @@
+
+
New Form
+
+ <%= form_with url: forms_path, method: :post, class: "space-y-6" do |f| %>
+
+ Form Name
+
+
+
+
+ Select sections to include:
+
+ <% FormBuilderService::SECTIONS.each do |key, section| %>
+
+ >
+ <%= section[:label] %>
+
+ <% end %>
+
+
+
+
+
+
+ Scholarship application form
+
+
+
+
+ <%= f.submit "Create Form", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/config/routes.rb b/config/routes.rb
index bbd10c7d9..71bc7c381 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -101,6 +101,11 @@
end
resources :comments, only: [ :index, :create ]
end
+ resources :forms, except: :show do
+ member do
+ patch :reorder_field
+ end
+ end
resources :events do
member do
get :manage
diff --git a/db/migrate/20260308120000_consolidate_form_builder.rb b/db/migrate/20260308120000_consolidate_form_builder.rb
new file mode 100644
index 000000000..0e81e612b
--- /dev/null
+++ b/db/migrate/20260308120000_consolidate_form_builder.rb
@@ -0,0 +1,25 @@
+class ConsolidateFormBuilder < ActiveRecord::Migration[8.0]
+ def change
+ # Rename tables
+ rename_table :person_forms, :form_submissions
+ rename_table :person_form_form_fields, :form_answers
+
+ # Rename foreign key column to match new table name
+ rename_column :form_answers, :person_form_id, :form_submission_id
+
+ # Add question_text snapshot to form_answers (preserves question at submission time)
+ add_column :form_answers, :question_text, :string
+
+ # Allow form_field deletion without orphaning answers
+ change_column_null :form_answers, :form_field_id, true
+
+ # Add sections and conditional visibility to forms
+ add_column :forms, :sections, :json
+ add_column :forms, :hide_answered_person_questions, :boolean, default: false, null: false
+ add_column :forms, :hide_answered_form_questions, :boolean, default: false, null: false
+
+ # Note: keeping form_fields.status column for now — still used by
+ # workshop logs, reports, and resources. Event registration code
+ # will stop filtering by status in this PR.
+ end
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index e62be6a6b..4e0e0140e 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -270,14 +270,24 @@ def find_or_create_by_name!(klass, name, **attrs, &block)
end
puts "Creating standalone registration forms…"
-unless Form.standalone.exists?(name: ShortEventRegistrationFormBuilder::FORM_NAME)
- ShortEventRegistrationFormBuilder.build_standalone!
+unless Form.standalone.exists?(name: "Short Event Registration")
+ FormBuilderService.new(
+ name: "Short Event Registration",
+ sections: %i[person_identifier consent event_feedback scholarship]
+ ).call
end
-unless Form.standalone.exists?(name: ExtendedEventRegistrationFormBuilder::FORM_NAME)
- ExtendedEventRegistrationFormBuilder.build_standalone!
+unless Form.standalone.exists?(name: "Extended Event Registration")
+ FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call
end
-unless Form.standalone.exists?(name: ScholarshipApplicationFormBuilder::FORM_NAME)
- ScholarshipApplicationFormBuilder.build_standalone!
+unless Form.standalone.scholarship_application.exists?
+ FormBuilderService.new(
+ name: "Scholarship Application",
+ sections: %i[scholarship],
+ scholarship_application: true
+ ).call
end
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index 4bc35700e..4e234ae43 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -876,9 +876,9 @@
puts "Creating Events with shared forms…"
admin_user = User.find_by(email: "umberto.user@example.com")
-long_form = Form.standalone.find_by!(name: ExtendedEventRegistrationFormBuilder::FORM_NAME)
-short_form = Form.standalone.find_by!(name: ShortEventRegistrationFormBuilder::FORM_NAME)
-scholarship_form = Form.standalone.find_by!(name: ScholarshipApplicationFormBuilder::FORM_NAME)
+long_form = Form.standalone.find_by!(name: "Extended Event Registration")
+short_form = Form.standalone.find_by!(name: "Short Event Registration")
+scholarship_form = Form.standalone.scholarship_application.first!
# Each entry: [title, form_type, cost_cents, scholarship?, visibility]
# form_type: :long, :short, or :none
diff --git a/spec/factories/form_answers.rb b/spec/factories/form_answers.rb
new file mode 100644
index 000000000..ce326ccd1
--- /dev/null
+++ b/spec/factories/form_answers.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :form_answer do
+ association :form_submission
+ association :form_field
+ text { Faker::Lorem.sentence }
+ question_text { nil }
+ end
+end
diff --git a/spec/factories/form_submissions.rb b/spec/factories/form_submissions.rb
new file mode 100644
index 000000000..1de1bcde8
--- /dev/null
+++ b/spec/factories/form_submissions.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :form_submission do
+ association :person
+ association :form
+ end
+end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 2ee7e5651..f81b85562 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -121,8 +121,8 @@
end
describe "#build_public_registration_form" do
- let!(:default_form) { create(:form, name: ShortEventRegistrationFormBuilder::FORM_NAME) }
- let!(:extended_form) { create(:form, name: ExtendedEventRegistrationFormBuilder::FORM_NAME) }
+ let!(:default_form) { create(:form, name: "Short Event Registration") }
+ let!(:extended_form) { create(:form, name: "Extended Event Registration") }
it "links the default registration form by default" do
event = create(:event, public_registration_enabled: true)
diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb
index 3cf3d7304..53bf817a9 100644
--- a/spec/requests/events/registrations_spec.rb
+++ b/spec/requests/events/registrations_spec.rb
@@ -148,9 +148,13 @@
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }
before do
- ExtendedEventRegistrationFormBuilder.build!(event)
+ form = FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call
+ EventForm.create!(event: event, form: form, role: "registration")
form = event.registration_form
- form.person_forms.create!(person: user.person)
+ form.form_submissions.create!(person: user.person)
end
it "allows access with a valid slug" do
diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb
new file mode 100644
index 000000000..b27fedd66
--- /dev/null
+++ b/spec/requests/forms_spec.rb
@@ -0,0 +1,81 @@
+require "rails_helper"
+
+RSpec.describe "Forms", type: :request do
+ let(:admin) { create(:user, super_user: true) }
+ let(:user) { create(:user) }
+
+ describe "GET /forms" do
+ context "as admin" do
+ before { sign_in admin }
+
+ it "lists standalone forms" do
+ create(:form, :standalone, name: "My Form")
+ get forms_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("My Form")
+ end
+ end
+
+ context "as regular user" do
+ before { sign_in user }
+
+ it "denies access" do
+ get forms_path
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ describe "GET /forms/new" do
+ before { sign_in admin }
+
+ it "shows section checkboxes" do
+ get new_form_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("Person identifier")
+ expect(response.body).to include("Scholarship")
+ end
+ end
+
+ describe "POST /forms" do
+ before { sign_in admin }
+
+ it "creates a form with selected sections" do
+ post forms_path, params: {
+ name: "Custom Form",
+ sections: %w[person_identifier consent]
+ }
+ form = Form.last
+ expect(form.name).to eq("Custom Form")
+ expect(form.sections).to eq(%w[person_identifier consent])
+ expect(response).to redirect_to(edit_form_path(form))
+ end
+
+ it "rejects when no sections selected" do
+ post forms_path, params: { name: "Empty", sections: [] }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe "GET /forms/:id/edit" do
+ before { sign_in admin }
+
+ it "shows form field editor" do
+ form = FormBuilderService.new(name: "Test", sections: %i[person_identifier]).call
+ get edit_form_path(form)
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("First Name")
+ end
+ end
+
+ describe "PATCH /forms/:id" do
+ before { sign_in admin }
+
+ it "updates form name" do
+ form = create(:form, :standalone, name: "Old Name")
+ patch form_path(form), params: { form: { name: "New Name" } }
+ expect(form.reload.name).to eq("New Name")
+ expect(response).to redirect_to(edit_form_path(form))
+ end
+ end
+end
diff --git a/spec/services/extended_event_registration_form_builder_spec.rb b/spec/services/extended_event_registration_form_builder_spec.rb
deleted file mode 100644
index 9fd7f7e21..000000000
--- a/spec/services/extended_event_registration_form_builder_spec.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-require "rails_helper"
-
-RSpec.describe ExtendedEventRegistrationFormBuilder do
- let(:event) { create(:event) }
-
- describe ".build!" do
- subject(:form) { described_class.build!(event) }
-
- it "creates a registration form linked to the event" do
- expect(form.name).to eq("Extended Event Registration")
- expect(event.registration_form).to eq(form)
- end
-
- it "assigns sequential positions starting at 1" do
- field_count = form.form_fields.count
- positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
- expect(positions).to eq((1..field_count).to_a)
- end
-
- it "inherits basic contact fields from BaseRegistrationFormBuilder" do
- %w[first_name last_name primary_email confirm_email].each do |key|
- field = form.form_fields.find_by(field_key: key)
- expect(field).to be_present, "expected field_key '#{key}' to exist"
- expect(field.field_group).to eq("contact")
- end
- end
-
- it "adds extended contact fields beyond the basic set" do
- extended_keys = %w[nickname pronouns primary_email_type secondary_email secondary_email_type
- mailing_street mailing_address_type mailing_city mailing_state mailing_zip
- phone phone_type agency_name agency_position]
- extended_keys.each do |key|
- field = form.form_fields.find_by(field_key: key)
- expect(field).to be_present, "expected field_key '#{key}' to exist"
- end
- end
-
- it "creates workshop_environments field with correct key" do
- field = form.form_fields.find_by(field_key: "workshop_environments")
- expect(field).to be_present
- expect(field.answer_type).to eq("multiple_choice_checkbox")
- end
-
- it "inherits consent fields from BaseRegistrationFormBuilder" do
- field = form.form_fields.find_by(field_key: "communication_consent")
- expect(field).to be_present
- expect(field.answer_type).to eq("multiple_choice_radio")
- expect(field.is_required).to be true
- expect(field.field_group).to eq("consent")
- end
-
- it "inherits scholarship fields from BaseRegistrationFormBuilder" do
- field = form.form_fields.find_by(field_key: "scholarship_eligibility")
- expect(field).to be_present
- expect(field.field_group).to eq("scholarship")
- end
-
- it "creates payment fields" do
- attendees = form.form_fields.find_by(field_key: "number_of_attendees")
- expect(attendees.answer_datatype).to eq("number_integer")
- expect(attendees.is_required).to be true
-
- payment = form.form_fields.find_by(field_key: "payment_method")
- expect(payment.answer_type).to eq("multiple_choice_radio")
- end
-
- it "creates all expected field groups" do
- groups = form.form_fields.pluck(:field_group).uniq.sort
- expect(groups).to contain_exactly("background", "consent", "contact", "payment", "professional", "qualitative", "scholarship")
- end
- end
-
- describe ".build! without contact fields" do
- subject(:form) { described_class.build!(event, include_contact_fields: false) }
-
- it "omits contact fields" do
- expect(form.form_fields.find_by(field_key: "first_name")).to be_nil
- expect(form.form_fields.find_by(field_key: "primary_email")).to be_nil
- end
-
- it "still includes consent fields" do
- expect(form.form_fields.find_by(field_key: "communication_consent")).to be_present
- end
-
- it "still includes scholarship fields" do
- expect(form.form_fields.find_by(field_key: "scholarship_eligibility")).to be_present
- end
- end
-
- describe ".build_standalone!" do
- it "creates a form without linking to an event" do
- form = described_class.build_standalone!
-
- expect(form.name).to eq("Extended Event Registration")
- expect(form.event_forms).to be_empty
- end
- end
-
- describe ".copy!" do
- it "duplicates a form for a new event" do
- source_form = described_class.build!(event)
- new_event = create(:event)
-
- copied = described_class.copy!(from_form: source_form, to_event: new_event)
-
- expect(copied.id).not_to eq(source_form.id)
- expect(copied.form_fields.count).to eq(source_form.form_fields.count)
- expect(new_event.registration_form).to eq(copied)
- end
- end
-end
diff --git a/spec/services/form_builder_service_spec.rb b/spec/services/form_builder_service_spec.rb
new file mode 100644
index 000000000..279147a5c
--- /dev/null
+++ b/spec/services/form_builder_service_spec.rb
@@ -0,0 +1,126 @@
+require "rails_helper"
+
+RSpec.describe FormBuilderService do
+ describe "#call" do
+ it "creates a form with the given name" do
+ form = described_class.new(name: "Test Form", sections: %i[person_identifier]).call
+ expect(form.name).to eq("Test Form")
+ end
+
+ it "stores selected sections on the form" do
+ form = described_class.new(name: "Test", sections: %i[person_identifier consent]).call
+ expect(form.sections).to eq(%w[person_identifier consent])
+ end
+
+ it "sets scholarship_application flag" do
+ form = described_class.new(name: "Scholarship", sections: %i[scholarship], scholarship_application: true).call
+ expect(form.scholarship_application).to be true
+ end
+
+ it "creates fields with sequential positions" do
+ form = described_class.new(name: "Test", sections: %i[person_identifier consent]).call
+ positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
+ expect(positions).to eq((1..positions.size).to_a)
+ end
+
+ context "person_identifier section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[person_identifier]).call }
+
+ it "creates first_name, last_name, primary_email, and confirm_email fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("first_name", "last_name", "primary_email", "confirm_email")
+ end
+ end
+
+ context "person_contact_info section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[person_contact_info]).call }
+
+ it "creates contact info fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("nickname", "pronouns", "phone", "mailing_city", "agency_name")
+ end
+ end
+
+ context "scholarship section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[scholarship]).call }
+
+ it "creates scholarship fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("scholarship_eligibility", "impact_description", "implementation_plan")
+ end
+ end
+
+ context "consent section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[consent]).call }
+
+ it "creates communication_consent field" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("communication_consent")
+ end
+ end
+
+ context "event_feedback section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[event_feedback]).call }
+
+ it "creates feedback fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("referral_source", "training_motivation", "interested_in_more")
+ end
+ end
+
+ context "payment section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[payment]).call }
+
+ it "creates payment fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("number_of_attendees", "payment_method")
+ end
+ end
+
+ context "post_event_feedback section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[post_event_feedback]).call }
+
+ it "creates post-event feedback fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("event_rating", "most_valuable", "improvement_suggestions")
+ end
+ end
+
+ context "with multiple sections (short event registration)" do
+ let(:form) do
+ described_class.new(
+ name: "Short Event Registration",
+ sections: %i[person_identifier consent event_feedback scholarship]
+ ).call
+ end
+
+ it "creates fields from all selected sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("first_name", "communication_consent", "referral_source", "scholarship_eligibility")
+ end
+
+ it "does not include fields from unselected sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).not_to include("nickname", "phone", "payment_method")
+ end
+ end
+
+ context "with multiple sections (extended event registration)" do
+ let(:form) do
+ described_class.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call
+ end
+
+ it "creates fields from all 8 sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include(
+ "first_name", "nickname", "racial_ethnic_identity",
+ "primary_service_area", "referral_source",
+ "scholarship_eligibility", "payment_method", "communication_consent"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/scholarship_application_form_builder_spec.rb b/spec/services/scholarship_application_form_builder_spec.rb
deleted file mode 100644
index b12998948..000000000
--- a/spec/services/scholarship_application_form_builder_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require "rails_helper"
-
-RSpec.describe ScholarshipApplicationFormBuilder do
- let(:event) { create(:event) }
-
- describe ".build!" do
- subject(:form) { described_class.build!(event) }
-
- it "creates a standalone scholarship form linked to the event" do
- expect(form.name).to eq("Scholarship Application")
- expect(form.scholarship_application).to be true
- expect(event.scholarship_form).to eq(form)
- end
-
- it "creates all expected form fields" do
- expect(form.form_fields.count).to eq(5)
- end
-
- it "assigns sequential positions starting at 1" do
- positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
- expect(positions).to eq((1..5).to_a)
- end
-
- it "creates a Scholarship Application header" do
- headers = form.form_fields.where(answer_type: :group_header).pluck(:question)
- expect(headers).to contain_exactly("Scholarship Application")
- end
-
- it "creates scholarship eligibility as a required checkbox" do
- field = form.form_fields.find_by(field_key: "scholarship_eligibility")
-
- expect(field.answer_type).to eq("multiple_choice_checkbox")
- expect(field.is_required).to be true
- expect(field.form_field_answer_options.count).to eq(1)
- end
-
- it "creates required paragraph fields for impact and implementation" do
- impact = form.form_fields.find_by(field_key: "impact_description")
- implementation = form.form_fields.find_by(field_key: "implementation_plan")
-
- expect(impact.answer_type).to eq("free_form_input_paragraph")
- expect(impact.is_required).to be true
- expect(implementation.answer_type).to eq("free_form_input_paragraph")
- expect(implementation.is_required).to be true
- end
-
- it "creates an optional additional comments field" do
- field = form.form_fields.find_by(field_key: "additional_comments")
-
- expect(field.answer_type).to eq("free_form_input_paragraph")
- expect(field.is_required).to be false
- end
-
- it "assigns all fields to the scholarship group" do
- groups = form.form_fields.pluck(:field_group).uniq
- expect(groups).to eq([ "scholarship" ])
- end
- end
-
- describe ".build_standalone!" do
- it "creates a form without linking to an event" do
- form = described_class.build_standalone!
-
- expect(form.name).to eq("Scholarship Application")
- expect(form.scholarship_application).to be true
- expect(form.event_forms).to be_empty
- end
- end
-end
diff --git a/spec/services/short_event_registration_form_builder_spec.rb b/spec/services/short_event_registration_form_builder_spec.rb
deleted file mode 100644
index dda53dbee..000000000
--- a/spec/services/short_event_registration_form_builder_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-require "rails_helper"
-
-RSpec.describe ShortEventRegistrationFormBuilder do
- let(:event) { create(:event) }
-
- describe ".build!" do
- subject(:form) { described_class.build!(event) }
-
- it "creates a registration form linked to the event" do
- expect(form.name).to eq("Short Event Registration")
- expect(event.registration_form).to eq(form)
- end
-
- it "assigns sequential positions starting at 1" do
- field_count = form.form_fields.count
- positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
- expect(positions).to eq((1..field_count).to_a)
- end
-
- it "inherits basic contact fields from BaseRegistrationFormBuilder" do
- %w[first_name last_name primary_email confirm_email].each do |key|
- field = form.form_fields.find_by(field_key: key)
- expect(field).to be_present, "expected field_key '#{key}' to exist"
- expect(field.field_group).to eq("contact")
- end
- end
-
- it "inherits consent fields from BaseRegistrationFormBuilder" do
- field = form.form_fields.find_by(field_key: "communication_consent")
- expect(field).to be_present
- expect(field.answer_type).to eq("multiple_choice_radio")
- expect(field.is_required).to be true
- expect(field.field_group).to eq("consent")
- end
-
- it "inherits scholarship fields from BaseRegistrationFormBuilder" do
- field = form.form_fields.find_by(field_key: "scholarship_eligibility")
- expect(field).to be_present
- expect(field.field_group).to eq("scholarship")
- end
-
- it "creates short-specific qualitative fields" do
- referral = form.form_fields.find_by(field_key: "referral_source")
- expect(referral.answer_type).to eq("multiple_choice_checkbox")
- expect(referral.is_required).to be true
-
- interest = form.form_fields.find_by(field_key: "training_interest")
- expect(interest.answer_type).to eq("multiple_choice_checkbox")
- expect(interest.is_required).to be true
- end
- end
-
- describe ".build_standalone!" do
- it "creates a form without linking to an event" do
- form = described_class.build_standalone!
-
- expect(form.name).to eq("Short Event Registration")
- expect(form.event_forms).to be_empty
- end
- end
-end
diff --git a/spec/system/event_registration_show_spec.rb b/spec/system/event_registration_show_spec.rb
index 0acfa50b9..d3296fb9c 100644
--- a/spec/system/event_registration_show_spec.rb
+++ b/spec/system/event_registration_show_spec.rb
@@ -50,9 +50,12 @@
describe "view registration form link" do
it "links to form show with slug param" do
- ExtendedEventRegistrationFormBuilder.build!(event)
+ FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call.tap { |form| EventForm.create!(event: event, form: form, role: "registration") }
form = event.registration_form
- form.person_forms.create!(person: user.person)
+ form.form_submissions.create!(person: user.person)
sign_in(user)
visit registration_ticket_path(registration.slug)
diff --git a/spec/system/events_show_spec.rb b/spec/system/events_show_spec.rb
index 5451c4342..169329642 100644
--- a/spec/system/events_show_spec.rb
+++ b/spec/system/events_show_spec.rb
@@ -43,7 +43,7 @@
context "when event has a public registration form" do
before do
- create(:form, name: ShortEventRegistrationFormBuilder::FORM_NAME)
+ create(:form, name: "Short Event Registration")
event.update!(public_registration_enabled: true)
end
diff --git a/spec/system/public_registration_new_spec.rb b/spec/system/public_registration_new_spec.rb
index e5feef547..ca2cb8b01 100644
--- a/spec/system/public_registration_new_spec.rb
+++ b/spec/system/public_registration_new_spec.rb
@@ -14,7 +14,11 @@
before do
driven_by(:rack_test)
- ExtendedEventRegistrationFormBuilder.build!(event)
+ form = FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call
+ EventForm.create!(event: event, form: form, role: "registration")
end
describe "back to event link" do
From 5c1992369dec7bb94a7c5586c3929f5b603b4bad Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:04:42 -0400
Subject: [PATCH 04/14] 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
---
db/seeds.rb | 23 -----------------------
db/seeds/dummy_dev_seeds.rb | 23 +++++++++++++++++++++++
2 files changed, 23 insertions(+), 23 deletions(-)
diff --git a/db/seeds.rb b/db/seeds.rb
index 4e0e0140e..28b08d498 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -268,26 +268,3 @@ def find_or_create_by_name!(klass, name, **attrs, &block)
end
cat.update!(published: true) unless cat.published?
end
-
-puts "Creating standalone registration forms…"
-unless Form.standalone.exists?(name: "Short Event Registration")
- FormBuilderService.new(
- name: "Short Event Registration",
- sections: %i[person_identifier consent event_feedback scholarship]
- ).call
-end
-
-unless Form.standalone.exists?(name: "Extended Event Registration")
- FormBuilderService.new(
- name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
- ).call
-end
-
-unless Form.standalone.scholarship_application.exists?
- FormBuilderService.new(
- name: "Scholarship Application",
- sections: %i[scholarship],
- scholarship_application: true
- ).call
-end
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index 4e234ae43..afae60b0e 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -874,6 +874,29 @@
end
+puts "Creating standalone registration forms…"
+unless Form.standalone.exists?(name: "Short Event Registration")
+ FormBuilderService.new(
+ name: "Short Event Registration",
+ sections: %i[person_identifier consent event_feedback scholarship]
+ ).call
+end
+
+unless Form.standalone.exists?(name: "Extended Event Registration")
+ FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ ).call
+end
+
+unless Form.standalone.scholarship_application.exists?
+ FormBuilderService.new(
+ name: "Scholarship Application",
+ sections: %i[scholarship],
+ scholarship_application: true
+ ).call
+end
+
puts "Creating Events with shared forms…"
admin_user = User.find_by(email: "umberto.user@example.com")
long_form = Form.standalone.find_by!(name: "Extended Event Registration")
From bc1681b6a734285bf6ac770d9495fa99a3307bd6 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:07:35 -0400
Subject: [PATCH 05/14] 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
---
app/views/forms/edit.html.erb | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index ec9757b4f..a89125fd7 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -1,7 +1,14 @@
Edit: <%= @form.display_name %>
- <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
+
+ <% preview_event = @form.events.first %>
+ <% if preview_event %>
+ <%= link_to "Preview form", new_event_public_registration_path(preview_event),
+ class: "text-sm text-blue-600 hover:text-blue-800 underline", target: "_blank" %>
+ <% end %>
+ <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
+
<%= form_with model: @form, class: "space-y-6" do |f| %>
From 79769a22105cf0174d1545f8bf5fdf6d63249600 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:10:08 -0400
Subject: [PATCH 06/14] 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
---
app/controllers/forms_controller.rb | 7 ++-
app/views/forms/edit.html.erb | 6 +-
app/views/forms/index.html.erb | 3 +-
app/views/forms/show.html.erb | 92 +++++++++++++++++++++++++++++
config/routes.rb | 2 +-
5 files changed, 102 insertions(+), 8 deletions(-)
create mode 100644 app/views/forms/show.html.erb
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index f4b4a2bb5..7849c6f03 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -1,11 +1,16 @@
class FormsController < ApplicationController
- before_action :set_form, only: %i[edit update destroy reorder_field]
+ before_action :set_form, only: %i[show edit update destroy reorder_field]
def index
authorize!
@forms = Form.standalone.order(:name)
end
+ def show
+ authorize! @form
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ end
+
def new
authorize!
end
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index a89125fd7..bec2dc283 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -2,11 +2,7 @@
Edit: <%= @form.display_name %>
- <% preview_event = @form.events.first %>
- <% if preview_event %>
- <%= link_to "Preview form", new_event_public_registration_path(preview_event),
- class: "text-sm text-blue-600 hover:text-blue-800 underline", target: "_blank" %>
- <% end %>
+ <%= link_to "View form", form_path(@form), class: "text-sm text-blue-600 hover:text-blue-800 underline" %>
<%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb
index c6a26eece..303ba23a1 100644
--- a/app/views/forms/index.html.erb
+++ b/app/views/forms/index.html.erb
@@ -22,7 +22,8 @@
<%= form.form_fields.size %>
<%= form.events.size %>
<%= form.form_submissions.size %>
-
+
+ <%= link_to "View", form_path(form), class: "text-blue-600 hover:text-blue-800 underline" %>
<%= link_to "Edit", edit_form_path(form), class: "text-blue-600 hover:text-blue-800 underline" %>
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb
new file mode 100644
index 000000000..847722b7e
--- /dev/null
+++ b/app/views/forms/show.html.erb
@@ -0,0 +1,92 @@
+
+
+
<%= @form.display_name %>
+
+ <%= link_to "Edit", edit_form_path(@form), class: "text-sm text-blue-600 hover:text-blue-800 underline" %>
+ <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
+
+
+
+
+
+
+
+ <%= image_tag("logo.png", alt: "Organization logo", class: "h-8 w-auto") %>
+
+
<%= @form.display_name %>
+
+
+
+
+
Preview only — form inputs are disabled.
+
+ <%
+ fields_by_key_check = @form_fields.select(&:field_key).index_by(&:field_key)
+ email_row = if fields_by_key_check["confirm_email"] && fields_by_key_check["primary_email_type"]
+ { keys: %w[primary_email confirm_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" }
+ elsif fields_by_key_check["confirm_email"]
+ { keys: %w[primary_email confirm_email], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }
+ else
+ { keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } }
+ end
+
+ row_groups = [
+ { keys: %w[first_name last_name], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ email_row,
+ { keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } },
+ { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
+ { keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[agency_city agency_state agency_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
+ { keys: %w[number_of_attendees payment_method], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ ]
+
+ field_key_to_group = {}
+ row_groups.each { |g| g[:keys].each { |k| field_key_to_group[k] = g } }
+
+ fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key)
+ rendered_keys = {}
+
+ has_secondary_email = fields_by_key["secondary_email"].present?
+ email_label_overrides = if has_secondary_email
+ { "primary_email" => "Primary Email", "confirm_email" => "Confirm Primary Email", "primary_email_type" => "Primary Email Type" }
+ else
+ {}
+ end
+ %>
+
+
+ <% @form_fields.each do |field| %>
+ <% next if field.field_key.present? && rendered_keys[field.field_key] %>
+
+ <% if field.group_header? %>
+
+
<%= field.question %>
+
+ <% elsif (group = field.field_key.present? && field_key_to_group[field.field_key]) %>
+
+ <% group[:keys].each do |key| %>
+ <% row_field = fields_by_key[key] %>
+ <% next unless row_field %>
+ <% rendered_keys[key] = true %>
+ <% span = group.dig(:spans, key) %>
+ <% label_override = email_label_overrides[key] %>
+ <% if span %>
+
+ <%= render "events/public_registrations/form_field", field: row_field, value: nil, label: label_override %>
+
+ <% else %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: nil, label: label_override %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+ <%= render "events/public_registrations/form_field", field: field, value: nil %>
+ <% end %>
+ <% end %>
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 71bc7c381..f0d2149dc 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -101,7 +101,7 @@
end
resources :comments, only: [ :index, :create ]
end
- resources :forms, except: :show do
+ resources :forms do
member do
patch :reorder_field
end
From 39f69c735d44bc2e0b45b973f7b37e1f6f434f4b Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:11:26 -0400
Subject: [PATCH 07/14] 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
---
app/views/forms/edit.html.erb | 10 ++++------
app/views/forms/show.html.erb | 10 ++++------
2 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index bec2dc283..dd400b9a3 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -1,11 +1,9 @@
-
-
Edit: <%= @form.display_name %>
-
- <%= link_to "View form", form_path(@form), class: "text-sm text-blue-600 hover:text-blue-800 underline" %>
- <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
-
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View form", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
Edit: <%= @form.display_name %>
<%= form_with model: @form, class: "space-y-6" do |f| %>
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb
index 847722b7e..e0fd36d93 100644
--- a/app/views/forms/show.html.erb
+++ b/app/views/forms/show.html.erb
@@ -1,11 +1,9 @@
-
-
<%= @form.display_name %>
-
- <%= link_to "Edit", edit_form_path(@form), class: "text-sm text-blue-600 hover:text-blue-800 underline" %>
- <%= link_to "← Back to Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
-
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit", edit_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
<%= @form.display_name %>
From 34ad1b81967c032a3f493dac737776de3df62fb6 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:13:19 -0400
Subject: [PATCH 08/14] 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
---
app/services/form_builder_service.rb | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index 439a6f7fe..cafc7e499 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -237,12 +237,12 @@ def build_consent_fields(form, position)
position = add_header(form, position, "Consent", group: "consent")
position = add_field(form, position,
"I agree to receive email communications from A Window Between Worlds.",
- :multiple_choice_radio,
+ :multiple_choice_checkbox,
key: "communication_consent", group: "consent", required: true,
hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
"including information about this event as well as upcoming events, training opportunities, resources, " \
"impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
- options: %w[Yes No])
+ options: [ "Yes" ])
position
end
From 4e6ba01de6bc0be455186144f05ef5a329045171 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:15:55 -0400
Subject: [PATCH 09/14] 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
---
app/controllers/forms_controller.rb | 20 +++++++-
app/services/form_builder_service.rb | 63 ++++++++++++++++++++++++++
app/views/forms/edit.html.erb | 1 +
app/views/forms/edit_sections.html.erb | 36 +++++++++++++++
config/routes.rb | 2 +
5 files changed, 121 insertions(+), 1 deletion(-)
create mode 100644 app/views/forms/edit_sections.html.erb
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index 7849c6f03..92727043b 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -1,5 +1,5 @@
class FormsController < ApplicationController
- before_action :set_form, only: %i[show edit update destroy reorder_field]
+ before_action :set_form, only: %i[show edit update destroy reorder_field edit_sections update_sections]
def index
authorize!
@@ -56,6 +56,24 @@ def destroy
redirect_to forms_path, notice: "Form deleted."
end
+ def edit_sections
+ authorize! @form
+ end
+
+ def update_sections
+ authorize! @form
+
+ sections = (params[:sections] || []).reject(&:blank?).map(&:to_sym)
+ if sections.empty?
+ flash.now[:alert] = "Please select at least one section."
+ render :edit_sections, status: :unprocessable_content
+ return
+ end
+
+ FormBuilderService.update_sections!(@form, sections)
+ redirect_to edit_form_path(@form), notice: "Sections updated."
+ end
+
def reorder_field
authorize! @form
field = @form.form_fields.find(params[:field_id])
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index cafc7e499..1ee47a70e 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -33,6 +33,69 @@ def call
form
end
+ SECTION_FIELD_KEYS = {
+ person_identifier: %w[first_name last_name primary_email confirm_email],
+ person_contact_info: %w[
+ primary_email_type nickname pronouns secondary_email secondary_email_type
+ mailing_street mailing_address_type mailing_city mailing_state mailing_zip
+ phone phone_type agency_name agency_position agency_street agency_city
+ agency_state agency_zip agency_type agency_website
+ ],
+ person_background: %w[racial_ethnic_identity],
+ professional_info: %w[primary_service_area workshop_environments client_life_experiences primary_age_group],
+ event_feedback: %w[referral_source training_motivation interested_in_more],
+ scholarship: %w[scholarship_eligibility impact_description implementation_plan additional_comments],
+ payment: %w[number_of_attendees payment_method],
+ consent: %w[communication_consent],
+ post_event_feedback: %w[event_rating most_valuable improvement_suggestions]
+ }.freeze
+
+ # Update sections on an existing form: add new sections, remove unchecked ones
+ def self.update_sections!(form, new_sections)
+ new_sections = new_sections.map(&:to_sym)
+ old_sections = (form.sections || []).map(&:to_sym)
+
+ added = new_sections - old_sections
+ removed = old_sections - new_sections
+
+ # Remove fields belonging to removed sections
+ remaining_groups = new_sections.map { |k| SECTION_FIELD_GROUPS.fetch(k) }.uniq
+ removed.each do |key|
+ field_keys = SECTION_FIELD_KEYS.fetch(key)
+ form.form_fields.where(field_key: field_keys).destroy_all
+ # Only remove headers if no remaining section shares the same field_group
+ group = SECTION_FIELD_GROUPS.fetch(key)
+ unless remaining_groups.include?(group)
+ form.form_fields.where(field_key: nil, field_group: group, answer_type: :group_header).destroy_all
+ end
+ end
+
+ # Add fields for new sections at the end
+ if added.any?
+ max_position = form.form_fields.maximum(:position) || 0
+ builder = new(name: form.name, sections: added)
+ added.each do |key|
+ section = SECTIONS.fetch(key)
+ max_position = builder.send(section[:method], form, max_position)
+ end
+ end
+
+ form.update!(sections: new_sections.map(&:to_s))
+ form
+ end
+
+ SECTION_FIELD_GROUPS = {
+ person_identifier: "contact",
+ person_contact_info: "contact",
+ person_background: "background",
+ professional_info: "professional",
+ event_feedback: "event_feedback",
+ scholarship: "scholarship",
+ payment: "payment",
+ consent: "consent",
+ post_event_feedback: "post_event_feedback"
+ }.freeze
+
private
def add_header(form, position, title, group:)
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index dd400b9a3..65ca92b74 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -2,6 +2,7 @@
<%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= link_to "View form", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit sections", edit_sections_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
Edit: <%= @form.display_name %>
diff --git a/app/views/forms/edit_sections.html.erb b/app/views/forms/edit_sections.html.erb
new file mode 100644
index 000000000..62c976c4b
--- /dev/null
+++ b/app/views/forms/edit_sections.html.erb
@@ -0,0 +1,36 @@
+
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit fields", edit_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View form", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
Edit Sections: <%= @form.display_name %>
+
+ <% current_sections = (@form.sections || []).map(&:to_s) %>
+
+ <%= form_with url: update_sections_form_path(@form), method: :patch, class: "space-y-6" do |f| %>
+
+ Select sections to include:
+
+ <% FormBuilderService::SECTIONS.each do |key, section| %>
+
+ >
+ <%= section[:label] %>
+
+ <% end %>
+
+
+
+
+ Unchecking a section will remove its fields from the form. Checking a new section will add its default fields at the end.
+ Existing answers are preserved via question text snapshots.
+
+
+
+ <%= f.submit "Update Sections", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", edit_form_path(@form), class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/config/routes.rb b/config/routes.rb
index f0d2149dc..30cc6e2d7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -104,6 +104,8 @@
resources :forms do
member do
patch :reorder_field
+ get :edit_sections
+ patch :update_sections
end
end
resources :events do
From e8d91d5e181f7a2401ab3c1ed0694c292b24b7e8 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Sun, 8 Mar 2026 22:17:39 -0400
Subject: [PATCH 10/14] 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
---
app/services/form_builder_service.rb | 36 ++++++++++++++--------------
1 file changed, 18 insertions(+), 18 deletions(-)
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index 1ee47a70e..0e2be6d89 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -50,6 +50,19 @@ def call
post_event_feedback: %w[event_rating most_valuable improvement_suggestions]
}.freeze
+ # Header questions created by each section's builder method
+ SECTION_HEADERS = {
+ person_identifier: [],
+ person_contact_info: [ "Contact Information", "Mailing Address", "Agency / Organization Information" ],
+ person_background: [ "Background Information" ],
+ professional_info: [ "Professional Information" ],
+ event_feedback: [ "About You" ],
+ scholarship: [ "Scholarship Application" ],
+ payment: [ "Payment Information" ],
+ consent: [ "Consent" ],
+ post_event_feedback: [ "Post-Event Feedback" ]
+ }.freeze
+
# Update sections on an existing form: add new sections, remove unchecked ones
def self.update_sections!(form, new_sections)
new_sections = new_sections.map(&:to_sym)
@@ -58,15 +71,14 @@ def self.update_sections!(form, new_sections)
added = new_sections - old_sections
removed = old_sections - new_sections
- # Remove fields belonging to removed sections
- remaining_groups = new_sections.map { |k| SECTION_FIELD_GROUPS.fetch(k) }.uniq
+ # Remove fields and headers belonging to removed sections
removed.each do |key|
field_keys = SECTION_FIELD_KEYS.fetch(key)
form.form_fields.where(field_key: field_keys).destroy_all
- # Only remove headers if no remaining section shares the same field_group
- group = SECTION_FIELD_GROUPS.fetch(key)
- unless remaining_groups.include?(group)
- form.form_fields.where(field_key: nil, field_group: group, answer_type: :group_header).destroy_all
+
+ headers = SECTION_HEADERS.fetch(key)
+ if headers.any?
+ form.form_fields.where(question: headers, answer_type: :group_header).destroy_all
end
end
@@ -84,18 +96,6 @@ def self.update_sections!(form, new_sections)
form
end
- SECTION_FIELD_GROUPS = {
- person_identifier: "contact",
- person_contact_info: "contact",
- person_background: "background",
- professional_info: "professional",
- event_feedback: "event_feedback",
- scholarship: "scholarship",
- payment: "payment",
- consent: "consent",
- post_event_feedback: "post_event_feedback"
- }.freeze
-
private
def add_header(form, position, title, group:)
From bb973c161f92ac090b08c7fbdef7bec46ebc5f80 Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 9 Mar 2026 01:05:17 -0400
Subject: [PATCH 11/14] 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
---
.../events/public_registrations_controller.rb | 61 ++++++++++++----
app/controllers/forms_controller.rb | 34 ++++++++-
.../form_fields_sortable_controller.js | 73 +++++++++++++++++++
app/services/form_builder_service.rb | 70 +++++++++---------
app/views/forms/_form_field_fields.html.erb | 69 ++++++++++++++++++
app/views/forms/_form_field_row.html.erb | 33 ---------
app/views/forms/edit.html.erb | 54 ++++++++++++--
app/views/forms/show.html.erb | 71 +++++++++++++++++-
config/routes.rb | 1 +
db/schema.rb | 64 ++++++++++------
db/seeds/dummy_dev_seeds.rb | 4 +-
spec/requests/events/registrations_spec.rb | 2 +-
spec/services/form_builder_service_spec.rb | 22 +++---
spec/system/event_registration_show_spec.rb | 2 +-
spec/system/public_registration_new_spec.rb | 2 +-
15 files changed, 430 insertions(+), 132 deletions(-)
create mode 100644 app/frontend/javascript/controllers/form_fields_sortable_controller.js
create mode 100644 app/views/forms/_form_field_fields.html.erb
delete mode 100644 app/views/forms/_form_field_row.html.erb
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index 3a44e48c0..274e01fb8 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -109,26 +109,34 @@ def scholarship_mode?
def visible_form_fields
scope = @form.form_fields
- if scholarship_mode?
- scope = scope.where.not(field_group: "payment")
- else
+ unless scholarship_mode?
scope = scope.where.not(field_group: "scholarship")
end
person = current_user&.person
if person
- existing_submission = @form.form_submissions.find_by(person: person)
- if existing_submission
- answered_field_ids = existing_submission.form_answers
- .where.not(text: [ nil, "" ])
- .pluck(:form_field_id)
-
- if @form.hide_answered_person_questions?
- person_ids = answered_field_ids & @form.form_fields.where(field_group: "contact").ids
- scope = scope.where.not(id: person_ids) if person_ids.any?
+ if @form.hide_answered_person_questions?
+ known_keys = person_known_field_keys(person)
+ if known_keys.any?
+ known_ids = @form.form_fields
+ .where(field_group: %w[person_identifier person_contact_info], field_key: known_keys)
+ .ids
+ scope = scope.where.not(id: known_ids) if known_ids.any?
end
- if @form.hide_answered_form_questions?
+ # Background fields (e.g. ethnicity) are always hidden for logged-in users
+ # since Person doesn't store these — they're collected once per event
+ scope = scope.where.not(field_group: "background")
+ end
+
+ if @form.hide_answered_form_questions?
+ existing_submission = @form.form_submissions.find_by(person: person)
+ if existing_submission
+ answered_field_ids = existing_submission.form_answers
+ .joins(:form_field)
+ .where(form_fields: { field_group: %w[professional marketing] })
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
scope = scope.where.not(id: answered_field_ids) if answered_field_ids.any?
end
end
@@ -137,6 +145,33 @@ def visible_form_fields
scope.reorder(position: :asc)
end
+ def person_known_field_keys(person)
+ keys = []
+ keys << "first_name" if person.first_name.present?
+ keys << "last_name" if person.last_name.present?
+ keys << "primary_email" << "confirm_email" if person.email.present?
+ keys << "primary_email_type" if person.email_type.present?
+ keys << "nickname" if person.legal_first_name.present? || person.first_name.present?
+ keys << "pronouns" if person.pronouns.present?
+ keys << "secondary_email" if person.email_2.present?
+ keys << "secondary_email_type" if person.email_2_type.present?
+
+ if person.addresses.exists?
+ address = person.addresses.find_by(primary: true) || person.addresses.first
+ keys << "mailing_street" if address.street_address.present?
+ keys << "mailing_address_type" if address.address_type.present?
+ keys << "mailing_city" if address.city.present?
+ keys << "mailing_state" if address.state.present?
+ keys << "mailing_zip" if address.zip_code.present?
+ end
+
+ if person.contact_methods.where(kind: :phone).exists?
+ keys << "phone" << "phone_type"
+ end
+
+ keys
+ end
+
def ensure_registerable
unless @event.registerable?
redirect_to event_path(@event), alert: "Registration is closed for this event."
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index 92727043b..98effab3d 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -1,5 +1,5 @@
class FormsController < ApplicationController
- before_action :set_form, only: %i[show edit update destroy reorder_field edit_sections update_sections]
+ before_action :set_form, only: %i[show edit update destroy reorder_field reorder_fields edit_sections update_sections]
def index
authorize!
@@ -8,7 +8,7 @@ def index
def show
authorize! @form
- @form_fields = @form.form_fields.reorder(position: :asc)
+ @form_fields = preview_form_fields
end
def new
@@ -81,12 +81,42 @@ def reorder_field
head :ok
end
+ def reorder_fields
+ authorize! @form
+ positions = JSON.parse(request.body.read)["positions"] || []
+ positions.each do |item|
+ @form.form_fields.where(id: item["id"]).update_all(position: item["position"])
+ end
+ head :ok
+ end
+
private
def set_form
@form = Form.find(params[:id])
end
+ def preview_form_fields
+ scope = @form.form_fields
+ form_sections = (@form.sections || []).map(&:to_s)
+
+ if params[:preview_scholarship].present?
+ # Scholarship mode: show scholarship fields (and keep payment visible)
+ elsif form_sections.include?("scholarship")
+ scope = scope.where.not(field_group: "scholarship")
+ end
+
+ if params[:preview_logged_in].present? && @form.hide_answered_person_questions?
+ scope = scope.where.not(field_group: %w[person_identifier person_contact_info background])
+ end
+
+ if params[:preview_answered].present? && @form.hide_answered_form_questions?
+ scope = scope.where.not(field_group: %w[professional marketing])
+ end
+
+ scope.reorder(position: :asc)
+ end
+
def form_params
params.require(:form).permit(
:name, :hide_answered_person_questions, :hide_answered_form_questions,
diff --git a/app/frontend/javascript/controllers/form_fields_sortable_controller.js b/app/frontend/javascript/controllers/form_fields_sortable_controller.js
new file mode 100644
index 000000000..d06cccb4a
--- /dev/null
+++ b/app/frontend/javascript/controllers/form_fields_sortable_controller.js
@@ -0,0 +1,73 @@
+import { Controller } from "@hotwired/stimulus"
+import { put } from "@rails/request.js"
+import Sortable from "sortablejs"
+
+/**
+ * Sortable controller for form field editing.
+ *
+ * Group-aware: dragging a group header also moves all fields
+ * that share the same field_group.
+ *
+ * Usage:
+ * data-controller="form-fields-sortable"
+ * data-form-fields-sortable-url-value="/forms/:id/reorder_fields"
+ *
+ * Each item needs:
+ * data-sortable-id=""
+ * data-sortable-handle (on the drag handle element)
+ * data-field-group="" (optional)
+ * data-group-header (on header rows)
+ */
+export default class extends Controller {
+ static values = { url: String }
+
+ connect() {
+ this.sortable = Sortable.create(this.element, {
+ onEnd: this.onEnd.bind(this),
+ handle: "[data-sortable-handle]",
+ })
+ }
+
+ disconnect() {
+ this.sortable.destroy()
+ }
+
+ onEnd(event) {
+ const { item } = event
+
+ // If a group header was moved, relocate its group members to follow it
+ if (item.dataset.groupHeader !== undefined) {
+ const group = item.dataset.fieldGroup
+ const members = []
+
+ for (const el of this.element.children) {
+ if (el !== item && el.dataset.fieldGroup === group && el.dataset.groupHeader === undefined) {
+ members.push(el)
+ }
+ }
+
+ let ref = item
+ for (const member of members) {
+ ref.after(member)
+ ref = member
+ }
+ }
+
+ // Update all positions based on current DOM order
+ const items = [...this.element.children]
+ const positions = []
+
+ items.forEach((el, index) => {
+ const pos = index + 1
+ positions.push({ id: parseInt(el.dataset.sortableId), position: pos })
+
+ // Update hidden position field so form submit stays in sync
+ const posInput = el.querySelector("input[name*='[position]']")
+ if (posInput) posInput.value = pos
+ })
+
+ put(this.urlValue, {
+ body: JSON.stringify({ positions }),
+ })
+ }
+}
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index 0e2be6d89..fc788e383 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -4,7 +4,7 @@ class FormBuilderService
person_contact_info: { label: "Person contact info", method: :build_person_contact_info_fields },
person_background: { label: "Person background", method: :build_person_background_fields },
professional_info: { label: "Professional info", method: :build_professional_info_fields },
- event_feedback: { label: "Event feedback", method: :build_event_feedback_fields },
+ marketing: { label: "Marketing", method: :build_marketing_fields },
scholarship: { label: "Scholarship", method: :build_scholarship_fields },
payment: { label: "Payment", method: :build_payment_fields },
consent: { label: "Consent", method: :build_consent_fields },
@@ -43,7 +43,7 @@ def call
],
person_background: %w[racial_ethnic_identity],
professional_info: %w[primary_service_area workshop_environments client_life_experiences primary_age_group],
- event_feedback: %w[referral_source training_motivation interested_in_more],
+ marketing: %w[referral_source training_motivation interested_in_more],
scholarship: %w[scholarship_eligibility impact_description implementation_plan additional_comments],
payment: %w[number_of_attendees payment_method],
consent: %w[communication_consent],
@@ -56,7 +56,7 @@ def call
person_contact_info: [ "Contact Information", "Mailing Address", "Agency / Organization Information" ],
person_background: [ "Background Information" ],
professional_info: [ "Professional Information" ],
- event_feedback: [ "About You" ],
+ marketing: [ "Marketing" ],
scholarship: [ "Scholarship Application" ],
payment: [ "Payment Information" ],
consent: [ "Consent" ],
@@ -142,73 +142,73 @@ def add_field(form, position, question, answer_type, key:, group:, required: tru
def build_person_identifier_fields(form, position)
position = add_field(form, position, "First Name", :free_form_input_one_line,
- key: "first_name", group: "contact", required: true)
+ key: "first_name", group: "person_identifier", required: true)
position = add_field(form, position, "Last Name", :free_form_input_one_line,
- key: "last_name", group: "contact", required: true)
+ key: "last_name", group: "person_identifier", required: true)
position = add_field(form, position, "Email", :free_form_input_one_line,
- key: "primary_email", group: "contact", required: true)
+ key: "primary_email", group: "person_identifier", required: true)
position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
- key: "confirm_email", group: "contact", required: true)
+ key: "confirm_email", group: "person_identifier", required: true)
position
end
def build_person_contact_info_fields(form, position)
- position = add_header(form, position, "Contact Information", group: "contact")
+ position = add_header(form, position, "Contact Information", group: "person_contact_info")
position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
- key: "primary_email_type", group: "contact", required: true,
+ key: "primary_email_type", group: "person_contact_info", required: true,
options: %w[Personal Work])
position = add_field(form, position, "Preferred Nickname", :free_form_input_one_line,
- key: "nickname", group: "contact", required: false)
+ key: "nickname", group: "person_contact_info", required: false)
position = add_field(form, position, "Pronouns", :free_form_input_one_line,
- key: "pronouns", group: "contact", required: false)
+ key: "pronouns", group: "person_contact_info", required: false)
position = add_field(form, position, "Secondary Email", :free_form_input_one_line,
- key: "secondary_email", group: "contact", required: false)
+ key: "secondary_email", group: "person_contact_info", required: false)
position = add_field(form, position, "Secondary Email Type", :multiple_choice_radio,
- key: "secondary_email_type", group: "contact", required: false,
+ key: "secondary_email_type", group: "person_contact_info", required: false,
options: %w[Personal Work])
- position = add_header(form, position, "Mailing Address", group: "contact")
+ position = add_header(form, position, "Mailing Address", group: "person_contact_info")
position = add_field(form, position, "Street Address", :free_form_input_one_line,
- key: "mailing_street", group: "contact", required: true)
+ key: "mailing_street", group: "person_contact_info", required: true)
position = add_field(form, position, "Address Type", :multiple_choice_radio,
- key: "mailing_address_type", group: "contact", required: true,
+ key: "mailing_address_type", group: "person_contact_info", required: true,
options: %w[Home Work])
position = add_field(form, position, "City", :free_form_input_one_line,
- key: "mailing_city", group: "contact", required: true)
+ key: "mailing_city", group: "person_contact_info", required: true)
position = add_field(form, position, "State / Province", :free_form_input_one_line,
- key: "mailing_state", group: "contact", required: true)
+ key: "mailing_state", group: "person_contact_info", required: true)
position = add_field(form, position, "Zip / Postal Code", :free_form_input_one_line,
- key: "mailing_zip", group: "contact", required: true)
+ key: "mailing_zip", group: "person_contact_info", required: true)
position = add_field(form, position, "Phone", :free_form_input_one_line,
- key: "phone", group: "contact", required: true)
+ key: "phone", group: "person_contact_info", required: true)
position = add_field(form, position, "Phone Type", :multiple_choice_radio,
- key: "phone_type", group: "contact", required: true,
+ key: "phone_type", group: "person_contact_info", required: true,
options: %w[Mobile Home Work])
- position = add_header(form, position, "Agency / Organization Information", group: "contact")
+ position = add_header(form, position, "Agency / Organization Information", group: "person_contact_info")
position = add_field(form, position, "Agency / Organization Name", :free_form_input_one_line,
- key: "agency_name", group: "contact", required: false)
+ key: "agency_name", group: "person_contact_info", required: false)
position = add_field(form, position, "Position / Title", :free_form_input_one_line,
- key: "agency_position", group: "contact", required: false)
+ key: "agency_position", group: "person_contact_info", required: false)
position = add_field(form, position, "Agency Street Address", :free_form_input_one_line,
- key: "agency_street", group: "contact", required: false)
+ key: "agency_street", group: "person_contact_info", required: false)
position = add_field(form, position, "Agency City", :free_form_input_one_line,
- key: "agency_city", group: "contact", required: false)
+ key: "agency_city", group: "person_contact_info", required: false)
position = add_field(form, position, "Agency State / Province", :free_form_input_one_line,
- key: "agency_state", group: "contact", required: false)
+ key: "agency_state", group: "person_contact_info", required: false)
position = add_field(form, position, "Agency Zip / Postal Code", :free_form_input_one_line,
- key: "agency_zip", group: "contact", required: false)
+ key: "agency_zip", group: "person_contact_info", required: false)
position = add_field(form, position, "Agency Type", :multiple_choice_radio,
- key: "agency_type", group: "contact", required: false,
+ key: "agency_type", group: "person_contact_info", required: false,
options: [
"Domestic Violence", "Homeless Shelter", "Hospital",
"Mental Health", "School", "After-School Program",
"Community Center", "Other"
])
position = add_field(form, position, "Agency Website", :free_form_input_one_line,
- key: "agency_website", group: "contact", required: false)
+ key: "agency_website", group: "person_contact_info", required: false)
position
end
@@ -246,16 +246,16 @@ def build_professional_info_fields(form, position)
position
end
- def build_event_feedback_fields(form, position)
- position = add_header(form, position, "About You", group: "event_feedback")
+ def build_marketing_fields(form, position)
+ position = add_header(form, position, "Marketing", group: "marketing")
position = add_field(form, position, "How did you hear about this training?", :free_form_input_paragraph,
- key: "referral_source", group: "event_feedback", required: false)
+ key: "referral_source", group: "marketing", required: false)
position = add_field(form, position, "What motivates you to attend this training?", :free_form_input_paragraph,
- key: "training_motivation", group: "event_feedback", required: false)
+ key: "training_motivation", group: "marketing", required: false)
position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?",
:multiple_choice_radio,
- key: "interested_in_more", group: "event_feedback", required: true,
+ key: "interested_in_more", group: "marketing", required: true,
options: %w[Yes No])
position
end
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb
new file mode 100644
index 000000000..0a101b16a
--- /dev/null
+++ b/app/views/forms/_form_field_fields.html.erb
@@ -0,0 +1,69 @@
+<%
+ field = f.object
+ conditions = []
+ form_sections = (@form.sections || []).map(&:to_s)
+
+ if field.field_group == "scholarship" && form_sections.include?("scholarship")
+ conditions << { label: "Scholarship-only", css: "bg-purple-100 text-purple-700" }
+ end
+
+ if %w[person_identifier person_contact_info background].include?(field.field_group) && @form.hide_answered_person_questions?
+ conditions << { label: "Logged out only", css: "bg-emerald-100 text-emerald-700" }
+ end
+
+ if %w[professional marketing].include?(field.field_group) && @form.hide_answered_form_questions? && !field.group_header?
+ conditions << { label: "Answers on file", css: "bg-amber-100 text-amber-700" }
+ end
+
+ indented = !field.group_header? && field.field_group.present?
+%>
+data-field-group="<%= field.field_group %>"<% end %>
+ <% if field.group_header? %>data-group-header<% end %>>
+
+
+
+
+ <%= f.hidden_field :id if field.persisted? %>
+ <%= f.hidden_field :position, value: field.position %>
+
+ <% if field.group_header? %>
+
+ <% conditions.each do |c| %>
+
<%= c[:label] %>
+ <% end %>
+
<%= field.question %>
+ <%= f.hidden_field :question %>
+
+ <%= link_to_remove_association "Remove", f,
+ class: "text-sm text-gray-400 hover:text-red-600 underline" %>
+
+
+ <% else %>
+
+
+ <% conditions.each do |c| %>
+ <%= c[:label] %>
+ <% end %>
+ <%= f.text_field :question, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+
+
+ <%= f.select :answer_type,
+ FormField.answer_types.keys.map { |t| [ t == "group_header" ? "Section header" : t.titleize.gsub("_", " "), t ] },
+ {},
+ class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+
+
+
+ <%= f.check_box :is_required, class: "rounded border-gray-300 text-blue-600" %>
+ Required
+
+
+
+ <%= link_to_remove_association "Remove", f,
+ class: "text-sm text-gray-400 hover:text-red-600 underline" %>
+
+
+ <% end %>
+
diff --git a/app/views/forms/_form_field_row.html.erb b/app/views/forms/_form_field_row.html.erb
deleted file mode 100644
index 7a8b4cfc4..000000000
--- a/app/views/forms/_form_field_row.html.erb
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
- <%= ff.hidden_field :id %>
- <%= ff.hidden_field :position, value: field.position %>
-
-
-
- <%= ff.text_field :question, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
-
-
- <%= ff.select :answer_type,
- FormField.answer_types.keys.map { |t| [ t.titleize.gsub("_", " "), t ] },
- {},
- class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
-
-
-
- <%= ff.check_box :is_required, class: "rounded border-gray-300 text-blue-600" %>
- Required
-
-
-
-
- <%= ff.check_box :_destroy, class: "rounded border-gray-300 text-red-600" %>
- Delete
-
-
-
-
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index 65ca92b74..eab53af00 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -13,14 +13,14 @@
<%= f.text_field :name, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
-
+
<%= f.check_box :hide_answered_person_questions, class: "rounded border-gray-300 text-blue-600" %>
- Hide already-answered person questions
+ Hide any answered person & organization questions
<%= f.check_box :hide_answered_form_questions, class: "rounded border-gray-300 text-blue-600" %>
- Hide already-answered form questions
+ Hide any answered form questions
@@ -31,13 +31,51 @@
<%= @form_fields.size %> fields
-
">
- <% @form_fields.each_with_index do |field, index| %>
- <%= f.fields_for :form_fields, field do |ff| %>
- <%= render "form_field_row", ff: ff, field: field, index: index %>
- <% end %>
+ <%
+ form_sections = (@form.sections || []).map(&:to_s)
+ has_scholarship = form_sections.include?("scholarship")
+ has_person = form_sections.include?("person_identifier") || form_sections.include?("person_contact_info") || form_sections.include?("person_background")
+ has_professional = form_sections.include?("professional_info") || form_sections.include?("marketing")
+ has_conditions = has_scholarship ||
+ (has_person && @form.hide_answered_person_questions?) ||
+ (has_professional && @form.hide_answered_form_questions?)
+ %>
+ <% if has_conditions %>
+
+
Conditional visibility
+
+ <% if has_scholarship %>
+
+ Scholarship-only
+ Shown only when registering for scholarship
+
+ <% end %>
+ <% if has_person && @form.hide_answered_person_questions? %>
+
+ Logged out only
+ Hidden for logged-in users
+
+ <% end %>
+ <% if has_professional && @form.hide_answered_form_questions? %>
+
+ Answers on file
+ Hidden when info is already known
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= f.fields_for :form_fields, @form_fields do |ff| %>
+ <%= render "form_field_fields", f: ff %>
<% end %>
+
+
+ <%= link_to_add_association "+ Add field", f, :form_fields,
+ class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
+
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb
index e0fd36d93..783fa8757 100644
--- a/app/views/forms/show.html.erb
+++ b/app/views/forms/show.html.erb
@@ -16,7 +16,76 @@
-
Preview only — form inputs are disabled.
+ <%
+ form_sections = (@form.sections || []).map(&:to_s)
+ has_scholarship = form_sections.include?("scholarship")
+ has_person = form_sections.include?("person_identifier") || form_sections.include?("person_contact_info") || form_sections.include?("person_background")
+ has_professional = form_sections.include?("professional_info") || form_sections.include?("marketing")
+ has_conditions = has_scholarship || (has_person && @form.hide_answered_person_questions?) || (has_professional && @form.hide_answered_form_questions?)
+
+ preview_logged_in = params[:preview_logged_in].present?
+ preview_scholarship = params[:preview_scholarship].present?
+ preview_answered = params[:preview_answered].present?
+ %>
+
+
+
Preview mode
+ <% if has_conditions %>
+
Toggle to simulate how the form appears under different conditions:
+
+ <% if has_person && @form.hide_answered_person_questions? %>
+ <%
+ logged_in_params = request.query_parameters.dup
+ if preview_logged_in
+ logged_in_params.delete("preview_logged_in")
+ else
+ logged_in_params["preview_logged_in"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, logged_in_params), class: "flex items-center gap-2 no-underline" do %>
+ User logged in
+
+
+
+ <% end %>
+ <% end %>
+ <% if has_professional && @form.hide_answered_form_questions? %>
+ <%
+ answered_params = request.query_parameters.dup
+ if preview_answered
+ answered_params.delete("preview_answered")
+ else
+ answered_params["preview_answered"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, answered_params), class: "flex items-center gap-2 no-underline" do %>
+ Answers on file
+
+
+
+ <% end %>
+ <% end %>
+ <% if has_scholarship %>
+ <%
+ scholarship_params = request.query_parameters.dup
+ if preview_scholarship
+ scholarship_params.delete("preview_scholarship")
+ else
+ scholarship_params["preview_scholarship"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, scholarship_params), class: "flex items-center gap-2 no-underline" do %>
+ Scholarship
+
+
+
+ <% end %>
+ <% end %>
+
+ <% else %>
+
No conditional visibility configured. All fields always shown.
+ <% end %>
+
<%
fields_by_key_check = @form_fields.select(&:field_key).index_by(&:field_key)
diff --git a/config/routes.rb b/config/routes.rb
index 30cc6e2d7..468c4091c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -104,6 +104,7 @@
resources :forms do
member do
patch :reorder_field
+ put :reorder_fields
get :edit_sections
patch :update_sections
end
diff --git a/db/schema.rb b/db/schema.rb
index ee45b0729..81ea1c06b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_01_120200) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_08_120000) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -408,6 +408,16 @@
t.index ["form_id"], name: "index_event_forms_on_form_id"
end
+ create_table "event_registration_organizations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "event_registration_id", null: false
+ t.integer "organization_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["event_registration_id", "organization_id"], name: "idx_event_reg_orgs_on_registration_and_org", unique: true
+ t.index ["event_registration_id"], name: "idx_on_event_registration_id_806bdcd019"
+ t.index ["organization_id"], name: "index_event_registration_organizations_on_organization_id"
+ end
+
create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "event_id"
@@ -481,6 +491,17 @@
t.datetime "updated_at", precision: nil, null: false
end
+ create_table "form_answers", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "form_field_id"
+ t.bigint "form_submission_id"
+ t.string "question_text"
+ t.text "text"
+ t.datetime "updated_at", null: false
+ t.index ["form_field_id"], name: "index_form_answers_on_form_field_id"
+ t.index ["form_submission_id"], name: "index_form_answers_on_form_submission_id"
+ end
+
create_table "form_builders", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.text "description", size: :long
@@ -520,13 +541,25 @@
t.index ["parent_id"], name: "index_form_fields_on_parent_id"
end
+ create_table "form_submissions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "form_id"
+ t.bigint "person_id"
+ t.datetime "updated_at", null: false
+ t.index ["form_id"], name: "index_form_submissions_on_form_id"
+ t.index ["person_id"], name: "index_form_submissions_on_person_id"
+ end
+
create_table "forms", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "form_builder_id"
+ t.boolean "hide_answered_form_questions", default: false, null: false
+ t.boolean "hide_answered_person_questions", default: false, null: false
t.string "name"
t.integer "owner_id"
t.string "owner_type"
t.boolean "scholarship_application", default: false, null: false
+ t.json "sections"
t.datetime "updated_at", precision: nil, null: false
t.index ["form_builder_id"], name: "index_forms_on_form_builder_id"
t.index ["owner_type", "owner_id"], name: "index_forms_on_owner_type_and_owner_id"
@@ -738,25 +771,6 @@
t.datetime "updated_at", precision: nil, null: false
end
- create_table "person_form_form_fields", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.integer "form_field_id"
- t.bigint "person_form_id"
- t.text "text"
- t.datetime "updated_at", null: false
- t.index ["form_field_id"], name: "index_person_form_form_fields_on_form_field_id"
- t.index ["person_form_id"], name: "index_person_form_form_fields_on_person_form_id"
- end
-
- create_table "person_forms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.integer "form_id"
- t.bigint "person_id"
- t.datetime "updated_at", null: false
- t.index ["form_id"], name: "index_person_forms_on_form_id"
- t.index ["person_id"], name: "index_person_forms_on_person_id"
- end
-
create_table "quotable_item_quotes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "legacy_id"
@@ -1342,14 +1356,20 @@
add_foreign_key "contact_methods", "addresses"
add_foreign_key "event_forms", "events"
add_foreign_key "event_forms", "forms"
+ add_foreign_key "event_registration_organizations", "event_registrations"
+ add_foreign_key "event_registration_organizations", "organizations"
add_foreign_key "event_registrations", "events"
add_foreign_key "event_registrations", "people", column: "registrant_id"
add_foreign_key "events", "locations"
add_foreign_key "events", "users", column: "created_by_id"
+ add_foreign_key "form_answers", "form_fields"
+ add_foreign_key "form_answers", "form_submissions"
add_foreign_key "form_builders", "windows_types"
add_foreign_key "form_field_answer_options", "answer_options"
add_foreign_key "form_field_answer_options", "form_fields"
add_foreign_key "form_fields", "forms"
+ add_foreign_key "form_submissions", "forms"
+ add_foreign_key "form_submissions", "people"
add_foreign_key "forms", "form_builders"
add_foreign_key "monthly_reports", "affiliations", column: "organization_user_id"
add_foreign_key "monthly_reports", "organizations"
@@ -1361,10 +1381,6 @@
add_foreign_key "payments", "events"
add_foreign_key "people", "users", column: "created_by_id"
add_foreign_key "people", "users", column: "updated_by_id"
- add_foreign_key "person_form_form_fields", "form_fields"
- add_foreign_key "person_form_form_fields", "person_forms"
- add_foreign_key "person_forms", "forms"
- add_foreign_key "person_forms", "people"
add_foreign_key "quotable_item_quotes", "quotes"
add_foreign_key "quotes", "workshops"
add_foreign_key "report_form_field_answers", "answer_options"
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index afae60b0e..a5f20f0d1 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -878,14 +878,14 @@
unless Form.standalone.exists?(name: "Short Event Registration")
FormBuilderService.new(
name: "Short Event Registration",
- sections: %i[person_identifier consent event_feedback scholarship]
+ sections: %i[person_identifier consent marketing scholarship]
).call
end
unless Form.standalone.exists?(name: "Extended Event Registration")
FormBuilderService.new(
name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
).call
end
diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb
index 53bf817a9..f1aee8a8b 100644
--- a/spec/requests/events/registrations_spec.rb
+++ b/spec/requests/events/registrations_spec.rb
@@ -150,7 +150,7 @@
before do
form = FormBuilderService.new(
name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
).call
EventForm.create!(event: event, form: form, role: "registration")
form = event.registration_form
diff --git a/spec/services/form_builder_service_spec.rb b/spec/services/form_builder_service_spec.rb
index 279147a5c..a23667ece 100644
--- a/spec/services/form_builder_service_spec.rb
+++ b/spec/services/form_builder_service_spec.rb
@@ -59,21 +59,21 @@
end
end
- context "event_feedback section" do
- let(:form) { described_class.new(name: "Test", sections: %i[event_feedback]).call }
+ context "payment section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[payment]).call }
- it "creates feedback fields" do
+ it "creates payment fields" do
keys = form.form_fields.pluck(:field_key).compact
- expect(keys).to include("referral_source", "training_motivation", "interested_in_more")
+ expect(keys).to include("number_of_attendees", "payment_method")
end
end
- context "payment section" do
- let(:form) { described_class.new(name: "Test", sections: %i[payment]).call }
+ context "marketing section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[marketing]).call }
- it "creates payment fields" do
+ it "creates marketing fields" do
keys = form.form_fields.pluck(:field_key).compact
- expect(keys).to include("number_of_attendees", "payment_method")
+ expect(keys).to include("referral_source", "training_motivation", "interested_in_more")
end
end
@@ -90,7 +90,7 @@
let(:form) do
described_class.new(
name: "Short Event Registration",
- sections: %i[person_identifier consent event_feedback scholarship]
+ sections: %i[person_identifier consent marketing scholarship]
).call
end
@@ -109,11 +109,11 @@
let(:form) do
described_class.new(
name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
).call
end
- it "creates fields from all 8 sections" do
+ it "creates fields from all sections" do
keys = form.form_fields.pluck(:field_key).compact
expect(keys).to include(
"first_name", "nickname", "racial_ethnic_identity",
diff --git a/spec/system/event_registration_show_spec.rb b/spec/system/event_registration_show_spec.rb
index d3296fb9c..e30d5aada 100644
--- a/spec/system/event_registration_show_spec.rb
+++ b/spec/system/event_registration_show_spec.rb
@@ -52,7 +52,7 @@
it "links to form show with slug param" do
FormBuilderService.new(
name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
).call.tap { |form| EventForm.create!(event: event, form: form, role: "registration") }
form = event.registration_form
form.form_submissions.create!(person: user.person)
diff --git a/spec/system/public_registration_new_spec.rb b/spec/system/public_registration_new_spec.rb
index ca2cb8b01..a14270938 100644
--- a/spec/system/public_registration_new_spec.rb
+++ b/spec/system/public_registration_new_spec.rb
@@ -16,7 +16,7 @@
driven_by(:rack_test)
form = FormBuilderService.new(
name: "Extended Event Registration",
- sections: %i[person_identifier person_contact_info person_background professional_info event_feedback scholarship payment consent]
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
).call
EventForm.create!(event: event, form: form, role: "registration")
end
From 43e8cdb4733a7390217c9c2d13bb3515831f629d Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 9 Mar 2026 07:44:12 -0400
Subject: [PATCH 12/14] 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
---
.../events/public_registrations_controller.rb | 39 +++++++++--
app/controllers/forms_controller.rb | 17 ++---
.../controllers/chip_select_controller.js | 32 +++++++++
app/models/form.rb | 3 +-
app/models/form_field.rb | 1 +
app/services/form_builder_service.rb | 18 ++++-
app/views/forms/_form_field_fields.html.erb | 57 +++++++++-------
app/views/forms/edit.html.erb | 66 +++++++++----------
app/views/forms/show.html.erb | 13 ++--
...309120000_add_visibility_to_form_fields.rb | 5 ++
db/schema.rb | 3 +-
11 files changed, 170 insertions(+), 84 deletions(-)
create mode 100644 app/frontend/javascript/controllers/chip_select_controller.js
create mode 100644 db/migrate/20260309120000_add_visibility_to_form_fields.rb
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index 274e01fb8..966bf8ecc 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -109,8 +109,9 @@ def scholarship_mode?
def visible_form_fields
scope = @form.form_fields
+
unless scholarship_mode?
- scope = scope.where.not(field_group: "scholarship")
+ scope = scope.where.not(visibility: :scholarship_only)
end
person = current_user&.person
@@ -119,14 +120,25 @@ def visible_form_fields
known_keys = person_known_field_keys(person)
if known_keys.any?
known_ids = @form.form_fields
- .where(field_group: %w[person_identifier person_contact_info], field_key: known_keys)
+ .where(visibility: :logged_out_only, field_key: known_keys)
.ids
scope = scope.where.not(id: known_ids) if known_ids.any?
end
- # Background fields (e.g. ethnicity) are always hidden for logged-in users
- # since Person doesn't store these — they're collected once per event
- scope = scope.where.not(field_group: "background")
+ # Hide logged_out_only headers when all their non-header fields are hidden
+ logged_out_groups = @form.form_fields.where(visibility: :logged_out_only)
+ .where.not(answer_type: :group_header)
+ .pluck(:field_group).uniq.compact
+ logged_out_groups.each do |group|
+ group_field_ids = @form.form_fields.where(field_group: group, visibility: :logged_out_only)
+ .where.not(answer_type: :group_header).ids
+ if group_field_ids.any? && known_keys.any? && (group_field_ids - scope.where(id: group_field_ids).ids).any?
+ remaining = scope.where(id: group_field_ids).ids
+ if remaining.empty?
+ scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :logged_out_only)
+ end
+ end
+ end
end
if @form.hide_answered_form_questions?
@@ -134,10 +146,23 @@ def visible_form_fields
if existing_submission
answered_field_ids = existing_submission.form_answers
.joins(:form_field)
- .where(form_fields: { field_group: %w[professional marketing] })
+ .where(form_fields: { visibility: :answers_on_file })
.where.not(text: [ nil, "" ])
.pluck(:form_field_id)
- scope = scope.where.not(id: answered_field_ids) if answered_field_ids.any?
+ if answered_field_ids.any?
+ scope = scope.where.not(id: answered_field_ids)
+
+ # Hide section headers when all their non-header fields are answered
+ answered_groups = @form.form_fields.where(id: answered_field_ids)
+ .pluck(:field_group).uniq.compact
+ answered_groups.each do |group|
+ group_field_ids = @form.form_fields.where(field_group: group, visibility: :answers_on_file)
+ .where.not(answer_type: :group_header).ids
+ if group_field_ids.any? && (group_field_ids - answered_field_ids).empty?
+ scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :answers_on_file)
+ end
+ end
+ end
end
end
end
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index 98effab3d..f23bc4445 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -98,20 +98,17 @@ def set_form
def preview_form_fields
scope = @form.form_fields
- form_sections = (@form.sections || []).map(&:to_s)
- if params[:preview_scholarship].present?
- # Scholarship mode: show scholarship fields (and keep payment visible)
- elsif form_sections.include?("scholarship")
- scope = scope.where.not(field_group: "scholarship")
+ unless params[:preview_scholarship].present?
+ scope = scope.where.not(visibility: :scholarship_only)
end
- if params[:preview_logged_in].present? && @form.hide_answered_person_questions?
- scope = scope.where.not(field_group: %w[person_identifier person_contact_info background])
+ if params[:preview_logged_in].present?
+ scope = scope.where.not(visibility: :logged_out_only)
end
- if params[:preview_answered].present? && @form.hide_answered_form_questions?
- scope = scope.where.not(field_group: %w[professional marketing])
+ if params[:preview_answered].present?
+ scope = scope.where.not(visibility: :answers_on_file)
end
scope.reorder(position: :asc)
@@ -122,7 +119,7 @@ def form_params
:name, :hide_answered_person_questions, :hide_answered_form_questions,
form_fields_attributes: [
:id, :question, :answer_type, :is_required, :instructional_hint,
- :field_key, :field_group, :position, :_destroy
+ :field_key, :field_group, :position, :visibility, :_destroy
]
)
end
diff --git a/app/frontend/javascript/controllers/chip_select_controller.js b/app/frontend/javascript/controllers/chip_select_controller.js
new file mode 100644
index 000000000..becd88e56
--- /dev/null
+++ b/app/frontend/javascript/controllers/chip_select_controller.js
@@ -0,0 +1,32 @@
+import { Controller } from "@hotwired/stimulus"
+
+/**
+ * Dynamically styles a as a colored chip based on its current value.
+ *
+ * Usage:
+ *
+ */
+export default class extends Controller {
+ static values = { styles: Object }
+
+ connect() {
+ this.update()
+ }
+
+ update() {
+ if (this._allClasses) {
+ this.element.classList.remove(...this._allClasses)
+ }
+ const classes = this.stylesValue[this.element.value]
+ if (classes) {
+ this.element.classList.add(...classes.split(" "))
+ }
+ }
+
+ stylesValueChanged() {
+ this._allClasses = Object.values(this.stylesValue).join(" ").split(" ")
+ this.update()
+ }
+}
diff --git a/app/models/form.rb b/app/models/form.rb
index f8c9b02d7..e88c9ef4d 100644
--- a/app/models/form.rb
+++ b/app/models/form.rb
@@ -9,7 +9,8 @@ class Form < ApplicationRecord
has_many :events, through: :event_forms
# Nested attributes
- accepts_nested_attributes_for :form_fields, allow_destroy: true
+ accepts_nested_attributes_for :form_fields, allow_destroy: true,
+ reject_if: proc { |attrs| attrs["question"].blank? && attrs["id"].blank? }
scope :scholarship_application, -> { where(scholarship_application: true) }
scope :standalone, -> { where(owner_id: nil, owner_type: nil) }
diff --git a/app/models/form_field.rb b/app/models/form_field.rb
index 412b87586..101f36453 100644
--- a/app/models/form_field.rb
+++ b/app/models/form_field.rb
@@ -12,6 +12,7 @@ class FormField < ApplicationRecord
# Enum
enum :status, [ :inactive, :active ]
+ enum :visibility, [ :always_ask, :scholarship_only, :logged_out_only, :answers_on_file ]
# TODO: Rails 6.1 requires enums to be symbols
# need additional refactoring in methods that call answer_type & answer_datatype to account for change to enum
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index fc788e383..035b63666 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -98,6 +98,18 @@ def self.update_sections!(form, new_sections)
private
+ GROUP_VISIBILITY = {
+ "person_identifier" => :logged_out_only,
+ "person_contact_info" => :logged_out_only,
+ "background" => :logged_out_only,
+ "professional" => :answers_on_file,
+ "marketing" => :answers_on_file,
+ "payment" => :answers_on_file,
+ "scholarship" => :scholarship_only,
+ "consent" => :answers_on_file,
+ "post_event_feedback" => :answers_on_file
+ }.freeze
+
def add_header(form, position, title, group:)
position += 1
form.form_fields.create!(
@@ -107,7 +119,8 @@ def add_header(form, position, title, group:)
position: position,
is_required: false,
field_key: nil,
- field_group: group
+ field_group: group,
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask)
)
position
end
@@ -123,7 +136,8 @@ def add_field(form, position, question, answer_type, key:, group:, required: tru
is_required: required,
instructional_hint: hint,
field_key: key,
- field_group: group
+ field_group: group,
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask)
)
if options.present?
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb
index 0a101b16a..cdccf11f0 100644
--- a/app/views/forms/_form_field_fields.html.erb
+++ b/app/views/forms/_form_field_fields.html.erb
@@ -1,23 +1,21 @@
<%
field = f.object
- conditions = []
- form_sections = (@form.sections || []).map(&:to_s)
- if field.field_group == "scholarship" && form_sections.include?("scholarship")
- conditions << { label: "Scholarship-only", css: "bg-purple-100 text-purple-700" }
- end
+ visibility_options = [
+ [ "Always ask", "always_ask" ],
+ [ "Scholarship-only", "scholarship_only" ],
+ [ "Logged out only", "logged_out_only" ],
+ [ "Answers on file", "answers_on_file" ]
+ ]
- if %w[person_identifier person_contact_info background].include?(field.field_group) && @form.hide_answered_person_questions?
- conditions << { label: "Logged out only", css: "bg-emerald-100 text-emerald-700" }
- end
-
- if %w[professional marketing].include?(field.field_group) && @form.hide_answered_form_questions? && !field.group_header?
- conditions << { label: "Answers on file", css: "bg-amber-100 text-amber-700" }
- end
-
- indented = !field.group_header? && field.field_group.present?
+ visibility_styles = {
+ always_ask: "bg-white text-gray-600 border-gray-300",
+ scholarship_only: "bg-purple-100 text-purple-700 border-purple-200",
+ logged_out_only: "bg-emerald-100 text-emerald-700 border-emerald-200",
+ answers_on_file: "bg-amber-100 text-amber-700 border-amber-200"
+ }
%>
-data-field-group="<%= field.field_group %>"<% end %>
<% if field.group_header? %>data-group-header<% end %>>
@@ -30,9 +28,10 @@
<% if field.group_header? %>
- <% conditions.each do |c| %>
-
<%= c[:label] %>
- <% end %>
+ <%= f.select :visibility, visibility_options,
+ {},
+ class: "text-xs rounded-full border px-2 py-0.5 cursor-pointer",
+ data: { controller: "chip-select", chip_select_styles_value: visibility_styles, action: "change->chip-select#update" } %>
<%= field.question %>
<%= f.hidden_field :question %>
@@ -43,14 +42,26 @@
<% else %>
- <% conditions.each do |c| %>
- <%= c[:label] %>
- <% end %>
+ <%= f.select :visibility, visibility_options,
+ {},
+ class: "text-xs rounded-full border px-2 py-0.5 shrink-0 cursor-pointer",
+ data: { controller: "chip-select", chip_select_styles_value: visibility_styles, action: "change->chip-select#update" } %>
<%= f.text_field :question, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
- <%= f.select :answer_type,
- FormField.answer_types.keys.map { |t| [ t == "group_header" ? "Section header" : t.titleize.gsub("_", " "), t ] },
+ <%
+ type_order = %w[free_form_input_one_line free_form_input_paragraph multiple_choice_radio multiple_choice_checkbox no_user_input group_header]
+ type_labels = {
+ "group_header" => "Section header",
+ "free_form_input_one_line" => "One line",
+ "free_form_input_paragraph" => "Paragraph",
+ "multiple_choice_radio" => "Multiple choice radio",
+ "multiple_choice_checkbox" => "Multiple choice checkbox",
+ "no_user_input" => "Informational-only"
+ }
+ type_options = type_order.map { |t| [ type_labels[t] || t.titleize.gsub("_", " "), t ] }
+ %>
+ <%= f.select :answer_type, type_options,
{},
class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index eab53af00..1a8a3ab67 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -7,6 +7,19 @@
Edit: <%= @form.display_name %>
<%= form_with model: @form, class: "space-y-6" do |f| %>
+ <% if @form.errors.any? %>
+
+
+ <%= pluralize(@form.errors.count, "error") %> prevented this form from being saved:
+
+
+ <% @form.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+ <% end %>
+
Form Name
@@ -20,7 +33,7 @@
<%= f.check_box :hide_answered_form_questions, class: "rounded border-gray-300 text-blue-600" %>
- Hide any answered form questions
+ Hide any questions the user already answered
@@ -31,40 +44,27 @@
<%= @form_fields.size %> fields
- <%
- form_sections = (@form.sections || []).map(&:to_s)
- has_scholarship = form_sections.include?("scholarship")
- has_person = form_sections.include?("person_identifier") || form_sections.include?("person_contact_info") || form_sections.include?("person_background")
- has_professional = form_sections.include?("professional_info") || form_sections.include?("marketing")
- has_conditions = has_scholarship ||
- (has_person && @form.hide_answered_person_questions?) ||
- (has_professional && @form.hide_answered_form_questions?)
- %>
- <% if has_conditions %>
-
-
Conditional visibility
-
- <% if has_scholarship %>
-
- Scholarship-only
- Shown only when registering for scholarship
-
- <% end %>
- <% if has_person && @form.hide_answered_person_questions? %>
-
- Logged out only
- Hidden for logged-in users
-
- <% end %>
- <% if has_professional && @form.hide_answered_form_questions? %>
-
- Answers on file
- Hidden when info is already known
-
- <% end %>
+
+
Conditional visibility
+
+
+ Always ask
+ Always shown to all users
+
+
+ Scholarship-only
+ Shown only when registering for scholarship
+
+
+ Logged out only
+ Hidden for logged-in users
+
+
+ Answers on file
+ Hidden when info is already known
- <% end %>
+
<%= f.fields_for :form_fields, @form_fields do |ff| %>
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb
index 783fa8757..659622f31 100644
--- a/app/views/forms/show.html.erb
+++ b/app/views/forms/show.html.erb
@@ -17,11 +17,10 @@
<%
- form_sections = (@form.sections || []).map(&:to_s)
- has_scholarship = form_sections.include?("scholarship")
- has_person = form_sections.include?("person_identifier") || form_sections.include?("person_contact_info") || form_sections.include?("person_background")
- has_professional = form_sections.include?("professional_info") || form_sections.include?("marketing")
- has_conditions = has_scholarship || (has_person && @form.hide_answered_person_questions?) || (has_professional && @form.hide_answered_form_questions?)
+ has_scholarship = @form.form_fields.where(visibility: :scholarship_only).exists?
+ has_logged_out = @form.form_fields.where(visibility: :logged_out_only).exists?
+ has_answers_on_file = @form.form_fields.where(visibility: :answers_on_file).exists?
+ has_conditions = has_scholarship || (has_logged_out && @form.hide_answered_person_questions?) || (has_answers_on_file && @form.hide_answered_form_questions?)
preview_logged_in = params[:preview_logged_in].present?
preview_scholarship = params[:preview_scholarship].present?
@@ -33,7 +32,7 @@
<% if has_conditions %>
Toggle to simulate how the form appears under different conditions:
- <% if has_person && @form.hide_answered_person_questions? %>
+ <% if has_logged_out && @form.hide_answered_person_questions? %>
<%
logged_in_params = request.query_parameters.dup
if preview_logged_in
@@ -49,7 +48,7 @@
<% end %>
<% end %>
- <% if has_professional && @form.hide_answered_form_questions? %>
+ <% if has_answers_on_file && @form.hide_answered_form_questions? %>
<%
answered_params = request.query_parameters.dup
if preview_answered
diff --git a/db/migrate/20260309120000_add_visibility_to_form_fields.rb b/db/migrate/20260309120000_add_visibility_to_form_fields.rb
new file mode 100644
index 000000000..a6c7ed697
--- /dev/null
+++ b/db/migrate/20260309120000_add_visibility_to_form_fields.rb
@@ -0,0 +1,5 @@
+class AddVisibilityToFormFields < ActiveRecord::Migration[8.0]
+ def change
+ add_column :form_fields, :visibility, :integer, default: 0, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 81ea1c06b..73c4364e1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_08_120000) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_09_120000) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -535,6 +535,7 @@
t.string "question", null: false
t.integer "status", default: 1
t.datetime "updated_at", precision: nil, null: false
+ t.integer "visibility", default: 0, null: false
t.index ["field_group"], name: "index_form_fields_on_field_group"
t.index ["field_key"], name: "index_form_fields_on_field_key"
t.index ["form_id"], name: "index_form_fields_on_form_id"
From f44d66184093163188965137a5f1548c7e546adc Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 9 Mar 2026 07:44:46 -0400
Subject: [PATCH 13/14] 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
---
app/frontend/javascript/controllers/index.js | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index b1ca21084..8781b16f6 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -15,6 +15,9 @@ application.register("autosave", AutosaveController)
import CarouselController from "./carousel_controller"
application.register("carousel", CarouselController)
+import ChipSelectController from "./chip_select_controller"
+application.register("chip-select", ChipSelectController)
+
import CocoonController from "./cocoon_controller"
application.register("cocoon", CocoonController)
@@ -39,6 +42,9 @@ application.register("dropdown", DropdownController)
import FilePreviewController from "./file_preview_controller"
application.register("file-preview", FilePreviewController)
+import FormFieldsSortableController from "./form_fields_sortable_controller"
+application.register("form-fields-sortable", FormFieldsSortableController)
+
import InactiveToggleController from "./inactive_toggle_controller"
application.register("inactive-toggle", InactiveToggleController)
From 385b205fc86689c81598044b61e4f29e4b10d20d Mon Sep 17 00:00:00 2001
From: maebeale
Date: Mon, 9 Mar 2026 08:16:17 -0400
Subject: [PATCH 14/14] 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
---
.../events/public_registrations_controller.rb | 60 +++++++++++-----
app/controllers/forms_controller.rb | 2 +-
app/services/form_builder_service.rb | 9 ++-
app/views/forms/_form_field_fields.html.erb | 69 +++++++++----------
app/views/forms/edit.html.erb | 4 +-
...60309140000_add_one_time_to_form_fields.rb | 5 ++
db/schema.rb | 8 ++-
7 files changed, 95 insertions(+), 62 deletions(-)
create mode 100644 db/migrate/20260309140000_add_one_time_to_form_fields.rb
diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index 966bf8ecc..47dcb0fd7 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -142,25 +142,47 @@ def visible_form_fields
end
if @form.hide_answered_form_questions?
- existing_submission = @form.form_submissions.find_by(person: person)
- if existing_submission
- answered_field_ids = existing_submission.form_answers
- .joins(:form_field)
- .where(form_fields: { visibility: :answers_on_file })
- .where.not(text: [ nil, "" ])
- .pluck(:form_field_id)
- if answered_field_ids.any?
- scope = scope.where.not(id: answered_field_ids)
-
- # Hide section headers when all their non-header fields are answered
- answered_groups = @form.form_fields.where(id: answered_field_ids)
- .pluck(:field_group).uniq.compact
- answered_groups.each do |group|
- group_field_ids = @form.form_fields.where(field_group: group, visibility: :answers_on_file)
- .where.not(answer_type: :group_header).ids
- if group_field_ids.any? && (group_field_ids - answered_field_ids).empty?
- scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :answers_on_file)
- end
+ answered_field_ids = []
+
+ # One-time fields: hide if answered on ANY form submission for this person
+ one_time_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: true)
+ .where.not(answer_type: :group_header).ids
+ if one_time_field_ids.any?
+ answered_one_time = FormAnswer.joins(:form_submission)
+ .where(form_submissions: { person_id: person.id })
+ .where(form_field_id: one_time_field_ids)
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
+ answered_field_ids.concat(answered_one_time)
+ end
+
+ # Regular fields: hide if answered on forms within this event
+ event_form_ids = @event.forms.ids
+ event_submissions = FormSubmission.where(person: person, form_id: event_form_ids)
+ if event_submissions.exists?
+ regular_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: false)
+ .where.not(answer_type: :group_header).ids
+ if regular_field_ids.any?
+ answered_regular = FormAnswer.where(form_submission: event_submissions)
+ .where(form_field_id: regular_field_ids)
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
+ answered_field_ids.concat(answered_regular)
+ end
+ end
+
+ answered_field_ids.uniq!
+ if answered_field_ids.any?
+ scope = scope.where.not(id: answered_field_ids)
+
+ # Hide section headers when all their non-header fields are answered
+ answered_groups = @form.form_fields.where(id: answered_field_ids)
+ .pluck(:field_group).uniq.compact
+ answered_groups.each do |group|
+ group_field_ids = @form.form_fields.where(field_group: group, visibility: :answers_on_file)
+ .where.not(answer_type: :group_header).ids
+ if group_field_ids.any? && (group_field_ids - answered_field_ids).empty?
+ scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :answers_on_file)
end
end
end
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
index f23bc4445..f44e2f84b 100644
--- a/app/controllers/forms_controller.rb
+++ b/app/controllers/forms_controller.rb
@@ -119,7 +119,7 @@ def form_params
:name, :hide_answered_person_questions, :hide_answered_form_questions,
form_fields_attributes: [
:id, :question, :answer_type, :is_required, :instructional_hint,
- :field_key, :field_group, :position, :visibility, :_destroy
+ :field_key, :field_group, :position, :visibility, :one_time, :_destroy
]
)
end
diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb
index 035b63666..bcc35fe57 100644
--- a/app/services/form_builder_service.rb
+++ b/app/services/form_builder_service.rb
@@ -110,6 +110,9 @@ def self.update_sections!(form, new_sections)
"post_event_feedback" => :answers_on_file
}.freeze
+ # Groups where answers carry across all events (ask once ever)
+ ONE_TIME_GROUPS = %w[professional background].freeze
+
def add_header(form, position, title, group:)
position += 1
form.form_fields.create!(
@@ -120,7 +123,8 @@ def add_header(form, position, title, group:)
is_required: false,
field_key: nil,
field_group: group,
- visibility: GROUP_VISIBILITY.fetch(group, :always_ask)
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask),
+ one_time: ONE_TIME_GROUPS.include?(group)
)
position
end
@@ -137,7 +141,8 @@ def add_field(form, position, question, answer_type, key:, group:, required: tru
instructional_hint: hint,
field_key: key,
field_group: group,
- visibility: GROUP_VISIBILITY.fetch(group, :always_ask)
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask),
+ one_time: ONE_TIME_GROUPS.include?(group)
)
if options.present?
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb
index cdccf11f0..2e9c275cf 100644
--- a/app/views/forms/_form_field_fields.html.erb
+++ b/app/views/forms/_form_field_fields.html.erb
@@ -32,49 +32,44 @@
{},
class: "text-xs rounded-full border px-2 py-0.5 cursor-pointer",
data: { controller: "chip-select", chip_select_styles_value: visibility_styles, action: "change->chip-select#update" } %>
- <%= field.question %>
- <%= f.hidden_field :question %>
+ <%= f.text_field :question, class: "flex-1 text-lg font-semibold text-gray-800 rounded border-gray-300 shadow-sm px-2 py-1" %>
<%= link_to_remove_association "Remove", f,
class: "text-sm text-gray-400 hover:text-red-600 underline" %>
<% else %>
-
-
- <%= f.select :visibility, visibility_options,
- {},
- class: "text-xs rounded-full border px-2 py-0.5 shrink-0 cursor-pointer",
- data: { controller: "chip-select", chip_select_styles_value: visibility_styles, action: "change->chip-select#update" } %>
- <%= f.text_field :question, class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
-
-
- <%
- type_order = %w[free_form_input_one_line free_form_input_paragraph multiple_choice_radio multiple_choice_checkbox no_user_input group_header]
- type_labels = {
- "group_header" => "Section header",
- "free_form_input_one_line" => "One line",
- "free_form_input_paragraph" => "Paragraph",
- "multiple_choice_radio" => "Multiple choice radio",
- "multiple_choice_checkbox" => "Multiple choice checkbox",
- "no_user_input" => "Informational-only"
- }
- type_options = type_order.map { |t| [ type_labels[t] || t.titleize.gsub("_", " "), t ] }
- %>
- <%= f.select :answer_type, type_options,
- {},
- class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
-
-
-
- <%= f.check_box :is_required, class: "rounded border-gray-300 text-blue-600" %>
- Required
-
-
-
- <%= link_to_remove_association "Remove", f,
- class: "text-sm text-gray-400 hover:text-red-600 underline" %>
-
+
+ <%= f.select :visibility, visibility_options,
+ {},
+ class: "text-xs rounded-full border px-2 py-0.5 shrink-0 cursor-pointer",
+ data: { controller: "chip-select", chip_select_styles_value: visibility_styles, action: "change->chip-select#update" } %>
+ <%= f.text_field :question, class: "min-w-0 flex-[3_1_10rem] rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+ <%
+ type_order = %w[free_form_input_one_line free_form_input_paragraph multiple_choice_radio multiple_choice_checkbox no_user_input group_header]
+ type_labels = {
+ "group_header" => "Section header",
+ "free_form_input_one_line" => "One line",
+ "free_form_input_paragraph" => "Paragraph",
+ "multiple_choice_radio" => "Multiple choice radio",
+ "multiple_choice_checkbox" => "Multiple choice checkbox",
+ "no_user_input" => "Informational-only"
+ }
+ type_options = type_order.map { |t| [ type_labels[t] || t.titleize.gsub("_", " "), t ] }
+ %>
+ <%= f.select :answer_type, type_options,
+ {},
+ class: "flex-[2_1_11rem] rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
+
+ <%= f.check_box :is_required, class: "rounded border-gray-300 text-blue-600" %>
+ Required
+
+
+ <%= f.check_box :one_time, class: "rounded border-gray-300 text-amber-600" %>
+ One-time
+
+ <%= link_to_remove_association "Remove", f,
+ class: "text-sm text-gray-400 hover:text-red-600 underline shrink-0 ml-auto" %>
<% end %>
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
index 1a8a3ab67..1c4aa6951 100644
--- a/app/views/forms/edit.html.erb
+++ b/app/views/forms/edit.html.erb
@@ -61,7 +61,7 @@
Answers on file
- Hidden when info is already known
+ Hidden when answered on this event's forms; if "One-time", hidden when answered on any form
@@ -74,6 +74,8 @@
<%= link_to_add_association "+ Add field", f, :form_fields,
+ data: { association_insertion_node: "[data-controller='form-fields-sortable']",
+ association_insertion_method: "append" },
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
diff --git a/db/migrate/20260309140000_add_one_time_to_form_fields.rb b/db/migrate/20260309140000_add_one_time_to_form_fields.rb
new file mode 100644
index 000000000..8e484bc73
--- /dev/null
+++ b/db/migrate/20260309140000_add_one_time_to_form_fields.rb
@@ -0,0 +1,5 @@
+class AddOneTimeToFormFields < ActiveRecord::Migration[8.0]
+ def change
+ add_column :form_fields, :one_time, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 73c4364e1..d280bdab6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_09_120000) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_09_140000) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -530,6 +530,7 @@
t.integer "form_id", null: false
t.text "instructional_hint"
t.boolean "is_required", default: true
+ t.boolean "one_time", default: false, null: false
t.integer "parent_id"
t.integer "position"
t.string "question", null: false
@@ -628,6 +629,9 @@
t.text "email_body_html"
t.text "email_body_text"
t.text "email_subject"
+ t.datetime "error_at"
+ t.string "error_class"
+ t.text "error_message"
t.string "kind", null: false
t.integer "noticeable_id"
t.string "noticeable_type"
@@ -835,7 +839,7 @@
t.string "type"
t.datetime "updated_at", precision: nil, null: false
t.integer "windows_type_id", null: false
- t.integer "workshop_id", null: false
+ t.integer "workshop_id"
t.string "workshop_name"
t.index ["created_by_id"], name: "index_reports_on_created_by_id"
t.index ["organization_id"], name: "index_reports_on_organization_id"