Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c1e9e03
Add email confirmation, address type, and consent fields to registrat…
maebeale Mar 1, 2026
74a4e25
Extract BaseRegistrationFormBuilder to share fields across form types
maebeale Mar 1, 2026
5ce0f5b
Consolidate form builders into configurable FormBuilderService with a…
maebeale Mar 8, 2026
061d7b9
Move registration form seeds into dummy dev seeds
maebeale Mar 9, 2026
4771209
Add preview form link to form editor
maebeale Mar 9, 2026
de30f48
Add form show page for previewing form fields
maebeale Mar 9, 2026
0ee8f23
Match form page nav links to person show/edit styling
maebeale Mar 9, 2026
9505889
Change consent field to checkbox with only "Yes" option
maebeale Mar 9, 2026
586016f
Add edit_sections page to add/remove form sections
maebeale Mar 9, 2026
191ad2f
Fix section removal to properly delete group headers
maebeale Mar 9, 2026
472a159
Improve form editor: conditional visibility, group sorting, cocoon fi…
maebeale Mar 9, 2026
44fe009
Add per-field visibility enum and fix cocoon new field saving
maebeale Mar 9, 2026
796d238
Register chip-select and form-fields-sortable Stimulus controllers
maebeale Mar 9, 2026
021ef78
Add one-time field hiding, flexbox layout, and cocoon insertion fix
maebeale Mar 9, 2026
a067fd3
Rename form_answers.text to question_answer for clarity
maebeale Mar 10, 2026
652025b
Consolidate form builder migrations and rename columns for clarity
maebeale Mar 11, 2026
5e534ad
Fix legacy references and add missing specs for form builder
maebeale Mar 11, 2026
bc71308
Add specs for FormBuilderService.update_sections!
maebeale Mar 11, 2026
18c229d
Fix schema/validation mismatches found in audit
maebeale Mar 11, 2026
ff4ae00
Update AGENTS.md with form builder models/services and fix minor issues
maebeale Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ AWBW Portal (Rails 8.1)
| `Person` | Organization affiliates with contacts, addresses, sectors |
| `Organization` | Groups with affiliations, addresses, logos via ActiveStorage |
| `Report` | STI base class for MonthlyReport and WorkshopLog |
| `Form` | Configurable forms with sections, conditional visibility, nested form fields |
| `FormField` | Individual fields on a form with visibility, input type, and ordering |
| `FormSubmission` | A person's submission of a form (replaces PersonForm) |
| `FormAnswer` | A single answer within a form submission (replaces PersonFormFormField) |

### STI Models

Expand Down Expand Up @@ -152,6 +156,7 @@ end
- `TaggingSearchService` — Search and filter tagging data
- `PersonFromUserService` — Create Person from User account
- `BulkInviteService` — Bulk send welcome instructions and reset created_at for users
- `FormBuilderService` — Builds configurable forms from composable sections with per-field visibility
- `ModelDeduper` — Deduplication logic
- `NotificationServices::CreateNotification` — Notification creation
- `NotificationServices::PersistDeliveredEmail` — Email delivery tracking
Expand Down Expand Up @@ -212,7 +217,7 @@ end
- **Always use Tailwind CSS** utility classes for styling — do not write custom CSS unless absolutely necessary
- Prefer Turbo for navigation and form submissions before reaching for Stimulus

### Stimulus Controllers (32)
### Stimulus Controllers (34)

Key controllers:
- `asset_picker` — Asset selection UI
Expand All @@ -225,6 +230,8 @@ Key controllers:
- `optimistic_bookmark` — Instant bookmark UI feedback
- `remote_select` — AJAX-powered select dropdown
- `searchable_select` — Tom Select autocomplete
- `chip_select` — Dynamically styles `<select>` elements as colored chips
- `form_fields_sortable` — SortableJS drag-and-drop reordering for form fields with section-aware header moves
- `sortable` — Drag-drop sorting (SortableJS)
- `tabs` — Tab panel navigation
- `rhino_source` — Rich text editor integration
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,9 @@ bundle exec bundle-audit check --update

## Migrations

- Name migration files using **UTC timestamps** (e.g., `20260228143000`), not sequential numbers (e.g., `20260228000007`)
- Multiple branches adding migrations on the same date will collide if they use sequential numbering
- Name migration files using **real UTC timestamps** with the current time (e.g., `20260228143052` for 14:30:52 UTC), not padded zeros (e.g., `20260228140000`) or sequential numbers (e.g., `20260228000007`)
- The last 6 digits are HHMMSS — use the actual current time, not `000000` or rounded values
- Multiple branches adding migrations on the same date will collide if they use sequential or zero-padded numbering

