-
Notifications
You must be signed in to change notification settings - Fork 22
Extract BaseRegistrationFormBuilder to share fields across form types #1301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ac0d07f
34b0511
d7fef0f
5c19923
bc1681b
79769a2
39f69c7
34ad1b8
4e6ba01
e8d91d5
bb973c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removing |
||
| @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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. changing responses from |
||
| @event = @event.decorate | ||
| end | ||
|
|
||
|
|
@@ -108,15 +108,70 @@ 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 = @form.form_fields | ||
| unless scholarship_mode? | ||
| scope = scope.where.not(field_group: "scholarship") | ||
| 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(field_group: %w[person_identifier person_contact_info], field_key: known_keys) | ||
| .ids | ||
| scope = scope.where.not(id: known_ids) if known_ids.any? | ||
| end | ||
|
|
||
| # Background fields (e.g. ethnicity) are always hidden for logged-in users | ||
| # since Person doesn't store these — they're collected once per event | ||
| scope = scope.where.not(field_group: "background") | ||
| end | ||
|
|
||
| if @form.hide_answered_form_questions? | ||
| existing_submission = @form.form_submissions.find_by(person: person) | ||
| if existing_submission | ||
| answered_field_ids = existing_submission.form_answers | ||
| .joins(:form_field) | ||
| .where(form_fields: { field_group: %w[professional marketing] }) | ||
| .where.not(text: [ nil, "" ]) | ||
| .pluck(:form_field_id) | ||
| scope = scope.where.not(id: answered_field_ids) if answered_field_ids.any? | ||
| end | ||
| end | ||
| 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 +197,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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| 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 | ||
| form_sections = (@form.sections || []).map(&:to_s) | ||
|
|
||
| if params[:preview_scholarship].present? | ||
| # Scholarship mode: show scholarship fields (and keep payment visible) | ||
| elsif form_sections.include?("scholarship") | ||
| scope = scope.where.not(field_group: "scholarship") | ||
| end | ||
|
|
||
| if params[:preview_logged_in].present? && @form.hide_answered_person_questions? | ||
| scope = scope.where.not(field_group: %w[person_identifier person_contact_info background]) | ||
| end | ||
|
|
||
| if params[:preview_answered].present? && @form.hide_answered_form_questions? | ||
| scope = scope.where.not(field_group: %w[professional marketing]) | ||
| end | ||
|
|
||
| scope.reorder(position: :asc) | ||
| end | ||
|
|
||
| def form_params | ||
| params.require(:form).permit( | ||
| :name, :hide_answered_person_questions, :hide_answered_form_questions, | ||
| form_fields_attributes: [ | ||
| :id, :question, :answer_type, :is_required, :instructional_hint, | ||
| :field_key, :field_group, :position, :_destroy | ||
| ] | ||
| ) | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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="<field_id>" | ||
| * data-sortable-handle (on the drag handle element) | ||
| * data-field-group="<group_name>" (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 }), | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| class FormPolicy < ApplicationPolicy | ||
| # Admin-only — all CRUD actions inherit manage? from ApplicationPolicy | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
changing name from
person_formstoform_submissions