From c1e9e037ae5c9620c83a86fb4f4c264f5d5f44f0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 14:45:20 -0500 Subject: [PATCH 01/20] 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 a8a70284f..70c7a99dc 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -147,7 +147,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 a5f799297..989612184 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -127,7 +127,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 @@ -256,6 +256,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 74a4e25b39f39b0d33a8d62413ccf9a82ba3f9ce Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 16:49:58 -0500 Subject: [PATCH 02/20] 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 989612184..d2681efe2 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -256,7 +256,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 06de38a18..a5d060677 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 5ce0f5b8159b7f1e2b17b8e7d14f58b0eb622138 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 15:41:01 -0400 Subject: [PATCH 03/20] 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 | 34 ++- 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 | 30 ++- 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, 727 insertions(+), 538 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 70c7a99dc..30a80f8c9 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -83,18 +83,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 = if registration&.scholarship_requested? - @form.form_fields.where(status: :active).where.not(field_group: "payment").reorder(position: :asc) - else - @form.form_fields.where(status: :active).where.not(field_group: "scholarship").reorder(position: :asc) - end - @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 @@ -113,12 +109,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 c043b3f52..7c65b82bd 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 556994930..aa99f91d8 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 d2681efe2..aa942f07f 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -34,12 +34,12 @@ def call existing.update!(status: "registered") send_notifications(existing) end - 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) @@ -241,20 +241,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" @@ -265,8 +265,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 a807f6c01..55f653241 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -45,7 +45,7 @@ <% all_event_form_ids = @event_registrations.flat_map { |er| er.event.event_forms.map(&:form_id) }.uniq %> - <% submitted_pairs = all_event_form_ids.any? ? PersonForm.where(form_id: all_event_form_ids, person_id: @event_registrations.map(&:registrant_id).uniq).pluck(:person_id, :form_id).to_set : Set.new %> + <% submitted_pairs = all_event_form_ids.any? ? FormSubmission.where(form_id: all_event_form_ids, person_id: @event_registrations.map(&:registrant_id).uniq).pluck(:person_id, :form_id).to_set : Set.new %> <% @event_registrations.each do |event_registration| %> 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 3b4c0707f..5d4c42e88 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -14,7 +14,7 @@ <% event_form_ids = @event.form_ids %> - <% form_submissions = event_form_ids.any? ? PersonForm.joins(:form).where(form_id: event_form_ids, person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at, Arel.sql("forms.name")).group_by(&:first).transform_values { |rows| rows.map { |_, ts, name| [ name, ts ] } } : {} %> + <% form_submissions = event_form_ids.any? ? FormSubmission.joins(:form).where(form_id: event_form_ids, person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at, Arel.sql("forms.name")).group_by(&:first).transform_values { |rows| rows.map { |_, ts, name| [ name, ts ] } } : {} %> <% 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 317d82d4f..4d0fd4c83 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,6 +99,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 eeb12772e..aae287710 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -472,14 +472,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! -end - -unless Form.standalone.exists?(name: ExtendedEventRegistrationFormBuilder::FORM_NAME) - ExtendedEventRegistrationFormBuilder.build_standalone! -end - -unless Form.standalone.exists?(name: ScholarshipApplicationFormBuilder::FORM_NAME) - ScholarshipApplicationFormBuilder.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: "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 e9c9c3445..0b4f9f64c 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -919,9 +919,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 ddd82c312..56f74bc11 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -151,9 +151,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 7aafe915b..fa65978cf 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 061d7b9224853425df6c218e5c5387869cfe2172 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:04:42 -0400 Subject: [PATCH 04/20] 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 aae287710..ccd14c0e9 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -470,26 +470,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 0b4f9f64c..3e88639d0 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -917,6 +917,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 477120978090977ab923f8d46c53b5f0f2b63fb1 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:07:35 -0400 Subject: [PATCH 05/20] 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 de30f484854a9a301997190e9235e85a8d4fb9a1 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:10:08 -0400 Subject: [PATCH 06/20] 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 4d0fd4c83..63af75467 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,7 +99,7 @@ end resources :comments, only: [ :index, :create ] end - resources :forms, except: :show do + resources :forms do member do patch :reorder_field end From 0ee8f23706076dfbf430e091a111a51ff8950d59 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:11:26 -0400 Subject: [PATCH 07/20] 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 95058890737bac801f60a2380e08380f37df10f5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:13:19 -0400 Subject: [PATCH 08/20] 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 586016fd0790908bcfd317aa641d8e39026ab0b5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:15:55 -0400 Subject: [PATCH 09/20] 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 63af75467..e625be416 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,8 @@ resources :forms do member do patch :reorder_field + get :edit_sections + patch :update_sections end end resources :events do From 191ad2fdbed411834bdcf9a39aabb35f65be1f74 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 8 Mar 2026 22:17:39 -0400 Subject: [PATCH 10/20] 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 472a159982297e76862c212061b97d7fce26799b Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 01:05:17 -0400 Subject: [PATCH 11/20] 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 | 50 +++++++------ 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, 417 insertions(+), 131 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 30a80f8c9..39c74a19f 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -110,26 +110,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 @@ -138,6 +146,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 e625be416..431ab72dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -102,6 +102,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 45964ab76..1fe2cd769 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -494,6 +494,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: :medium @@ -532,13 +543,25 @@ t.index ["form_id"], name: "index_form_fields_on_form_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" end @@ -752,25 +775,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" @@ -1362,10 +1366,14 @@ 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" @@ -1377,10 +1385,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 3e88639d0..f8ec9803f 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -921,14 +921,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 56f74bc11..8c1ffbf5c 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -153,7 +153,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 44fe0098eb0d193b43b22ec798ead48506bd5f61 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 07:44:12 -0400 Subject: [PATCH 12/20] 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 | 1 + 11 files changed, 169 insertions(+), 83 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 39c74a19f..c9f3cdcd7 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -110,8 +110,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 @@ -120,14 +121,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? @@ -135,10 +147,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 1fe2cd769..4d479e97f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -538,6 +538,7 @@ t.string "question" 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 796d238cb8d30c17754ef13f8effe044c4d19e79 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 07:44:46 -0400 Subject: [PATCH 13/20] 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 48d8f2f28..503194f6d 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) @@ -42,6 +45,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 021ef78a20ebe1ca4006bfb43ebd7159bf075214 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 08:16:17 -0400 Subject: [PATCH 14/20] 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 | 3 +- 7 files changed, 91 insertions(+), 61 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 c9f3cdcd7..252f24ce1 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -143,25 +143,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 4d479e97f..8f475740e 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 @@ -533,6 +533,7 @@ t.integer "form_id" 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" From a067fd3bc08b65d567b18b59976969ec13667d25 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 10 Mar 2026 07:14:59 -0400 Subject: [PATCH 15/20] Rename form_answers.text to question_answer for clarity Co-Authored-By: Claude Opus 4.6 --- app/controllers/events/public_registrations_controller.rb | 4 ++-- app/helpers/event_helper.rb | 8 ++++---- app/models/form_answer.rb | 2 +- .../event_registration_services/public_registration.rb | 2 +- ...0000_rename_text_to_question_answer_in_form_answers.rb | 5 +++++ db/schema.rb | 4 ++-- spec/factories/form_answers.rb | 2 +- 7 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20260309150000_rename_text_to_question_answer_in_form_answers.rb diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 252f24ce1..5ad0c7f58 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -152,7 +152,7 @@ def visible_form_fields 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, "" ]) + .where.not(question_answer: [ nil, "" ]) .pluck(:form_field_id) answered_field_ids.concat(answered_one_time) end @@ -166,7 +166,7 @@ def visible_form_fields if regular_field_ids.any? answered_regular = FormAnswer.where(form_submission: event_submissions) .where(form_field_id: regular_field_ids) - .where.not(text: [ nil, "" ]) + .where.not(question_answer: [ nil, "" ]) .pluck(:form_field_id) answered_field_ids.concat(answered_regular) end diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 1df91f994..958c59114 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -78,14 +78,14 @@ def event_profile_button(event, truncate_at: nil, subtitle: nil, data: {}) end def display_response_text(field, response) - return tag.span("—", class: "text-gray-400") if response&.text.blank? + return tag.span("—", class: "text-gray-400") if response&.question_answer.blank? if field.field_key == "primary_service_area" - response.text.split(", ").map { |id| Sector.find_by(id: id)&.name }.compact.join(", ").presence || response.text + response.question_answer.split(", ").map { |id| Sector.find_by(id: id)&.name }.compact.join(", ").presence || response.question_answer elsif field.field_key.in?(%w[workshop_environments client_life_experiences primary_age_group]) - response.text.split(", ").map { |id| Category.find_by(id: id)&.name }.compact.join(", ").presence || response.text + response.question_answer.split(", ").map { |id| Category.find_by(id: id)&.name }.compact.join(", ").presence || response.question_answer else - response.text + response.question_answer end end end diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb index 07e1eeefe..b82d32575 100644 --- a/app/models/form_answer.rb +++ b/app/models/form_answer.rb @@ -3,6 +3,6 @@ class FormAnswer < ApplicationRecord belongs_to :form_submission def name - "#{question_text.presence || form_field&.question}: #{text}" + "#{question_text.presence || form_field&.question}: #{question_answer}" end end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index aa942f07f..d056404bb 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -266,7 +266,7 @@ def save_form_answers(submission) end record = submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(text: text, question_text: field.question) + record.update!(question_answer: text, question_text: field.question) end end diff --git a/db/migrate/20260309150000_rename_text_to_question_answer_in_form_answers.rb b/db/migrate/20260309150000_rename_text_to_question_answer_in_form_answers.rb new file mode 100644 index 000000000..340e7ec7e --- /dev/null +++ b/db/migrate/20260309150000_rename_text_to_question_answer_in_form_answers.rb @@ -0,0 +1,5 @@ +class RenameTextToQuestionAnswerInFormAnswers < ActiveRecord::Migration[8.0] + def change + rename_column :form_answers, :text, :question_answer + end +end diff --git a/db/schema.rb b/db/schema.rb index 8f475740e..8b2c05ab3 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_140000) do +ActiveRecord::Schema[8.1].define(version: 2026_03_09_150000) 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 @@ -498,8 +498,8 @@ t.datetime "created_at", null: false t.integer "form_field_id" t.bigint "form_submission_id" + t.text "question_answer" 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" diff --git a/spec/factories/form_answers.rb b/spec/factories/form_answers.rb index ce326ccd1..d5083f46e 100644 --- a/spec/factories/form_answers.rb +++ b/spec/factories/form_answers.rb @@ -2,7 +2,7 @@ factory :form_answer do association :form_submission association :form_field - text { Faker::Lorem.sentence } + question_answer { Faker::Lorem.sentence } question_text { nil } end end From 652025b79f1da7cac65a54c643a117814716754a Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 11 Mar 2026 08:12:42 -0400 Subject: [PATCH 16/20] Consolidate form builder migrations and rename columns for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse four separate migrations into a single consolidation migration. Rename columns across form_fields, form_answers, and form_submissions to use clearer, more consistent names (e.g. field_key → field_identifier, question → name, answer_datatype → input_type). Add NOT NULL constraints to match model associations. Rename tables person_forms → form_submissions and person_form_form_fields → form_answers. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 5 +- .../events/public_registrations_controller.rb | 54 +-- app/controllers/forms_controller.rb | 4 +- .../form_fields_sortable_controller.js | 12 +- app/helpers/event_helper.rb | 12 +- app/models/form.rb | 2 +- app/models/form_answer.rb | 2 +- app/models/form_field.rb | 18 +- app/models/report_form_field_answer.rb | 2 +- app/models/user_form_form_field.rb | 2 +- .../public_registration.rb | 10 +- app/services/form_builder_service.rb | 44 +- .../public_registrations/_form_field.html.erb | 32 +- .../events/public_registrations/new.html.erb | 26 +- .../events/public_registrations/show.html.erb | 24 +- app/views/forms/_form_field_fields.html.erb | 8 +- app/views/forms/show.html.erb | 26 +- app/views/monthly_reports/_form.html.erb | 4 +- .../_report_form_field_answers.html.erb | 24 +- app/views/monthly_reports/edit.html.erb | 4 +- app/views/monthly_reports/show.html.erb | 2 +- app/views/reports/edit.html.erb | 4 +- app/views/reports/show.html.erb | 2 +- app/views/shared/_form_field.html.erb | 34 +- .../_report_form_field_answers.html.erb | 24 +- .../shared/_user_form_form_fields.html.erb | 2 +- .../_report_form_field_answer_fields.html.erb | 12 +- app/views/workshop_logs/show.html.erb | 2 +- ...20260308120000_consolidate_form_builder.rb | 25 - ...309120000_add_visibility_to_form_fields.rb | 5 - ...60309140000_add_one_time_to_form_fields.rb | 5 - ...text_to_question_answer_in_form_answers.rb | 5 - ...20260310113404_consolidate_form_builder.rb | 44 ++ db/schema.rb | 455 +++++++++--------- db/seeds/dummy_dev_seeds.rb | 2 +- spec/factories/form_answers.rb | 4 +- spec/factories/form_fields.rb | 4 +- spec/models/form_field_spec.rb | 8 +- .../public_registration_spec.rb | 11 +- spec/services/form_builder_service_spec.rb | 20 +- 40 files changed, 494 insertions(+), 491 deletions(-) delete mode 100644 db/migrate/20260308120000_consolidate_form_builder.rb delete mode 100644 db/migrate/20260309120000_add_visibility_to_form_fields.rb delete mode 100644 db/migrate/20260309140000_add_one_time_to_form_fields.rb delete mode 100644 db/migrate/20260309150000_rename_text_to_question_answer_in_form_answers.rb create mode 100644 db/migrate/20260310113404_consolidate_form_builder.rb diff --git a/CLAUDE.md b/CLAUDE.md index 5f5220e75..0132abb6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,8 +168,9 @@ bundle exec bundle-audit check --update ## Migrations -- Name migration files using **UTC timestamps** (e.g., `20260228143000`), not sequential numbers (e.g., `20260228000007`) -- Multiple branches adding migrations on the same date will collide if they use sequential numbering +- Name migration files using **real UTC timestamps** with the current time (e.g., `20260228143052` for 14:30:52 UTC), not padded zeros (e.g., `20260228140000`) or sequential numbers (e.g., `20260228000007`) +- The last 6 digits are HHMMSS — use the actual current time, not `000000` or rounded values +- Multiple branches adding migrations on the same date will collide if they use sequential or zero-padded numbering ## Git diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index 5ad0c7f58..12e7e7472 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -118,25 +118,25 @@ def visible_form_fields person = current_user&.person if person if @form.hide_answered_person_questions? - known_keys = person_known_field_keys(person) - if known_keys.any? + known_identifiers = person_known_identifiers(person) + if known_identifiers.any? known_ids = @form.form_fields - .where(visibility: :logged_out_only, field_key: known_keys) + .where(visibility: :logged_out_only, field_identifier: known_identifiers) .ids scope = scope.where.not(id: known_ids) if known_ids.any? end # 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 + logged_out_sections = @form.form_fields.where(visibility: :logged_out_only) + .where.not(answer_type: :group_header) + .pluck(:section).uniq.compact + logged_out_sections.each do |sect| + section_field_ids = @form.form_fields.where(section: sect, visibility: :logged_out_only) + .where.not(answer_type: :group_header).ids + if section_field_ids.any? && known_identifiers.any? && (section_field_ids - scope.where(id: section_field_ids).ids).any? + remaining = scope.where(id: section_field_ids).ids if remaining.empty? - scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :logged_out_only) + scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :logged_out_only) end end end @@ -152,7 +152,7 @@ def visible_form_fields answered_one_time = FormAnswer.joins(:form_submission) .where(form_submissions: { person_id: person.id }) .where(form_field_id: one_time_field_ids) - .where.not(question_answer: [ nil, "" ]) + .where.not(submitted_answer: [ nil, "" ]) .pluck(:form_field_id) answered_field_ids.concat(answered_one_time) end @@ -166,7 +166,7 @@ def visible_form_fields if regular_field_ids.any? answered_regular = FormAnswer.where(form_submission: event_submissions) .where(form_field_id: regular_field_ids) - .where.not(question_answer: [ nil, "" ]) + .where.not(submitted_answer: [ nil, "" ]) .pluck(:form_field_id) answered_field_ids.concat(answered_regular) end @@ -177,13 +177,13 @@ def visible_form_fields 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) + answered_sections = @form.form_fields.where(id: answered_field_ids) + .pluck(:section).uniq.compact + answered_sections.each do |sect| + section_field_ids = @form.form_fields.where(section: sect, visibility: :answers_on_file) + .where.not(answer_type: :group_header).ids + if section_field_ids.any? && (section_field_ids - answered_field_ids).empty? + scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :answers_on_file) end end end @@ -193,7 +193,7 @@ def visible_form_fields scope.reorder(position: :asc) end - def person_known_field_keys(person) + def person_known_identifiers(person) keys = [] keys << "first_name" if person.first_name.present? keys << "last_name" if person.last_name.present? @@ -229,14 +229,14 @@ def ensure_registerable def validate_required_fields(form_params) errors = {} fields = visible_form_fields - fields_by_key = fields.select { |f| f.field_key.present? }.index_by(&:field_key) + fields_by_identifier = fields.select { |f| f.field_identifier.present? }.index_by(&:field_identifier) fields.find_each do |field| next if field.group_header? value = form_params[field.id.to_s] - if field.is_required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?)) + if field.required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?)) errors[field.id] = "can't be blank" next end @@ -245,13 +245,13 @@ 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|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ + elsif field.field_identifier&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ errors[field.id] = "must be a valid email address" end end - confirm_field = fields_by_key["confirm_email"] - email_field = fields_by_key["primary_email"] + confirm_field = fields_by_identifier["confirm_email"] + email_field = fields_by_identifier["primary_email"] if confirm_field && email_field && errors[confirm_field.id].nil? confirm_value = form_params[confirm_field.id.to_s].to_s.strip email_value = form_params[email_field.id.to_s].to_s.strip diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index f44e2f84b..4d95a6709 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -118,8 +118,8 @@ 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, :visibility, :one_time, :_destroy + :id, :name, :answer_type, :required, :hint_text, + :field_identifier, :section, :position, :visibility, :one_time, :_destroy ] ) end diff --git a/app/frontend/javascript/controllers/form_fields_sortable_controller.js b/app/frontend/javascript/controllers/form_fields_sortable_controller.js index d06cccb4a..e56d93bf0 100644 --- a/app/frontend/javascript/controllers/form_fields_sortable_controller.js +++ b/app/frontend/javascript/controllers/form_fields_sortable_controller.js @@ -5,8 +5,8 @@ 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. + * Section-aware: dragging a group header also moves all fields + * that share the same section. * * Usage: * data-controller="form-fields-sortable" @@ -15,7 +15,7 @@ import Sortable from "sortablejs" * Each item needs: * data-sortable-id="" * data-sortable-handle (on the drag handle element) - * data-field-group="" (optional) + * data-section="" (optional) * data-group-header (on header rows) */ export default class extends Controller { @@ -35,13 +35,13 @@ export default class extends Controller { onEnd(event) { const { item } = event - // If a group header was moved, relocate its group members to follow it + // If a group header was moved, relocate its section members to follow it if (item.dataset.groupHeader !== undefined) { - const group = item.dataset.fieldGroup + const section = item.dataset.section const members = [] for (const el of this.element.children) { - if (el !== item && el.dataset.fieldGroup === group && el.dataset.groupHeader === undefined) { + if (el !== item && el.dataset.section === section && el.dataset.groupHeader === undefined) { members.push(el) } } diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 958c59114..e385fecb0 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -78,14 +78,14 @@ def event_profile_button(event, truncate_at: nil, subtitle: nil, data: {}) end def display_response_text(field, response) - return tag.span("—", class: "text-gray-400") if response&.question_answer.blank? + return tag.span("—", class: "text-gray-400") if response&.submitted_answer.blank? - if field.field_key == "primary_service_area" - response.question_answer.split(", ").map { |id| Sector.find_by(id: id)&.name }.compact.join(", ").presence || response.question_answer - elsif field.field_key.in?(%w[workshop_environments client_life_experiences primary_age_group]) - response.question_answer.split(", ").map { |id| Category.find_by(id: id)&.name }.compact.join(", ").presence || response.question_answer + if field.field_identifier == "primary_service_area" + response.submitted_answer.split(", ").map { |id| Sector.find_by(id: id)&.name }.compact.join(", ").presence || response.submitted_answer + elsif field.field_identifier.in?(%w[workshop_environments client_life_experiences primary_age_group]) + response.submitted_answer.split(", ").map { |id| Category.find_by(id: id)&.name }.compact.join(", ").presence || response.submitted_answer else - response.question_answer + response.submitted_answer end end end diff --git a/app/models/form.rb b/app/models/form.rb index e88c9ef4d..0e5657f9c 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -10,7 +10,7 @@ class Form < ApplicationRecord # Nested attributes accepts_nested_attributes_for :form_fields, allow_destroy: true, - reject_if: proc { |attrs| attrs["question"].blank? && attrs["id"].blank? } + reject_if: proc { |attrs| attrs["name"].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_answer.rb b/app/models/form_answer.rb index b82d32575..18549ab68 100644 --- a/app/models/form_answer.rb +++ b/app/models/form_answer.rb @@ -3,6 +3,6 @@ class FormAnswer < ApplicationRecord belongs_to :form_submission def name - "#{question_text.presence || form_field&.question}: #{question_answer}" + "#{question_name_when_answered.presence || form_field&.name}: #{submitted_answer}" end end diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 101f36453..92a0eaa07 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -8,14 +8,12 @@ class FormField < ApplicationRecord has_many :answer_options, through: :form_field_answer_options # Validations - validates_presence_of :question + validates_presence_of :name # 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 enum :answer_type, [ :free_form_input_one_line, :free_form_input_paragraph, @@ -25,7 +23,7 @@ class FormField < ApplicationRecord :group_header ] - enum :answer_datatype, [ + enum :input_type, [ :text_alphanumeric, :number_integer, :number_decimal, @@ -39,16 +37,12 @@ class FormField < ApplicationRecord scope :published, -> { where(status: "active") } # Methods - def name - question - end - def multiple_choice? - answer_type ? answer_type.include?("multiple choice") : false + answer_type ? answer_type.include?("multiple_choice") : false end def html_id - self.question.tr(" /#,')(.", "_").downcase + self.name.tr(" /#,')(.", "_").downcase end def html_input_type @@ -77,9 +71,7 @@ def html_input_type end end - # This one bellow should be removed and use - # html_input_type - def input_type + def form_helper_type case answer_type when "free-form input - one line" :text_field diff --git a/app/models/report_form_field_answer.rb b/app/models/report_form_field_answer.rb index e11487cae..85995c293 100644 --- a/app/models/report_form_field_answer.rb +++ b/app/models/report_form_field_answer.rb @@ -6,7 +6,7 @@ class ReportFormFieldAnswer < ApplicationRecord belongs_to :answer_option, optional: true def name - "#{form_field.question} - #{response}" unless form_field.nil? + "#{form_field.name} - #{response}" unless form_field.nil? end def response diff --git a/app/models/user_form_form_field.rb b/app/models/user_form_form_field.rb index ae65c65e5..d7b5c92ee 100644 --- a/app/models/user_form_form_field.rb +++ b/app/models/user_form_form_field.rb @@ -3,6 +3,6 @@ class UserFormFormField < ApplicationRecord belongs_to :user_form def name - "#{form_field.question}: #{text}" + "#{form_field.name}: #{text}" end end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index d056404bb..1dc32a991 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -59,7 +59,7 @@ def call private def field_value(key) - field = @form.form_fields.find_by(field_key: key) + field = @form.form_fields.find_by(field_identifier: key) return nil unless field @form_params[field.id.to_s] end @@ -226,8 +226,8 @@ def assign_tags(person, organization) end end - def collect_ids_from_checkboxes(field_key) - field = @form.form_fields.find_by(field_key: field_key) + def collect_ids_from_checkboxes(identifier) + field = @form.form_fields.find_by(field_identifier: identifier) return [] unless field value = @form_params[field.id.to_s] @@ -256,7 +256,7 @@ def update_form_submission(person) def save_form_answers(submission) @form.form_fields.find_each do |field| next if field.group_header? - next if field.field_key == "confirm_email" + next if field.field_identifier == "confirm_email" raw_value = @form_params[field.id.to_s] text = if raw_value.is_a?(Array) @@ -266,7 +266,7 @@ def save_form_answers(submission) end record = submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(question_answer: text, question_text: field.question) + record.update!(submitted_answer: text, question_name_when_answered: field.name) end end diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index bcc35fe57..8a15ec4f9 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -33,7 +33,7 @@ def call form end - SECTION_FIELD_KEYS = { + SECTION_FIELD_IDENTIFIERS = { 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 @@ -73,12 +73,12 @@ def self.update_sections!(form, new_sections) # 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 + identifiers = SECTION_FIELD_IDENTIFIERS.fetch(key) + form.form_fields.where(field_identifier: identifiers).destroy_all headers = SECTION_HEADERS.fetch(key) if headers.any? - form.form_fields.where(question: headers, answer_type: :group_header).destroy_all + form.form_fields.where(name: headers, answer_type: :group_header).destroy_all end end @@ -98,7 +98,7 @@ def self.update_sections!(form, new_sections) private - GROUP_VISIBILITY = { + SECTION_VISIBILITY = { "person_identifier" => :logged_out_only, "person_contact_info" => :logged_out_only, "background" => :logged_out_only, @@ -110,39 +110,39 @@ 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 + # Sections where answers carry across all events (ask once ever) + ONE_TIME_SECTIONS = %w[professional background].freeze def add_header(form, position, title, group:) position += 1 form.form_fields.create!( - question: title, + name: title, answer_type: :group_header, status: :active, position: position, - is_required: false, - field_key: nil, - field_group: group, - visibility: GROUP_VISIBILITY.fetch(group, :always_ask), - one_time: ONE_TIME_GROUPS.include?(group) + required: false, + field_identifier: nil, + section: group, + visibility: SECTION_VISIBILITY.fetch(group, :always_ask), + one_time: ONE_TIME_SECTIONS.include?(group) ) position end - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) + def add_field(form, position, field_name, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) position += 1 field = form.form_fields.create!( - question: question, + name: field_name, answer_type: answer_type, - answer_datatype: datatype, + input_type: datatype, status: :active, position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group, - visibility: GROUP_VISIBILITY.fetch(group, :always_ask), - one_time: ONE_TIME_GROUPS.include?(group) + required: required, + hint_text: hint, + field_identifier: key, + section: group, + visibility: SECTION_VISIBILITY.fetch(group, :always_ask), + one_time: ONE_TIME_SECTIONS.include?(group) ) if options.present? diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 9cf64881d..50c912184 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -6,14 +6,14 @@
- <% if field.instructional_hint.present? %> -

