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 @@
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 %> - <% 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" %> +
+
+ +
+
+ +
+
+
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| %> +
+
+ + <%= f.text_field :name, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %> +
+ +
+ + +
+
+ +
+
+

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" %> +
+ +
+
+ + + + + + + + + + + <% @forms.each do |form| %> + + + + + + + + <% end %> + +
NameFieldsEventsSubmissions
<%= 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" %> +
+
+ 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| %> +
+ + +
+ +
+ Select sections to include: +
+ <% FormBuilderService::SECTIONS.each do |key, section| %> + + <% end %> +
+
+ +
+ +
+ +
+ <%= 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| %> + + <% 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" %> +
+
+ +
+
+ <%= 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" %> -
-
- -
-
- -
-
-
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" %>
-
+
@@ -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 + */ +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 %> +
@@ -20,7 +33,7 @@
@@ -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" %> -
-
- -
-
- <%= 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" %> + + + <%= 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"