## Git

Expand Down
133 changes: 115 additions & 18 deletions app/controllers/events/public_registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,14 @@ def show
return
end

@person_form = @form.person_forms.find_by(person: person)
unless @person_form
@form_submission = @form.form_submissions.find_by(person: person)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing name from person_forms to form_submissions

unless @form_submission
redirect_to event_path(@event), alert: "No registration form submission found."
return
end

@form_fields = if registration&.scholarship_requested?
@form.form_fields.where(status: :active).where.not(field_group: "payment").reorder(position: :asc)
else
@form.form_fields.where(status: :active).where.not(field_group: "scholarship").reorder(position: :asc)
end
@responses = @person_form.person_form_form_fields.index_by(&:form_field_id)
@form_fields = @form.form_fields.reorder(position: :asc)
@responses = @form_submission.form_answers.index_by(&:form_field_id)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing responses from person_form_form_fields to form_answers

@event = @event.decorate
end

Expand All @@ -113,15 +109,116 @@ 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
# Always hide logged_out_only fields for logged-in users with known data
known_identifiers = person_known_identifiers(person)
if known_identifiers.any?
known_ids = @form.form_fields
.where(visibility: :logged_out_only, field_identifier: known_identifiers)
.ids
scope = scope.where.not(id: known_ids) if known_ids.any?
end

# Hide logged_out_only headers when all their non-header fields are hidden
logged_out_sections = @form.form_fields.where(visibility: :logged_out_only)
.where.not(answer_type: :group_header)
.pluck(:section).uniq.compact
logged_out_sections.each do |sect|
section_field_ids = @form.form_fields.where(section: sect, visibility: :logged_out_only)
.where.not(answer_type: :group_header).ids
if section_field_ids.any? && known_identifiers.any? && (section_field_ids - scope.where(id: section_field_ids).ids).any?
remaining = scope.where(id: section_field_ids).ids
if remaining.empty?
scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :logged_out_only)
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(submitted_answer: [ 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(submitted_answer: [ 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_sections = @form.form_fields.where(id: answered_field_ids)
.pluck(:section).uniq.compact
answered_sections.each do |sect|
section_field_ids = @form.form_fields.where(section: sect, visibility: :answers_on_file)
.where.not(answer_type: :group_header).ids
if section_field_ids.any? && (section_field_ids - answered_field_ids).empty?
scope = scope.where.not(section: sect, answer_type: :group_header, visibility: :answers_on_file)
end
end
end
end
end

scope.reorder(position: :asc)
end

def person_known_identifiers(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."
Expand All @@ -131,14 +228,14 @@ def ensure_registerable
def validate_required_fields(form_params)
errors = {}
fields = visible_form_fields
fields_by_key = fields.select { |f| f.field_key.present? }.index_by(&:field_key)
fields_by_identifier = fields.select { |f| f.field_identifier.present? }.index_by(&:field_identifier)

fields.find_each do |field|
next if field.group_header?

value = form_params[field.id.to_s]

if field.is_required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?))
if field.required && (value.blank? || (value.is_a?(Array) && value.reject(&:blank?).empty?))
errors[field.id] = "can't be blank"
next
end
Expand All @@ -147,13 +244,13 @@ def validate_required_fields(form_params)

if field.number_integer? && value.to_s !~ /\A\d+\z/
errors[field.id] = "must be a whole number"
elsif field.field_key&.match?(/email(?!_type)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
elsif field.field_identifier&.match?(/email(?!_type|_confirmation)/) && value.to_s !~ /\A[^@\s]+@[^@\s]+\z/
errors[field.id] = "must be a valid email address"
end
end

confirm_field = fields_by_key["confirm_email"]
email_field = fields_by_key["primary_email"]
confirm_field = fields_by_identifier["confirm_email"]
email_field = fields_by_identifier["primary_email"]
if confirm_field && email_field && errors[confirm_field.id].nil?
confirm_value = form_params[confirm_field.id.to_s].to_s.strip
email_value = form_params[email_field.id.to_s].to_s.strip
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
@@ -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, :name, :answer_type, :required, :hint_text,
:field_identifier, :section, :position, :visibility, :one_time, :_destroy
]
)
end
end
32 changes: 32 additions & 0 deletions app/frontend/javascript/controllers/chip_select_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Controller } from "@hotwired/stimulus"

/**
* Dynamically styles a <select> as a colored chip based on its current value.
*
* Usage:
* <select data-controller="chip-select"
* data-chip-select-styles-value='{"active":"bg-green-100 text-green-700","inactive":"bg-gray-100 text-gray-600"}'
* data-action="chip-select#update">
*/
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()
}
}
Loading