<%= field.instructional_hint %>

+ <% if field.hint_text.present? %> +

<%= field.hint_text %>

<% end %> <% field_name = "public_registration[form_fields][#{field.id}]" %> @@ -21,11 +21,11 @@ <% case field.answer_type %> <% when "free_form_input_one_line" %> - <% if field.field_key&.end_with?("_state") %> + <% if field.field_identifier&.end_with?("_state") %> <% else %> <% - input_type = case field.field_key + html_type = case field.field_identifier when /email(?!_type)/ then "email" when "phone" then "tel" when "agency_website" then "url" @@ -43,12 +43,12 @@ extra_attrs = [] extra_attrs << 'min="1" step="1" pattern="[0-9]*" inputmode="numeric"' if field.number_integer? %> - + <%= "required" if field.required %> <%= raw(extra_attrs.join(" ")) %>> <% end %> @@ -57,7 +57,7 @@ id="<%= field_id %>" rows="4" class="w-full rounded-md border <%= error_border %> px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500" - <%= "required" if field.is_required %>><%= value %> + <%= "required" if field.required %>><%= value %> <% when "multiple_choice_radio" %>
@@ -67,8 +67,8 @@ name="<%= field_name %>" value="<%= ffao.answer_option.name %>" class="text-blue-600 focus:ring-blue-500" - <%= "checked" if value == ffao.answer_option.name || (value.blank? && field.field_key == "interested_in_more" && ffao.answer_option.name == "Yes") %> - <%= "required" if field.is_required %>> + <%= "checked" if value == ffao.answer_option.name || (value.blank? && field.field_identifier == "interested_in_more" && ffao.answer_option.name == "Yes") %> + <%= "required" if field.required %>> <%= ffao.answer_option.name %> <% end %> @@ -78,9 +78,9 @@ <% checkbox_name = "public_registration[form_fields][#{field.id}][]" %> <% selected_values = Array(value) %> - <% if field.field_key.in?(%w[primary_service_area workshop_environments client_life_experiences primary_age_group]) %> + <% if field.field_identifier.in?(%w[primary_service_area workshop_environments client_life_experiences primary_age_group]) %> <%# Professional fields: render dynamic sector/category options %> - <% if field.field_key == "primary_service_area" %> + <% if field.field_identifier == "primary_service_area" %> <% options = Sector.published.order(:name) %>
<% options.each do |sector| %> @@ -100,7 +100,7 @@ "workshop_environments" => "WorkshopEnvironment", "client_life_experiences" => "StoryPopulation", "primary_age_group" => "AgeRange" - }[field.field_key] %> + }[field.field_identifier] %> <% ct = CategoryType.find_by(name: category_type_name) %> <% options = ct&.categories&.published&.order(:position, :name) || [] %>
@@ -135,6 +135,6 @@ <% end %> <% if error %> -

