diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb
index ade7bfcec..47dcb0fd7 100644
--- a/app/controllers/events/public_registrations_controller.rb
+++ b/app/controllers/events/public_registrations_controller.rb
@@ -82,14 +82,14 @@ def show
return
end
- @person_form = @form.person_forms.find_by(person: person)
- unless @person_form
+ @form_submission = @form.form_submissions.find_by(person: person)
+ unless @form_submission
redirect_to event_path(@event), alert: "No registration form submission found."
return
end
- @form_fields = @form.form_fields.where(status: :active).reorder(position: :asc)
- @responses = @person_form.person_form_form_fields.index_by(&:form_field_id)
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ @responses = @form_submission.form_answers.index_by(&:form_field_id)
@event = @event.decorate
end
@@ -108,15 +108,117 @@ def scholarship_mode?
end
def visible_form_fields
- scope = @form.form_fields.where(status: :active)
- if scholarship_mode?
- scope = scope.where.not(field_group: "payment")
- else
- scope = scope.where.not(field_group: "scholarship")
+ scope = @form.form_fields
+
+ unless scholarship_mode?
+ scope = scope.where.not(visibility: :scholarship_only)
+ end
+
+ person = current_user&.person
+ if person
+ if @form.hide_answered_person_questions?
+ known_keys = person_known_field_keys(person)
+ if known_keys.any?
+ known_ids = @form.form_fields
+ .where(visibility: :logged_out_only, field_key: known_keys)
+ .ids
+ scope = scope.where.not(id: known_ids) if known_ids.any?
+ end
+
+ # Hide logged_out_only headers when all their non-header fields are hidden
+ logged_out_groups = @form.form_fields.where(visibility: :logged_out_only)
+ .where.not(answer_type: :group_header)
+ .pluck(:field_group).uniq.compact
+ logged_out_groups.each do |group|
+ group_field_ids = @form.form_fields.where(field_group: group, visibility: :logged_out_only)
+ .where.not(answer_type: :group_header).ids
+ if group_field_ids.any? && known_keys.any? && (group_field_ids - scope.where(id: group_field_ids).ids).any?
+ remaining = scope.where(id: group_field_ids).ids
+ if remaining.empty?
+ scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :logged_out_only)
+ end
+ end
+ end
+ end
+
+ if @form.hide_answered_form_questions?
+ answered_field_ids = []
+
+ # One-time fields: hide if answered on ANY form submission for this person
+ one_time_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: true)
+ .where.not(answer_type: :group_header).ids
+ if one_time_field_ids.any?
+ answered_one_time = FormAnswer.joins(:form_submission)
+ .where(form_submissions: { person_id: person.id })
+ .where(form_field_id: one_time_field_ids)
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
+ answered_field_ids.concat(answered_one_time)
+ end
+
+ # Regular fields: hide if answered on forms within this event
+ event_form_ids = @event.forms.ids
+ event_submissions = FormSubmission.where(person: person, form_id: event_form_ids)
+ if event_submissions.exists?
+ regular_field_ids = @form.form_fields.where(visibility: :answers_on_file, one_time: false)
+ .where.not(answer_type: :group_header).ids
+ if regular_field_ids.any?
+ answered_regular = FormAnswer.where(form_submission: event_submissions)
+ .where(form_field_id: regular_field_ids)
+ .where.not(text: [ nil, "" ])
+ .pluck(:form_field_id)
+ answered_field_ids.concat(answered_regular)
+ end
+ end
+
+ answered_field_ids.uniq!
+ if answered_field_ids.any?
+ scope = scope.where.not(id: answered_field_ids)
+
+ # Hide section headers when all their non-header fields are answered
+ answered_groups = @form.form_fields.where(id: answered_field_ids)
+ .pluck(:field_group).uniq.compact
+ answered_groups.each do |group|
+ group_field_ids = @form.form_fields.where(field_group: group, visibility: :answers_on_file)
+ .where.not(answer_type: :group_header).ids
+ if group_field_ids.any? && (group_field_ids - answered_field_ids).empty?
+ scope = scope.where.not(field_group: group, answer_type: :group_header, visibility: :answers_on_file)
+ end
+ end
+ end
+ end
end
+
scope.reorder(position: :asc)
end
+ def person_known_field_keys(person)
+ keys = []
+ keys << "first_name" if person.first_name.present?
+ keys << "last_name" if person.last_name.present?
+ keys << "primary_email" << "confirm_email" if person.email.present?
+ keys << "primary_email_type" if person.email_type.present?
+ keys << "nickname" if person.legal_first_name.present? || person.first_name.present?
+ keys << "pronouns" if person.pronouns.present?
+ keys << "secondary_email" if person.email_2.present?
+ keys << "secondary_email_type" if person.email_2_type.present?
+
+ if person.addresses.exists?
+ address = person.addresses.find_by(primary: true) || person.addresses.first
+ keys << "mailing_street" if address.street_address.present?
+ keys << "mailing_address_type" if address.address_type.present?
+ keys << "mailing_city" if address.city.present?
+ keys << "mailing_state" if address.state.present?
+ keys << "mailing_zip" if address.zip_code.present?
+ end
+
+ if person.contact_methods.where(kind: :phone).exists?
+ keys << "phone" << "phone_type"
+ end
+
+ keys
+ end
+
def ensure_registerable
unless @event.registerable?
redirect_to event_path(@event), alert: "Registration is closed for this event."
@@ -142,7 +244,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/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 4188bc8dc..72a3c3d86 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -199,7 +199,7 @@ def assign_event_forms(event)
end
end
- scholarship_form = Form.standalone.find_by(name: ScholarshipApplicationFormBuilder::FORM_NAME)
+ scholarship_form = Form.standalone.scholarship_application.first
if scholarship_form && event.cost_cents.to_i > 0
event.event_forms.find_or_create_by!(form: scholarship_form, role: "scholarship")
elsif event.cost_cents.to_i == 0
diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb
new file mode 100644
index 000000000..f44e2f84b
--- /dev/null
+++ b/app/controllers/forms_controller.rb
@@ -0,0 +1,126 @@
+class FormsController < ApplicationController
+ before_action :set_form, only: %i[show edit update destroy reorder_field reorder_fields edit_sections update_sections]
+
+ def index
+ authorize!
+ @forms = Form.standalone.order(:name)
+ end
+
+ def show
+ authorize! @form
+ @form_fields = preview_form_fields
+ end
+
+ def new
+ authorize!
+ end
+
+ def create
+ authorize!
+
+ sections = (params[:sections] || []).reject(&:blank?).map(&:to_sym)
+ if sections.empty?
+ flash.now[:alert] = "Please select at least one section."
+ render :new, status: :unprocessable_content
+ return
+ end
+
+ form = FormBuilderService.new(
+ name: params[:name].presence || "New Form",
+ sections: sections,
+ scholarship_application: params[:scholarship_application] == "1"
+ ).call
+
+ redirect_to edit_form_path(form), notice: "Form created with #{form.form_fields.size} fields."
+ end
+
+ def edit
+ authorize! @form
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ end
+
+ def update
+ authorize! @form
+
+ if @form.update(form_params)
+ redirect_to edit_form_path(@form), notice: "Form updated."
+ else
+ @form_fields = @form.form_fields.reorder(position: :asc)
+ render :edit, status: :unprocessable_content
+ end
+ end
+
+ def destroy
+ authorize! @form
+ @form.destroy!
+ redirect_to forms_path, notice: "Form deleted."
+ end
+
+ def edit_sections
+ authorize! @form
+ end
+
+ def update_sections
+ authorize! @form
+
+ sections = (params[:sections] || []).reject(&:blank?).map(&:to_sym)
+ if sections.empty?
+ flash.now[:alert] = "Please select at least one section."
+ render :edit_sections, status: :unprocessable_content
+ return
+ end
+
+ FormBuilderService.update_sections!(@form, sections)
+ redirect_to edit_form_path(@form), notice: "Sections updated."
+ end
+
+ def reorder_field
+ authorize! @form
+ field = @form.form_fields.find(params[:field_id])
+ field.update!(position: params[:position].to_i)
+ head :ok
+ end
+
+ def reorder_fields
+ authorize! @form
+ positions = JSON.parse(request.body.read)["positions"] || []
+ positions.each do |item|
+ @form.form_fields.where(id: item["id"]).update_all(position: item["position"])
+ end
+ head :ok
+ end
+
+ private
+
+ def set_form
+ @form = Form.find(params[:id])
+ end
+
+ def preview_form_fields
+ scope = @form.form_fields
+
+ unless params[:preview_scholarship].present?
+ scope = scope.where.not(visibility: :scholarship_only)
+ end
+
+ if params[:preview_logged_in].present?
+ scope = scope.where.not(visibility: :logged_out_only)
+ end
+
+ if params[:preview_answered].present?
+ scope = scope.where.not(visibility: :answers_on_file)
+ end
+
+ scope.reorder(position: :asc)
+ end
+
+ def form_params
+ params.require(:form).permit(
+ :name, :hide_answered_person_questions, :hide_answered_form_questions,
+ form_fields_attributes: [
+ :id, :question, :answer_type, :is_required, :instructional_hint,
+ :field_key, :field_group, :position, :visibility, :one_time, :_destroy
+ ]
+ )
+ end
+end
diff --git a/app/frontend/javascript/controllers/chip_select_controller.js b/app/frontend/javascript/controllers/chip_select_controller.js
new file mode 100644
index 000000000..becd88e56
--- /dev/null
+++ b/app/frontend/javascript/controllers/chip_select_controller.js
@@ -0,0 +1,32 @@
+import { Controller } from "@hotwired/stimulus"
+
+/**
+ * Dynamically styles a as a colored chip based on its current value.
+ *
+ * Usage:
+ *
+ */
+export default class extends Controller {
+ static values = { styles: Object }
+
+ connect() {
+ this.update()
+ }
+
+ update() {
+ if (this._allClasses) {
+ this.element.classList.remove(...this._allClasses)
+ }
+ const classes = this.stylesValue[this.element.value]
+ if (classes) {
+ this.element.classList.add(...classes.split(" "))
+ }
+ }
+
+ stylesValueChanged() {
+ this._allClasses = Object.values(this.stylesValue).join(" ").split(" ")
+ this.update()
+ }
+}
diff --git a/app/frontend/javascript/controllers/form_fields_sortable_controller.js b/app/frontend/javascript/controllers/form_fields_sortable_controller.js
new file mode 100644
index 000000000..d06cccb4a
--- /dev/null
+++ b/app/frontend/javascript/controllers/form_fields_sortable_controller.js
@@ -0,0 +1,73 @@
+import { Controller } from "@hotwired/stimulus"
+import { put } from "@rails/request.js"
+import Sortable from "sortablejs"
+
+/**
+ * Sortable controller for form field editing.
+ *
+ * Group-aware: dragging a group header also moves all fields
+ * that share the same field_group.
+ *
+ * Usage:
+ * data-controller="form-fields-sortable"
+ * data-form-fields-sortable-url-value="/forms/:id/reorder_fields"
+ *
+ * Each item needs:
+ * data-sortable-id=""
+ * data-sortable-handle (on the drag handle element)
+ * data-field-group="" (optional)
+ * data-group-header (on header rows)
+ */
+export default class extends Controller {
+ static values = { url: String }
+
+ connect() {
+ this.sortable = Sortable.create(this.element, {
+ onEnd: this.onEnd.bind(this),
+ handle: "[data-sortable-handle]",
+ })
+ }
+
+ disconnect() {
+ this.sortable.destroy()
+ }
+
+ onEnd(event) {
+ const { item } = event
+
+ // If a group header was moved, relocate its group members to follow it
+ if (item.dataset.groupHeader !== undefined) {
+ const group = item.dataset.fieldGroup
+ const members = []
+
+ for (const el of this.element.children) {
+ if (el !== item && el.dataset.fieldGroup === group && el.dataset.groupHeader === undefined) {
+ members.push(el)
+ }
+ }
+
+ let ref = item
+ for (const member of members) {
+ ref.after(member)
+ ref = member
+ }
+ }
+
+ // Update all positions based on current DOM order
+ const items = [...this.element.children]
+ const positions = []
+
+ items.forEach((el, index) => {
+ const pos = index + 1
+ positions.push({ id: parseInt(el.dataset.sortableId), position: pos })
+
+ // Update hidden position field so form submit stays in sync
+ const posInput = el.querySelector("input[name*='[position]']")
+ if (posInput) posInput.value = pos
+ })
+
+ put(this.urlValue, {
+ body: JSON.stringify({ positions }),
+ })
+ }
+}
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index b1ca21084..8781b16f6 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -15,6 +15,9 @@ application.register("autosave", AutosaveController)
import CarouselController from "./carousel_controller"
application.register("carousel", CarouselController)
+import ChipSelectController from "./chip_select_controller"
+application.register("chip-select", ChipSelectController)
+
import CocoonController from "./cocoon_controller"
application.register("cocoon", CocoonController)
@@ -39,6 +42,9 @@ application.register("dropdown", DropdownController)
import FilePreviewController from "./file_preview_controller"
application.register("file-preview", FilePreviewController)
+import FormFieldsSortableController from "./form_fields_sortable_controller"
+application.register("form-fields-sortable", FormFieldsSortableController)
+
import InactiveToggleController from "./inactive_toggle_controller"
application.register("inactive-toggle", InactiveToggleController)
diff --git a/app/models/event.rb b/app/models/event.rb
index 8f67557f0..bed4328f4 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -149,7 +149,7 @@ def public_registration_just_enabled?
def build_public_registration_form
return if event_forms.registration.exists?
- form_name = title&.match?(/training/i) ? ExtendedEventRegistrationFormBuilder::FORM_NAME : ShortEventRegistrationFormBuilder::FORM_NAME
+ form_name = title&.match?(/training/i) ? "Extended Event Registration" : "Short Event Registration"
form = Form.standalone.find_by(name: form_name)
return unless form
diff --git a/app/models/form.rb b/app/models/form.rb
index 6c238178b..e88c9ef4d 100644
--- a/app/models/form.rb
+++ b/app/models/form.rb
@@ -3,13 +3,14 @@ class Form < ApplicationRecord
has_many :form_fields, dependent: :destroy, inverse_of: :form
has_many :event_forms, dependent: :destroy
has_many :user_forms
- has_many :person_forms
+ has_many :form_submissions
has_many :reports, as: :owner
# has_many through
has_many :events, through: :event_forms
# Nested attributes
- accepts_nested_attributes_for :form_fields, allow_destroy: true
+ accepts_nested_attributes_for :form_fields, allow_destroy: true,
+ reject_if: proc { |attrs| attrs["question"].blank? && attrs["id"].blank? }
scope :scholarship_application, -> { where(scholarship_application: true) }
scope :standalone, -> { where(owner_id: nil, owner_type: nil) }
diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb
new file mode 100644
index 000000000..07e1eeefe
--- /dev/null
+++ b/app/models/form_answer.rb
@@ -0,0 +1,8 @@
+class FormAnswer < ApplicationRecord
+ belongs_to :form_field, optional: true
+ belongs_to :form_submission
+
+ def name
+ "#{question_text.presence || form_field&.question}: #{text}"
+ end
+end
diff --git a/app/models/form_field.rb b/app/models/form_field.rb
index 412b87586..101f36453 100644
--- a/app/models/form_field.rb
+++ b/app/models/form_field.rb
@@ -12,6 +12,7 @@ class FormField < ApplicationRecord
# Enum
enum :status, [ :inactive, :active ]
+ enum :visibility, [ :always_ask, :scholarship_only, :logged_out_only, :answers_on_file ]
# TODO: Rails 6.1 requires enums to be symbols
# need additional refactoring in methods that call answer_type & answer_datatype to account for change to enum
diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb
new file mode 100644
index 000000000..d54a48bef
--- /dev/null
+++ b/app/models/form_submission.rb
@@ -0,0 +1,7 @@
+class FormSubmission < ApplicationRecord
+ belongs_to :person
+ belongs_to :form
+ has_many :form_answers, dependent: :destroy
+
+ accepts_nested_attributes_for :form_answers
+end
diff --git a/app/models/person.rb b/app/models/person.rb
index 304bbd5c5..e5563af3f 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -23,7 +23,7 @@ class Person < ApplicationRecord
has_many :events, through: :event_registrations
has_many :categories, through: :categorizable_items
has_many :sectors, through: :sectorable_items
- has_many :person_forms, dependent: :destroy
+ has_many :form_submissions, dependent: :destroy
# Asset associations
has_one_attached :avatar, dependent: :purge do |attachable|
diff --git a/app/models/person_form.rb b/app/models/person_form.rb
deleted file mode 100644
index 2c4a4cfcd..000000000
--- a/app/models/person_form.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class PersonForm < ApplicationRecord
- belongs_to :person
- belongs_to :form
- has_many :person_form_form_fields, dependent: :destroy
-
- accepts_nested_attributes_for :person_form_form_fields
-end
diff --git a/app/models/person_form_form_field.rb b/app/models/person_form_form_field.rb
deleted file mode 100644
index 2ef92ebf0..000000000
--- a/app/models/person_form_form_field.rb
+++ /dev/null
@@ -1,8 +0,0 @@
-class PersonFormFormField < ApplicationRecord
- belongs_to :form_field
- belongs_to :person_form
-
- def name
- "#{form_field.question}: #{text}"
- end
-end
diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb
new file mode 100644
index 000000000..f2514e20c
--- /dev/null
+++ b/app/policies/form_policy.rb
@@ -0,0 +1,3 @@
+class FormPolicy < ApplicationPolicy
+ # Admin-only — all CRUD actions inherit manage? from ApplicationPolicy
+end
diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb
index af5b734e7..11495b2c7 100644
--- a/app/services/event_registration_services/public_registration.rb
+++ b/app/services/event_registration_services/public_registration.rb
@@ -30,12 +30,12 @@ def call
existing = @event.event_registrations.find_by(registrant: person)
if existing
existing.update!(scholarship_requested: true) if @scholarship_requested
- update_person_form(person)
+ update_form_submission(person)
return Result.new(success?: true, event_registration: existing, errors: [])
end
event_registration = create_event_registration(person)
- create_person_form(person)
+ create_form_submission(person)
send_notifications(event_registration)
@@ -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
@@ -237,21 +237,22 @@ def create_event_registration(person)
)
end
- def create_person_form(person)
- person_form = PersonForm.create!(person: person, form: @form)
- save_form_fields(person_form)
- person_form
+ def create_form_submission(person)
+ submission = FormSubmission.create!(person: person, form: @form)
+ save_form_answers(submission)
+ submission
end
- def update_person_form(person)
- person_form = PersonForm.find_or_create_by!(person: person, form: @form)
- save_form_fields(person_form)
- person_form
+ def update_form_submission(person)
+ submission = FormSubmission.find_or_create_by!(person: person, form: @form)
+ save_form_answers(submission)
+ submission
end
- def save_form_fields(person_form)
- @form.form_fields.where(status: :active).find_each do |field|
+ def save_form_answers(submission)
+ @form.form_fields.find_each do |field|
next if field.group_header?
+ next if field.field_key == "confirm_email"
raw_value = @form_params[field.id.to_s]
text = if raw_value.is_a?(Array)
@@ -260,8 +261,8 @@ def save_form_fields(person_form)
raw_value.to_s
end
- record = person_form.person_form_form_fields.find_or_initialize_by(form_field: field)
- record.update!(text: text)
+ record = submission.form_answers.find_or_initialize_by(form_field: field)
+ record.update!(text: text, question_text: field.question)
end
end
diff --git a/app/services/extended_event_registration_form_builder.rb b/app/services/extended_event_registration_form_builder.rb
deleted file mode 100644
index 9a7e2678b..000000000
--- a/app/services/extended_event_registration_form_builder.rb
+++ /dev/null
@@ -1,264 +0,0 @@
-class ExtendedEventRegistrationFormBuilder
- FORM_NAME = "Extended Event Registration"
-
- def self.build_standalone!(include_contact_fields: true)
- form = Form.create!(name: FORM_NAME)
- new(nil, include_contact_fields:).build_fields!(form)
- form
- end
-
- def self.build!(event, include_contact_fields: true)
- form = Form.create!(name: FORM_NAME)
- new(event, include_contact_fields:).build_fields!(form)
- EventForm.create!(event: event, form: form, role: "registration") if event
- form
- end
-
- def self.copy!(from_form:, to_event:)
- new_form = Form.create!(
- name: from_form.name,
- form_builder_id: from_form.form_builder_id
- )
-
- from_form.form_fields.unscoped.where(form_id: from_form.id).order(:position).each do |source_field|
- new_field = new_form.form_fields.create!(
- question: source_field.question,
- answer_type: source_field.answer_type,
- answer_datatype: source_field.answer_datatype,
- status: source_field.status,
- position: source_field.position,
- is_required: source_field.is_required,
- instructional_hint: source_field.instructional_hint,
- field_key: source_field.field_key,
- field_group: source_field.field_group
- )
-
- source_field.form_field_answer_options.each do |ffao|
- new_field.form_field_answer_options.create!(answer_option: ffao.answer_option)
- end
- end
-
- EventForm.create!(event: to_event, form: new_form, role: "registration")
- new_form
- end
-
- def initialize(event = nil, include_contact_fields: true)
- @event = event
- @include_contact_fields = include_contact_fields
- end
-
- def build_fields!(form)
- position = 0
-
- if @include_contact_fields
- position = build_contact_fields(form, position)
- end
-
- position = build_background_fields(form, position)
- position = build_professional_fields(form, position)
- position = build_qualitative_fields(form, position)
- position = build_scholarship_fields(form, position)
- build_payment_fields(form, position)
-
- form
- end
-
- private
-
- 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 = 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, "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,
- key: "secondary_email_type", group: "contact", required: false,
- options: %w[Personal Work])
-
- 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, "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)
- position = add_field(form, position, "Phone Type", :multiple_choice_radio,
- key: "phone_type", group: "contact", required: true,
- options: %w[Mobile Home Work])
-
- position = add_header(form, position, "Agency / Organization Information", group: "contact")
- position = add_field(form, position, "Agency / Organization Name", :free_form_input_one_line,
- key: "agency_name", group: "contact", required: false)
- position = add_field(form, position, "Position / Title", :free_form_input_one_line,
- key: "agency_position", group: "contact", required: false)
- position = add_field(form, position, "Agency Street Address", :free_form_input_one_line,
- key: "agency_street", group: "contact", required: false)
- position = add_field(form, position, "Agency City", :free_form_input_one_line,
- key: "agency_city", group: "contact", required: false)
- position = add_field(form, position, "Agency State / Province", :free_form_input_one_line,
- key: "agency_state", group: "contact", required: false)
- position = add_field(form, position, "Agency Zip / Postal Code", :free_form_input_one_line,
- key: "agency_zip", group: "contact", required: false)
- position = add_field(form, position, "Agency Type", :multiple_choice_radio,
- key: "agency_type", group: "contact", required: false,
- options: [
- "Domestic Violence", "Homeless Shelter", "Hospital",
- "Mental Health", "School", "After-School Program",
- "Community Center", "Other"
- ])
- position = add_field(form, position, "Agency Website", :free_form_input_one_line,
- key: "agency_website", group: "contact", required: false)
-
- position
- end
-
- def build_background_fields(form, position)
- position = add_header(form, position, "Background Information", group: "background")
-
- position = add_field(form, position, "Racial / Ethnic Identity", :free_form_input_one_line,
- key: "racial_ethnic_identity", group: "background", required: false,
- hint: "This information helps us understand the diversity of our community.")
-
- position
- end
-
- def build_professional_fields(form, position)
- position = add_header(form, position, "Professional Information", group: "professional")
-
- position = add_field(form, position, "Primary Service Area(s)", :multiple_choice_checkbox,
- 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,
- hint: "Select all settings where you facilitate or plan to facilitate workshops.",
- options: [
- "Clinical", "Educational", "Events / conferences",
- "Faith-based", "Home visits", "Hospitals",
- "Law enforcement / court / legal", "Outreach",
- "Prisons / jails", "Private practice", "Residential",
- "Virtually", "With staff", "Other"
- ])
- position = add_field(form, position, "Client Life Experiences", :multiple_choice_checkbox,
- key: "client_life_experiences", group: "professional", required: false,
- hint: "Select all that describe the populations you work with.")
- position = add_field(form, position, "Primary Age Group(s) Served", :multiple_choice_checkbox,
- key: "primary_age_group", group: "professional", required: false,
- hint: "Select all age groups you primarily serve.")
-
- position
- end
-
- def build_qualitative_fields(form, position)
- position = add_header(form, position, "About You", group: "qualitative")
-
- position = add_field(form, position, "How did you hear about this training?", :free_form_input_paragraph,
- key: "referral_source", group: "qualitative", required: false)
- position = add_field(form, position, "What motivates you to attend this training?", :free_form_input_paragraph,
- key: "training_motivation", group: "qualitative", required: false)
- position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?", :multiple_choice_radio,
- key: "interested_in_more", group: "qualitative", required: true,
- options: %w[Yes No])
-
- position
- end
-
- def build_scholarship_fields(form, position)
- position = add_header(form, position, "Scholarship Application", group: "scholarship")
-
- position = add_field(form, position,
- "I / my agency cannot afford the full training cost and need a scholarship to attend.",
- :multiple_choice_checkbox,
- key: "scholarship_eligibility", group: "scholarship", required: true,
- options: [ "Yes" ])
- position = add_field(form, position,
- "How will what you gain from this training directly impact the people you serve?",
- :free_form_input_paragraph,
- key: "impact_description", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position,
- "Please describe one way in which you plan to use art workshops and how you envision it will help.",
- :free_form_input_paragraph,
- key: "implementation_plan", group: "scholarship", required: true,
- hint: "Please describe in 3-5+ sentences.")
- position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
- key: "additional_comments", group: "scholarship", required: false)
-
- position
- end
-
- def build_payment_fields(form, position)
- position = add_header(form, position, "Payment Information", group: "payment")
-
- position = add_field(form, position, "Number of Attendees", :free_form_input_one_line,
- key: "number_of_attendees", group: "payment", required: true,
- hint: "How many people are you registering (including yourself)?",
- datatype: :number_integer)
- position = add_field(form, position, "Payment Method", :multiple_choice_radio,
- key: "payment_method", group: "payment", required: true,
- options: [ "Credit Card", "Check", "Purchase Order", "Other" ])
-
- position
- end
-
- # --- 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/form_builder_service.rb b/app/services/form_builder_service.rb
new file mode 100644
index 000000000..bcc35fe57
--- /dev/null
+++ b/app/services/form_builder_service.rb
@@ -0,0 +1,343 @@
+class FormBuilderService
+ SECTIONS = {
+ person_identifier: { label: "Person identifier", method: :build_person_identifier_fields },
+ person_contact_info: { label: "Person contact info", method: :build_person_contact_info_fields },
+ person_background: { label: "Person background", method: :build_person_background_fields },
+ professional_info: { label: "Professional info", method: :build_professional_info_fields },
+ marketing: { label: "Marketing", method: :build_marketing_fields },
+ scholarship: { label: "Scholarship", method: :build_scholarship_fields },
+ payment: { label: "Payment", method: :build_payment_fields },
+ consent: { label: "Consent", method: :build_consent_fields },
+ post_event_feedback: { label: "Post-event feedback", method: :build_post_event_feedback_fields }
+ }.freeze
+
+ def initialize(name:, sections:, scholarship_application: false)
+ @name = name
+ @sections = sections.map(&:to_sym)
+ @scholarship_application = scholarship_application
+ end
+
+ def call
+ form = Form.create!(
+ name: @name,
+ sections: @sections.map(&:to_s),
+ scholarship_application: @scholarship_application
+ )
+
+ position = 0
+ @sections.each do |key|
+ section = SECTIONS.fetch(key)
+ position = send(section[:method], form, position)
+ end
+
+ form
+ end
+
+ SECTION_FIELD_KEYS = {
+ person_identifier: %w[first_name last_name primary_email confirm_email],
+ person_contact_info: %w[
+ primary_email_type nickname pronouns secondary_email secondary_email_type
+ mailing_street mailing_address_type mailing_city mailing_state mailing_zip
+ phone phone_type agency_name agency_position agency_street agency_city
+ agency_state agency_zip agency_type agency_website
+ ],
+ person_background: %w[racial_ethnic_identity],
+ professional_info: %w[primary_service_area workshop_environments client_life_experiences primary_age_group],
+ marketing: %w[referral_source training_motivation interested_in_more],
+ scholarship: %w[scholarship_eligibility impact_description implementation_plan additional_comments],
+ payment: %w[number_of_attendees payment_method],
+ consent: %w[communication_consent],
+ post_event_feedback: %w[event_rating most_valuable improvement_suggestions]
+ }.freeze
+
+ # Header questions created by each section's builder method
+ SECTION_HEADERS = {
+ person_identifier: [],
+ person_contact_info: [ "Contact Information", "Mailing Address", "Agency / Organization Information" ],
+ person_background: [ "Background Information" ],
+ professional_info: [ "Professional Information" ],
+ marketing: [ "Marketing" ],
+ scholarship: [ "Scholarship Application" ],
+ payment: [ "Payment Information" ],
+ consent: [ "Consent" ],
+ post_event_feedback: [ "Post-Event Feedback" ]
+ }.freeze
+
+ # Update sections on an existing form: add new sections, remove unchecked ones
+ def self.update_sections!(form, new_sections)
+ new_sections = new_sections.map(&:to_sym)
+ old_sections = (form.sections || []).map(&:to_sym)
+
+ added = new_sections - old_sections
+ removed = old_sections - new_sections
+
+ # Remove fields and headers belonging to removed sections
+ removed.each do |key|
+ field_keys = SECTION_FIELD_KEYS.fetch(key)
+ form.form_fields.where(field_key: field_keys).destroy_all
+
+ headers = SECTION_HEADERS.fetch(key)
+ if headers.any?
+ form.form_fields.where(question: headers, answer_type: :group_header).destroy_all
+ end
+ end
+
+ # Add fields for new sections at the end
+ if added.any?
+ max_position = form.form_fields.maximum(:position) || 0
+ builder = new(name: form.name, sections: added)
+ added.each do |key|
+ section = SECTIONS.fetch(key)
+ max_position = builder.send(section[:method], form, max_position)
+ end
+ end
+
+ form.update!(sections: new_sections.map(&:to_s))
+ form
+ end
+
+ private
+
+ GROUP_VISIBILITY = {
+ "person_identifier" => :logged_out_only,
+ "person_contact_info" => :logged_out_only,
+ "background" => :logged_out_only,
+ "professional" => :answers_on_file,
+ "marketing" => :answers_on_file,
+ "payment" => :answers_on_file,
+ "scholarship" => :scholarship_only,
+ "consent" => :answers_on_file,
+ "post_event_feedback" => :answers_on_file
+ }.freeze
+
+ # Groups where answers carry across all events (ask once ever)
+ ONE_TIME_GROUPS = %w[professional background].freeze
+
+ def add_header(form, position, title, group:)
+ position += 1
+ form.form_fields.create!(
+ question: title,
+ answer_type: :group_header,
+ status: :active,
+ position: position,
+ is_required: false,
+ field_key: nil,
+ field_group: group,
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask),
+ one_time: ONE_TIME_GROUPS.include?(group)
+ )
+ 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,
+ visibility: GROUP_VISIBILITY.fetch(group, :always_ask),
+ one_time: ONE_TIME_GROUPS.include?(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
+
+ # ---- Section builders ----
+
+ def build_person_identifier_fields(form, position)
+ position = add_field(form, position, "First Name", :free_form_input_one_line,
+ key: "first_name", group: "person_identifier", required: true)
+ position = add_field(form, position, "Last Name", :free_form_input_one_line,
+ key: "last_name", group: "person_identifier", required: true)
+ position = add_field(form, position, "Email", :free_form_input_one_line,
+ key: "primary_email", group: "person_identifier", required: true)
+ position = add_field(form, position, "Confirm Email", :free_form_input_one_line,
+ key: "confirm_email", group: "person_identifier", required: true)
+ position
+ end
+
+ def build_person_contact_info_fields(form, position)
+ position = add_header(form, position, "Contact Information", group: "person_contact_info")
+
+ position = add_field(form, position, "Primary Email Type", :multiple_choice_radio,
+ key: "primary_email_type", group: "person_contact_info", required: true,
+ options: %w[Personal Work])
+ position = add_field(form, position, "Preferred Nickname", :free_form_input_one_line,
+ key: "nickname", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Pronouns", :free_form_input_one_line,
+ key: "pronouns", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Secondary Email", :free_form_input_one_line,
+ key: "secondary_email", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Secondary Email Type", :multiple_choice_radio,
+ key: "secondary_email_type", group: "person_contact_info", required: false,
+ options: %w[Personal Work])
+
+ position = add_header(form, position, "Mailing Address", group: "person_contact_info")
+ position = add_field(form, position, "Street Address", :free_form_input_one_line,
+ key: "mailing_street", group: "person_contact_info", required: true)
+ position = add_field(form, position, "Address Type", :multiple_choice_radio,
+ key: "mailing_address_type", group: "person_contact_info", required: true,
+ options: %w[Home Work])
+ position = add_field(form, position, "City", :free_form_input_one_line,
+ key: "mailing_city", group: "person_contact_info", required: true)
+ position = add_field(form, position, "State / Province", :free_form_input_one_line,
+ key: "mailing_state", group: "person_contact_info", required: true)
+ position = add_field(form, position, "Zip / Postal Code", :free_form_input_one_line,
+ key: "mailing_zip", group: "person_contact_info", required: true)
+
+ position = add_field(form, position, "Phone", :free_form_input_one_line,
+ key: "phone", group: "person_contact_info", required: true)
+ position = add_field(form, position, "Phone Type", :multiple_choice_radio,
+ key: "phone_type", group: "person_contact_info", required: true,
+ options: %w[Mobile Home Work])
+
+ position = add_header(form, position, "Agency / Organization Information", group: "person_contact_info")
+ position = add_field(form, position, "Agency / Organization Name", :free_form_input_one_line,
+ key: "agency_name", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Position / Title", :free_form_input_one_line,
+ key: "agency_position", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Agency Street Address", :free_form_input_one_line,
+ key: "agency_street", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Agency City", :free_form_input_one_line,
+ key: "agency_city", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Agency State / Province", :free_form_input_one_line,
+ key: "agency_state", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Agency Zip / Postal Code", :free_form_input_one_line,
+ key: "agency_zip", group: "person_contact_info", required: false)
+ position = add_field(form, position, "Agency Type", :multiple_choice_radio,
+ key: "agency_type", group: "person_contact_info", required: false,
+ options: [
+ "Domestic Violence", "Homeless Shelter", "Hospital",
+ "Mental Health", "School", "After-School Program",
+ "Community Center", "Other"
+ ])
+ position = add_field(form, position, "Agency Website", :free_form_input_one_line,
+ key: "agency_website", group: "person_contact_info", required: false)
+ position
+ end
+
+ def build_person_background_fields(form, position)
+ position = add_header(form, position, "Background Information", group: "background")
+
+ position = add_field(form, position, "Racial / Ethnic Identity", :free_form_input_one_line,
+ key: "racial_ethnic_identity", group: "background", required: false,
+ hint: "This information helps us understand the diversity of our community.")
+ position
+ end
+
+ def build_professional_info_fields(form, position)
+ position = add_header(form, position, "Professional Information", group: "professional")
+
+ position = add_field(form, position, "Primary Service Area(s)", :multiple_choice_checkbox,
+ 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_environments", group: "professional", required: false,
+ hint: "Select all settings where you facilitate or plan to facilitate workshops.",
+ options: [
+ "Clinical", "Educational", "Events / conferences",
+ "Faith-based", "Home visits", "Hospitals",
+ "Law enforcement / court / legal", "Outreach",
+ "Prisons / jails", "Private practice", "Residential",
+ "Virtually", "With staff", "Other"
+ ])
+ position = add_field(form, position, "Client Life Experiences", :multiple_choice_checkbox,
+ key: "client_life_experiences", group: "professional", required: false,
+ hint: "Select all that describe the populations you work with.")
+ position = add_field(form, position, "Primary Age Group(s) Served", :multiple_choice_checkbox,
+ key: "primary_age_group", group: "professional", required: false,
+ hint: "Select all age groups you primarily serve.")
+ position
+ end
+
+ def build_marketing_fields(form, position)
+ position = add_header(form, position, "Marketing", group: "marketing")
+
+ position = add_field(form, position, "How did you hear about this training?", :free_form_input_paragraph,
+ key: "referral_source", group: "marketing", required: false)
+ position = add_field(form, position, "What motivates you to attend this training?", :free_form_input_paragraph,
+ key: "training_motivation", group: "marketing", required: false)
+ position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?",
+ :multiple_choice_radio,
+ key: "interested_in_more", group: "marketing", required: true,
+ options: %w[Yes No])
+ position
+ end
+
+ def build_scholarship_fields(form, position)
+ position = add_header(form, position, "Scholarship Application", group: "scholarship")
+
+ position = add_field(form, position,
+ "I / my agency cannot afford the full training cost and need a scholarship to attend.",
+ :multiple_choice_checkbox,
+ key: "scholarship_eligibility", group: "scholarship", required: true,
+ options: [ "Yes" ])
+ position = add_field(form, position,
+ "How will what you gain from this training directly impact the people you serve?",
+ :free_form_input_paragraph,
+ key: "impact_description", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position,
+ "Please describe one way in which you plan to use art workshops and how you envision it will help.",
+ :free_form_input_paragraph,
+ key: "implementation_plan", group: "scholarship", required: true,
+ hint: "Please describe in 3-5+ sentences.")
+ position = add_field(form, position, "Anything else you'd like to share with us?", :free_form_input_paragraph,
+ key: "additional_comments", group: "scholarship", required: false)
+ position
+ end
+
+ def build_payment_fields(form, position)
+ position = add_header(form, position, "Payment Information", group: "payment")
+
+ position = add_field(form, position, "Number of Attendees", :free_form_input_one_line,
+ key: "number_of_attendees", group: "payment", required: true,
+ hint: "How many people are you registering (including yourself)?",
+ datatype: :number_integer)
+ position = add_field(form, position, "Payment Method", :multiple_choice_radio,
+ key: "payment_method", group: "payment", required: true,
+ options: [ "Credit Card", "Check", "Purchase Order", "Other" ])
+ position
+ end
+
+ def build_consent_fields(form, position)
+ position = add_header(form, position, "Consent", group: "consent")
+ position = add_field(form, position,
+ "I agree to receive email communications from A Window Between Worlds.",
+ :multiple_choice_checkbox,
+ key: "communication_consent", group: "consent", required: true,
+ hint: "By submitting this form, I consent to receive updates from A Window Between Worlds, " \
+ "including information about this event as well as upcoming events, training opportunities, resources, " \
+ "impact stories, and ways to support our mission. I understand I can unsubscribe at any time.",
+ options: [ "Yes" ])
+ position
+ end
+
+ def build_post_event_feedback_fields(form, position)
+ position = add_header(form, position, "Post-Event Feedback", group: "post_event_feedback")
+
+ position = add_field(form, position, "How would you rate this event?", :multiple_choice_radio,
+ key: "event_rating", group: "post_event_feedback", required: true,
+ options: [ "Excellent", "Good", "Fair", "Poor" ])
+ position = add_field(form, position, "What did you find most valuable?", :free_form_input_paragraph,
+ key: "most_valuable", group: "post_event_feedback", required: false)
+ position = add_field(form, position, "Any suggestions for improvement?", :free_form_input_paragraph,
+ key: "improvement_suggestions", group: "post_event_feedback", required: false)
+ position
+ end
+end
diff --git a/app/services/scholarship_application_form_builder.rb b/app/services/scholarship_application_form_builder.rb
deleted file mode 100644
index 10331fc2a..000000000
--- a/app/services/scholarship_application_form_builder.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-class ScholarshipApplicationFormBuilder
- FORM_NAME = "Scholarship Application"
-
- def self.build_standalone!
- form = Form.create!(name: FORM_NAME, scholarship_application: true)
- new.build_fields!(form)
- form
- end
-
- def self.build!(event)
- form = Form.create!(name: FORM_NAME, scholarship_application: true)
- new.build_fields!(form)
- EventForm.create!(event: event, form: form, role: "scholarship") if event
- form
- end
-
- def build_fields!(form)
- 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)
-
- 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
deleted file mode 100644
index 7d5ec291f..000000000
--- a/app/services/short_event_registration_form_builder.rb
+++ /dev/null
@@ -1,112 +0,0 @@
-class ShortEventRegistrationFormBuilder
- FORM_NAME = "Short Event Registration"
-
- def self.build_standalone!
- form = Form.create!(name: FORM_NAME)
- new.build_fields!(form)
- form
- end
-
- def self.build!(event)
- form = Form.create!(name: FORM_NAME)
- new.build_fields!(form)
- EventForm.create!(event: event, form: form, role: "registration") if event
- form
- end
-
- def build_fields!(form)
- position = 0
-
- position = 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 = 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." ])
-
- position = add_field(form, position, "How did you hear about this event?", :multiple_choice_checkbox,
- key: "referral_source", group: "qualitative", required: true,
- options: [ "AWBW Email", "Facebook", "Instagram", "LinkedIn", "Online Search", "Word of Mouth", "Other" ])
-
- position = add_field(form, position, "Are you interested in learning more about upcoming trainings or resources?",
- :multiple_choice_checkbox,
- key: "training_interest", group: "qualitative", required: true,
- options: [ "Yes", "Not right now" ])
-
- position = 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/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index bb81e7db8..fab37ce76 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -141,7 +141,7 @@
<% if event_registration.active? %>
<% registration_form = event_registration.event.registration_form %>
- <% if registration_form && registration_form.person_forms.exists?(person: event_registration.registrant) %>
+ <% if registration_form && registration_form.form_submissions.exists?(person: event_registration.registrant) %>
<%= link_to "View registration details",
event_public_registration_path(event_registration.event, reg: event_registration.slug),
class: "text-xs text-gray-400 hover:text-blue-600 underline" %>
diff --git a/app/views/event_registrations/index.html.erb b/app/views/event_registrations/index.html.erb
index 71bb042d4..64e62ec45 100644
--- a/app/views/event_registrations/index.html.erb
+++ b/app/views/event_registrations/index.html.erb
@@ -49,7 +49,7 @@
<%= person_profile_button(event_registration.registrant) %>
<% reg_form = event_registration.event.registration_form %>
- <% if reg_form && reg_form.person_forms.exists?(person: event_registration.registrant) %>
+ <% if reg_form && reg_form.form_submissions.exists?(person: event_registration.registrant) %>
<% elsif reg_form %>
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb
index fe725c838..e503de836 100644
--- a/app/views/events/_form.html.erb
+++ b/app/views/events/_form.html.erb
@@ -386,24 +386,27 @@
<% default_form_id = if current_reg_form
current_reg_form.id
elsif @event.title&.match?(/training/i)
- @registration_forms.find { |rf| rf.name == ExtendedEventRegistrationFormBuilder::FORM_NAME }&.id
+ @registration_forms.find { |rf| rf.name == "Extended Event Registration" }&.id
else
- @registration_forms.find { |rf| rf.name == ShortEventRegistrationFormBuilder::FORM_NAME }&.id
+ @registration_forms.find { |rf| rf.name == "Short Event Registration" }&.id
end %>
No registration form
<% @registration_forms.each do |reg_form| %>
<% label = case reg_form.name
- when ExtendedEventRegistrationFormBuilder::FORM_NAME, "Public Registration" then "Extended registration form"
- when ShortEventRegistrationFormBuilder::FORM_NAME, "Short Registration" then "Short registration form"
+ when "Extended Event Registration", "Public Registration" then "Extended registration form"
+ when "Short Event Registration", "Short Registration" then "Short registration form"
else reg_form.name
end %>
><%= label %> (<%= reg_form.form_fields.size %> fields)
<% end %>
- <% if @event.cost_cents.to_i > 0 %>
-
Scholarship application form will be linked automatically for paid events.
- <% end %>
+
+ <%= link_to "Create new form", new_form_path, class: "text-blue-600 hover:text-blue-800 underline", target: "_blank" %>
+ <% if @event.cost_cents.to_i > 0 %>
+ · Scholarship application form will be linked automatically for paid events.
+ <% end %>
+
diff --git a/app/views/events/_manage_results.html.erb b/app/views/events/_manage_results.html.erb
index 15fccb9a4..9f5be81a6 100644
--- a/app/views/events/_manage_results.html.erb
+++ b/app/views/events/_manage_results.html.erb
@@ -4,7 +4,7 @@
<%= @event_registrations.size %> registrant<%= "s" if @event_registrations.size != 1 %>
<% reg_form = @event.registration_form %>
- <% form_submissions = reg_form ? reg_form.person_forms.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %>
+ <% form_submissions = reg_form ? reg_form.form_submissions.where(person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at).to_h : {} %>
<% if @event_registrations.any? %>
diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb
index d5fe9730f..9cf64881d 100644
--- a/app/views/events/public_registrations/_form_field.html.erb
+++ b/app/views/events/public_registrations/_form_field.html.erb
@@ -1,4 +1,4 @@
-<%# locals: (field:, value: nil) %>
+<%# locals: (field:, value: nil, label: nil) %>
<% return if field.group_header? %>
<% error = @field_errors&.dig(field.id) %>
@@ -6,7 +6,7 @@
- <%= field.question %>
+ <%= label || field.question %>
<% if field.is_required %>
*
<% end %>
diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb
index 8d802542b..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" } }
@@ -74,6 +76,7 @@
{ keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
email_row,
{ keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } },
+ { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
{ keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
{ keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
@@ -86,6 +89,13 @@
fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key)
rendered_keys = {}
+
+ has_secondary_email = fields_by_key["secondary_email"].present?
+ email_label_overrides = if has_secondary_email
+ { "primary_email" => "Primary Email", "confirm_email" => "Confirm Primary Email", "primary_email_type" => "Primary Email Type" }
+ else
+ {}
+ end
%>
<% @form_fields.each do |field| %>
@@ -103,12 +113,13 @@
<% rendered_keys[key] = true %>
<% span = group.dig(:spans, key) %>
<% submitted_value = params.dig(:public_registration, :form_fields, row_field.id.to_s) %>
+ <% label_override = email_label_overrides[key] %>
<% if span %>
- <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value, label: label_override %>
<% else %>
- <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: submitted_value, label: label_override %>
<% end %>
<% end %>
diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb
index 94373ae41..cd64aa177 100644
--- a/app/views/events/public_registrations/show.html.erb
+++ b/app/views/events/public_registrations/show.html.erb
@@ -1,6 +1,6 @@
<% content_for(:page_bg_class, "public") %>
-<% reg = @person_form.person.event_registrations.find_by(event: @event) %>
+<% reg = @form_submission.person.event_registrations.find_by(event: @event) %>
<% back_path = reg&.slug.present? ? registration_ticket_path(reg.slug) : event_path(@event) %>
<%= link_to "← Back to Registration", back_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
@@ -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 == "confirm_email" %>
<% next if field.field_key.present? && rendered_keys[field.field_key] %>
<% if field.group_header? %>
@@ -82,7 +84,7 @@
- Submitted on <%= @person_form.created_at.strftime("%B %d, %Y at %l:%M %P") %>
+ Submitted on <%= @form_submission.created_at.strftime("%B %d, %Y at %l:%M %P") %>
diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb
new file mode 100644
index 000000000..2e9c275cf
--- /dev/null
+++ b/app/views/forms/_form_field_fields.html.erb
@@ -0,0 +1,75 @@
+<%
+ field = f.object
+
+ visibility_options = [
+ [ "Always ask", "always_ask" ],
+ [ "Scholarship-only", "scholarship_only" ],
+ [ "Logged out only", "logged_out_only" ],
+ [ "Answers on file", "answers_on_file" ]
+ ]
+
+ visibility_styles = {
+ always_ask: "bg-white text-gray-600 border-gray-300",
+ scholarship_only: "bg-purple-100 text-purple-700 border-purple-200",
+ logged_out_only: "bg-emerald-100 text-emerald-700 border-emerald-200",
+ answers_on_file: "bg-amber-100 text-amber-700 border-amber-200"
+ }
+%>
+
diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb
new file mode 100644
index 000000000..1c4aa6951
--- /dev/null
+++ b/app/views/forms/edit.html.erb
@@ -0,0 +1,88 @@
+
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View form", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit sections", edit_sections_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
Edit: <%= @form.display_name %>
+
+ <%= form_with model: @form, class: "space-y-6" do |f| %>
+ <% if @form.errors.any? %>
+
+
+ <%= pluralize(@form.errors.count, "error") %> prevented this form from being saved:
+
+
+ <% @form.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ Form Name
+ <%= f.text_field :name, class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 text-sm" %>
+
+
+
+
+ <%= f.check_box :hide_answered_person_questions, class: "rounded border-gray-300 text-blue-600" %>
+ Hide any answered person & organization questions
+
+
+ <%= f.check_box :hide_answered_form_questions, class: "rounded border-gray-300 text-blue-600" %>
+ Hide any questions the user already answered
+
+
+
+
+
+
+
Fields
+ <%= @form_fields.size %> fields
+
+
+
+
Conditional visibility
+
+
+ Always ask
+ Always shown to all users
+
+
+ Scholarship-only
+ Shown only when registering for scholarship
+
+
+ Logged out only
+ Hidden for logged-in users
+
+
+ Answers on file
+ Hidden when answered on this event's forms; if "One-time", hidden when answered on any form
+
+
+
+
+
+ <%= f.fields_for :form_fields, @form_fields do |ff| %>
+ <%= render "form_field_fields", f: ff %>
+ <% end %>
+
+
+
+ <%= link_to_add_association "+ Add field", f, :form_fields,
+ data: { association_insertion_node: "[data-controller='form-fields-sortable']",
+ association_insertion_method: "append" },
+ class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
+
+
+
+
+ <%= f.submit "Save Changes", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/app/views/forms/edit_sections.html.erb b/app/views/forms/edit_sections.html.erb
new file mode 100644
index 000000000..62c976c4b
--- /dev/null
+++ b/app/views/forms/edit_sections.html.erb
@@ -0,0 +1,36 @@
+
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit fields", edit_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View form", form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
Edit Sections: <%= @form.display_name %>
+
+ <% current_sections = (@form.sections || []).map(&:to_s) %>
+
+ <%= form_with url: update_sections_form_path(@form), method: :patch, class: "space-y-6" do |f| %>
+
+ Select sections to include:
+
+ <% FormBuilderService::SECTIONS.each do |key, section| %>
+
+ >
+ <%= section[:label] %>
+
+ <% end %>
+
+
+
+
+ Unchecking a section will remove its fields from the form. Checking a new section will add its default fields at the end.
+ Existing answers are preserved via question text snapshots.
+
+
+
+ <%= f.submit "Update Sections", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", edit_form_path(@form), class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb
new file mode 100644
index 000000000..303ba23a1
--- /dev/null
+++ b/app/views/forms/index.html.erb
@@ -0,0 +1,34 @@
+
+
+
Forms
+ <%= link_to "New Form", new_form_path, class: "btn btn-primary" %>
+
+
+
+
+
+
+ Name
+ Fields
+ Events
+ Submissions
+
+
+
+
+ <% @forms.each do |form| %>
+
+ <%= form.display_name %>
+ <%= form.form_fields.size %>
+ <%= form.events.size %>
+ <%= form.form_submissions.size %>
+
+ <%= link_to "View", form_path(form), class: "text-blue-600 hover:text-blue-800 underline" %>
+ <%= link_to "Edit", edit_form_path(form), class: "text-blue-600 hover:text-blue-800 underline" %>
+
+
+ <% end %>
+
+
+
+
diff --git a/app/views/forms/new.html.erb b/app/views/forms/new.html.erb
new file mode 100644
index 000000000..eb123abfe
--- /dev/null
+++ b/app/views/forms/new.html.erb
@@ -0,0 +1,39 @@
+
+
New Form
+
+ <%= form_with url: forms_path, method: :post, class: "space-y-6" do |f| %>
+
+ Form Name
+
+
+
+
+ Select sections to include:
+
+ <% FormBuilderService::SECTIONS.each do |key, section| %>
+
+ >
+ <%= section[:label] %>
+
+ <% end %>
+
+
+
+
+
+
+ Scholarship application form
+
+
+
+
+ <%= f.submit "Create Form", class: "btn btn-primary cursor-pointer" %>
+ <%= link_to "Cancel", forms_path, class: "btn btn-secondary-outline" %>
+
+ <% end %>
+
diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb
new file mode 100644
index 000000000..659622f31
--- /dev/null
+++ b/app/views/forms/show.html.erb
@@ -0,0 +1,158 @@
+
+
+ <%= link_to "Forms", forms_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "Edit", edit_form_path(@form), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
<%= @form.display_name %>
+
+
+
+
+
+ <%= image_tag("logo.png", alt: "Organization logo", class: "h-8 w-auto") %>
+
+
<%= @form.display_name %>
+
+
+
+
+ <%
+ has_scholarship = @form.form_fields.where(visibility: :scholarship_only).exists?
+ has_logged_out = @form.form_fields.where(visibility: :logged_out_only).exists?
+ has_answers_on_file = @form.form_fields.where(visibility: :answers_on_file).exists?
+ has_conditions = has_scholarship || (has_logged_out && @form.hide_answered_person_questions?) || (has_answers_on_file && @form.hide_answered_form_questions?)
+
+ preview_logged_in = params[:preview_logged_in].present?
+ preview_scholarship = params[:preview_scholarship].present?
+ preview_answered = params[:preview_answered].present?
+ %>
+
+
+
Preview mode
+ <% if has_conditions %>
+
Toggle to simulate how the form appears under different conditions:
+
+ <% if has_logged_out && @form.hide_answered_person_questions? %>
+ <%
+ logged_in_params = request.query_parameters.dup
+ if preview_logged_in
+ logged_in_params.delete("preview_logged_in")
+ else
+ logged_in_params["preview_logged_in"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, logged_in_params), class: "flex items-center gap-2 no-underline" do %>
+ User logged in
+
+
+
+ <% end %>
+ <% end %>
+ <% if has_answers_on_file && @form.hide_answered_form_questions? %>
+ <%
+ answered_params = request.query_parameters.dup
+ if preview_answered
+ answered_params.delete("preview_answered")
+ else
+ answered_params["preview_answered"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, answered_params), class: "flex items-center gap-2 no-underline" do %>
+ Answers on file
+
+
+
+ <% end %>
+ <% end %>
+ <% if has_scholarship %>
+ <%
+ scholarship_params = request.query_parameters.dup
+ if preview_scholarship
+ scholarship_params.delete("preview_scholarship")
+ else
+ scholarship_params["preview_scholarship"] = "1"
+ end
+ %>
+ <%= link_to form_path(@form, scholarship_params), class: "flex items-center gap-2 no-underline" do %>
+ Scholarship
+
+
+
+ <% end %>
+ <% end %>
+
+ <% else %>
+
No conditional visibility configured. All fields always shown.
+ <% end %>
+
+
+ <%
+ fields_by_key_check = @form_fields.select(&:field_key).index_by(&:field_key)
+ email_row = if fields_by_key_check["confirm_email"] && fields_by_key_check["primary_email_type"]
+ { keys: %w[primary_email confirm_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" }
+ elsif fields_by_key_check["confirm_email"]
+ { keys: %w[primary_email confirm_email], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" }
+ else
+ { keys: %w[primary_email primary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "primary_email" => "md:col-span-2" } }
+ end
+
+ row_groups = [
+ { keys: %w[first_name last_name], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[nickname pronouns], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ email_row,
+ { keys: %w[secondary_email secondary_email_type], grid: "grid grid-cols-1 md:grid-cols-3 gap-4", spans: { "secondary_email" => "md:col-span-2" } },
+ { keys: %w[mailing_street mailing_address_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[mailing_city mailing_state mailing_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
+ { keys: %w[phone phone_type], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[agency_name agency_position], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ { keys: %w[agency_city agency_state agency_zip], grid: "grid grid-cols-1 md:grid-cols-3 gap-4" },
+ { keys: %w[number_of_attendees payment_method], grid: "grid grid-cols-1 md:grid-cols-2 gap-4" },
+ ]
+
+ field_key_to_group = {}
+ row_groups.each { |g| g[:keys].each { |k| field_key_to_group[k] = g } }
+
+ fields_by_key = @form_fields.select(&:field_key).index_by(&:field_key)
+ rendered_keys = {}
+
+ has_secondary_email = fields_by_key["secondary_email"].present?
+ email_label_overrides = if has_secondary_email
+ { "primary_email" => "Primary Email", "confirm_email" => "Confirm Primary Email", "primary_email_type" => "Primary Email Type" }
+ else
+ {}
+ end
+ %>
+
+
+ <% @form_fields.each do |field| %>
+ <% next if field.field_key.present? && rendered_keys[field.field_key] %>
+
+ <% if field.group_header? %>
+
+
<%= field.question %>
+
+ <% elsif (group = field.field_key.present? && field_key_to_group[field.field_key]) %>
+
+ <% group[:keys].each do |key| %>
+ <% row_field = fields_by_key[key] %>
+ <% next unless row_field %>
+ <% rendered_keys[key] = true %>
+ <% span = group.dig(:spans, key) %>
+ <% label_override = email_label_overrides[key] %>
+ <% if span %>
+
+ <%= render "events/public_registrations/form_field", field: row_field, value: nil, label: label_override %>
+
+ <% else %>
+ <%= render "events/public_registrations/form_field", field: row_field, value: nil, label: label_override %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+ <%= render "events/public_registrations/form_field", field: field, value: nil %>
+ <% end %>
+ <% end %>
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index bbd10c7d9..468c4091c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -101,6 +101,14 @@
end
resources :comments, only: [ :index, :create ]
end
+ resources :forms do
+ member do
+ patch :reorder_field
+ put :reorder_fields
+ get :edit_sections
+ patch :update_sections
+ end
+ end
resources :events do
member do
get :manage
diff --git a/db/migrate/20260308120000_consolidate_form_builder.rb b/db/migrate/20260308120000_consolidate_form_builder.rb
new file mode 100644
index 000000000..0e81e612b
--- /dev/null
+++ b/db/migrate/20260308120000_consolidate_form_builder.rb
@@ -0,0 +1,25 @@
+class ConsolidateFormBuilder < ActiveRecord::Migration[8.0]
+ def change
+ # Rename tables
+ rename_table :person_forms, :form_submissions
+ rename_table :person_form_form_fields, :form_answers
+
+ # Rename foreign key column to match new table name
+ rename_column :form_answers, :person_form_id, :form_submission_id
+
+ # Add question_text snapshot to form_answers (preserves question at submission time)
+ add_column :form_answers, :question_text, :string
+
+ # Allow form_field deletion without orphaning answers
+ change_column_null :form_answers, :form_field_id, true
+
+ # Add sections and conditional visibility to forms
+ add_column :forms, :sections, :json
+ add_column :forms, :hide_answered_person_questions, :boolean, default: false, null: false
+ add_column :forms, :hide_answered_form_questions, :boolean, default: false, null: false
+
+ # Note: keeping form_fields.status column for now — still used by
+ # workshop logs, reports, and resources. Event registration code
+ # will stop filtering by status in this PR.
+ end
+end
diff --git a/db/migrate/20260309120000_add_visibility_to_form_fields.rb b/db/migrate/20260309120000_add_visibility_to_form_fields.rb
new file mode 100644
index 000000000..a6c7ed697
--- /dev/null
+++ b/db/migrate/20260309120000_add_visibility_to_form_fields.rb
@@ -0,0 +1,5 @@
+class AddVisibilityToFormFields < ActiveRecord::Migration[8.0]
+ def change
+ add_column :form_fields, :visibility, :integer, default: 0, null: false
+ end
+end
diff --git a/db/migrate/20260309140000_add_one_time_to_form_fields.rb b/db/migrate/20260309140000_add_one_time_to_form_fields.rb
new file mode 100644
index 000000000..8e484bc73
--- /dev/null
+++ b/db/migrate/20260309140000_add_one_time_to_form_fields.rb
@@ -0,0 +1,5 @@
+class AddOneTimeToFormFields < ActiveRecord::Migration[8.0]
+ def change
+ add_column :form_fields, :one_time, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ee45b0729..d280bdab6 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_01_120200) do
+ActiveRecord::Schema[8.1].define(version: 2026_03_09_140000) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -408,6 +408,16 @@
t.index ["form_id"], name: "index_event_forms_on_form_id"
end
+ create_table "event_registration_organizations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.bigint "event_registration_id", null: false
+ t.integer "organization_id", null: false
+ t.datetime "updated_at", null: false
+ t.index ["event_registration_id", "organization_id"], name: "idx_event_reg_orgs_on_registration_and_org", unique: true
+ t.index ["event_registration_id"], name: "idx_on_event_registration_id_806bdcd019"
+ t.index ["organization_id"], name: "index_event_registration_organizations_on_organization_id"
+ end
+
create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.bigint "event_id"
@@ -481,6 +491,17 @@
t.datetime "updated_at", precision: nil, null: false
end
+ create_table "form_answers", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "form_field_id"
+ t.bigint "form_submission_id"
+ t.string "question_text"
+ t.text "text"
+ t.datetime "updated_at", null: false
+ t.index ["form_field_id"], name: "index_form_answers_on_form_field_id"
+ t.index ["form_submission_id"], name: "index_form_answers_on_form_submission_id"
+ end
+
create_table "form_builders", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.text "description", size: :long
@@ -509,24 +530,38 @@
t.integer "form_id", null: false
t.text "instructional_hint"
t.boolean "is_required", default: true
+ t.boolean "one_time", default: false, null: false
t.integer "parent_id"
t.integer "position"
t.string "question", null: false
t.integer "status", default: 1
t.datetime "updated_at", precision: nil, null: false
+ t.integer "visibility", default: 0, null: false
t.index ["field_group"], name: "index_form_fields_on_field_group"
t.index ["field_key"], name: "index_form_fields_on_field_key"
t.index ["form_id"], name: "index_form_fields_on_form_id"
t.index ["parent_id"], name: "index_form_fields_on_parent_id"
end
+ create_table "form_submissions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.integer "form_id"
+ t.bigint "person_id"
+ t.datetime "updated_at", null: false
+ t.index ["form_id"], name: "index_form_submissions_on_form_id"
+ t.index ["person_id"], name: "index_form_submissions_on_person_id"
+ end
+
create_table "forms", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "form_builder_id"
+ t.boolean "hide_answered_form_questions", default: false, null: false
+ t.boolean "hide_answered_person_questions", default: false, null: false
t.string "name"
t.integer "owner_id"
t.string "owner_type"
t.boolean "scholarship_application", default: false, null: false
+ t.json "sections"
t.datetime "updated_at", precision: nil, null: false
t.index ["form_builder_id"], name: "index_forms_on_form_builder_id"
t.index ["owner_type", "owner_id"], name: "index_forms_on_owner_type_and_owner_id"
@@ -594,6 +629,9 @@
t.text "email_body_html"
t.text "email_body_text"
t.text "email_subject"
+ t.datetime "error_at"
+ t.string "error_class"
+ t.text "error_message"
t.string "kind", null: false
t.integer "noticeable_id"
t.string "noticeable_type"
@@ -738,25 +776,6 @@
t.datetime "updated_at", precision: nil, null: false
end
- create_table "person_form_form_fields", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.integer "form_field_id"
- t.bigint "person_form_id"
- t.text "text"
- t.datetime "updated_at", null: false
- t.index ["form_field_id"], name: "index_person_form_form_fields_on_form_field_id"
- t.index ["person_form_id"], name: "index_person_form_form_fields_on_person_form_id"
- end
-
- create_table "person_forms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
- t.datetime "created_at", null: false
- t.integer "form_id"
- t.bigint "person_id"
- t.datetime "updated_at", null: false
- t.index ["form_id"], name: "index_person_forms_on_form_id"
- t.index ["person_id"], name: "index_person_forms_on_person_id"
- end
-
create_table "quotable_item_quotes", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "legacy_id"
@@ -820,7 +839,7 @@
t.string "type"
t.datetime "updated_at", precision: nil, null: false
t.integer "windows_type_id", null: false
- t.integer "workshop_id", null: false
+ t.integer "workshop_id"
t.string "workshop_name"
t.index ["created_by_id"], name: "index_reports_on_created_by_id"
t.index ["organization_id"], name: "index_reports_on_organization_id"
@@ -1342,14 +1361,20 @@
add_foreign_key "contact_methods", "addresses"
add_foreign_key "event_forms", "events"
add_foreign_key "event_forms", "forms"
+ add_foreign_key "event_registration_organizations", "event_registrations"
+ add_foreign_key "event_registration_organizations", "organizations"
add_foreign_key "event_registrations", "events"
add_foreign_key "event_registrations", "people", column: "registrant_id"
add_foreign_key "events", "locations"
add_foreign_key "events", "users", column: "created_by_id"
+ add_foreign_key "form_answers", "form_fields"
+ add_foreign_key "form_answers", "form_submissions"
add_foreign_key "form_builders", "windows_types"
add_foreign_key "form_field_answer_options", "answer_options"
add_foreign_key "form_field_answer_options", "form_fields"
add_foreign_key "form_fields", "forms"
+ add_foreign_key "form_submissions", "forms"
+ add_foreign_key "form_submissions", "people"
add_foreign_key "forms", "form_builders"
add_foreign_key "monthly_reports", "affiliations", column: "organization_user_id"
add_foreign_key "monthly_reports", "organizations"
@@ -1361,10 +1386,6 @@
add_foreign_key "payments", "events"
add_foreign_key "people", "users", column: "created_by_id"
add_foreign_key "people", "users", column: "updated_by_id"
- add_foreign_key "person_form_form_fields", "form_fields"
- add_foreign_key "person_form_form_fields", "person_forms"
- add_foreign_key "person_forms", "forms"
- add_foreign_key "person_forms", "people"
add_foreign_key "quotable_item_quotes", "quotes"
add_foreign_key "quotes", "workshops"
add_foreign_key "report_form_field_answers", "answer_options"
diff --git a/db/seeds.rb b/db/seeds.rb
index e62be6a6b..28b08d498 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -268,16 +268,3 @@ def find_or_create_by_name!(klass, name, **attrs, &block)
end
cat.update!(published: true) unless cat.published?
end
-
-puts "Creating standalone registration forms…"
-unless Form.standalone.exists?(name: ShortEventRegistrationFormBuilder::FORM_NAME)
- ShortEventRegistrationFormBuilder.build_standalone!
-end
-
-unless Form.standalone.exists?(name: ExtendedEventRegistrationFormBuilder::FORM_NAME)
- ExtendedEventRegistrationFormBuilder.build_standalone!
-end
-
-unless Form.standalone.exists?(name: ScholarshipApplicationFormBuilder::FORM_NAME)
- ScholarshipApplicationFormBuilder.build_standalone!
-end
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index 4bc35700e..a5f20f0d1 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -874,11 +874,34 @@
end
+puts "Creating standalone registration forms…"
+unless Form.standalone.exists?(name: "Short Event Registration")
+ FormBuilderService.new(
+ name: "Short Event Registration",
+ sections: %i[person_identifier consent marketing scholarship]
+ ).call
+end
+
+unless Form.standalone.exists?(name: "Extended Event Registration")
+ FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
+ ).call
+end
+
+unless Form.standalone.scholarship_application.exists?
+ FormBuilderService.new(
+ name: "Scholarship Application",
+ sections: %i[scholarship],
+ scholarship_application: true
+ ).call
+end
+
puts "Creating Events with shared forms…"
admin_user = User.find_by(email: "umberto.user@example.com")
-long_form = Form.standalone.find_by!(name: ExtendedEventRegistrationFormBuilder::FORM_NAME)
-short_form = Form.standalone.find_by!(name: ShortEventRegistrationFormBuilder::FORM_NAME)
-scholarship_form = Form.standalone.find_by!(name: ScholarshipApplicationFormBuilder::FORM_NAME)
+long_form = Form.standalone.find_by!(name: "Extended Event Registration")
+short_form = Form.standalone.find_by!(name: "Short Event Registration")
+scholarship_form = Form.standalone.scholarship_application.first!
# Each entry: [title, form_type, cost_cents, scholarship?, visibility]
# form_type: :long, :short, or :none
diff --git a/spec/factories/form_answers.rb b/spec/factories/form_answers.rb
new file mode 100644
index 000000000..ce326ccd1
--- /dev/null
+++ b/spec/factories/form_answers.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :form_answer do
+ association :form_submission
+ association :form_field
+ text { Faker::Lorem.sentence }
+ question_text { nil }
+ end
+end
diff --git a/spec/factories/form_submissions.rb b/spec/factories/form_submissions.rb
new file mode 100644
index 000000000..1de1bcde8
--- /dev/null
+++ b/spec/factories/form_submissions.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :form_submission do
+ association :person
+ association :form
+ end
+end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index 2ee7e5651..f81b85562 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -121,8 +121,8 @@
end
describe "#build_public_registration_form" do
- let!(:default_form) { create(:form, name: ShortEventRegistrationFormBuilder::FORM_NAME) }
- let!(:extended_form) { create(:form, name: ExtendedEventRegistrationFormBuilder::FORM_NAME) }
+ let!(:default_form) { create(:form, name: "Short Event Registration") }
+ let!(:extended_form) { create(:form, name: "Extended Event Registration") }
it "links the default registration form by default" do
event = create(:event, public_registration_enabled: true)
diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb
index 3cf3d7304..f1aee8a8b 100644
--- a/spec/requests/events/registrations_spec.rb
+++ b/spec/requests/events/registrations_spec.rb
@@ -148,9 +148,13 @@
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }
before do
- ExtendedEventRegistrationFormBuilder.build!(event)
+ form = FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
+ ).call
+ EventForm.create!(event: event, form: form, role: "registration")
form = event.registration_form
- form.person_forms.create!(person: user.person)
+ form.form_submissions.create!(person: user.person)
end
it "allows access with a valid slug" do
diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb
new file mode 100644
index 000000000..b27fedd66
--- /dev/null
+++ b/spec/requests/forms_spec.rb
@@ -0,0 +1,81 @@
+require "rails_helper"
+
+RSpec.describe "Forms", type: :request do
+ let(:admin) { create(:user, super_user: true) }
+ let(:user) { create(:user) }
+
+ describe "GET /forms" do
+ context "as admin" do
+ before { sign_in admin }
+
+ it "lists standalone forms" do
+ create(:form, :standalone, name: "My Form")
+ get forms_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("My Form")
+ end
+ end
+
+ context "as regular user" do
+ before { sign_in user }
+
+ it "denies access" do
+ get forms_path
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
+ describe "GET /forms/new" do
+ before { sign_in admin }
+
+ it "shows section checkboxes" do
+ get new_form_path
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("Person identifier")
+ expect(response.body).to include("Scholarship")
+ end
+ end
+
+ describe "POST /forms" do
+ before { sign_in admin }
+
+ it "creates a form with selected sections" do
+ post forms_path, params: {
+ name: "Custom Form",
+ sections: %w[person_identifier consent]
+ }
+ form = Form.last
+ expect(form.name).to eq("Custom Form")
+ expect(form.sections).to eq(%w[person_identifier consent])
+ expect(response).to redirect_to(edit_form_path(form))
+ end
+
+ it "rejects when no sections selected" do
+ post forms_path, params: { name: "Empty", sections: [] }
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+ end
+
+ describe "GET /forms/:id/edit" do
+ before { sign_in admin }
+
+ it "shows form field editor" do
+ form = FormBuilderService.new(name: "Test", sections: %i[person_identifier]).call
+ get edit_form_path(form)
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include("First Name")
+ end
+ end
+
+ describe "PATCH /forms/:id" do
+ before { sign_in admin }
+
+ it "updates form name" do
+ form = create(:form, :standalone, name: "Old Name")
+ patch form_path(form), params: { form: { name: "New Name" } }
+ expect(form.reload.name).to eq("New Name")
+ expect(response).to redirect_to(edit_form_path(form))
+ end
+ end
+end
diff --git a/spec/services/form_builder_service_spec.rb b/spec/services/form_builder_service_spec.rb
new file mode 100644
index 000000000..a23667ece
--- /dev/null
+++ b/spec/services/form_builder_service_spec.rb
@@ -0,0 +1,126 @@
+require "rails_helper"
+
+RSpec.describe FormBuilderService do
+ describe "#call" do
+ it "creates a form with the given name" do
+ form = described_class.new(name: "Test Form", sections: %i[person_identifier]).call
+ expect(form.name).to eq("Test Form")
+ end
+
+ it "stores selected sections on the form" do
+ form = described_class.new(name: "Test", sections: %i[person_identifier consent]).call
+ expect(form.sections).to eq(%w[person_identifier consent])
+ end
+
+ it "sets scholarship_application flag" do
+ form = described_class.new(name: "Scholarship", sections: %i[scholarship], scholarship_application: true).call
+ expect(form.scholarship_application).to be true
+ end
+
+ it "creates fields with sequential positions" do
+ form = described_class.new(name: "Test", sections: %i[person_identifier consent]).call
+ positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
+ expect(positions).to eq((1..positions.size).to_a)
+ end
+
+ context "person_identifier section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[person_identifier]).call }
+
+ it "creates first_name, last_name, primary_email, and confirm_email fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("first_name", "last_name", "primary_email", "confirm_email")
+ end
+ end
+
+ context "person_contact_info section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[person_contact_info]).call }
+
+ it "creates contact info fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("nickname", "pronouns", "phone", "mailing_city", "agency_name")
+ end
+ end
+
+ context "scholarship section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[scholarship]).call }
+
+ it "creates scholarship fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("scholarship_eligibility", "impact_description", "implementation_plan")
+ end
+ end
+
+ context "consent section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[consent]).call }
+
+ it "creates communication_consent field" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("communication_consent")
+ end
+ end
+
+ context "payment section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[payment]).call }
+
+ it "creates payment fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("number_of_attendees", "payment_method")
+ end
+ end
+
+ context "marketing section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[marketing]).call }
+
+ it "creates marketing fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("referral_source", "training_motivation", "interested_in_more")
+ end
+ end
+
+ context "post_event_feedback section" do
+ let(:form) { described_class.new(name: "Test", sections: %i[post_event_feedback]).call }
+
+ it "creates post-event feedback fields" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("event_rating", "most_valuable", "improvement_suggestions")
+ end
+ end
+
+ context "with multiple sections (short event registration)" do
+ let(:form) do
+ described_class.new(
+ name: "Short Event Registration",
+ sections: %i[person_identifier consent marketing scholarship]
+ ).call
+ end
+
+ it "creates fields from all selected sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include("first_name", "communication_consent", "referral_source", "scholarship_eligibility")
+ end
+
+ it "does not include fields from unselected sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).not_to include("nickname", "phone", "payment_method")
+ end
+ end
+
+ context "with multiple sections (extended event registration)" do
+ let(:form) do
+ described_class.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
+ ).call
+ end
+
+ it "creates fields from all sections" do
+ keys = form.form_fields.pluck(:field_key).compact
+ expect(keys).to include(
+ "first_name", "nickname", "racial_ethnic_identity",
+ "primary_service_area", "referral_source",
+ "scholarship_eligibility", "payment_method", "communication_consent"
+ )
+ end
+ end
+ end
+end
diff --git a/spec/services/scholarship_application_form_builder_spec.rb b/spec/services/scholarship_application_form_builder_spec.rb
deleted file mode 100644
index b12998948..000000000
--- a/spec/services/scholarship_application_form_builder_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require "rails_helper"
-
-RSpec.describe ScholarshipApplicationFormBuilder do
- let(:event) { create(:event) }
-
- describe ".build!" do
- subject(:form) { described_class.build!(event) }
-
- it "creates a standalone scholarship form linked to the event" do
- expect(form.name).to eq("Scholarship Application")
- expect(form.scholarship_application).to be true
- expect(event.scholarship_form).to eq(form)
- end
-
- it "creates all expected form fields" do
- expect(form.form_fields.count).to eq(5)
- end
-
- it "assigns sequential positions starting at 1" do
- positions = form.form_fields.unscoped.where(form: form).order(:position).pluck(:position)
- expect(positions).to eq((1..5).to_a)
- end
-
- it "creates a Scholarship Application header" do
- headers = form.form_fields.where(answer_type: :group_header).pluck(:question)
- expect(headers).to contain_exactly("Scholarship Application")
- end
-
- it "creates scholarship eligibility as a required checkbox" do
- field = form.form_fields.find_by(field_key: "scholarship_eligibility")
-
- expect(field.answer_type).to eq("multiple_choice_checkbox")
- expect(field.is_required).to be true
- expect(field.form_field_answer_options.count).to eq(1)
- end
-
- it "creates required paragraph fields for impact and implementation" do
- impact = form.form_fields.find_by(field_key: "impact_description")
- implementation = form.form_fields.find_by(field_key: "implementation_plan")
-
- expect(impact.answer_type).to eq("free_form_input_paragraph")
- expect(impact.is_required).to be true
- expect(implementation.answer_type).to eq("free_form_input_paragraph")
- expect(implementation.is_required).to be true
- end
-
- it "creates an optional additional comments field" do
- field = form.form_fields.find_by(field_key: "additional_comments")
-
- expect(field.answer_type).to eq("free_form_input_paragraph")
- expect(field.is_required).to be false
- end
-
- it "assigns all fields to the scholarship group" do
- groups = form.form_fields.pluck(:field_group).uniq
- expect(groups).to eq([ "scholarship" ])
- end
- end
-
- describe ".build_standalone!" do
- it "creates a form without linking to an event" do
- form = described_class.build_standalone!
-
- expect(form.name).to eq("Scholarship Application")
- expect(form.scholarship_application).to be true
- expect(form.event_forms).to be_empty
- end
- end
-end
diff --git a/spec/system/event_registration_show_spec.rb b/spec/system/event_registration_show_spec.rb
index 0acfa50b9..e30d5aada 100644
--- a/spec/system/event_registration_show_spec.rb
+++ b/spec/system/event_registration_show_spec.rb
@@ -50,9 +50,12 @@
describe "view registration form link" do
it "links to form show with slug param" do
- ExtendedEventRegistrationFormBuilder.build!(event)
+ FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
+ ).call.tap { |form| EventForm.create!(event: event, form: form, role: "registration") }
form = event.registration_form
- form.person_forms.create!(person: user.person)
+ form.form_submissions.create!(person: user.person)
sign_in(user)
visit registration_ticket_path(registration.slug)
diff --git a/spec/system/events_show_spec.rb b/spec/system/events_show_spec.rb
index 5451c4342..169329642 100644
--- a/spec/system/events_show_spec.rb
+++ b/spec/system/events_show_spec.rb
@@ -43,7 +43,7 @@
context "when event has a public registration form" do
before do
- create(:form, name: ShortEventRegistrationFormBuilder::FORM_NAME)
+ create(:form, name: "Short Event Registration")
event.update!(public_registration_enabled: true)
end
diff --git a/spec/system/public_registration_new_spec.rb b/spec/system/public_registration_new_spec.rb
index e5feef547..a14270938 100644
--- a/spec/system/public_registration_new_spec.rb
+++ b/spec/system/public_registration_new_spec.rb
@@ -14,7 +14,11 @@
before do
driven_by(:rack_test)
- ExtendedEventRegistrationFormBuilder.build!(event)
+ form = FormBuilderService.new(
+ name: "Extended Event Registration",
+ sections: %i[person_identifier person_contact_info person_background professional_info marketing scholarship payment consent]
+ ).call
+ EventForm.create!(event: event, form: form, role: "registration")
end
describe "back to event link" do