From ac0d07f2c2fd546fc40a5a7dd8b99f0986640319 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 14:45:20 -0500 Subject: [PATCH 01/13] Add email confirmation, address type, and consent fields to registration forms - Add confirm email field with server-side match validation - Group email, confirm email, and email type in one row - Label as "Email" on short forms, "Primary Email" when secondary exists - Add address type (Home/Work) on same row as street address - Add consent and training interest questions (appear on all forms) - Skip storing confirmation field value in user form submissions Co-Authored-By: Claude Opus 4.6 --- .../events/public_registrations_controller.rb | 2 +- .../public_registration.rb | 3 ++- ...xtended_event_registration_form_builder.rb | 23 +++++++++++++++---- .../public_registrations/_form_field.html.erb | 4 ++-- .../events/public_registrations/new.html.erb | 13 +++++++++-- .../events/public_registrations/show.html.erb | 2 ++ 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index ade7bfcec..d9dd62012 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -142,7 +142,7 @@ def validate_required_fields(form_params) if field.number_integer? && value.to_s !~ /\A\d+\z/ errors[field.id] = "must be a whole number" - elsif field.field_key&.match?(/email(?!_type)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ + elsif field.field_key&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/ errors[field.id] = "must be a valid email address" end end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index af5b734e7..209a5264a 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -123,7 +123,7 @@ def create_mailing_address(person) state: new_state, zip_code: field_value("mailing_zip"), locality: "Unknown", - address_type: "mailing", + address_type: field_value("mailing_address_type")&.downcase || "mailing", primary: true ) end @@ -252,6 +252,7 @@ def update_person_form(person) def save_form_fields(person_form) @form.form_fields.where(status: :active).find_each do |field| next if field.group_header? + next if field.field_key == "primary_email_confirmation" raw_value = @form_params[field.id.to_s] text = if raw_value.is_a?(Array) diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb index 9a7e2678b..e65a9fc29 100644 --- a/app/services/extended_event_registration_form_builder.rb +++ b/app/services/extended_event_registration_form_builder.rb @@ -58,7 +58,8 @@ def build_fields!(form) position = build_professional_fields(form, position) position = build_qualitative_fields(form, position) position = build_scholarship_fields(form, position) - build_payment_fields(form, position) + position = build_payment_fields(form, position) + build_consent_fields(form, position) form end @@ -78,6 +79,8 @@ def build_contact_fields(form, position) key: "pronouns", group: "contact", required: false) position = add_field(form, position, "Primary Email", :free_form_input_one_line, key: "primary_email", group: "contact", required: true) + position = add_field(form, position, "Confirm Primary Email", :free_form_input_one_line, + key: "primary_email_confirmation", group: "contact", required: true) position = add_field(form, position, "Primary Email Type", :multiple_choice_radio, key: "primary_email_type", group: "contact", required: true, options: %w[Personal Work]) @@ -90,15 +93,15 @@ def build_contact_fields(form, position) position = add_header(form, position, "Mailing Address", group: "contact") position = add_field(form, position, "Street Address", :free_form_input_one_line, key: "mailing_street", group: "contact", required: true) + position = add_field(form, position, "Address Type", :multiple_choice_radio, + key: "mailing_address_type", group: "contact", required: true, + options: %w[Home Work]) position = add_field(form, position, "City", :free_form_input_one_line, key: "mailing_city", group: "contact", required: true) position = add_field(form, position, "State / Province", :free_form_input_one_line, key: "mailing_state", group: "contact", required: true) position = add_field(form, position, "Zip / Postal Code", :free_form_input_one_line, key: "mailing_zip", group: "contact", required: true) - position = add_field(form, position, "Mailing Address Type", :multiple_choice_radio, - key: "mailing_address_type", group: "contact", required: true, - options: %w[Work Personal]) position = add_field(form, position, "Phone", :free_form_input_one_line, key: "phone", group: "contact", required: true) @@ -220,6 +223,18 @@ def build_payment_fields(form, position) position end + def build_consent_fields(form, position) + position = add_header(form, position, "Consent", group: "consent") + position = add_field(form, position, + "I agree to receive email communications from A Window Between Worlds.", + :multiple_choice_radio, + key: "communication_consent", group: "consent", required: true, + hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, including information about this event as well as upcoming events, training opportunities, resources, impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", + options: %w[Yes No]) + + position + end + # --- helpers --- def add_header(form, position, title, group:) diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index d5fe9730f..9cf64881d 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -1,4 +1,4 @@ -<%# locals: (field:, value: nil) %> +<%# locals: (field:, value: nil, label: nil) %> <% return if field.group_header? %> <% error = @field_errors&.dig(field.id) %> @@ -6,7 +6,7 @@
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index 94373ae41..b4550c811 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -32,6 +32,7 @@ { keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } }, { keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } }, + { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" }, { keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, { keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }, @@ -47,6 +48,7 @@ %> <% @form_fields.each do |field| %> + <% next if field.field_key == "primary_email_confirmation" %> <% next if field.field_key.present? && rendered_keys[field.field_key] %> <% if field.group_header? %> From 34b0511cc8e50be8331a807ee2b2786f98eb2d15 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 16:49:58 -0500 Subject: [PATCH 02/13] Extract BaseRegistrationFormBuilder to share fields across form types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Short, Extended, and Scholarship form builders now inherit from a common base class that provides shared helpers (add_header, add_field) and reusable field sections (basic contact, scholarship, consent). This eliminates duplication and ensures changes to shared fields propagate to all form types. Also fixes three bugs: - Email confirmation key mismatch (primary_email_confirmation → confirm_email) so validation now works for extended forms - workshop_settings → workshop_environments so professional tags get assigned - confirm_email responses no longer stored redundantly in PersonForm Co-Authored-By: Claude Opus 4.6 --- .../base_registration_form_builder.rb | 94 +++++++++++++++ .../public_registration.rb | 2 +- ...xtended_event_registration_form_builder.rb | 98 ++-------------- .../scholarship_application_form_builder.rb | 64 +--------- .../short_event_registration_form_builder.rb | 90 ++------------ .../events/public_registrations/new.html.erb | 8 +- .../events/public_registrations/show.html.erb | 2 +- ...ed_event_registration_form_builder_spec.rb | 111 ++++++++++++++++++ ...rt_event_registration_form_builder_spec.rb | 61 ++++++++++ 9 files changed, 292 insertions(+), 238 deletions(-) create mode 100644 app/services/base_registration_form_builder.rb create mode 100644 spec/services/extended_event_registration_form_builder_spec.rb create mode 100644 spec/services/short_event_registration_form_builder_spec.rb diff --git a/app/services/base_registration_form_builder.rb b/app/services/base_registration_form_builder.rb new file mode 100644 index 000000000..64c2b9ad8 --- /dev/null +++ b/app/services/base_registration_form_builder.rb @@ -0,0 +1,94 @@ +class BaseRegistrationFormBuilder + protected + + def build_basic_contact_fields(form, position) + position = add_field(form, position, "First Name", :free_form_input_one_line, + key: "first_name", group: "contact", required: true) + position = add_field(form, position, "Last Name", :free_form_input_one_line, + key: "last_name", group: "contact", required: true) + position = add_field(form, position, "Email", :free_form_input_one_line, + key: "primary_email", group: "contact", required: true) + position = add_field(form, position, "Confirm Email", :free_form_input_one_line, + key: "confirm_email", group: "contact", required: true) + + position + end + + def build_scholarship_fields(form, position) + position = add_header(form, position, "Scholarship Application", group: "scholarship") + + position = add_field(form, position, + "I / my agency cannot afford the full training cost and need a scholarship to attend.", + :multiple_choice_checkbox, + key: "scholarship_eligibility", group: "scholarship", required: true, + options: [ "Yes" ]) + position = add_field(form, position, + "How will what you gain from this training directly impact the people you serve?", + :free_form_input_paragraph, + key: "impact_description", group: "scholarship", required: true, + hint: "Please describe in 3-5+ sentences.") + position = add_field(form, position, + "Please describe one way in which you plan to use art workshops and how you envision it will help.", + :free_form_input_paragraph, + key: "implementation_plan", group: "scholarship", required: true, + hint: "Please describe in 3-5+ sentences.") + position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, + key: "additional_comments", group: "scholarship", required: false) + + position + end + + def build_consent_fields(form, position) + position = add_header(form, position, "Consent", group: "consent") + position = add_field(form, position, + "I agree to receive email communications from A Window Between Worlds.", + :multiple_choice_radio, + key: "communication_consent", group: "consent", required: true, + hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \ + "including information about this event as well as upcoming events, training opportunities, resources, " \ + "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", + options: %w[Yes No]) + + position + end + + def add_header(form, position, title, group:) + position += 1 + form.form_fields.create!( + question: title, + answer_type: :group_header, + status: :active, + position: position, + is_required: false, + field_key: nil, + field_group: group + ) + position + end + + def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) + position += 1 + field = form.form_fields.create!( + question: question, + answer_type: answer_type, + answer_datatype: datatype, + status: :active, + position: position, + is_required: required, + instructional_hint: hint, + field_key: key, + field_group: group + ) + + if options.present? + options.each_with_index do |opt, idx| + ao = AnswerOption.find_or_create_by!(name: opt) do |a| + a.position = idx + end + field.form_field_answer_options.create!(answer_option: ao) + end + end + + position + end +end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 209a5264a..c681a75dd 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -252,7 +252,7 @@ def update_person_form(person) def save_form_fields(person_form) @form.form_fields.where(status: :active).find_each do |field| next if field.group_header? - next if field.field_key == "primary_email_confirmation" + next if field.field_key == "confirm_email" raw_value = @form_params[field.id.to_s] text = if raw_value.is_a?(Array) diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb index e65a9fc29..f6e093eac 100644 --- a/app/services/extended_event_registration_form_builder.rb +++ b/app/services/extended_event_registration_form_builder.rb @@ -1,4 +1,4 @@ -class ExtendedEventRegistrationFormBuilder +class ExtendedEventRegistrationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Extended Event Registration" def self.build_standalone!(include_contact_fields: true) @@ -69,21 +69,15 @@ def build_fields!(form) def build_contact_fields(form, position) position = add_header(form, position, "Contact Information", group: "contact") - position = add_field(form, position, "First Name", :free_form_input_one_line, - key: "first_name", group: "contact", required: true) - position = add_field(form, position, "Last Name", :free_form_input_one_line, - key: "last_name", group: "contact", required: true) + position = build_basic_contact_fields(form, position) + + position = add_field(form, position, "Primary Email Type", :multiple_choice_radio, + key: "primary_email_type", group: "contact", required: true, + options: %w[Personal Work]) position = add_field(form, position, "Preferred Nickname", :free_form_input_one_line, key: "nickname", group: "contact", required: false) position = add_field(form, position, "Pronouns", :free_form_input_one_line, key: "pronouns", group: "contact", required: false) - position = add_field(form, position, "Primary Email", :free_form_input_one_line, - key: "primary_email", group: "contact", required: true) - position = add_field(form, position, "Confirm Primary Email", :free_form_input_one_line, - key: "primary_email_confirmation", group: "contact", required: true) - position = add_field(form, position, "Primary Email Type", :multiple_choice_radio, - key: "primary_email_type", group: "contact", required: true, - options: %w[Personal Work]) position = add_field(form, position, "Secondary Email", :free_form_input_one_line, key: "secondary_email", group: "contact", required: false) position = add_field(form, position, "Secondary Email Type", :multiple_choice_radio, @@ -152,7 +146,7 @@ def build_professional_fields(form, position) key: "primary_service_area", group: "professional", required: false, hint: "Select all that apply. These represent the sectors you primarily serve.") position = add_field(form, position, "Workshop Settings", :multiple_choice_checkbox, - key: "workshop_settings", group: "professional", required: false, + key: "workshop_environments", group: "professional", required: false, hint: "Select all settings where you facilitate or plan to facilitate workshops.", options: [ "Clinical", "Educational", "Events / conferences", @@ -185,30 +179,6 @@ def build_qualitative_fields(form, position) position end - def build_scholarship_fields(form, position) - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - - position - end - def build_payment_fields(form, position) position = add_header(form, position, "Payment Information", group: "payment") @@ -222,58 +192,4 @@ def build_payment_fields(form, position) position end - - def build_consent_fields(form, position) - position = add_header(form, position, "Consent", group: "consent") - position = add_field(form, position, - "I agree to receive email communications from A Window Between Worlds.", - :multiple_choice_radio, - key: "communication_consent", group: "consent", required: true, - hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, including information about this event as well as upcoming events, training opportunities, resources, impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", - options: %w[Yes No]) - - position - end - - # --- helpers --- - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil, datatype: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - answer_datatype: datatype, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) do |a| - a.position = idx - end - field.form_field_answer_options.create!(answer_option: ao) - end - end - - position - end end diff --git a/app/services/scholarship_application_form_builder.rb b/app/services/scholarship_application_form_builder.rb index 10331fc2a..b0e42c382 100644 --- a/app/services/scholarship_application_form_builder.rb +++ b/app/services/scholarship_application_form_builder.rb @@ -1,4 +1,4 @@ -class ScholarshipApplicationFormBuilder +class ScholarshipApplicationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Scholarship Application" def self.build_standalone! @@ -15,67 +15,7 @@ def self.build!(event) end def build_fields!(form) - position = 0 - - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - + build_scholarship_fields(form, 0) form end - - private - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - end - - position - end end diff --git a/app/services/short_event_registration_form_builder.rb b/app/services/short_event_registration_form_builder.rb index 7d5ec291f..900d4c6f9 100644 --- a/app/services/short_event_registration_form_builder.rb +++ b/app/services/short_event_registration_form_builder.rb @@ -1,4 +1,4 @@ -class ShortEventRegistrationFormBuilder +class ShortEventRegistrationFormBuilder < BaseRegistrationFormBuilder FORM_NAME = "Short Event Registration" def self.build_standalone! @@ -17,22 +17,17 @@ def self.build!(event) def build_fields!(form) position = 0 - position = add_field(form, position, "First Name", :free_form_input_one_line, - key: "first_name", group: "contact", required: true) - position = add_field(form, position, "Last Name", :free_form_input_one_line, - key: "last_name", group: "contact", required: true) - position = add_field(form, position, "Enter Email", :free_form_input_one_line, - key: "primary_email", group: "contact", required: true) - position = add_field(form, position, "Confirm Email", :free_form_input_one_line, - key: "confirm_email", group: "contact", required: true) + position = build_basic_contact_fields(form, position) + position = build_consent_fields(form, position) + position = build_qualitative_fields(form, position) + position = build_scholarship_fields(form, position) + + position + end - position = add_field(form, position, "Consent", :multiple_choice_checkbox, - key: "consent", group: "consent", required: true, - hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \ - "including information about this event as well as upcoming events, training opportunities, resources, " \ - "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.", - options: [ "I agree to receive email communications from A Window Between Worlds." ]) + private + def build_qualitative_fields(form, position) position = add_field(form, position, "How did you hear about this event?", :multiple_choice_checkbox, key: "referral_source", group: "qualitative", required: true, options: [ "AWBW Email", "Facebook", "Instagram", "LinkedIn", "Online Search", "Word of Mouth", "Other" ]) @@ -42,71 +37,6 @@ def build_fields!(form) key: "training_interest", group: "qualitative", required: true, options: [ "Yes", "Not right now" ]) - position = build_scholarship_fields(form, position) - - position - end - - private - - def build_scholarship_fields(form, position) - position = add_header(form, position, "Scholarship Application", group: "scholarship") - - position = add_field(form, position, - "I / my agency cannot afford the full training cost and need a scholarship to attend.", - :multiple_choice_checkbox, - key: "scholarship_eligibility", group: "scholarship", required: true, - options: [ "Yes" ]) - position = add_field(form, position, - "How will what you gain from this training directly impact the people you serve?", - :free_form_input_paragraph, - key: "impact_description", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, - "Please describe one way in which you plan to use art workshops and how you envision it will help.", - :free_form_input_paragraph, - key: "implementation_plan", group: "scholarship", required: true, - hint: "Please describe in 3-5+ sentences.") - position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph, - key: "additional_comments", group: "scholarship", required: false) - - position - end - - def add_header(form, position, title, group:) - position += 1 - form.form_fields.create!( - question: title, - answer_type: :group_header, - status: :active, - position: position, - is_required: false, - field_key: nil, - field_group: group - ) - position - end - - def add_field(form, position, question, answer_type, key:, group:, required: true, hint: nil, options: nil) - position += 1 - field = form.form_fields.create!( - question: question, - answer_type: answer_type, - status: :active, - position: position, - is_required: required, - instructional_hint: hint, - field_key: key, - field_group: group - ) - - if options.present? - options.each_with_index do |opt, idx| - ao = AnswerOption.find_or_create_by!(name: opt) { |a| a.position = idx } - field.form_field_answer_options.create!(answer_option: ao) - end - end - position end end diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index 5804c94d7..1b2750fe4 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -63,7 +63,9 @@ <%# Row groupings: related fields share a grid row on md+ screens %> <% fields_by_key_check = @form_fields.select(&:field_key).index_by(&:field_key) - email_row = if fields_by_key_check["confirm_email"] + email_row = if fields_by_key_check["confirm_email"] && fields_by_key_check["primary_email_type"] + { keys: %w[primary_email confirm_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" } + elsif fields_by_key_check["confirm_email"] { keys: %w[primary_email confirm_email], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" } else { keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } } @@ -90,9 +92,9 @@ has_secondary_email = fields_by_key["secondary_email"].present? email_label_overrides = if has_secondary_email - {} + { "primary_email" => "Primary Email", "confirm_email" => "Confirm Primary Email", "primary_email_type" => "Primary Email Type" } else - { "primary_email" => "Email", "primary_email_confirmation" => "Confirm Email", "primary_email_type" => "Email Type" } + {} end %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index b4550c811..b94b25005 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -48,7 +48,7 @@ %> <% @form_fields.each do |field| %> - <% next if field.field_key == "primary_email_confirmation" %> + <% next if field.field_key == "confirm_email" %> <% next if field.field_key.present? && rendered_keys[field.field_key] %> <% if field.group_header? %> diff --git a/spec/services/extended_event_registration_form_builder_spec.rb b/spec/services/extended_event_registration_form_builder_spec.rb new file mode 100644 index 000000000..9fd7f7e21 --- /dev/null +++ b/spec/services/extended_event_registration_form_builder_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe ExtendedEventRegistrationFormBuilder do + let(:event) { create(:event) } + + describe ".build!" do + subject(:form) { described_class.build!(event) } + + it "creates a registration form linked to the event" do + expect(form.name).to eq("Extended Event Registration") + expect(event.registration_form).to eq(form) + end + + it "assigns sequential positions starting at 1" do + field_count = form.form_fields.count + positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position) + expect(positions).to eq((1..field_count).to_a) + end + + it "inherits basic contact fields from BaseRegistrationFormBuilder" do + %w[first_name last_name primary_email confirm_email].each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + expect(field.field_group).to eq("contact") + end + end + + it "adds extended contact fields beyond the basic set" do + extended_keys = %w[nickname pronouns primary_email_type secondary_email secondary_email_type + mailing_street mailing_address_type mailing_city mailing_state mailing_zip + phone phone_type agency_name agency_position] + extended_keys.each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + end + end + + it "creates workshop_environments field with correct key" do + field = form.form_fields.find_by(field_key: "workshop_environments") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_checkbox") + end + + it "inherits consent fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "communication_consent") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_radio") + expect(field.is_required).to be true + expect(field.field_group).to eq("consent") + end + + it "inherits scholarship fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "scholarship_eligibility") + expect(field).to be_present + expect(field.field_group).to eq("scholarship") + end + + it "creates payment fields" do + attendees = form.form_fields.find_by(field_key: "number_of_attendees") + expect(attendees.answer_datatype).to eq("number_integer") + expect(attendees.is_required).to be true + + payment = form.form_fields.find_by(field_key: "payment_method") + expect(payment.answer_type).to eq("multiple_choice_radio") + end + + it "creates all expected field groups" do + groups = form.form_fields.pluck(:field_group).uniq.sort + expect(groups).to contain_exactly("background", "consent", "contact", "payment", "professional", "qualitative", "scholarship") + end + end + + describe ".build! without contact fields" do + subject(:form) { described_class.build!(event, include_contact_fields: false) } + + it "omits contact fields" do + expect(form.form_fields.find_by(field_key: "first_name")).to be_nil + expect(form.form_fields.find_by(field_key: "primary_email")).to be_nil + end + + it "still includes consent fields" do + expect(form.form_fields.find_by(field_key: "communication_consent")).to be_present + end + + it "still includes scholarship fields" do + expect(form.form_fields.find_by(field_key: "scholarship_eligibility")).to be_present + end + end + + describe ".build_standalone!" do + it "creates a form without linking to an event" do + form = described_class.build_standalone! + + expect(form.name).to eq("Extended Event Registration") + expect(form.event_forms).to be_empty + end + end + + describe ".copy!" do + it "duplicates a form for a new event" do + source_form = described_class.build!(event) + new_event = create(:event) + + copied = described_class.copy!(from_form: source_form, to_event: new_event) + + expect(copied.id).not_to eq(source_form.id) + expect(copied.form_fields.count).to eq(source_form.form_fields.count) + expect(new_event.registration_form).to eq(copied) + end + end +end diff --git a/spec/services/short_event_registration_form_builder_spec.rb b/spec/services/short_event_registration_form_builder_spec.rb new file mode 100644 index 000000000..dda53dbee --- /dev/null +++ b/spec/services/short_event_registration_form_builder_spec.rb @@ -0,0 +1,61 @@ +require "rails_helper" + +RSpec.describe ShortEventRegistrationFormBuilder do + let(:event) { create(:event) } + + describe ".build!" do + subject(:form) { described_class.build!(event) } + + it "creates a registration form linked to the event" do + expect(form.name).to eq("Short Event Registration") + expect(event.registration_form).to eq(form) + end + + it "assigns sequential positions starting at 1" do + field_count = form.form_fields.count + positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position) + expect(positions).to eq((1..field_count).to_a) + end + + it "inherits basic contact fields from BaseRegistrationFormBuilder" do + %w[first_name last_name primary_email confirm_email].each do |key| + field = form.form_fields.find_by(field_key: key) + expect(field).to be_present, "expected field_key '#{key}' to exist" + expect(field.field_group).to eq("contact") + end + end + + it "inherits consent fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "communication_consent") + expect(field).to be_present + expect(field.answer_type).to eq("multiple_choice_radio") + expect(field.is_required).to be true + expect(field.field_group).to eq("consent") + end + + it "inherits scholarship fields from BaseRegistrationFormBuilder" do + field = form.form_fields.find_by(field_key: "scholarship_eligibility") + expect(field).to be_present + expect(field.field_group).to eq("scholarship") + end + + it "creates short-specific qualitative fields" do + referral = form.form_fields.find_by(field_key: "referral_source") + expect(referral.answer_type).to eq("multiple_choice_checkbox") + expect(referral.is_required).to be true + + interest = form.form_fields.find_by(field_key: "training_interest") + expect(interest.answer_type).to eq("multiple_choice_checkbox") + expect(interest.is_required).to be true + end + end + + describe ".build_standalone!" do + it "creates a form without linking to an event" do + form = described_class.build_standalone! + + expect(form.name).to eq("Short Event Registration") + expect(form.event_forms).to be_empty + end + end +end From ca26dce5e87f722b1f40d9f29a455cd78fb73d18 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 16:02:20 -0500 Subject: [PATCH 03/13] Check all event forms for registration icon, not just current one (#1288) Previously the form submission icon on the manage registrations and event registrations index pages only checked the single form tagged as "registration" role. If an event's registration form was swapped, people who submitted the old form lost their green icon. Now both pages check across all forms linked to the event via event_forms. Also fixes N+1 queries on the event registrations index by batch- loading form submissions upfront. Co-authored-by: Claude Opus 4.6 --- app/views/event_registrations/index.html.erb | 8 ++-- app/views/events/_manage_results.html.erb | 13 ++--- spec/factories/person_forms.rb | 6 +++ spec/requests/event_registrations_spec.rb | 40 ++++++++++++++++ spec/requests/events_spec.rb | 50 ++++++++++++++++++++ 5 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 spec/factories/person_forms.rb diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb index 71bb042d4..c5a82def6 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -42,16 +42,18 @@ + <% 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 %> <% @event_registrations.each do |event_registration| %>
<%= person_profile_button(event_registration.registrant) %> - <% reg_form = event_registration.event.registration_form %> - <% if reg_form && reg_form.person_forms.exists?(person: event_registration.registrant) %> + <% er_form_ids = event_registration.event.event_forms.map(&:form_id) %> + <% if er_form_ids.any? && er_form_ids.any? { |fid| submitted_pairs.include?([ event_registration.registrant_id, fid ]) } %> - <% elsif reg_form %> + <% elsif er_form_ids.any? %> <% end %>
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb index 15fccb9a4..f6001f29d 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -3,8 +3,8 @@

<%= @event_registrations.size %> registrant<%= "s" if @event_registrations.size != 1 %>

- <% reg_form = @event.registration_form %> - <% form_submissions = reg_form ? reg_form.person_forms.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %> + <% 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 ] } } : {} %> <% if @event_registrations.any? %>
@@ -28,17 +28,18 @@ <% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %>
<%= person_profile_button(person, subtitle: (person.preferred_email if show_email), data: { turbo_frame: "_top" }) %> - <% if reg_form && form_submissions.key?(person.id) %> - <% submitted_at = form_submissions[person.id] %> + <% if event_form_ids.any? && (submissions = form_submissions[person.id]) %> <% form_show_params = registration.slug.present? ? { reg: registration.slug } : { person_id: person.id } %> + <% tooltip_parts = submissions.map { |name, ts| "#{name} — Submitted #{ts.strftime('%B %d, %Y at %l:%M %P')}" } %> + <% tooltip_parts << "Scholarship requested" if registration.scholarship_requested? %> <%= link_to event_public_registration_path(@event, **form_show_params), class: "text-green-600 hover:text-green-800", - title: "#{reg_form.name} — Submitted #{submitted_at.strftime('%B %d, %Y at %l:%M %P')}#{' — Scholarship requested' if registration.scholarship_requested?}", + title: tooltip_parts.join("\n"), target: "_blank", data: { turbo_frame: "_top" } do %> <% end %> - <% elsif reg_form %> + <% elsif event_form_ids.any? %> <% end %> <% if registration.comments.any? %> diff --git a/spec/factories/person_forms.rb b/spec/factories/person_forms.rb new file mode 100644 index 000000000..39a87691b --- /dev/null +++ b/spec/factories/person_forms.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :person_form do + association :person + association :form + end +end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 222681718..9502acebc 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -49,6 +49,46 @@ expect(data_rows).to include(expected_row) end + context "registration form icon" do + let(:reg_form) { create(:form, :standalone, name: "Registration Form") } + let(:person) { existing_registration.registrant } + + it "shows green icon when person submitted the current registration form" do + create(:event_form, event: event, form: reg_form, role: "registration") + create(:person_form, person: person, form: reg_form) + + get event_registrations_path + + expect(response.body).to include("fa-solid fa-file-lines") + end + + it "shows gray icon when person has not submitted any form" do + create(:event_form, event: event, form: reg_form, role: "registration") + + get event_registrations_path + + expect(response.body).to include("fa-regular fa-file-lines") + end + + it "shows green icon when person submitted a non-registration form for the event" do + scholarship_form = create(:form, :standalone, name: "Scholarship Form") + create(:event_form, event: event, form: reg_form, role: "registration") + create(:event_form, event: event, form: scholarship_form, role: "scholarship") + create(:person_form, person: person, form: scholarship_form) + + get event_registrations_path + + expect(response.body).to include("fa-solid fa-file-lines") + expect(response.body).not_to include("fa-regular fa-file-lines") + end + + it "does not show any form icon when event has no forms" do + get event_registrations_path + + expect(response.body).not_to include("fa-file-lines") + end + end + it "paginates results" do additional = create_list(:event_registration, 3) diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index cd30ac332..eac574b37 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -242,4 +242,54 @@ end end end + + describe "GET /events/:id/manage" do + let(:person) { create(:person) } + let!(:registration) { create(:event_registration, event: event, registrant: person) } + + before { sign_in admin } + + context "registration form icon" do + let(:reg_form) { create(:form, :standalone, name: "Registration Form") } + + it "shows green icon when person submitted the current registration form" do + create(:event_form, event: event, form: reg_form, role: "registration") + create(:person_form, person: person, form: reg_form) + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-solid fa-file-lines') + end + + it "shows gray icon when person has not submitted any form" do + create(:event_form, event: event, form: reg_form, role: "registration") + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-regular fa-file-lines') + end + + it "shows green icon when person submitted a non-registration form for the event" do + scholarship_form = create(:form, :standalone, name: "Scholarship Form") + create(:event_form, event: event, form: reg_form, role: "registration") + create(:event_form, event: event, form: scholarship_form, role: "scholarship") + create(:person_form, person: person, form: scholarship_form) + + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('fa-solid fa-file-lines') + expect(response.body).not_to include('fa-regular fa-file-lines') + end + + it "does not show any form icon when event has no forms" do + get manage_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).not_to include('fa-file-lines') + end + end + end end From 175de01425a224f6980c0d50debdd85c22897b33 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 16:24:09 -0500 Subject: [PATCH 04/13] Fix delete registration button and return_to routing (#1289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix delete registration button and return_to routing The delete button on the registration edit form wasn't working because data-turbo="false" on the parent form disabled Turbo for the delete link which relies on turbo_method: :delete. Added turbo: true override. All CRUD actions (create, update, delete) now respect return_to param so admins are redirected back to the page they came from — manage registrants or registrations index — instead of always landing on the index or ticket. Also adds event registration seed data with various scenarios and a system spec for the delete button. Co-Authored-By: Claude Opus 4.6 * Fix request specs for return_to routing changes Update tests to pass return_to param explicitly since create no longer infers destination from event_id presence. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../event_registrations_controller.rb | 44 ++-- app/views/event_registrations/_form.html.erb | 13 +- app/views/event_registrations/index.html.erb | 4 +- app/views/events/manage.html.erb | 2 +- db/seeds/dummy_dev_seeds.rb | 230 ++++++++++++++++++ spec/requests/event_registrations_spec.rb | 4 +- spec/system/event_registration_edit_spec.rb | 22 ++ 7 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 spec/system/event_registration_edit_spec.rb diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 20bfb6a9a..9e44fddfa 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -56,27 +56,15 @@ def create if @event_registration.save respond_to do |format| format.html { - if current_user.super_user? - return_to = params.dig(:event_registration, :event_id).present? ? "manage" : "ticket" - redirect_to confirm_event_registration_path(@event_registration, return_to: return_to) - elsif params.dig(:event_registration, :event_id).present? - redirect_to manage_event_path(@event_registration.event), - notice: "Registration created." - else - redirect_to registration_ticket_path(@event_registration.slug), - notice: "Registration created." - end + redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) } end else respond_to do |format| format.html { - if @event_registration.event_id.present? - redirect_to manage_event_path(@event_registration.event), - alert: @event_registration.errors.full_messages.to_sentence - else - redirect_to event_registrations_path, - alert: @event_registration.errors.full_messages.to_sentence + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), alert: @event_registration.errors.full_messages.to_sentence + else redirect_to event_registrations_path, alert: @event_registration.errors.full_messages.to_sentence end } end @@ -93,10 +81,10 @@ def update respond_to do |format| format.turbo_stream format.html { - if params[:return_to] == "manage" - redirect_to manage_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - else - redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other + else redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other end } end @@ -133,22 +121,26 @@ def process_confirm current_user: current_user ) - if params[:return_to] == "manage" - redirect_to manage_event_path(@event_registration.event), notice: result.summary - else - redirect_to registration_ticket_path(@event_registration.slug), notice: result.summary + case params[:return_to] + when "manage" then redirect_to manage_event_path(@event_registration.event), notice: result.summary + when "index" then redirect_to event_registrations_path, notice: result.summary + else redirect_to registration_ticket_path(@event_registration.slug), notice: result.summary end end def destroy authorize! @event_registration + event = @event_registration.event if @event_registration.destroy flash[:notice] = "Registration deleted." - else flash[:alert] = @event_registration.errors.full_messages.to_sentence end - redirect_to event_registrations_path + + case params[:return_to] + when "manage" then redirect_to manage_event_path(event) + else redirect_to event_registrations_path + end end # Optional hooks for setting variables for forms or index diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 151ee27f6..95307ed8b 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -126,12 +126,15 @@
<% if allowed_to?(:destroy?, f.object) %> - <%= link_to "Delete", @event_registration, class: "btn btn-danger-outline", - data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> + <%= link_to "Delete", event_registration_path(@event_registration, return_to: params[:return_to].presence), class: "btn btn-danger-outline", + data: { turbo: true, turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> - <%= link_to "Cancel", - (allowed_to?(:index?, EventRegistration) ? event_registrations_path : root_path), - class: "btn btn-secondary-outline" %> + <% cancel_path = case params[:return_to] + when "manage" then manage_event_path(event_registration.event) + when "index" then event_registrations_path + else allowed_to?(:index?, EventRegistration) ? event_registrations_path : root_path + end %> + <%= link_to "Cancel", cancel_path, class: "btn btn-secondary-outline" %> <%= f.button :submit, class: "btn btn-primary" %>
diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb index c5a82def6..29282f4d0 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -12,7 +12,7 @@ class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if allowed_to?(:new?, EventRegistration) %> <%= link_to "New #{EventRegistration.model_name.human.downcase}", - new_event_registration_path, + new_event_registration_path(return_to: "index"), class: "admin-only bg-blue-100 btn btn-primary-outline" %> <% end %> @@ -74,7 +74,7 @@
<%= link_to "View", event_registration_path(event_registration), class: "btn btn-secondary-outline" %> - <%= link_to "Edit", edit_event_registration_path(event_registration), + <%= link_to "Edit", edit_event_registration_path(event_registration, return_to: "index"), class: "btn btn-secondary-outline" %>
diff --git a/app/views/events/manage.html.erb b/app/views/events/manage.html.erb index 013ed4fdb..cb16c0305 100644 --- a/app/views/events/manage.html.erb +++ b/app/views/events/manage.html.erb @@ -15,7 +15,7 @@ <%= link_to "View", event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if allowed_to?(:edit?, @event) %> <%= link_to "Edit", edit_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <%= link_to "New registration", new_event_registration_path(event_id: @event.id), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "New registration", new_event_registration_path(event_id: @event.id, return_to: "manage"), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% if @event.event_forms.registration.exists? %> <%= link_to "Public registration", new_event_public_registration_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1", target: "_blank" %> <%= link_to "Scholarship version", new_event_public_registration_path(@event, scholarship: 1), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1", target: "_blank" %> diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index 4bc35700e..a0eca2fc2 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -943,7 +943,237 @@ end end +puts "Creating Event Registrations…" +# Key people for named scenarios +amy_person = User.find_by(email: "amy.user@example.com")&.person +maria_j = Person.find_by(first_name: "Maria", last_name: "Johnson") +anna_g = Person.find_by(first_name: "Anna", last_name: "Garcia") +sarah_s = Person.find_by(first_name: "Sarah", last_name: "Smith") +lisa_w = Person.find_by(first_name: "Lisa", last_name: "Williams") +jessica_b = Person.find_by(first_name: "Jessica", last_name: "Brown") +kim_d = Person.find_by(first_name: "Kim", last_name: "Davis") +rosa_dlc = Person.find_by(first_name: "Rosa", last_name: "De La Cruz") +mario_j = Person.find_by(first_name: "Mario", last_name: "Johnson") # no user +angel_g = Person.find_by(first_name: "Angel", last_name: "Garcia") # no user +linda_w = Person.find_by(first_name: "Linda", last_name: "Williams") # no user + +# Events by name for clarity +facilitator_training = Event.find_by(title: "AWBW Facilitator Training") +trauma_training = Event.find_by(title: "Facilitator Training: Trauma-Informed Art Practices") +wellness_day = Event.find_by(title: "A Year of Healing and Rebuilding Together Wellness Day") +youth_day = Event.find_by(title: "Youth Creativity Day") +mindful_art = Event.find_by(title: "Mindful Art for Survivors Workshop") +virtual_session = Event.find_by(title: "Art as Healing: Virtual Group Session") +roundtable = Event.find_by(title: "Leaders in Creativity: Facilitator Roundtable") +family_day = Event.find_by(title: "Family Creative Expression Day") +# "Community Open Studio Night" and "Annual Celebration of Voices" have no registration forms — left with zero registrations + +registrations_data = [] + +# --- Facilitator Training: multiple registrations from different people, extended form --- +# Amy: registered, with form submission, scholarship recipient +# Maria Johnson: registered, with form submission (has user) +# Anna Garcia: attended, with form submission (has user) +# Mario Johnson: registered, no form submission (no user) +# Kim Davis: cancelled (has user) +if facilitator_training + [ + { person: amy_person, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: false }, + { person: maria_j, status: "registered" }, + { person: anna_g, status: "attended" }, + { person: mario_j, status: "registered" }, + { person: kim_d, status: "cancelled" } + ].each do |data| + next unless data[:person] + registrations_data << data.merge(event: facilitator_training) + end +end + +# --- Trauma Training: extended form, scholarship --- +# Sarah Smith: registered with form (has user) +# Jessica Brown: registered with form, scholarship (has user) +# Angel Garcia: registered, no form (no user) +# Linda Williams: no_show (no user) +if trauma_training + [ + { person: sarah_s, status: "registered" }, + { person: jessica_b, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: true }, + { person: angel_g, status: "registered" }, + { person: linda_w, status: "no_show" } + ].each do |data| + next unless data[:person] + registrations_data << data.merge(event: trauma_training) + end +end + +# --- Amy registered to multiple events (person registered across events) --- +if amy_person + [ wellness_day, mindful_art, virtual_session ].compact.each do |evt| + registrations_data << { person: amy_person, event: evt, status: "registered" } + end +end + +# --- Maria Johnson also registered to multiple events --- +if maria_j + [ wellness_day, youth_day ].compact.each do |evt| + registrations_data << { person: maria_j, event: evt, status: "registered" } + end +end + +# --- Rosa De La Cruz registered to a couple events (has user) --- +if rosa_dlc + [ wellness_day, family_day ].compact.each do |evt| + registrations_data << { person: rosa_dlc, event: evt, status: "registered" } + end +end + +# --- Lisa Williams: incomplete_attendance on one event --- +if lisa_w && roundtable + registrations_data << { person: lisa_w, event: roundtable, status: "incomplete_attendance" } +end + +# --- Wellness Day gets extra registrations (popular free event, short form) --- +if wellness_day + [ sarah_s, jessica_b, lisa_w, kim_d ].compact.each do |person| + registrations_data << { person: person, event: wellness_day, status: "registered" } + end +end + +# Create all registrations +registrations_data.each do |data| + next unless data[:event] && data[:person] + next if EventRegistration.exists?(event: data[:event], registrant: data[:person]) + + EventRegistration.create!( + event: data[:event], + registrant: data[:person], + status: data[:status] || "registered", + scholarship_recipient: data[:scholarship_recipient] || false, + scholarship_tasks_completed: data[:scholarship_tasks_completed] || false, + scholarship_requested: data[:scholarship_recipient] || false + ) +end + +puts "Creating Registration Form Submissions…" +# Create person_form records linking registrants to their event's registration form. +# This simulates people who filled out the registration form. +form_submissions = [] + +# Facilitator Training (extended form) — some registrants filled it out, one didn't +if facilitator_training + reg_form = facilitator_training.registration_form + if reg_form + # People with users who filled out the form + [ amy_person, maria_j, anna_g ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Mario Johnson (no user) did NOT fill out the form — registration without form submission + end + + # Amy also filled out the scholarship form + scholarship_f = facilitator_training.scholarship_form + if scholarship_f && amy_person + form_submissions << { person: amy_person, form: scholarship_f } + end +end + +# Trauma Training (extended form) +if trauma_training + reg_form = trauma_training.registration_form + if reg_form + # Sarah Smith (has user) and Jessica Brown (has user) filled out forms + [ sarah_s, jessica_b ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Angel Garcia (no user) filled out the form — person without user + form + form_submissions << { person: angel_g, form: reg_form } if angel_g + # Linda Williams (no user) did NOT fill out the form + end + + # Jessica filled out the scholarship form + scholarship_f = trauma_training.scholarship_form + if scholarship_f && jessica_b + form_submissions << { person: jessica_b, form: scholarship_f } + end +end + +# Wellness Day (short form) — most filled it out +if wellness_day + reg_form = wellness_day.registration_form + if reg_form + # People with users + [ amy_person, maria_j, sarah_s, jessica_b, kim_d ].compact.each do |person| + form_submissions << { person: person, form: reg_form } + end + # Rosa (has user) filled it out too + form_submissions << { person: rosa_dlc, form: reg_form } if rosa_dlc + # Lisa Williams (has user) registered but didn't fill out the form — person with user + no form + end +end + +# Mindful Art (short form, has scholarship) — Amy filled out both +if mindful_art + reg_form = mindful_art.registration_form + form_submissions << { person: amy_person, form: reg_form } if reg_form && amy_person + + scholarship_f = mindful_art.scholarship_form + form_submissions << { person: amy_person, form: scholarship_f } if scholarship_f && amy_person +end + +# Youth Day (short form) — Maria filled it out +if youth_day + reg_form = youth_day.registration_form + form_submissions << { person: maria_j, form: reg_form } if reg_form && maria_j +end + +# Virtual Session (short form) — Amy (has user) registered but no form submission — person with user + no form +# Family Day (short form) — Rosa filled it out +if family_day + reg_form = family_day.registration_form + form_submissions << { person: rosa_dlc, form: reg_form } if reg_form && rosa_dlc +end + +# Create all form submissions with sample field responses +form_submissions.each do |data| + next unless data[:person] && data[:form] + next if PersonForm.exists?(person: data[:person], form: data[:form]) + + pf = PersonForm.create!(person: data[:person], form: data[:form]) + + # Fill in required text fields with sample data + data[:form].form_fields.where(answer_type: [ :free_form_input_one_line, :free_form_input_paragraph ]).each do |field| + sample_text = case field.field_key + when "first_name" then data[:person].first_name + when "last_name" then data[:person].last_name + when "primary_email", "enter_email", "confirm_email" then data[:person].preferred_email || "sample@example.com" + when "phone" then "(555) #{rand(100..999)}-#{rand(1000..9999)}" + when "street_address", "agency_street_address" then Faker::Address.street_address + when "city", "agency_city" then Faker::Address.city + when "state_province", "agency_state_province" then Faker::Address.state_abbr + when "zip_postal_code", "agency_zip_postal_code" then Faker::Address.zip_code + when "agency_organization_name" then Faker::Company.name + when "position_title" then "Facilitator" + when "agency_website" then "https://example.org" + when "racial_ethnic_identity" then "Prefer not to say" + when "secondary_email" then data[:person].email_2 + when "preferred_nickname" then data[:person].first_name + when "pronouns" then [ "she/her", "he/him", "they/them" ].sample + else + if field.answer_type == "free_form_input_paragraph" + Faker::Lorem.paragraph(sentence_count: 3) + else + Faker::Lorem.word.capitalize + end + end + + PersonFormFormField.create!( + person_form: pf, + form_field: field, + text: sample_text.to_s + ) + end +end puts "Creating Resources…" 10.times do |i| diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 9502acebc..da600744f 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -108,7 +108,7 @@ it "creates registration and redirects admin to confirm page" do expect { post event_registrations_path, - params: { event_registration: { event_id: event.id, registrant_id: admin.person.id } } + params: { return_to: "manage", event_registration: { event_id: event.id, registrant_id: admin.person.id } } }.to change(EventRegistration, :count).by(1) registration = EventRegistration.last @@ -273,7 +273,7 @@ } }.not_to change(EventRegistration, :count) - expect(response).to redirect_to(manage_event_path(event)) + expect(response).to redirect_to(event_registrations_path) expect(flash[:alert]).to be_present end end diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb new file mode 100644 index 000000000..59a14ff42 --- /dev/null +++ b/spec/system/event_registration_edit_spec.rb @@ -0,0 +1,22 @@ +require "rails_helper" + +RSpec.describe "Event registration edit page", type: :system do + let(:admin) { create(:user, :with_person, super_user: true) } + let(:event) { create(:event, :published, title: "Test Event") } + let!(:registration) { create(:event_registration, event: event, registrant: admin.person) } + + describe "delete button" do + it "deletes the registration" do + sign_in(admin) + visit edit_event_registration_path(registration) + + accept_confirm("Are you sure you want to delete?") do + click_on "Delete" + end + + expect(page).to have_current_path(event_registrations_path) + expect(page).to have_text("Registration deleted") + expect(EventRegistration.exists?(registration.id)).to be false + end + end +end From 322cf1a8e7e7a358c04479c054a488034ca99ce8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 16:51:32 -0500 Subject: [PATCH 05/13] Use rhino_description for registration confirmation email (#1291) * Use rhino_description for registration confirmation email details The email was showing the plain-text description column (lorem ipsum placeholder) instead of the actual event details from rhino_description, which is the same content used in calendar events. Co-Authored-By: Claude Opus 4.6 * Add EventMailer spec covering confirmation and cancellation emails Tests that rhino_description content appears in the confirmation email body, and verifies basic rendering for both mailer actions. Co-Authored-By: Claude Opus 4.6 * Remove cancelled email tests from EventMailer spec Cancellation email doesn't reference description, so no test needed. Co-Authored-By: Claude Opus 4.6 * Fix rubocop empty line at block body end Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../event_registration_confirmation.html.erb | 4 +- .../event_registration_confirmation.text.erb | 4 +- spec/mailers/event_mailer_spec.rb | 49 +++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 spec/mailers/event_mailer_spec.rb diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index 7fc392cdd..08b151a7a 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -50,10 +50,10 @@ <% end %> - <% if @event.detail.present? %> + <% if @event.rhino_description.present? %> Details

- <%= simple_format(@event.detail) %> + <%= simple_format(@event.rhino_description.to_plain_text) %>


<% end %> diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index bcbe8a3fa..bd0a2e5f2 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -20,9 +20,9 @@ Videoconference URL: <%= @event.videoconference_url %> <%= @event.labelled_cost %> <% end %> -<% if @event.detail.present? %> +<% if @event.rhino_description.present? %> Event details: - <%= @event.detail %> + <%= @event.rhino_description.to_plain_text %> <% end %> View your registration: diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb new file mode 100644 index 000000000..5ed83c5d2 --- /dev/null +++ b/spec/mailers/event_mailer_spec.rb @@ -0,0 +1,49 @@ +require "rails_helper" + +RSpec.describe EventMailer, type: :mailer do + describe "#event_registration_confirmation" do + let(:event_registration) { create(:event_registration) } + let(:mail) { described_class.event_registration_confirmation(event_registration) } + + it "renders without raising" do + expect { mail.deliver_now }.not_to raise_error + end + + it "sends to the registrant" do + expect(mail.to).to eq([ event_registration.registrant.preferred_email ]) + end + + it "includes the event title in the subject" do + expect(mail.subject).to include(event_registration.event.title) + end + + it "includes the event title in the body" do + expect(mail.body.encoded).to include(event_registration.event.title) + end + + it "includes the registrant name in the body" do + expect(mail.body.encoded).to include(event_registration.registrant.full_name) + end + + context "when the event has a rhino_description" do + let(:event) { create(:event, rhino_description: "Join us for an art healing workshop") } + let(:event_registration) { create(:event_registration, event: event) } + + it "includes the rhino_description in the body" do + expect(mail.body.encoded).to include("Join us for an art healing workshop") + end + + it "includes the Details heading" do + expect(mail.body.encoded).to include("Details") + end + end + + context "when the event has no rhino_description" do + let(:event_registration) { create(:event_registration) } + + it "does not include the Details section" do + expect(mail.body.encoded).not_to include("Details") + end + end + end +end From 7c37356f122d0b4b16f95ec46fb406fe5c9db8df Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 17:01:58 -0500 Subject: [PATCH 06/13] Add direct organization associations to event registrations (#1292) * Add direct organization associations to event registrations Registrations now have their own many-to-many relationship with organizations via a join table, decoupled from the registrant's affiliations. Active orgs are snapshotted at registration time, and admins can add/remove orgs from the registration edit page using toggleable chips and a Stimulus controller. Co-Authored-By: Claude Opus 4.6 * Register people with multiple affiliations in seeds Ensures Mariana Johnson (3 affiliations), Samuel Smith, Lisa Williamson, Kim Davidson, and Sarah Davis (2 each) are registered to events so their organizations get snapshotted via the after_create callback. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../event_registrations_controller.rb | 5 +- app/controllers/events_controller.rb | 2 +- app/frontend/javascript/controllers/index.js | 3 ++ .../controllers/org_toggle_controller.js | 50 +++++++++++++++++++ app/models/event_registration.rb | 9 ++++ app/models/event_registration_organization.rb | 6 +++ app/models/organization.rb | 2 + app/views/event_registrations/_form.html.erb | 48 +++++++++++++----- app/views/event_registrations/index.html.erb | 2 + app/views/events/_manage_results.html.erb | 18 ++----- ...create_event_registration_organizations.rb | 15 ++++++ db/schema.rb | 14 +++++- db/seeds/dummy_dev_seeds.rb | 13 +++++ .../event_registration_organizations.rb | 6 +++ .../event_registration_organization_spec.rb | 14 ++++++ spec/models/event_registration_spec.rb | 48 ++++++++++++++++++ 16 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 app/frontend/javascript/controllers/org_toggle_controller.js create mode 100644 app/models/event_registration_organization.rb create mode 100644 db/migrate/20260301143000_create_event_registration_organizations.rb create mode 100644 spec/factories/event_registration_organizations.rb create mode 100644 spec/models/event_registration_organization_spec.rb diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 9e44fddfa..144178bd7 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -159,14 +159,15 @@ def set_form_variables private def set_event_registration - @event_registration = EventRegistration.includes(:registrant, { event: [ :location, :event_forms ] }, comments: [ :created_by, :updated_by ]).find(params[:id]) + @event_registration = EventRegistration.includes({ registrant: { affiliations: :organization } }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end # Strong parameters def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, - :scholarship_recipient, :scholarship_tasks_completed, + :scholarship_requested, :scholarship_recipient, :scholarship_tasks_completed, + organization_ids: [], comments_attributes: [ :id, :body, :_destroy ] ) end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 4188bc8dc..25fb20354 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -39,7 +39,7 @@ def manage authorize! @event, to: :manage? @event = @event.decorate scope = @event.event_registrations - .includes(:payments, :comments, registrant: [ :user, :contact_methods, { affiliations: :organization }, { avatar_attachment: :blob } ]) + .includes(:payments, :comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present? diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index b1ca21084..1c152e3cb 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -45,6 +45,9 @@ application.register("inactive-toggle", InactiveToggleController) import OptimisticBookmarkController from "./optimistic_bookmark_controller" application.register("optimistic-bookmark", OptimisticBookmarkController) +import OrgToggleController from "./org_toggle_controller" +application.register("org-toggle", OrgToggleController) + import PaginatedFieldsController from "./paginated_fields_controller" application.register("paginated-fields", PaginatedFieldsController) diff --git a/app/frontend/javascript/controllers/org_toggle_controller.js b/app/frontend/javascript/controllers/org_toggle_controller.js new file mode 100644 index 000000000..dec1cc027 --- /dev/null +++ b/app/frontend/javascript/controllers/org_toggle_controller.js @@ -0,0 +1,50 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="org-toggle" +// Allows adding an org from the "current orgs" list to the +// "organizations at registration" chip list. + +export default class extends Controller { + static targets = ["chips"] + + add(event) { + const button = event.currentTarget + const orgId = button.dataset.orgId + const orgName = button.dataset.orgName + + // Check if already present + const existing = this.chipsTarget.querySelector(`input[value="${orgId}"]`) + if (existing) { + existing.checked = true + return + } + + const label = document.createElement("label") + label.className = "inline-flex items-center gap-1 px-4 py-1 rounded-full text-xs border cursor-pointer transition-colors bg-red-50 border-red-200 text-red-400 line-through has-[:checked]:bg-emerald-50 has-[:checked]:border-emerald-200 has-[:checked]:text-emerald-800 has-[:checked]:no-underline" + + const input = document.createElement("input") + input.type = "checkbox" + input.name = "event_registration[organization_ids][]" + input.value = orgId + input.checked = true + input.className = "sr-only" + + const nameSpan = document.createElement("span") + nameSpan.textContent = orgName + + const xSpan = document.createElement("span") + xSpan.className = "text-xs opacity-50" + xSpan.innerHTML = "×" + + label.appendChild(input) + label.appendChild(nameSpan) + label.appendChild(xSpan) + + this.chipsTarget.appendChild(label) + + // Show the section if it was hidden + this.chipsTarget.closest("[data-org-toggle-target='section']")?.classList.remove("hidden") + + button.remove() + } +} diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 5716d66f9..793c196a5 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -2,7 +2,9 @@ class EventRegistration < ApplicationRecord belongs_to :registrant, class_name: "Person" belongs_to :event has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy + has_many :event_registration_organizations, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :destroy + has_many :organizations, through: :event_registration_organizations has_many :payments, as: :payable before_destroy :create_refund_payments @@ -10,6 +12,7 @@ class EventRegistration < ApplicationRecord accepts_nested_attributes_for :comments, reject_if: proc { |attrs| attrs["body"].blank? } before_create :generate_slug + after_create :snapshot_registrant_organizations after_commit :send_cancellation_emails, if: :status_changed_to_cancelled? ACTIVE_STATUSES = %w[ registered attended incomplete_attendance ].freeze @@ -127,6 +130,12 @@ def attendance_status_label private + def snapshot_registrant_organizations + registrant.affiliations.active.includes(:organization).find_each do |aff| + event_registration_organizations.create(organization: aff.organization) + end + end + def create_refund_payments paid_cents = payments.successful.sum(:amount_cents) return if paid_cents <= 0 diff --git a/app/models/event_registration_organization.rb b/app/models/event_registration_organization.rb new file mode 100644 index 000000000..c8a3b1d37 --- /dev/null +++ b/app/models/event_registration_organization.rb @@ -0,0 +1,6 @@ +class EventRegistrationOrganization < ApplicationRecord + belongs_to :event_registration + belongs_to :organization + + validates :organization_id, uniqueness: { scope: :event_registration_id } +end diff --git a/app/models/organization.rb b/app/models/organization.rb index 1cbe9a8a4..11fdf835e 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -7,6 +7,8 @@ class Organization < ApplicationRecord has_many :addresses, as: :addressable, dependent: :destroy has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :affiliations, dependent: :restrict_with_error + has_many :event_registration_organizations, dependent: :restrict_with_error + has_many :event_registrations, through: :event_registration_organizations has_many :people, through: :affiliations has_many :users, through: :people has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 95307ed8b..a2c946932 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -2,7 +2,7 @@ <%= hidden_field_tag :return_to, params[:return_to] if params[:return_to].present? %> <%= render 'shared/errors', resource: event_registration if event_registration.errors.any? %> -
+
<% if f.object.persisted? %> @@ -25,10 +25,16 @@
<% if f.object.persisted? %> -
+
<%= person_profile_button(f.object.registrant, subtitle: f.object.registrant.preferred_email) %> - <% f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.each do |org| %> - <%= organization_profile_button(org) %> + <% active_orgs = f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.sort_by(&:name) %> + <% connected_org_ids = f.object.organizations.map(&:id) %> + <% if active_orgs.any? %> +

+ <% active_orgs.each_with_index do |org, i| %> + <%= org.name %><% unless connected_org_ids.include?(org.id) %><% end %><%= ", " unless i == active_orgs.size - 1 %> + <% end %> +

<% end %>
<% else %> @@ -61,6 +67,26 @@ selected: f.object.status %>
+
> + +
+ <% f.object.organizations.sort_by(&:name).each do |org| %> + + <% end %> +
+

Click to toggle. Crossed-out organizations will be removed on save.

+
+
+ +
<% if f.object.event.cost_cents.to_i > 0 %> @@ -79,17 +105,15 @@ <% else %>

Free event

<% end %> - <% if f.object.scholarship? %> - Scholarship recipient - <% end %>
- <% if f.object.scholarship_requested? %> - Scholarship requested - <% end %> - <%= f.input :scholarship_recipient, as: :boolean %> - <%= f.input :scholarship_tasks_completed, as: :boolean, label: "Scholarship tasks completed" %> +
+
Scholarship
+ <%= f.input :scholarship_requested, as: :boolean %> + <%= f.input :scholarship_recipient, as: :boolean %> + <%= f.input :scholarship_tasks_completed, as: :boolean, label: "Scholarship tasks completed" %> +
<% end %> diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb index 29282f4d0..a807f6c01 100644 --- a/app/views/event_registrations/index.html.erb +++ b/app/views/event_registrations/index.html.erb @@ -7,6 +7,8 @@ <%= EventRegistration.model_name.human.pluralize %> (<%= @event_registrations_count %>)
+ <%= link_to "Events", events_path, + class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <%= link_to "Export CSV", event_registrations_path(request.query_parameters.merge(format: :csv)), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb index f6001f29d..004bdfd39 100644 --- a/app/views/events/_manage_results.html.erb +++ b/app/views/events/_manage_results.html.erb @@ -52,20 +52,12 @@
- <% orgs = person.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq %> - <% if orgs.any? %> -
- <% orgs.first(3).each do |org| %> - <%= link_to org.name, organization_path(org), - data: { turbo_frame: "_top" }, - class: "inline-block px-3 py-1 rounded-md text-sm font-medium #{DomainTheme.bg_class_for(:organizations, intensity: 100)} #{DomainTheme.text_class_for(:organizations)} #{DomainTheme.bg_class_for(:organizations, intensity: 100, hover: true)}" %> + <% if registration.organizations.any? %> +
    + <% registration.organizations.each do |org| %> +
  • <%= org.name %>
  • <% end %> - <% if orgs.size > 3 %> - <%= link_to "+#{orgs.size - 3}", person_path(person), - data: { turbo_frame: "_top" }, - class: "inline-block px-3 py-1 rounded-md text-sm font-medium text-gray-500 hover:text-gray-700" %> - <% end %> -
+ <% else %> <% end %> diff --git a/db/migrate/20260301143000_create_event_registration_organizations.rb b/db/migrate/20260301143000_create_event_registration_organizations.rb new file mode 100644 index 000000000..f151d00e4 --- /dev/null +++ b/db/migrate/20260301143000_create_event_registration_organizations.rb @@ -0,0 +1,15 @@ +class CreateEventRegistrationOrganizations < ActiveRecord::Migration[8.1] + def change + create_table :event_registration_organizations do |t| + t.references :event_registration, null: false, foreign_key: true + t.references :organization, null: false, foreign_key: true, type: :integer + + t.timestamps + end + + add_index :event_registration_organizations, + [ :event_registration_id, :organization_id ], + unique: true, + name: "idx_event_reg_orgs_on_registration_and_org" + end +end diff --git a/db/schema.rb b/db/schema.rb index ee45b0729..a5a0a96e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_01_120200) do +ActiveRecord::Schema[8.1].define(version: 2026_03_01_143000) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -408,6 +408,16 @@ t.index ["form_id"], name: "index_event_forms_on_form_id" end + create_table "event_registration_organizations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "event_registration_id", null: false + t.integer "organization_id", null: false + t.datetime "updated_at", null: false + t.index ["event_registration_id", "organization_id"], name: "idx_event_reg_orgs_on_registration_and_org", unique: true + t.index ["event_registration_id"], name: "idx_on_event_registration_id_806bdcd019" + t.index ["organization_id"], name: "index_event_registration_organizations_on_organization_id" + end + create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "event_id" @@ -1342,6 +1352,8 @@ add_foreign_key "contact_methods", "addresses" add_foreign_key "event_forms", "events" add_foreign_key "event_forms", "forms" + add_foreign_key "event_registration_organizations", "event_registrations" + add_foreign_key "event_registration_organizations", "organizations" add_foreign_key "event_registrations", "events" add_foreign_key "event_registrations", "people", column: "registrant_id" add_foreign_key "events", "locations" diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index a0eca2fc2..9cf0f20d0 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -1033,6 +1033,19 @@ registrations_data << { person: lisa_w, event: roundtable, status: "incomplete_attendance" } end +# --- People with multiple active affiliations — ensures org snapshots get exercised --- +mariana_j = Person.find_by(first_name: "Mariana", last_name: "Johnson") +samuel_s = Person.find_by(first_name: "Samuel", last_name: "Smith") +lisa_wn = Person.find_by(first_name: "Lisa", last_name: "Williamson") +kim_dv = Person.find_by(first_name: "Kim", last_name: "Davidson") +sarah_d = Person.find_by(first_name: "Sarah", last_name: "Davis") + +{ mariana_j => youth_day, samuel_s => mindful_art, lisa_wn => virtual_session, + kim_dv => family_day, sarah_d => roundtable }.each do |person, evt| + next unless person && evt + registrations_data << { person: person, event: evt, status: "registered" } +end + # --- Wellness Day gets extra registrations (popular free event, short form) --- if wellness_day [ sarah_s, jessica_b, lisa_w, kim_d ].compact.each do |person| diff --git a/spec/factories/event_registration_organizations.rb b/spec/factories/event_registration_organizations.rb new file mode 100644 index 000000000..624721636 --- /dev/null +++ b/spec/factories/event_registration_organizations.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :event_registration_organization do + association :event_registration + association :organization + end +end diff --git a/spec/models/event_registration_organization_spec.rb b/spec/models/event_registration_organization_spec.rb new file mode 100644 index 000000000..385deb7fa --- /dev/null +++ b/spec/models/event_registration_organization_spec.rb @@ -0,0 +1,14 @@ +require "rails_helper" + +RSpec.describe EventRegistrationOrganization, type: :model do + describe "associations" do + it { should belong_to(:event_registration).required } + it { should belong_to(:organization).required } + end + + describe "validations" do + subject { create(:event_registration_organization) } + + it { should validate_uniqueness_of(:organization_id).scoped_to(:event_registration_id) } + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 6079feacf..863bd77f0 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -7,6 +7,8 @@ it { should belong_to(:event).required } it { should belong_to(:registrant).required } it { should have_many(:comments).dependent(:destroy) } + it { should have_many(:event_registration_organizations).dependent(:destroy) } + it { should have_many(:organizations).through(:event_registration_organizations) } end describe "#active?" do @@ -259,6 +261,52 @@ end end + describe "snapshot_registrant_organizations" do + it "copies active affiliations to the registration on create" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to include(org) + end + + it "copies multiple active affiliations" do + org1 = create(:organization) + org2 = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org1) + create(:affiliation, person: person, organization: org2) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to contain_exactly(org1, org2) + end + + it "skips inactive affiliations" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org, inactive: true) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + + it "skips affiliations with past end dates" do + org = create(:organization) + person = create(:person) + create(:affiliation, person: person, organization: org, end_date: 1.day.ago) + + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + + it "creates no records when registrant has no affiliations" do + person = create(:person) + reg = create(:event_registration, registrant: person) + expect(reg.organizations).to be_empty + end + end + describe "slug" do it "generates a slug on create" do registration = create(:event_registration) From fb4aa8b3c1e1d04de011d53c45bb3beca6c2c19d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 19:57:27 -0500 Subject: [PATCH 07/13] Rescue RecordNotUnique in controllers with ActionText fields (#1294) A RecordNotUnique error (duplicate action_text_rich_texts entry) was surfacing as a 500 in production during workshop updates, likely from a race condition (e.g. double form submission). The transaction rescue blocks only caught RecordInvalid and RecordNotSaved, letting the uniqueness violation propagate uncaught. Added RecordNotUnique to rescue clauses in all 10 controllers that save models with has_rich_text fields, so duplicate-key errors roll back gracefully and re-render the form instead of crashing. Co-authored-by: Claude Opus 4.6 --- app/controllers/community_news_controller.rb | 4 +- app/controllers/events_controller.rb | 4 +- app/controllers/resources_controller.rb | 4 +- app/controllers/stories_controller.rb | 4 +- app/controllers/story_ideas_controller.rb | 4 +- app/controllers/tutorials_controller.rb | 4 +- app/controllers/workshop_ideas_controller.rb | 10 +++++ .../workshop_variation_ideas_controller.rb | 10 +++++ .../workshop_variations_controller.rb | 2 +- app/controllers/workshops_controller.rb | 4 +- spec/requests/workshop_ideas_spec.rb | 25 +++++++++++ .../requests/workshop_variation_ideas_spec.rb | 27 ++++++++++++ spec/requests/workshops_spec.rb | 41 +++++++++++++++++++ 13 files changed, 128 insertions(+), 15 deletions(-) diff --git a/app/controllers/community_news_controller.rb b/app/controllers/community_news_controller.rb index ac5792d9c..11012c7b0 100644 --- a/app/controllers/community_news_controller.rb +++ b/app/controllers/community_news_controller.rb @@ -71,7 +71,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Community news create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -95,7 +95,7 @@ def update assign_associations(@community_news) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Community news update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 25fb20354..c043b3f52 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -75,7 +75,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Event create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -105,7 +105,7 @@ def update else raise ActiveRecord::Rollback end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Event update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/resources_controller.rb b/app/controllers/resources_controller.rb index c12c50b0e..a519531a7 100644 --- a/app/controllers/resources_controller.rb +++ b/app/controllers/resources_controller.rb @@ -73,7 +73,7 @@ def create assign_associations(@resource) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Resource create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -99,7 +99,7 @@ def update assign_associations(@resource) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Resource update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb index f483efcfb..bc1fdb081 100644 --- a/app/controllers/stories_controller.rb +++ b/app/controllers/stories_controller.rb @@ -75,7 +75,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Story create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -101,7 +101,7 @@ def update end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Story update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/story_ideas_controller.rb b/app/controllers/story_ideas_controller.rb index e7e287759..5e1bfe803 100644 --- a/app/controllers/story_ideas_controller.rb +++ b/app/controllers/story_ideas_controller.rb @@ -59,7 +59,7 @@ def create notification_type: 0) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "StoryIdea create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -88,7 +88,7 @@ def update assign_associations(@story_idea) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "StoryIdea update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb index aca8c3f7d..17d5faf23 100644 --- a/app/controllers/tutorials_controller.rb +++ b/app/controllers/tutorials_controller.rb @@ -51,7 +51,7 @@ def create assign_associations(@tutorial) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Tutorial create failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end @@ -75,7 +75,7 @@ def update assign_associations(@tutorial) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e Rails.logger.error "Tutorial update failed: #{e.class} - #{e.message}" raise ActiveRecord::Rollback end diff --git a/app/controllers/workshop_ideas_controller.rb b/app/controllers/workshop_ideas_controller.rb index 2b1eda2c6..681e2fd0a 100644 --- a/app/controllers/workshop_ideas_controller.rb +++ b/app/controllers/workshop_ideas_controller.rb @@ -43,6 +43,11 @@ def create set_form_variables render :new, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopIdea create failed: #{e.class} - #{e.message}" + @workshop_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :new, status: :unprocessable_content end def edit @@ -59,6 +64,11 @@ def update set_form_variables render :edit, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopIdea update failed: #{e.class} - #{e.message}" + @workshop_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :edit, status: :unprocessable_content end def destroy diff --git a/app/controllers/workshop_variation_ideas_controller.rb b/app/controllers/workshop_variation_ideas_controller.rb index 773cc8cda..b3c305cf1 100644 --- a/app/controllers/workshop_variation_ideas_controller.rb +++ b/app/controllers/workshop_variation_ideas_controller.rb @@ -70,6 +70,11 @@ def create set_form_variables render :new, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopVariationIdea create failed: #{e.class} - #{e.message}" + @workshop_variation_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :new, status: :unprocessable_content end def update @@ -80,6 +85,11 @@ def update set_form_variables render :edit, status: :unprocessable_content end + rescue ActiveRecord::RecordNotUnique => e + Rails.logger.error "WorkshopVariationIdea update failed: #{e.class} - #{e.message}" + @workshop_variation_idea.errors.add(:base, "could not be saved due to a conflict. Please try again.") + set_form_variables + render :edit, status: :unprocessable_content end def destroy diff --git a/app/controllers/workshop_variations_controller.rb b/app/controllers/workshop_variations_controller.rb index 94fb27403..3c55fa029 100644 --- a/app/controllers/workshop_variations_controller.rb +++ b/app/controllers/workshop_variations_controller.rb @@ -35,7 +35,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("creation", e) raise ActiveRecord::Rollback end diff --git a/app/controllers/workshops_controller.rb b/app/controllers/workshops_controller.rb index d132fc940..03ac9f226 100644 --- a/app/controllers/workshops_controller.rb +++ b/app/controllers/workshops_controller.rb @@ -100,7 +100,7 @@ def create end success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("creation", e) raise ActiveRecord::Rollback end @@ -158,7 +158,7 @@ def update assign_associations(@workshop) success = true end - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e log_workshop_error("update", e) raise ActiveRecord::Rollback end diff --git a/spec/requests/workshop_ideas_spec.rb b/spec/requests/workshop_ideas_spec.rb index 49ceae05c..1bd7af479 100644 --- a/spec/requests/workshop_ideas_spec.rb +++ b/spec/requests/workshop_ideas_spec.rb @@ -78,6 +78,18 @@ expect(response).to redirect_to(workshop_idea_path(WorkshopIdea.last)) end + + it "handles RecordNotUnique gracefully" do + allow_any_instance_of(WorkshopIdea).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshop_ideas_path, params: { workshop_idea: valid_attributes } + }.not_to change(WorkshopIdea, :count) + + expect(response).to have_http_status(:unprocessable_content) + end end describe "PATCH /update" do @@ -90,6 +102,19 @@ expect(idea.reload.title).to eq("Updated Title") expect(response).to redirect_to(workshop_idea_path(idea)) end + + it "handles RecordNotUnique gracefully" do + idea = create(:workshop_idea, valid_attributes) + + allow_any_instance_of(WorkshopIdea).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_idea_path(idea), + params: { workshop_idea: { title: "Updated Title" } } + + expect(response).to have_http_status(:unprocessable_content) + end end describe "DELETE /destroy" do diff --git a/spec/requests/workshop_variation_ideas_spec.rb b/spec/requests/workshop_variation_ideas_spec.rb index 8df894035..14a8593f0 100644 --- a/spec/requests/workshop_variation_ideas_spec.rb +++ b/spec/requests/workshop_variation_ideas_spec.rb @@ -109,6 +109,20 @@ expect(response).to have_http_status(:unprocessable_content) end end + + context "when RecordNotUnique is raised" do + it "handles it gracefully" do + allow_any_instance_of(WorkshopVariationIdea).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshop_variation_ideas_path, params: { workshop_variation_idea: valid_attributes } + }.not_to change(WorkshopVariationIdea, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end end describe "PATCH /update" do @@ -121,6 +135,19 @@ expect(idea.reload.name).to eq("Updated Name") expect(response).to redirect_to(workshop_variation_idea_path(idea)) end + + it "handles RecordNotUnique gracefully" do + idea = create(:workshop_variation_idea, valid_attributes) + + allow_any_instance_of(WorkshopVariationIdea).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_variation_idea_path(idea), + params: { workshop_variation_idea: { name: "Updated Name" } } + + expect(response).to have_http_status(:unprocessable_content) + end end describe "DELETE /destroy" do diff --git a/spec/requests/workshops_spec.rb b/spec/requests/workshops_spec.rb index 3ac3061db..254923873 100644 --- a/spec/requests/workshops_spec.rb +++ b/spec/requests/workshops_spec.rb @@ -100,4 +100,45 @@ end end end + + # --- RECORD NOT UNIQUE HANDLING ----------------------------------------------- + describe "RecordNotUnique handling" do + let(:admin) { create(:user, :admin) } + + before { sign_in admin } + + describe "POST /create" do + it "handles RecordNotUnique gracefully" do + allow_any_instance_of(Workshop).to receive(:save).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + expect { + post workshops_url, params: { + workshop: { title: "Test Workshop", category_ids: [ "" ] } + } + }.not_to change(Workshop, :count) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Unable to save the workshop") + end + end + + describe "PATCH /update" do + it "handles RecordNotUnique gracefully" do + workshop = create(:workshop) + + allow_any_instance_of(Workshop).to receive(:update).and_raise( + ActiveRecord::RecordNotUnique.new("Duplicate entry") + ) + + patch workshop_url(workshop), params: { + workshop: { title: "Updated Title", category_ids: [ "" ] } + } + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Unable to update the workshop") + end + end + end end From f4f191192c6a6b73fb228387337d4560b057a020 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 20:23:12 -0500 Subject: [PATCH 08/13] Prevent deletion of users with associated records (#1295) * Prevent deletion of users with associated records Users who have created reports, workshop logs, resources, workshops, stories, or ideas can no longer be deleted. The delete button is hidden in the UI and the controller rescues InvalidForeignKey as a safety net. Co-Authored-By: Claude Opus 4.6 * Fix report factory in deletable? test to include workshop The report factory doesn't include a workshop association, but the Report model requires one. Pass it explicitly in the test. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/controllers/users_controller.rb | 2 ++ app/models/user.rb | 23 ++++++++++++----------- app/views/users/_form.html.erb | 2 +- spec/models/user_spec.rb | 22 ++++++++++++++++++++++ spec/requests/users_spec.rb | 9 +++++++++ 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 78ed72cba..966402b7f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -151,6 +151,8 @@ def destroy authorize! @user @user.destroy! redirect_to users_path, notice: "User was successfully destroyed." + rescue ActiveRecord::InvalidForeignKey + redirect_to @user, alert: "Unable to delete this user because they have associated records that cannot be removed." end # --------------------------------------------------------- diff --git a/app/models/user.rb b/app/models/user.rb index 2b5ee786a..e8f9bde52 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,7 +19,6 @@ class User < ApplicationRecord after_update :track_password_reset_sent before_destroy :track_account_deleted - before_destroy :reassign_reports_and_logs_to_orphaned_user # Associations belongs_to :person, optional: true @@ -171,6 +170,18 @@ def organization_workshop_logs(date, windows_type, organization_id) end end + def deletable? + !reports.exists? && + !workshop_logs.exists? && + !resources.exists? && + !workshops.exists? && + !stories_as_creator.exists? && + !story_ideas_as_creator.exists? && + !workshop_ideas_as_creator.exists? && + !workshop_variations_as_creator.exists? && + !workshop_variation_ideas_creator.exists? + end + def name person ? person.name : email end @@ -243,16 +254,6 @@ def person_id_must_be_present_if_previously_set errors.add(:person_id, "cannot be removed once set") end - def reassign_reports_and_logs_to_orphaned_user - orphaned_user = User.find_by(email: "orphaned_reports@awbw.org") - return unless orphaned_user - - # Reassign reports - reports.update_all(created_by_id: orphaned_user.id) - - # Reassign workshop_logs - workshop_logs.update_all(created_by_id: orphaned_user.id) - end def after_confirmation super diff --git a/app/views/users/_form.html.erb b/app/views/users/_form.html.erb index 1ff64fb68..f4c1a93df 100644 --- a/app/views/users/_form.html.erb +++ b/app/views/users/_form.html.erb @@ -140,7 +140,7 @@ <% end %>
- <% if allowed_to?(:destroy?, f.object) %> + <% if allowed_to?(:destroy?, f.object) && f.object.deletable? %> <%= link_to "Delete", @user, class: "btn btn-danger-outline", data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete?" } %> <% end %> diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 7e992aabf..b57c8ce3b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -267,6 +267,28 @@ end end + describe "#deletable?" do + it "returns true when user has no created records" do + user = create(:user) + expect(user.deletable?).to be true + end + + it "returns false when user has created reports" do + report = create(:report, workshop: create(:workshop)) + expect(report.created_by.deletable?).to be false + end + + it "returns false when user has created workshops" do + workshop = create(:workshop) + expect(workshop.created_by.deletable?).to be false + end + + it "returns false when user has created resources" do + resource = create(:resource) + expect(resource.created_by.deletable?).to be false + end + end + describe '.search_by_params' do let!(:admin_user) { create(:user, first_name: 'Alice', last_name: 'Admin', email: 'alice@example.com', super_user: true) } let!(:regular_user) { create(:user, first_name: 'Bob', last_name: 'Regular', email: 'bob@example.com', super_user: false) } diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index 3b3f22dfc..767c353d3 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -286,6 +286,15 @@ delete user_url(user) expect(response).to redirect_to(users_url) end + + it "does not destroy a user with created records and shows alert" do + create(:workshop, created_by: user) + expect { + delete user_url(user) + }.not_to change(User, :count) + expect(response).to redirect_to(user_url(user)) + expect(flash[:alert]).to be_present + end end context "as regular_user" do From 15cba8d68e3aa214997d59cd220a922d093dc335 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 20:57:20 -0500 Subject: [PATCH 09/13] Fix workshop form admin layout (#1298) * Remove dead assets/form partial reference from workshop edit The partial was removed in a previous revert but the render call was left behind, causing a MissingTemplate error on ?admin=true. Co-Authored-By: Claude Opus 4.6 * Fix workshop form admin fields layout and conditional author name Stack admin fields vertically instead of horizontally to prevent label truncation and field overlap. Only show Author's name field when the value is present and ?admin=true. Co-Authored-By: Claude Opus 4.6 * Move workshop created date under Workshop Idea parent Relocate the date field from the bottom of the form into the admin grid column alongside Workshop Idea parent. Rename label from "Date created" to "Workshop created" with consistent sizing. Co-Authored-By: Claude Opus 4.6 * Remove full_name fill from create workshop test The Author's name field is now conditionally shown only when the value is present and ?admin=true, so it won't appear on the new workshop form. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/views/workshops/_form.html.erb | 60 +++++++++++++++--------------- app/views/workshops/edit.html.erb | 6 --- spec/system/workshops_spec.rb | 1 - 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/app/views/workshops/_form.html.erb b/app/views/workshops/_form.html.erb index 3a60feceb..e84f6f791 100644 --- a/app/views/workshops/_form.html.erb +++ b/app/views/workshops/_form.html.erb @@ -44,14 +44,16 @@
<% if allowed_to?(:manage?, Workshop) %> -
- <%= f.input :full_name, as: :text, - label: "Author's name", - hint: "Display name", - input_html: { - rows: 1, - class: "block w-full rounded-md border-gray-300 shadow-sm - focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ <% if f.object.full_name.present? && params[:admin] == "true" %> + <%= f.input :full_name, as: :text, + label: "Author's name", + hint: "Display name", + input_html: { + rows: 1, + class: "block w-full rounded-md border-gray-300 shadow-sm + focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <% end %>
<%= f.input :created_by_id, @@ -74,7 +76,7 @@ label: "Author credit preference", hint: "Controls how the author's name is displayed" %>
-
+
<%= f.input :workshop_idea_id, as: :select, label: "Workshop Idea parent", @@ -84,6 +86,24 @@ hint: "If this workshop was created from a Workshop Idea, select it here.", input_html: { value: f.object.workshop_idea_id, class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> +
+ +
+ <%= f.input :month, as: :select, + collection: Date::MONTHNAMES.compact.each_with_index.map { |m, i| [m, i + 1] }, + selected: f.object.month || Date.today.month, + wrapper: false, label: false, + input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> + <%= f.input :year, + as: :integer, + wrapper: false, + label: false, + input_html: { + value: f.object.year || Date.today.year, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" + } %> +
+
@@ -228,28 +248,6 @@ <%= rhino_editor(f, :extra_field) %> <% end %> - <% if allowed_to?(:manage?, Workshop) %> -
- - -
- <%= f.input :month, as: :select, - collection: Date::MONTHNAMES.compact.each_with_index.map { |m, i| [m, i + 1] }, - selected: f.object.month || Date.today.month, - wrapper: false, label: false, - input_html: { class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %> - - <%= f.input :year, - as: :integer, - wrapper: false, - label: false, - input_html: { - value: f.object.year || Date.today.year, - class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" - } %> -
-
- <% end %> <% end %> diff --git a/app/views/workshops/edit.html.erb b/app/views/workshops/edit.html.erb index 68e8bf5c8..1794ed784 100644 --- a/app/views/workshops/edit.html.erb +++ b/app/views/workshops/edit.html.erb @@ -16,11 +16,5 @@ <%= render "form", workshop: @workshop %> <%= render "shared/audit_info", resource: @workshop %>
- <% if params[:admin] == 'true' %> -
<%= render "assets/form", owner: @workshop %>
- <%= turbo_frame_tag "editor_assets_lazy", src: edit_workshop_path(@workshop) do %> - <%= render "shared/loading" %> - <% end %> - <% end %>
diff --git a/spec/system/workshops_spec.rb b/spec/system/workshops_spec.rb index 544d3989d..5ee3b95e7 100644 --- a/spec/system/workshops_spec.rb +++ b/spec/system/workshops_spec.rb @@ -122,7 +122,6 @@ select adult_window.short_name, from: 'workshop_windows_type_id' find("#workshop_published", visible: :all).check find('#body-button').click - fill_in 'workshop_full_name', with: 'Jane Doe' click_on 'Submit' From 34a1a0da1a37446b51100ef439dd48d9a2cef847 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 21:06:42 -0500 Subject: [PATCH 10/13] Allow workshop logs with external title and no workshop (#1299) * Allow workshop logs without workshop_id when external title present Workshop logs can now be submitted with an external_workshop_title instead of requiring a workshop_id. This supports users logging workshops that don't exist in the system. - Make workshop association optional on Report - Add migration to remove NOT NULL constraint on reports.workshop_id - Add custom validation requiring either workshop_id or external_workshop_title Co-Authored-By: Claude Opus 4.6 * Show external workshop title on workshop log show page Display external_workshop_title in heading and Workshop field when no workshop record is associated. When both are present, show the external title inline next to the workshop chip. Co-Authored-By: Claude Opus 4.6 * Add tests for external workshop title on workshop logs - Model spec: validate workshop_id or external_workshop_title required - Request spec: create workshop log with external title and no workshop - System spec: display external title in heading and Workshop field Co-Authored-By: Claude Opus 4.6 * Fix CI failures in workshop log tests - Check workshop.present? instead of workshop_id.present? so validation works with both build (in-memory) and create (persisted) - Use specific Capybara selector to avoid ambiguous div matches Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/models/report.rb | 2 +- app/models/workshop_log.rb | 6 ++ app/views/workshop_logs/show.html.erb | 14 ++++- ...00_make_workshop_id_optional_on_reports.rb | 5 ++ db/schema.rb | 4 +- spec/models/workshop_log_spec.rb | 22 ++++--- spec/requests/workshop_logs_spec.rb | 30 +++++++++ spec/system/person_views_workshop_log_spec.rb | 61 +++++++++++++++++++ 8 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb diff --git a/app/models/report.rb b/app/models/report.rb index 71bf1c168..9129dfc03 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -3,7 +3,7 @@ class Report < ApplicationRecord belongs_to :created_by, class_name: "User" belongs_to :organization belongs_to :windows_type - belongs_to :workshop + belongs_to :workshop, optional: true has_one :form, as: :owner has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :notifications, as: :noticeable, dependent: :destroy, autosave: false diff --git a/app/models/workshop_log.rb b/app/models/workshop_log.rb index 30bf03133..2b31b7c07 100644 --- a/app/models/workshop_log.rb +++ b/app/models/workshop_log.rb @@ -4,6 +4,7 @@ class WorkshopLog < Report validates :children_ongoing, :teens_ongoing, :adults_ongoing, :children_first_time, :teens_first_time, :adults_first_time, numericality: { greater_than_or_equal_to: 0, only_integer: true } + validate :workshop_or_external_title_present # Callbacks after_save :update_owner_and_date @@ -137,6 +138,11 @@ def totals private + def workshop_or_external_title_present + return if workshop.present? || external_workshop_title.present? + errors.add(:base, "Please select a workshop or provide an external workshop title") + end + def update_workshop_log_count return unless owner new_led_count = owner.workshop_logs.size diff --git a/app/views/workshop_logs/show.html.erb b/app/views/workshop_logs/show.html.erb index d6f3f8ac5..3318e59fb 100644 --- a/app/views/workshop_logs/show.html.erb +++ b/app/views/workshop_logs/show.html.erb @@ -12,7 +12,7 @@
-

<%= @workshop_log.workshop&.title || "Workshop Log" %>

+

<%= @workshop_log.workshop&.title || @workshop_log.external_workshop_title || "Workshop Log" %>

@@ -49,8 +49,18 @@ <%= link_to @workshop_log.workshop.title, workshop_path(@workshop_log.workshop), data: { turbo_frame: "_top" }, class: "inline-block px-3 py-1 rounded-md text-sm font-medium #{DomainTheme.bg_class_for(:workshops, intensity: 100)} #{DomainTheme.text_class_for(:workshops)} #{DomainTheme.bg_class_for(:workshops, intensity: 100, hover: true)}" %> + <% if @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> + <% end %> + <% elsif @workshop_log.workshop %> + <%= @workshop_log.workshop.title %> + <% if @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> + <% end %> + <% elsif @workshop_log.external_workshop_title.present? %> + <%= @workshop_log.external_workshop_title %> <% else %> - <%= @workshop_log.workshop&.title || "—" %> + — <% end %>
<%= @workshop_log.date&.strftime("%B %d, %Y") || "—" %>
diff --git a/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb b/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb new file mode 100644 index 000000000..4e950d49b --- /dev/null +++ b/db/migrate/20260301150000_make_workshop_id_optional_on_reports.rb @@ -0,0 +1,5 @@ +class MakeWorkshopIdOptionalOnReports < ActiveRecord::Migration[8.1] + def change + change_column_null :reports, :workshop_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index a5a0a96e3..c5332b2a8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_01_143000) do +ActiveRecord::Schema[8.1].define(version: 2026_03_01_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 @@ -830,7 +830,7 @@ t.string "type" t.datetime "updated_at", precision: nil, null: false t.integer "windows_type_id", null: false - t.integer "workshop_id", null: false + t.integer "workshop_id" t.string "workshop_name" t.index ["created_by_id"], name: "index_reports_on_created_by_id" t.index ["organization_id"], name: "index_reports_on_organization_id" diff --git a/spec/models/workshop_log_spec.rb b/spec/models/workshop_log_spec.rb index c40d85b4f..4b2b9e5c8 100644 --- a/spec/models/workshop_log_spec.rb +++ b/spec/models/workshop_log_spec.rb @@ -9,7 +9,7 @@ describe 'associations' do # Explicitly defined here - it { should belong_to(:workshop) } + it { should belong_to(:workshop).optional } it { should belong_to(:created_by) } it { should belong_to(:organization) } # Inherited via Report but also explicit? it { should have_many(:media_files) } @@ -22,13 +22,21 @@ end describe 'validations' do - # Inherited from Report - # Add specific WorkshopLog validations if any - end + it "is valid with a workshop_id" do + workshop_log = build(:workshop_log) + expect(workshop_log).to be_valid + end - it 'is valid with valid attributes' do - # Note: Factory needs associations uncommented for create - # expect(build(:workshop_log)).to be_valid + it "is valid with external_workshop_title and no workshop" do + workshop_log = build(:workshop_log, workshop: nil, owner: nil, external_workshop_title: "Community Art Workshop") + expect(workshop_log).to be_valid + end + + it "is invalid without workshop_id or external_workshop_title" do + workshop_log = build(:workshop_log, workshop: nil, owner: nil, external_workshop_title: nil) + expect(workshop_log).not_to be_valid + expect(workshop_log.errors[:base]).to include("Please select a workshop or provide an external workshop title") + end end describe '#workshop_title' do diff --git a/spec/requests/workshop_logs_spec.rb b/spec/requests/workshop_logs_spec.rb index 2f75182ff..b7c7166a2 100644 --- a/spec/requests/workshop_logs_spec.rb +++ b/spec/requests/workshop_logs_spec.rb @@ -25,6 +25,23 @@ } end + let(:external_title_attributes) do + { + date: Date.current, + workshop_id: nil, + external_workshop_title: "Community Art Workshop", + organization_id: organization.id, + windows_type_id: windows_type.id, + created_by_id: user.id, + children_first_time: 1, + children_ongoing: 2, + teens_first_time: 0, + teens_ongoing: 0, + adults_first_time: 0, + adults_ongoing: 3 + } + end + let(:invalid_attributes) do { date: nil, @@ -88,6 +105,19 @@ expect(response).to have_http_status(:redirect) end + it "creates a WorkshopLog with external_workshop_title and no workshop" do + expect { + post workshop_logs_path, params: { + workshop_log: external_title_attributes + } + }.to change(WorkshopLog, :count).by(1) + + log = WorkshopLog.last + expect(log.workshop_id).to be_nil + expect(log.external_workshop_title).to eq("Community Art Workshop") + expect(response).to have_http_status(:redirect) + end + it "creates admin and submitter notifications and enqueues mail" do expect { post workshop_logs_path, params: { diff --git a/spec/system/person_views_workshop_log_spec.rb b/spec/system/person_views_workshop_log_spec.rb index e68d3777b..3de119e7c 100644 --- a/spec/system/person_views_workshop_log_spec.rb +++ b/spec/system/person_views_workshop_log_spec.rb @@ -57,6 +57,67 @@ end end + describe "workshop log with external title and no workshop" do + let(:user) { create(:user) } + let!(:workshop_log) do + create(:workshop_log, + created_by: user, + organization: organization, + owner: nil, + workshop: nil, + windows_type: windows_type, + external_workshop_title: "Community Mural Project", + date: 1.day.ago) + end + + before { sign_in user } + + it "displays the external title in the heading" do + visit workshop_log_path(workshop_log) + + expect(page).to have_css("h1", text: "Community Mural Project") + end + + it "displays the external title next to the Workshop label" do + visit workshop_log_path(workshop_log) + + workshop_div = find("span", text: "Workshop:").ancestor("div", match: :first) + expect(workshop_div).to have_text("Community Mural Project") + end + end + + describe "workshop log with both workshop and external title" do + let(:user) { create(:user) } + let(:person) { create(:person, user: user) } + let!(:affiliation) { create(:affiliation, person: person, organization: organization) } + let!(:workshop_log) do + create(:workshop_log, + created_by: user, + organization: organization, + owner: workshop, + workshop: workshop, + windows_type: windows_type, + external_workshop_title: "Guest-led Session", + date: 1.day.ago) + end + + before { sign_in user } + + it "displays the workshop name in the heading" do + visit workshop_log_path(workshop_log) + + expect(page).to have_css("h1", text: "Healing Through Art") + end + + it "displays both the workshop chip and external title" do + visit workshop_log_path(workshop_log) + + workshop_div = find("span", text: "Workshop:").ancestor("div", match: :first) + expect(workshop_div).to have_text("Healing Through Art") + expect(workshop_div).to have_text("Guest-led Session") + end + end + describe "as an admin" do let(:admin) { create(:user, :admin) } let(:person) { create(:person, user: admin) } From fad53937ec021c35c4afc72c7d83609649d76c31 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 21:07:30 -0500 Subject: [PATCH 11/13] Use rhino rich text fields instead of native columns across the app (#1297) * Use OuterJoin in RichTextSearchable so records without rich text still appear InnerJoin was filtering out records that had no action_text_rich_texts entry, causing them to disappear from search results entirely. Co-Authored-By: Claude Opus 4.6 * Update SearchCop configs to search rich text content - Resource: enable rich text search (was commented out), remove native body - Story: add grouped default attributes so rich text + title both searched - Tutorial: add rich text search, split multi-word search terms for independent matching, search both native body and rhino_body - WorkshopVariation: add RichTextSearchable, replace native body search with rich text search, use rhino_body in description method Co-Authored-By: Claude Opus 4.6 * Use rhino rich text fields instead of native columns in decorators - StoryDecorator: use rhino_body.to_plain_text in detail - TutorialDecorator: use rhino_body in display_text and detail - ResourceDecorator: use rhino_body in detail, truncated_text, flex_text, display_text, and html - WorkshopDecorator: prepend rhino_ prefix in spanish_field_values, use rhino fields in html_content and html_objective Co-Authored-By: Claude Opus 4.6 * Use rhino rich text fields instead of native columns in views - Story shares: use rhino_body.to_plain_text instead of strip_tags(body) - Tutorial partial: display rhino_body instead of body.html_safe - Workshop variations index: use rhino_body.to_plain_text instead of body Co-Authored-By: Claude Opus 4.6 * Update tutorial specs to use rhino_body for test data Co-Authored-By: Claude Opus 4.6 * Fix tutorial view spec to include rhino_body for rendered output The show view now renders rhino_body instead of native body, so the test data needs to populate the rich text field. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/decorators/resource_decorator.rb | 11 ++++++----- app/decorators/story_decorator.rb | 3 ++- app/decorators/tutorial_decorator.rb | 5 +++-- app/decorators/workshop_decorator.rb | 11 ++++++----- app/models/concerns/rich_text_searchable.rb | 2 +- app/models/resource.rb | 11 +++++------ app/models/story.rb | 5 ++++- app/models/tutorial.rb | 17 ++++++++++++----- app/models/workshop_variation.rb | 11 ++++++++--- app/views/story_shares/_sector_index.html.erb | 2 +- .../story_shares/_sector_index_row.html.erb | 2 +- app/views/tutorials/_form.html.erb | 3 +-- app/views/tutorials/_tutorial.html.erb | 4 ++-- app/views/workshop_variations/index.html.erb | 5 +++-- spec/models/tutorial_spec.rb | 4 ++-- spec/views/tutorials/show.html.erb_spec.rb | 1 + 16 files changed, 58 insertions(+), 39 deletions(-) diff --git a/app/decorators/resource_decorator.rb b/app/decorators/resource_decorator.rb index f0c1c5f54..2d37dca29 100644 --- a/app/decorators/resource_decorator.rb +++ b/app/decorators/resource_decorator.rb @@ -1,6 +1,7 @@ class ResourceDecorator < ApplicationDecorator def detail(length: nil) - length ? body&.truncate(length) : body # TODO - rename field + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end def default_display_image @@ -20,7 +21,7 @@ def truncated_title end def truncated_text(ln = 100) - h.truncate(html.text.gsub(/(<[^>]+>)/, ""), length: ln) + h.truncate(rhino_body.to_plain_text, length: ln) end def display_title @@ -28,7 +29,7 @@ def display_title end def flex_text - h.truncate(html.text, length: 200) + h.truncate(rhino_body.to_plain_text, length: 200) end def breadcrumbs @@ -44,7 +45,7 @@ def display_date end def display_text - "
#{text}
".html_safe + "
#{rhino_body}
".html_safe end def card_class @@ -58,7 +59,7 @@ def toolkit_and_form? private def html - Nokogiri::HTML(text) + Nokogiri::HTML(rhino_body.to_s) end def type_link diff --git a/app/decorators/story_decorator.rb b/app/decorators/story_decorator.rb index d89f6ed1d..1f40ab565 100644 --- a/app/decorators/story_decorator.rb +++ b/app/decorators/story_decorator.rb @@ -2,7 +2,8 @@ class StoryDecorator < ApplicationDecorator include ::Linkable def detail(length: 50) - body&.truncate(length) + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end def external_url diff --git a/app/decorators/tutorial_decorator.rb b/app/decorators/tutorial_decorator.rb index 6b8b0c10d..36763f93c 100644 --- a/app/decorators/tutorial_decorator.rb +++ b/app/decorators/tutorial_decorator.rb @@ -2,10 +2,11 @@ class TutorialDecorator < ApplicationDecorator delegate_all def display_text - "
#{body}
".html_safe + "
#{rhino_body}
".html_safe end def detail(length: nil) - length ? body&.truncate(length) : body + text = rhino_body&.to_plain_text + length ? text&.truncate(length) : text end end diff --git a/app/decorators/workshop_decorator.rb b/app/decorators/workshop_decorator.rb index 3803e627b..fb3a5ac10 100644 --- a/app/decorators/workshop_decorator.rb +++ b/app/decorators/workshop_decorator.rb @@ -99,9 +99,10 @@ def formatted_objective(length: 100) end def spanish_field_values - display_spanish_fields.map do |field| - workshop.send(field) unless workshop.send(field).blank? - end.compact + display_spanish_fields.filter_map do |field| + value = workshop.send(:"rhino_#{field}") + value unless value.blank? + end end @@ -154,10 +155,10 @@ def labels_spanish private def html_content - Nokogiri::HTML("#{objective} #{materials} #{timeframe} #{setup} #{workshop.introduction}") + Nokogiri::HTML("#{rhino_objective} #{rhino_materials} #{timeframe} #{rhino_setup} #{rhino_introduction}") end def html_objective - Nokogiri::HTML(objective) + Nokogiri::HTML(rhino_objective.to_s) end end diff --git a/app/models/concerns/rich_text_searchable.rb b/app/models/concerns/rich_text_searchable.rb index 647a063b3..e44f8c9b6 100644 --- a/app/models/concerns/rich_text_searchable.rb +++ b/app/models/concerns/rich_text_searchable.rb @@ -11,7 +11,7 @@ def join_rich_texts join_condition = rich_texts[:record_id].eq(table[:id]) .and(rich_texts[:record_type].eq(name)) # polymorphic record_type = model name - joins(table.join(rich_texts, Arel::Nodes::InnerJoin).on(join_condition).join_sources) + joins(table.join(rich_texts, Arel::Nodes::OuterJoin).on(join_condition).join_sources) end end end diff --git a/app/models/resource.rb b/app/models/resource.rb index 530eb2781..0981c9fe0 100644 --- a/app/models/resource.rb +++ b/app/models/resource.rb @@ -66,15 +66,14 @@ def self.mentionable_rich_text_fields allow_destroy: true, reject_if: proc { |resource| Resource.reject?(resource) } - # Search Cop — only attributes on resources table so MATCH() uses the FULLTEXT index (title, author, body). - # No join to action_text_rich_texts; that would require MATCH across two tables, which MySQL cannot index. include SearchCop search_scope :search do - attributes :title, :author, :body + attributes all: [ :title, :author ] + options :all, type: :text, default: true, default_operator: :or - # scope { join_rich_texts } - # attributes action_text_body: "action_text_rich_texts.plain_text_body" - # options :action_text_body, type: :text, default: true, default_operator: :or + scope { join_rich_texts } + attributes action_text_body: "action_text_rich_texts.plain_text_body" + options :action_text_body, type: :text, default: true, default_operator: :or end # Scopes diff --git a/app/models/story.rb b/app/models/story.rb index a0d34192e..cf5833992 100644 --- a/app/models/story.rb +++ b/app/models/story.rb @@ -43,7 +43,10 @@ class Story < ApplicationRecord # SearchCop include SearchCop search_scope :search do - attributes :title, :published, person_first: "people.first_name", person_last: "people.last_name" + attributes all: [ :title, :published ] + attributes :title, :published + attributes person_first: "people.first_name", person_last: "people.last_name" + options :all, type: :text, default: true, default_operator: :or scope { join_rich_texts.left_joins(created_by: :person) } attributes action_text_body: "action_text_rich_texts.plain_text_body" diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 6d48451a6..88936a372 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -24,14 +24,18 @@ class Tutorial < ApplicationRecord # SearchCop include SearchCop search_scope :search do - attributes :title, :body + attributes all: [ :title, :body ] + options :all, type: :text, default: true, default_operator: :or scope { join_rich_texts } attributes action_text_body: "action_text_rich_texts.plain_text_body" options :action_text_body, type: :text, default: true, default_operator: :or end - scope :body, ->(body) { where("body like ?", "%#{ body }%") } + scope :body, ->(body) { + left_joins(:rich_text_rhino_body) + .where("tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: "%#{body}%") + } scope :title, ->(title) { where("title like ?", "%#{ title }%") } scope :tutorial_name, ->(tutorial_name) { title(tutorial_name) } scope :with_sector_ids, ->(sector_hash) { @@ -51,9 +55,12 @@ class Tutorial < ApplicationRecord } scope :title_or_body, ->(term) { - pattern = "%#{term}%" - left_joins(:rich_text_rhino_body) - .where("tutorials.title LIKE :q OR tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: pattern) + scope = left_joins(:rich_text_rhino_body) + term.split.each do |word| + pattern = "%#{word}%" + scope = scope.where("tutorials.title LIKE :q OR tutorials.body LIKE :q OR action_text_rich_texts.body LIKE :q", q: pattern) + end + scope } def self.search_by_params(params) diff --git a/app/models/workshop_variation.rb b/app/models/workshop_variation.rb index 888fef63c..5cc80e187 100644 --- a/app/models/workshop_variation.rb +++ b/app/models/workshop_variation.rb @@ -1,9 +1,14 @@ class WorkshopVariation < ApplicationRecord include AuthorCreditable - include Publishable, Trendable + include Publishable, Trendable, RichTextSearchable include SearchCop search_scope :search do - attributes :name, :body + attributes all: [ :name ] + options :all, type: :text, default: true, default_operator: :or + + scope { join_rich_texts } + attributes action_text_body: "action_text_rich_texts.plain_text_body" + options :action_text_body, type: :text, default: true, default_operator: :or end def self.search_by_params(params) @@ -43,7 +48,7 @@ def self.search_by_params(params) # See Publishable, Trendable def description - body + rhino_body.to_plain_text end def title diff --git a/app/views/story_shares/_sector_index.html.erb b/app/views/story_shares/_sector_index.html.erb index 1f18e4140..fb5741bc2 100644 --- a/app/views/story_shares/_sector_index.html.erb +++ b/app/views/story_shares/_sector_index.html.erb @@ -41,7 +41,7 @@

- <%= truncate(strip_tags(story.body.to_s), length: 140) %> + <%= truncate(story.rhino_body.to_plain_text, length: 140) %>

diff --git a/app/views/story_shares/_sector_index_row.html.erb b/app/views/story_shares/_sector_index_row.html.erb index 45498dccb..d04a110e5 100644 --- a/app/views/story_shares/_sector_index_row.html.erb +++ b/app/views/story_shares/_sector_index_row.html.erb @@ -14,7 +14,7 @@

- <%= truncate(strip_tags(story.body.to_s), length: 280) %> + <%= truncate(story.rhino_body.to_plain_text, length: 280) %>

diff --git a/app/views/tutorials/_form.html.erb b/app/views/tutorials/_form.html.erb index a2c17caa6..ef211188b 100644 --- a/app/views/tutorials/_form.html.erb +++ b/app/views/tutorials/_form.html.erb @@ -33,8 +33,7 @@ <%= render "shared/form_image_fields", f: f, include_primary_asset: true %>
- <%= f.input :body, as: :text, - input_html: { rows: 10, value: f.object.body } %> + <%= f.input :body, as: :text, input_html: { rows: 10, value: f.object.body } %>
diff --git a/app/views/tutorials/_tutorial.html.erb b/app/views/tutorials/_tutorial.html.erb index 20c7f619d..119ca7e8c 100644 --- a/app/views/tutorials/_tutorial.html.erb +++ b/app/views/tutorials/_tutorial.html.erb @@ -35,9 +35,9 @@ <% end %> - <% if tutorial.body.present? %> + <% if tutorial.rhino_body.present? %>
- <%= tutorial.body.to_s.html_safe %> + <%= tutorial.rhino_body %>
<% end %> diff --git a/app/views/workshop_variations/index.html.erb b/app/views/workshop_variations/index.html.erb index a15b3fe52..4f17fbd8c 100644 --- a/app/views/workshop_variations/index.html.erb +++ b/app/views/workshop_variations/index.html.erb @@ -41,8 +41,9 @@
<%= truncate(workshop_variation.name, length: 30) %> - - <%= truncate(strip_tags(workshop_variation.body), length: 30) %> + <% plain = workshop_variation.rhino_body.to_plain_text %> + + <%= truncate(plain, length: 30) %> diff --git a/spec/models/tutorial_spec.rb b/spec/models/tutorial_spec.rb index 1872dc491..3b444536c 100644 --- a/spec/models/tutorial_spec.rb +++ b/spec/models/tutorial_spec.rb @@ -2,8 +2,8 @@ RSpec.describe Tutorial, type: :model do describe '.search_by_params' do - let!(:published_tutorial) { create(:tutorial, :published, title: 'Getting Started Guide', body: 'Welcome to AWBW') } - let!(:draft_tutorial) { create(:tutorial, title: 'Advanced Workshop Tips', body: 'For experienced facilitators') } + let!(:published_tutorial) { create(:tutorial, :published, title: 'Getting Started Guide', rhino_body: 'Welcome to AWBW') } + let!(:draft_tutorial) { create(:tutorial, title: 'Advanced Workshop Tips', rhino_body: 'For experienced facilitators') } it 'returns all when no params' do results = Tutorial.search_by_params({}) diff --git a/spec/views/tutorials/show.html.erb_spec.rb b/spec/views/tutorials/show.html.erb_spec.rb index 54f9806d5..af95e6dd2 100644 --- a/spec/views/tutorials/show.html.erb_spec.rb +++ b/spec/views/tutorials/show.html.erb_spec.rb @@ -9,6 +9,7 @@ assign(:tutorial, Tutorial.create!( title: "Title", body: "MyText", + rhino_body: "MyText", featured: false, published: false, position: 2, From 550cdf647fc47c8512bc06cf9d85e296e6fc3bf9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 1 Mar 2026 21:08:25 -0500 Subject: [PATCH 12/13] Restrict person profile to read-only for non-admins (#1296) * Restrict person edit/update to admin only - Remove owner? from edit? and update? in PersonPolicy - Hide "My profile" menu link unless admin manage - Update policy spec to expect owner cannot edit Co-Authored-By: Claude Opus 4.6 * Add admin-only styling and permission gates to person views - Gate People and Edit links with policy checks and admin-only styling - Show email with admin-only styling when profile_show_email is off - Remove comments section from person show (keep in edit form only) - Add admin-only styling to comments section on person edit form Co-Authored-By: Claude Opus 4.6 * Add request specs for person show authorization and profile flags - Test edit/update blocked for owners, allowed for admin - Test show page gated content (Edit link, email, Submitted content, Comments) - Test all 16 profile flags for visibility on own and admin-viewed profiles - Test email admin-only styling when profile_show_email is off Co-Authored-By: Claude Opus 4.6 * Fix navbar avatar system test for admin-only person edit The test was using a regular user to edit a person record, which is now restricted to admins only. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- app/policies/person_policy.rb | 4 +- app/views/people/_form.html.erb | 4 +- app/views/people/show.html.erb | 28 ++-- app/views/shared/_navbar_user.html.erb | 2 +- spec/policies/person_policy_spec.rb | 2 +- spec/requests/people_authorization_spec.rb | 85 +++++++++++ spec/requests/people_profile_flags_spec.rb | 166 +++++++++++++++++++++ spec/system/navbar_avatar_updates_spec.rb | 2 +- 8 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 spec/requests/people_profile_flags_spec.rb diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 2ee027953..8adbbb3a8 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -10,11 +10,11 @@ def show? end def edit? - admin? || owner? + admin? end def update? - admin? || owner? + admin? end def destroy? diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 7805f3c6a..51b83e1f4 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -379,10 +379,10 @@ <% if f.object.persisted? %> <% if allowed_to?(:manage?, Comment) %> -
+
Comments
-
<%= f.simple_fields_for :comments do |cf| %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index af8e2b48f..048793e04 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -21,10 +21,12 @@
<% end %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> - <%= link_to "People", people_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:index?, Person) %> + <%= link_to "People", people_path, class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> <% if allowed_to?(:edit?, @person) %> <%= link_to "Edit", edit_person_path(@person), - class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> <% end %> <%= render "bookmarks/editable_bookmark_button", resource: @person.object %> @@ -66,10 +68,16 @@
<% email = @person.user&.email || @person.email %> - <% if (@person.profile_show_email? || allowed_to?(:manage?, Person)) && email.present? %> - - 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> - + <% if email.present? %> + <% if @person.profile_show_email? %> + + 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> + + <% elsif allowed_to?(:manage?, Person) %> + + 📧 <%= mail_to email, email, class: "text-blue-600 hover:underline" %> + + <% end %> <% end %> <% phone = @person.phone_number || @person.user&.phone %> <% if @person.profile_show_phone? && phone.present? %> @@ -210,7 +218,7 @@
<% end %> - <% if allowed_to?(:edit?, @person) %> + <% if allowed_to?(:show?, @person) %>
@@ -263,12 +271,6 @@
<% end %> - - <% if allowed_to?(:manage?, Comment) %> -
- <%= render "comments/section", commentable: @person.object %> -
- <% end %>
diff --git a/app/views/shared/_navbar_user.html.erb b/app/views/shared/_navbar_user.html.erb index 37d84fe64..16558087a 100644 --- a/app/views/shared/_navbar_user.html.erb +++ b/app/views/shared/_navbar_user.html.erb @@ -31,7 +31,7 @@ <% end %> <% end %> - <% if person && allowed_to?(:show?, person) %> + <% if person && allowed_to?(:manage?, Person) %> <%= link_to person_path(person), class: "admin-only bg-blue-100 flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 space-x-2" do %> diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 41a49d194..faf0ae846 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -75,7 +75,7 @@ def policy_for(record: nil, user:) context "with owner" do subject { policy_for(record: owned_person, user: owner_user) } - it { is_expected.to be_allowed_to(:edit?) } + it { is_expected.not_to be_allowed_to(:edit?) } end context "with regular user who is not the owner" do diff --git a/spec/requests/people_authorization_spec.rb b/spec/requests/people_authorization_spec.rb index ebef4a307..a23238cb2 100644 --- a/spec/requests/people_authorization_spec.rb +++ b/spec/requests/people_authorization_spec.rb @@ -63,4 +63,89 @@ end end end + + describe "GET /people/:id show page content" do + context "as an admin" do + before do + sign_in admin + get person_path(other_person) + end + + it "shows the Edit link" do + expect(response.body).to include("Edit") + end + + it "shows email with admin-only styling when profile_show_email is off" do + other_person.update!(profile_show_email: false) + get person_path(other_person) + email = other_person.user&.email || other_person.email + expect(response.body).to include("admin-only") + expect(response.body).to include(email) if email.present? + end + + it "shows the Submitted content section" do + expect(response.body).to include("Submitted content") + end + + it "does not show the Comments section" do + expect(response.body).not_to include("comment_form") + end + end + + context "as the owner" do + before do + sign_in regular_user + get person_path(regular_user.person) + end + + it "does not show the Edit link" do + expect(response.body).not_to include(edit_person_path(regular_user.person)) + end + + it "shows the Submitted content section" do + expect(response.body).to include("Submitted content") + end + end + end + + describe "GET /people/:id/edit" do + context "as the owner" do + before { sign_in regular_user } + + it "redirects to root" do + get edit_person_path(regular_user.person) + expect(response).to redirect_to(root_path) + end + end + + context "as an admin" do + before { sign_in admin } + + it "renders successfully" do + get edit_person_path(other_person) + expect(response).to have_http_status(:ok) + end + end + end + + describe "PATCH /people/:id" do + context "as the owner" do + before { sign_in regular_user } + + it "redirects to root" do + patch person_path(regular_user.person), params: { person: { first_name: "Changed" } } + expect(response).to redirect_to(root_path) + end + end + + context "as an admin" do + before { sign_in admin } + + it "updates the person" do + patch person_path(other_person), params: { person: { first_name: "Updated" } } + expect(response).to redirect_to(person_path(other_person)) + expect(other_person.reload.first_name).to eq("Updated") + end + end + end end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb new file mode 100644 index 000000000..8408ad693 --- /dev/null +++ b/spec/requests/people_profile_flags_spec.rb @@ -0,0 +1,166 @@ +require "rails_helper" + +RSpec.describe "Person profile flag visibility", type: :request do + let(:admin) { create(:user, :admin) } + let(:owner_user) { create(:user, :with_person) } + let(:person) { owner_user.person } + + before do + person.update!( + pronouns: "they/them", + bio: "A passionate facilitator", + linked_in_url: "https://linkedin.com/in/test" + ) + owner_user.update!(phone: "555-123-4567") + create(:affiliation, person: person, title: "Facilitator", start_date: Date.new(2020, 1, 1)) + end + + # Flags whose content is visible to any viewer with show access + { + profile_show_pronouns: "they/them", + profile_show_member_since: "Facilitator since", + profile_show_phone: "555-123-4567", + profile_show_affiliations: "Affiliations", + profile_show_sectors: "mb-3\">Sectors", + profile_show_bio: "mb-3\">Bio", + profile_show_workshops: "mb-3\">Workshops authored", + profile_show_workshop_variations: "Workshop variations authored", + profile_show_stories: "Stories authored/featured", + profile_show_events_registered: "Participation history" + }.each do |flag, marker| + describe "##{flag}" do + context "when false" do + before { person.update!(flag => false) } + + it "hides content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(marker) + end + + it "hides content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).not_to include(marker) + end + end + + context "when true" do + before { person.update!(flag => true) } + + it "shows content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(marker) + end + + it "shows content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(marker) + end + end + end + end + + describe "#profile_show_social_media" do + context "when false" do + before { person.update!(profile_show_social_media: false) } + + it "hides social media on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include("fa-linkedin-in") + end + end + + context "when true" do + before { person.update!(profile_show_social_media: true) } + + it "shows social media on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include("fa-linkedin-in") + end + + it "shows social media when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include("fa-linkedin-in") + end + end + end + + describe "#profile_show_email" do + let(:email) { person.user.email } + + context "when false" do + before { person.update!(profile_show_email: false) } + + it "hides email on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(email) + end + + it "shows email with admin-only styling when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(email) + expect(response.body).to include("admin-only bg-blue-100") + end + end + + context "when true" do + before { person.update!(profile_show_email: true) } + + it "shows email on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(email) + end + + it "shows email when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(email) + end + end + end + + # Flags inside the "Submitted content" section (gated by show? policy) + { + profile_show_workshop_ideas: "Workshop ideas submitted", + profile_show_workshop_variation_ideas: "Workshop variation ideas submitted", + profile_show_story_ideas: "Story ideas submitted", + profile_show_workshop_logs: "Workshop logs submitted" + }.each do |flag, marker| + describe "##{flag}" do + context "when false" do + before { person.update!(flag => false) } + + it "hides content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).not_to include(marker) + end + end + + context "when true" do + before { person.update!(flag => true) } + + it "shows content on own profile" do + sign_in owner_user + get person_path(person) + expect(response.body).to include(marker) + end + + it "shows content when admin views profile" do + sign_in admin + get person_path(person) + expect(response.body).to include(marker) + end + end + end + end +end diff --git a/spec/system/navbar_avatar_updates_spec.rb b/spec/system/navbar_avatar_updates_spec.rb index 8316952db..67640c044 100644 --- a/spec/system/navbar_avatar_updates_spec.rb +++ b/spec/system/navbar_avatar_updates_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe "Navbar avatar behavior", type: :system do - let(:user) { create(:user) } + let(:user) { create(:user, :admin) } let!(:person) { create(:person, user: user) } before do From 22bbe1e57a3a8e058004634391623aa209459bde Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 7 Mar 2026 21:31:41 -0500 Subject: [PATCH 13/13] Add admin CRUD for forms with question library picker Admins can now manage forms through a dedicated UI: - Index lists all forms with field counts, linked events, and type badges - New form flow: interstitial page to select builder type (short/extended registration, scholarship, or blank generic), then redirect to edit - Edit page supports inline editing of form name and question labels, toggling required/status, adding/removing fields via cocoon, and cloning questions from other forms via a searchable Turbo Frame picker - Show page displays fields grouped by section with linked events Co-Authored-By: Claude Opus 4.6 --- app/controllers/forms_controller.rb | 141 +++++++++++++ app/frontend/javascript/controllers/index.js | 3 + .../question_library_controller.js | 15 ++ app/helpers/admin_cards_helper.rb | 3 +- app/policies/form_policy.rb | 6 + app/views/forms/_form.html.erb | 76 +++++++ app/views/forms/_form_field_fields.html.erb | 44 ++++ app/views/forms/_form_field_row.html.erb | 47 +++++ app/views/forms/_question_library.html.erb | 46 +++++ app/views/forms/edit.html.erb | 16 ++ app/views/forms/index.html.erb | 66 ++++++ app/views/forms/new.html.erb | 38 ++++ app/views/forms/question_library.html.erb | 1 + app/views/forms/show.html.erb | 76 +++++++ config/routes.rb | 6 + lib/domain_theme.rb | 1 + spec/factories/forms.rb | 11 + spec/policies/form_policy_spec.rb | 40 ++++ spec/requests/forms_spec.rb | 194 ++++++++++++++++++ 19 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 app/controllers/forms_controller.rb create mode 100644 app/frontend/javascript/controllers/question_library_controller.js create mode 100644 app/policies/form_policy.rb create mode 100644 app/views/forms/_form.html.erb create mode 100644 app/views/forms/_form_field_fields.html.erb create mode 100644 app/views/forms/_form_field_row.html.erb create mode 100644 app/views/forms/_question_library.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 app/views/forms/question_library.html.erb create mode 100644 app/views/forms/show.html.erb create mode 100644 spec/policies/form_policy_spec.rb create mode 100644 spec/requests/forms_spec.rb diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb new file mode 100644 index 000000000..7de7111d3 --- /dev/null +++ b/app/controllers/forms_controller.rb @@ -0,0 +1,141 @@ +class FormsController < ApplicationController + before_action :set_form, only: %i[show edit update destroy question_library add_questions] + + def index + authorize! + @forms = authorized_scope(Form.all) + .includes(:form_fields, :events) + .order(:name) + .paginate(page: params[:page], per_page: 25) + end + + def show + authorize! @form + @form_fields_by_group = grouped_fields + @linked_events = @form.events.order(start_date: :desc) + end + + def new + authorize! Form.new + @builders = builder_options + end + + def create + authorize! Form.new + + @form = case params[:builder_type] + when "short_registration" + ShortEventRegistrationFormBuilder.build_standalone! + when "extended_registration" + ExtendedEventRegistrationFormBuilder.build_standalone! + when "scholarship_application" + ScholarshipApplicationFormBuilder.build_standalone! + when "generic" + Form.create!(name: params[:form_name].presence || "New Form") + else + Form.create!(name: "New Form") + end + + redirect_to edit_form_path(@form), notice: "Form was successfully created. Customize it below." + end + + def edit + authorize! @form + set_form_variables + end + + def update + authorize! @form + if @form.update(form_params) + redirect_to @form, notice: "Form was successfully updated.", status: :see_other + else + set_form_variables + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize! @form + @form.destroy! + redirect_to forms_path, notice: "Form was successfully destroyed." + end + + def question_library + authorize! @form + existing_keys = @form.form_fields.reorder(position: :asc).pluck(:field_key).compact + scope = FormField.unscoped + .where.not(form_id: @form.id) + .where.not(field_key: [ nil, "" ]) + scope = scope.where.not(field_key: existing_keys) if existing_keys.any? + @available_fields = scope.order(:field_group, :position).group_by(&:field_group) + render layout: false + end + + def add_questions + authorize! @form + source_field_ids = Array(params[:source_field_ids]).map(&:to_i) + max_position = @form.form_fields.reorder(position: :asc).maximum(:position) || 0 + + FormField.unscoped.where(id: source_field_ids).find_each do |source| + max_position += 1 + new_field = @form.form_fields.create!( + question: source.question, + answer_type: source.answer_type, + answer_datatype: source.answer_datatype, + status: source.status, + position: max_position, + is_required: source.is_required, + instructional_hint: source.instructional_hint, + field_key: source.field_key, + field_group: source.field_group + ) + + source.form_field_answer_options.each do |ffao| + new_field.form_field_answer_options.create!(answer_option: ffao.answer_option) + end + end + + redirect_to edit_form_path(@form), notice: "Questions added successfully." + end + + private + + def set_form + @form = Form.find(params[:id]) + end + + def set_form_variables + @form_fields_by_group = grouped_fields + @field_groups = @form.form_fields.reorder(position: :asc).pluck(:field_group).compact.uniq + @answer_type_options = FormField.answer_types.keys.map { |k| [ k.humanize, k ] } + end + + def grouped_fields + @form.form_fields.reorder(position: :asc).group_by(&:field_group) + end + + def builder_options + [ + { key: "short_registration", name: ShortEventRegistrationFormBuilder::FORM_NAME, + description: "Contact, consent, qualitative, and scholarship fields" }, + { key: "extended_registration", name: ExtendedEventRegistrationFormBuilder::FORM_NAME, + description: "Contact, background, professional, qualitative, scholarship, and payment fields" }, + { key: "scholarship_application", name: ScholarshipApplicationFormBuilder::FORM_NAME, + description: "Scholarship-specific fields" }, + { key: "generic", name: "Generic Form", + description: "Start with a blank form and add fields manually" } + ] + end + + def form_params + params.require(:form).permit( + :name, + :scholarship_application, + form_fields_attributes: [ + :id, :question, :answer_type, :answer_datatype, + :status, :position, :is_required, :instructional_hint, + :field_key, :field_group, :_destroy + ] + ) + end +end diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 1c152e3cb..269a529d1 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -51,6 +51,9 @@ application.register("org-toggle", OrgToggleController) import PaginatedFieldsController from "./paginated_fields_controller" application.register("paginated-fields", PaginatedFieldsController) +import QuestionLibraryController from "./question_library_controller" +application.register("question-library", QuestionLibraryController) + import PasswordToggleController from "./password_toggle_controller" application.register("password-toggle", PasswordToggleController) diff --git a/app/frontend/javascript/controllers/question_library_controller.js b/app/frontend/javascript/controllers/question_library_controller.js new file mode 100644 index 000000000..6306a7913 --- /dev/null +++ b/app/frontend/javascript/controllers/question_library_controller.js @@ -0,0 +1,15 @@ +import { Controller } from "@hotwired/stimulus"; + +// Handles filtering/searching the question library picker on the forms edit page. +export default class extends Controller { + static targets = ["search", "list", "item"]; + + filter() { + const query = this.searchTarget.value.toLowerCase().trim(); + + this.itemTargets.forEach((item) => { + const text = item.dataset.questionText || ""; + item.style.display = text.includes(query) ? "" : "none"; + }); + } +} diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index 22ba7ffb3..76e651a0b 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -52,7 +52,8 @@ def reference_cards intensity: 100, title: "Sectors", params: { published: true }), - custom_card("Windows types", windows_types_path, icon: "🪟") + custom_card("Windows types", windows_types_path, icon: "🪟"), + model_card(:forms, icon: "📋", intensity: 100) ] end diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb new file mode 100644 index 000000000..875340ee7 --- /dev/null +++ b/app/policies/form_policy.rb @@ -0,0 +1,6 @@ +class FormPolicy < ApplicationPolicy + relation_scope do |relation| + next relation if admin? + relation.none + end +end diff --git a/app/views/forms/_form.html.erb b/app/views/forms/_form.html.erb new file mode 100644 index 000000000..81c315817 --- /dev/null +++ b/app/views/forms/_form.html.erb @@ -0,0 +1,76 @@ +<%= simple_form_for(@form, html: { data: { controller: "dirty-form" } }) do |f| %> + <%= render "shared/errors", resource: @form if @form.errors.any? %> + +
+
+ <%= f.input :name, label: "Form name", + input_html: { class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm" } %> +
+
+ <%= f.input :scholarship_application, as: :boolean %> +
+
+ + <%# Fields grouped by section %> +
+ <% @field_groups.each do |group| %> +
+
+

<%= group.presence || "Ungrouped" %>

+
+ +
+ <% (@form_fields_by_group[group] || []).each do |field| %> + <%= f.simple_fields_for :form_fields, field do |ff| %> + <%= render "form_field_row", ff: ff, field: field %> + <% end %> + <% end %> +
+ +
+ <%= link_to_add_association "Add blank field", f, :form_fields, + class: "text-blue-600 hover:underline text-sm", + data: { + association_insertion_node: "#fields_#{group}", + association_insertion_method: "append" + } %> +
+
+ <% end %> + + <%# Show ungrouped section if no groups exist yet %> + <% if @field_groups.empty? %> +
+

Fields

+ +
+
+ +
+ <%= link_to_add_association "Add blank field", f, :form_fields, + class: "text-blue-600 hover:underline text-sm", + data: { + association_insertion_node: "#fields_new", + association_insertion_method: "append" + } %> +
+
+ <% end %> +
+ +
+ <% if allowed_to?(:destroy?, @form) %> + <%= link_to "Delete", @form, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this form?" } %> + <% end %> + <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %> + <%= f.button :submit, class: "btn btn-primary" %> +
+<% end %> + +<%# Question Library (outside the main form to avoid nested forms) %> +
+ <%= turbo_frame_tag "question_library", src: question_library_form_path(@form), loading: :lazy do %> +

Loading question library...

+ <% 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..11c0a19f5 --- /dev/null +++ b/app/views/forms/_form_field_fields.html.erb @@ -0,0 +1,44 @@ +
+
+ <%# Question label %> +
+ <%= f.input :question, label: false, placeholder: "Question text", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-sm" } %> +
+ + <%# Answer type %> +
+ <%= f.input :answer_type, + collection: FormField.answer_types.keys.map { |k| [ k.humanize, k ] }, + label: false, + input_html: { class: "w-full text-xs rounded border-gray-300" } %> +
+ + <%# Field group %> +
+ <%= f.input :field_group, label: false, placeholder: "Group", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs" } %> +
+ + <%# Field key %> +
+ <%= f.input :field_key, label: false, placeholder: "field_key", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs font-mono text-gray-500" } %> +
+ + <%# Required %> +
+ +
+ + <%# Remove %> +
+ <%= link_to_remove_association "Remove", + f, + class: "text-xs text-red-600 hover:text-red-800 underline dynamic" %> +
+
+
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..082550ea5 --- /dev/null +++ b/app/views/forms/_form_field_row.html.erb @@ -0,0 +1,47 @@ +
+ <%= ff.hidden_field :id %> + +
+ <%# Question label (editable) %> +
+ <%= ff.input :question, label: false, placeholder: "Question text", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-sm" } %> +
+ + <%# Answer type %> +
+ <%= ff.input :answer_type, + collection: FormField.answer_types.keys.map { |k| [ k.humanize, k ] }, + label: false, + input_html: { class: "w-full text-xs rounded border-gray-300" } %> +
+ + <%# Field group %> +
+ <%= ff.input :field_group, label: false, placeholder: "Group", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs" } %> +
+ + <%# Field key (readonly reference) %> +
+ <%= ff.input :field_key, label: false, placeholder: "field_key", + input_html: { class: "w-full rounded border-gray-300 px-2 py-1 text-xs font-mono text-gray-500", + readonly: field.persisted? } %> +
+ + <%# Required + Status toggles %> +
+ +
+ + <%# Remove %> +
+ <%= link_to_remove_association "Remove", + ff, + class: "text-xs text-red-600 hover:text-red-800 underline" %> +
+
+
diff --git a/app/views/forms/_question_library.html.erb b/app/views/forms/_question_library.html.erb new file mode 100644 index 000000000..659a9437c --- /dev/null +++ b/app/views/forms/_question_library.html.erb @@ -0,0 +1,46 @@ +<%= turbo_frame_tag "question_library" do %> +
+

Add questions from library

+

Select questions from other forms to add to this form.

+ + <% if @available_fields.present? %> + <%= form_with url: add_questions_form_path(@form), method: :post do |f| %> +
+ <%# Search filter %> + + +
+ <% @available_fields.each do |group, fields| %> +
+

<%= group.presence || "Ungrouped" %>

+
+ <% fields.uniq(&:field_key).each do |field| %> + + <% end %> +
+
+ <% end %> +
+ + +
+ <% end %> + <% else %> +

No additional questions available to add.

+ <% end %> +
+<% end %> diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb new file mode 100644 index 000000000..f6cfb6df9 --- /dev/null +++ b/app/views/forms/edit.html.erb @@ -0,0 +1,16 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "View", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+

Edit form

+ +
+
+ <%= render "form" %> + <%= render "shared/audit_info", resource: @form %> +
+
+
diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb new file mode 100644 index 000000000..e9447b98d --- /dev/null +++ b/app/views/forms/index.html.erb @@ -0,0 +1,66 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+
+

Forms

+

Manage registration and application forms.

+
+
+ <% if allowed_to?(:new?, Form) %> + <%= link_to "New Form", new_form_path, class: "admin-only bg-blue-100 btn btn-primary-outline" %> + <% end %> +
+
+ +
+ <% if @forms.present? %> +
+ + + + + + + + + + + + <% @forms.each do |form| %> + + + + + + + + <% end %> + +
NameFieldsLinked EventsTypeActions
+ <%= link_to form.display_name, form_path(form), class: "hover:text-blue-700" %> + + <%= form.form_fields.size %> + + <% if form.events.any? %> + <%= form.events.map(&:title).join(", ") %> + <% else %> + None + <% end %> + + <% if form.scholarship_application? %> + Scholarship + <% elsif form.owner_id.nil? %> + Standalone + <% else %> + Owned + <% end %> + + <%= link_to "Edit", edit_form_path(form), class: "btn btn-secondary-outline" %> +
+
+ + <% else %> +

No forms found.

+ <% end %> +
+
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb new file mode 100644 index 000000000..0c9787695 --- /dev/null +++ b/app/views/forms/new.html.erb @@ -0,0 +1,38 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> +
+ +

Create a new form

+

Choose a template to start with, or create a blank form.

+ +
+ <% @builders.each do |builder| %> + <% if builder[:key] == "generic" %> + <%= form_with url: forms_path, method: :post, class: "block" do %> + +
+

<%= builder[:name] %>

+

<%= builder[:description] %>

+
+ + +
+
+ <% end %> + <% else %> + <%= form_with url: forms_path, method: :post, class: "block" do %> + + + <% end %> + <% end %> + <% end %> +
+
diff --git a/app/views/forms/question_library.html.erb b/app/views/forms/question_library.html.erb new file mode 100644 index 000000000..d03cbc8f7 --- /dev/null +++ b/app/views/forms/question_library.html.erb @@ -0,0 +1 @@ +<%= render "question_library" %> diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb new file mode 100644 index 000000000..ea0beaf96 --- /dev/null +++ b/app/views/forms/show.html.erb @@ -0,0 +1,76 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +
+
+ <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% if allowed_to?(:edit?, @form) %> + <%= link_to "Edit", edit_form_path(@form), class: "admin-only bg-blue-100 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %> + <% end %> +
+ +
+

<%= @form.display_name %>

+ <% if @form.scholarship_application? %> + Scholarship + <% end %> +
+ + <%# Linked Events %> + <% if @linked_events.any? %> +
+

Linked Events

+
+ <% @linked_events.each do |event| %> + <%= link_to event.title, event_path(event), + class: "inline-block px-3 py-1 text-sm bg-blue-50 text-blue-700 rounded hover:bg-blue-100" %> + <% end %> +
+
+ <% end %> + + <%# Form Fields by Group %> +
+ <% @form_fields_by_group.each do |group, fields| %> +
+

<%= group.presence || "Ungrouped" %>

+
+ <% fields.each do |field| %> +
+
+
+ <%= field.question %> + <% if field.is_required? %> + Required + <% end %> + <% if field.inactive? %> + Inactive + <% end %> +
+ <% if field.instructional_hint.present? %> +

<%= field.instructional_hint %>

+ <% end %> +
+
+ <%= field.answer_type.humanize %> + <% if field.field_key.present? %> + <%= field.field_key %> + <% end %> +
+
+ <% end %> +
+
+ <% end %> +
+ + <%# Actions %> +
+ <% if allowed_to?(:edit?, @form) %> + <%= link_to "Edit", edit_form_path(@form), class: "btn btn-primary-outline" %> + <% end %> + <% if allowed_to?(:destroy?, @form) %> + <%= link_to "Delete", @form, class: "btn btn-danger-outline", + data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this form?" } %> + <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index bbd10c7d9..a511b0f2f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,12 @@ resources :comments, only: [ :index, :create ] end resources :faqs + resources :forms do + member do + get :question_library + post :add_questions + end + end resources :notifications, only: [ :index, :show ] do member do post :resend diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index d188127fb..a9c155abf 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -18,6 +18,7 @@ module DomainTheme categories: :lime, category_types: :lime, + forms: :stone, faqs: :pink, tutorials: :cyan, diff --git a/spec/factories/forms.rb b/spec/factories/forms.rb index 26d2b2570..5f5ee3e22 100644 --- a/spec/factories/forms.rb +++ b/spec/factories/forms.rb @@ -9,5 +9,16 @@ trait :with_owner do association :owner, factory: :user end + + trait :with_fields do + after(:create) do |form| + create_list(:form_field, 3, form: form) + end + end + + trait :scholarship do + scholarship_application { true } + name { "Scholarship Application" } + end end end diff --git a/spec/policies/form_policy_spec.rb b/spec/policies/form_policy_spec.rb new file mode 100644 index 000000000..694abb81a --- /dev/null +++ b/spec/policies/form_policy_spec.rb @@ -0,0 +1,40 @@ +require "rails_helper" + +RSpec.describe FormPolicy, type: :policy do + let(:admin_user) { build_stubbed :user, super_user: true } + let(:regular_user) { build_stubbed :user, super_user: false } + let(:persisted_form) { create :form } + let(:new_form) { build :form } + + def policy_for(record: nil, user:) + described_class.new(record, user: user) + end + + describe "admin user" do + subject { policy_for(record: persisted_form, user: admin_user) } + + it { is_expected.to be_allowed_to(:index?) } + it { is_expected.to be_allowed_to(:show?) } + it { is_expected.to be_allowed_to(:create?) } + it { is_expected.to be_allowed_to(:update?) } + it { is_expected.to be_allowed_to(:destroy?) } + end + + describe "regular user" do + subject { policy_for(record: persisted_form, user: regular_user) } + + it { is_expected.not_to be_allowed_to(:index?) } + it { is_expected.not_to be_allowed_to(:show?) } + it { is_expected.not_to be_allowed_to(:create?) } + it { is_expected.not_to be_allowed_to(:update?) } + it { is_expected.not_to be_allowed_to(:destroy?) } + end + + describe "#destroy?" do + context "with non-persisted record" do + subject { policy_for(record: new_form, user: admin_user) } + + it { is_expected.not_to be_allowed_to(:destroy?) } + end + end +end diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb new file mode 100644 index 000000000..7050cba1a --- /dev/null +++ b/spec/requests/forms_spec.rb @@ -0,0 +1,194 @@ +require "rails_helper" + +RSpec.describe "/forms", type: :request do + let(:admin) { create(:user, :admin) } + let(:regular_user) { create(:user) } + let!(:form) { create(:form, :standalone, name: "Test Registration Form") } + + describe "GET /index" do + context "as an admin" do + before { sign_in admin } + + it "renders successfully" do + get forms_path + expect(response).to be_successful + end + + it "shows forms in the body" do + get forms_path + expect(response.body).to include("Test Registration Form") + end + end + + context "as a non-admin user" do + before { sign_in regular_user } + + it "redirects with unauthorized" do + get forms_path + expect(response).to redirect_to(root_path) + end + end + + context "as a guest" do + it "redirects to sign in" do + get forms_path + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe "GET /show" do + before { sign_in admin } + + it "renders a successful response" do + get form_url(form) + expect(response).to be_successful + expect(response.body).to include("Test Registration Form") + end + end + + describe "GET /new" do + before { sign_in admin } + + it "renders the builder selection page" do + get new_form_url + expect(response).to be_successful + expect(response.body).to include("Short Event Registration") + expect(response.body).to include("Extended Event Registration") + expect(response.body).to include("Scholarship Application") + expect(response.body).to include("Generic Form") + end + end + + describe "GET /edit" do + before { sign_in admin } + + it "renders a successful response" do + get edit_form_url(form) + expect(response).to be_successful + end + end + + describe "POST /create" do + before { sign_in admin } + + it "creates a form with the short registration builder" do + expect { + post forms_url, params: { builder_type: "short_registration" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Short Event Registration") + expect(new_form.form_fields).to be_present + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a form with the extended registration builder" do + expect { + post forms_url, params: { builder_type: "extended_registration" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Extended Event Registration") + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a form with the scholarship builder" do + expect { + post forms_url, params: { builder_type: "scholarship_application" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("Scholarship Application") + expect(new_form.scholarship_application?).to be true + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a generic form with a custom name" do + expect { + post forms_url, params: { builder_type: "generic", form_name: "My Custom Form" } + }.to change(Form, :count).by(1) + + new_form = Form.last + expect(new_form.name).to eq("My Custom Form") + expect(new_form.form_fields).to be_empty + expect(response).to redirect_to(edit_form_url(new_form)) + end + + it "creates a generic form with default name when blank" do + post forms_url, params: { builder_type: "generic", form_name: "" } + expect(Form.last.name).to eq("New Form") + end + end + + describe "PATCH /update" do + before { sign_in admin } + + it "updates the form name" do + patch form_url(form), params: { form: { name: "Updated Form Name" } } + form.reload + expect(form.name).to eq("Updated Form Name") + expect(response).to redirect_to(form_url(form)) + end + + it "updates a nested field question label" do + field = create(:form_field, form: form, question: "Original Question") + patch form_url(form), params: { + form: { + form_fields_attributes: { + "0" => { id: field.id, question: "Updated Question" } + } + } + } + field.reload + expect(field.question).to eq("Updated Question") + end + + it "removes a field via _destroy" do + field = create(:form_field, form: form) + expect { + patch form_url(form), params: { + form: { + form_fields_attributes: { + "0" => { id: field.id, _destroy: "1" } + } + } + } + }.to change(FormField, :count).by(-1) + end + end + + describe "DELETE /destroy" do + before { sign_in admin } + + it "destroys the requested form" do + form_to_delete = create(:form, :standalone) + expect { + delete form_url(form_to_delete) + }.to change(Form, :count).by(-1) + end + + it "redirects to the forms list" do + delete form_url(form) + expect(response).to redirect_to(forms_url) + end + end + + describe "POST /add_questions" do + before { sign_in admin } + + it "clones questions from another form" do + source_form = create(:form, :standalone, name: "Source Form") + source_field = create(:form_field, form: source_form, question: "Source Q", field_key: "source_q") + + expect { + post add_questions_form_url(form), params: { source_field_ids: [ source_field.id ] } + }.to change(form.form_fields, :count).by(1) + + cloned = form.form_fields.reorder(position: :asc).last + expect(cloned.question).to eq("Source Q") + expect(cloned.field_key).to eq("source_q") + expect(response).to redirect_to(edit_form_url(form)) + end + end +end