<%= field.question %> <%= error %>

+

<%= field.name %> <%= error %>

<% end %>
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index a5d060677..244a90d50 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -62,10 +62,10 @@ <%# 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"] && fields_by_key_check["primary_email_type"] + fields_by_id_check = @form_fields.select(&:field_identifier).index_by(&:field_identifier) + email_row = if fields_by_id_check["confirm_email"] && fields_by_id_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"] + elsif fields_by_id_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" } } @@ -84,13 +84,13 @@ { 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 } } + identifier_to_group = {} + row_groups.each { |g| g[:keys].each { |k| identifier_to_group[k] = g } } - fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key) - rendered_keys = {} + fields_by_identifier = @form_fields.select(&:field_identifier).index_by(&:field_identifier) + rendered_ids = {} - has_secondary_email = fields_by_key["secondary_email"].present? + has_secondary_email = fields_by_identifier["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 @@ -99,18 +99,18 @@ %> <% @form_fields.each do |field| %> - <% next if field.field_key.present? && rendered_keys[field.field_key] %> + <% next if field.field_identifier.present? && rendered_ids[field.field_identifier] %> <% if field.group_header? %>
-

<%= field.question %>

+

<%= field.name %>

- <% elsif (group = field.field_key.present? && field_key_to_group[field.field_key]) %> + <% elsif (group = field.field_identifier.present? && identifier_to_group[field.field_identifier]) %>
<% group[:keys].each do |key| %> - <% row_field = fields_by_key[key] %> + <% row_field = fields_by_identifier[key] %> <% next unless row_field %> - <% rendered_keys[key] = true %> + <% rendered_ids[key] = true %> <% span = group.dig(:spans, key) %> <% submitted_value = params.dig(:public_registration, :form_fields, row_field.id.to_s) %> <% label_override = email_label_overrides[key] %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index cd64aa177..ad2c03d50 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -40,31 +40,31 @@ { 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 } } + identifier_to_group = {} + row_groups.each { |g| g[:keys].each { |k| identifier_to_group[k] = g } } - fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key) - rendered_keys = {} + fields_by_identifier = @form_fields.select(&:field_identifier).index_by(&:field_identifier) + rendered_ids = {} %> <% @form_fields.each do |field| %> - <% next if field.field_key == "confirm_email" %> - <% next if field.field_key.present? && rendered_keys[field.field_key] %> + <% next if field.field_identifier == "confirm_email" %> + <% next if field.field_identifier.present? && rendered_ids[field.field_identifier] %> <% if field.group_header? %>
-

<%= field.question %>

+

<%= field.name %>

- <% elsif (group = field.field_key.present? && field_key_to_group[field.field_key]) %> + <% elsif (group = field.field_identifier.present? && identifier_to_group[field.field_identifier]) %>
<% group[:keys].each do |key| %> - <% row_field = fields_by_key[key] %> + <% row_field = fields_by_identifier[key] %> <% next unless row_field %> - <% rendered_keys[key] = true %> + <% rendered_ids[key] = true %> <% response = @responses[row_field.id] %> <% span = group.dig(:spans, key) %>
-
<%= row_field.question %>
+
<%= row_field.name %>
<%= display_response_text(row_field, response) %>
@@ -74,7 +74,7 @@ <% else %> <% response = @responses[field.id] %>
-
<%= field.question %>
+
<%= field.name %>
<%= display_response_text(field, response) %>
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb index 2e9c275cf..d94af6e96 100644 --- a/app/views/forms/_form_field_fields.html.erb +++ b/app/views/forms/_form_field_fields.html.erb @@ -17,7 +17,7 @@ %>
data-field-group="<%= field.field_group %>"<% end %> + <% if field.section.present? %>data-section="<%= field.section %>"<% end %> <% if field.group_header? %>data-group-header<% end %>>
@@ -32,7 +32,7 @@ {}, 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" } %> - <%= f.text_field :question, class: "flex-1 text-lg font-semibold text-gray-800 rounded border-gray-300 shadow-sm px-2 py-1" %> + <%= f.text_field :name, 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" %> @@ -44,7 +44,7 @@ {}, 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" %> + <%= f.text_field :name, 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 = { @@ -61,7 +61,7 @@ {}, class: "flex-[2_1_11rem] rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %>
<% - 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"] + fields_by_id_check = @form_fields.select(&:field_identifier).index_by(&:field_identifier) + email_row = if fields_by_id_check["confirm_email"] && fields_by_id_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"] + elsif fields_by_id_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" } } @@ -109,13 +109,13 @@ { 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 } } + identifier_to_group = {} + row_groups.each { |g| g[:keys].each { |k| identifier_to_group[k] = g } } - fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key) - rendered_keys = {} + fields_by_identifier = @form_fields.select(&:field_identifier).index_by(&:field_identifier) + rendered_ids = {} - has_secondary_email = fields_by_key["secondary_email"].present? + has_secondary_email = fields_by_identifier["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 @@ -125,18 +125,18 @@
<% @form_fields.each do |field| %> - <% next if field.field_key.present? && rendered_keys[field.field_key] %> + <% next if field.field_identifier.present? && rendered_ids[field.field_identifier] %> <% if field.group_header? %>
-

<%= field.question %>

+

<%= field.name %>

- <% elsif (group = field.field_key.present? && field_key_to_group[field.field_key]) %> + <% elsif (group = field.field_identifier.present? && identifier_to_group[field.field_identifier]) %>
<% group[:keys].each do |key| %> - <% row_field = fields_by_key[key] %> + <% row_field = fields_by_identifier[key] %> <% next unless row_field %> - <% rendered_keys[key] = true %> + <% rendered_ids[key] = true %> <% span = group.dig(:spans, key) %> <% label_override = email_label_overrides[key] %> <% if span %> diff --git a/app/views/monthly_reports/_form.html.erb b/app/views/monthly_reports/_form.html.erb index 097b2d934..085c787f5 100644 --- a/app/views/monthly_reports/_form.html.erb +++ b/app/views/monthly_reports/_form.html.erb @@ -122,8 +122,8 @@ <% @report.log_fields.each do |field| %> <%= render 'shared/form_field', :field => field, :report => @report %> - <% if field.question == 'Share challenges for this month' || - (field.question == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2) %> + <% if field.name == 'Share challenges for this month' || + (field.name == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2) %>
- <% if field.question == 'Share challenges for this month' || - (field.question == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2)%> + <% if field.name == 'Share challenges for this month' || + (field.name == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2)%> - <% if field.question == 'Share challenges for this month' || - (field.question == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2)%> + <% if field.name == 'Share challenges for this month' || + (field.name == 'Anything we can do to help you?' and params[:form_builder_id].to_i == 2)%>