From 998733bb16f02023ff0e406f49b530eb4ed98b18 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 16 Aug 2026 21:30:10 -0400 Subject: [PATCH 01/12] Add slug + published to Form; extract FormAnswerPersistence for public forms Foundation for a public pretty-URL endpoint for standalone (event-less) forms. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/form.rb | 33 ++++++++++ app/policies/form_policy.rb | 7 ++ .../public_registration.rb | 58 +--------------- app/services/form_answer_persistence.rb | 66 +++++++++++++++++++ ...7012835_add_slug_and_published_to_forms.rb | 13 ++++ db/schema.rb | 3 + 6 files changed, 124 insertions(+), 56 deletions(-) create mode 100644 app/services/form_answer_persistence.rb create mode 100644 db/migrate/20260817012835_add_slug_and_published_to_forms.rb diff --git a/app/models/form.rb b/app/models/form.rb index eac21430ab..1a80be82f2 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -18,8 +18,41 @@ class Form < ApplicationRecord reject_if: proc { |attrs| attrs["name"].blank? && attrs["id"].blank? } scope :standalone, -> { where(owner_id: nil, owner_type: nil) } + scope :published, -> { where(published: true) } + + before_validation :normalize_slug + + validates :slug, uniqueness: true, allow_nil: true + validates :slug, format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, + message: "may only contain lowercase letters, numbers, and hyphens" }, allow_blank: true + validate :published_form_has_slug def display_name name.presence || (owner ? "#{owner.try(:name)} Form" : "New Form") end + + def standalone? + owner_id.nil? && owner_type.nil? + end + + # True when the form can be filled out at its public pretty URL: a standalone, + # published form with a slug. The public controller and policy both gate on this. + def publicly_fillable? + standalone? && published? && slug.present? + end + + private + + # Store the slug in URL-safe form so an admin can type "Volunteer Interest" and + # get "volunteer-interest". A blank slug stays nil (not "") so the uniqueness + # index tolerates the many forms that have none. + def normalize_slug + self.slug = slug.presence&.parameterize + end + + def published_form_has_slug + return unless published? && slug.blank? + + errors.add(:slug, "is required to publish a form") + end end diff --git a/app/policies/form_policy.rb b/app/policies/form_policy.rb index f2514e20cb..c53e44a4d7 100644 --- a/app/policies/form_policy.rb +++ b/app/policies/form_policy.rb @@ -1,3 +1,10 @@ class FormPolicy < ApplicationPolicy # Admin-only — all CRUD actions inherit manage? from ApplicationPolicy + + # The public pretty-URL form. Open to anyone (no account), but only for a + # standalone form its admin has deliberately published — never an event form or + # an unpublished draft. + def public_show? + record.publicly_fillable? + end end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index c0b0c16b71..1551855879 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -1,13 +1,8 @@ module EventRegistrationServices class PublicRegistration - Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true) - - # Raised when a file-upload answer's value isn't a usable upload — a tampered, - # stale, or otherwise unverifiable direct-upload signed id. Rescued in #call so - # the registrant gets a form error instead of an unhandled exception. - UnreadableUpload = Class.new(StandardError) + include FormAnswerPersistence - UNREADABLE_UPLOAD_MESSAGE = "We couldn't read one of your uploaded files. Please choose it again." + Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true) # Well-known field_identifier of the "magic" CE question seeded onto the # registration form. Answering it "Yes" creates a ContinuingEducationRegistration @@ -524,55 +519,6 @@ def save_form_answers(submission) end end - # Save one field's answer. File-upload fields attach their blob to the - # answer's Asset; everything else stores the (comma-joined) text. - def persist_answer(submission, field, raw_value) - record = submission.form_answers.find_or_initialize_by(form_field: field) - record.question_name_when_answered = field.name - - if field.file_upload? - attach_uploaded_file(record, raw_value) - else - record.update!(submitted_answer: answer_text(raw_value)) - end - end - - def answer_text(raw_value) - raw_value.is_a?(Array) ? raw_value.reject(&:blank?).join(", ") : raw_value.to_s - end - - # Attach the uploaded blob (a direct-upload signed id, or an uploaded file) - # to the answer's Asset. The answer row is saved first so the polymorphic - # owner id resolves; submitted_answer then caches the filename so text-only - # views, exports, and notifications still read. Asset enforces the content - # type and size on save, rolling back the whole submission on a rejected file. - def attach_uploaded_file(record, raw_value) - # An untouched file input still posts a blank value, so blank means "no new - # upload" — keep the file, and its filename, the answer already has. - record.sync_uploaded_filename! - return if raw_value.blank? - - # Named explicitly: assets.type defaults to "PrimaryAsset", which accepts - # only images, so a bare build_asset would reject every document type the - # upload field offers. - asset = record.asset || record.build_asset(type: FormUploadAsset.name) - asset.file.attach(upload_attachable(raw_value)) - asset.save! - record.sync_uploaded_filename! - end - - # A direct upload arrives as a signed blob id. Handing the raw string to - # `attach` resolves it with find_signed!, which raises InvalidSignature or - # RecordNotFound on a tampered or stale id — neither is rescued here, so a - # forged param would 500 a public endpoint. Resolve it leniently instead and - # turn a miss into a form error. Anything else (a multipart UploadedFile, - # when the direct-upload JS didn't run) attaches as-is. - def upload_attachable(raw_value) - return raw_value unless raw_value.is_a?(String) - - ActiveStorage::Blob.find_signed(raw_value) || raise(UnreadableUpload, UNREADABLE_UPLOAD_MESSAGE) - end - # Persist the answers to the separate scholarship form (when one is asked and a # scholarship was requested) as its own role: "scholarship" submission tied to # the event, mirroring how the registration submission is saved above. diff --git a/app/services/form_answer_persistence.rb b/app/services/form_answer_persistence.rb new file mode 100644 index 0000000000..9d4c45e028 --- /dev/null +++ b/app/services/form_answer_persistence.rb @@ -0,0 +1,66 @@ +# Shared answer-persistence for the public form-submission flows — event +# registration (EventRegistrationServices::PublicRegistration) and standalone +# public forms (PublicFormSubmission). Persists each submitted field's answer +# onto a submission, attaching file-upload blobs to the answer's Asset. The +# file-upload path is hardened against forged, stale, and oversized uploads (see +# #upload_attachable and Asset's own content-type/size validation), so every +# public submission flow goes through here rather than reimplementing it. +module FormAnswerPersistence + # Raised when a file-upload answer's value isn't a usable upload — a tampered, + # stale, or otherwise unverifiable direct-upload signed id. Callers rescue it so + # the respondent gets a form error instead of an unhandled exception. + UnreadableUpload = Class.new(StandardError) + + UNREADABLE_UPLOAD_MESSAGE = "We couldn't read one of your uploaded files. Please choose it again.".freeze + + private + + # Save one field's answer. File-upload fields attach their blob to the answer's + # Asset; everything else stores the (comma-joined) text. + def persist_answer(submission, field, raw_value) + record = submission.form_answers.find_or_initialize_by(form_field: field) + record.question_name_when_answered = field.name + + if field.file_upload? + attach_uploaded_file(record, raw_value) + else + record.update!(submitted_answer: answer_text(raw_value)) + end + end + + def answer_text(raw_value) + raw_value.is_a?(Array) ? raw_value.reject(&:blank?).join(", ") : raw_value.to_s + end + + # Attach the uploaded blob (a direct-upload signed id, or an uploaded file) to + # the answer's Asset. The answer row is saved first so the polymorphic owner id + # resolves; submitted_answer then caches the filename so text-only views, + # exports, and notifications still read. Asset enforces the content type and + # size on save, rolling back the whole submission on a rejected file. + def attach_uploaded_file(record, raw_value) + # An untouched file input still posts a blank value, so blank means "no new + # upload" — keep the file, and its filename, the answer already has. + record.sync_uploaded_filename! + return if raw_value.blank? + + # Named explicitly: assets.type defaults to "PrimaryAsset", which accepts only + # images, so a bare build_asset would reject every document type the upload + # field offers. + asset = record.asset || record.build_asset(type: FormUploadAsset.name) + asset.file.attach(upload_attachable(raw_value)) + asset.save! + record.sync_uploaded_filename! + end + + # A direct upload arrives as a signed blob id. Handing the raw string to + # `attach` resolves it with find_signed!, which raises InvalidSignature or + # RecordNotFound on a tampered or stale id — neither is rescued here, so a + # forged param would 500 a public endpoint. Resolve it leniently instead and + # turn a miss into a form error. Anything else (a multipart UploadedFile, when + # the direct-upload JS didn't run) attaches as-is. + def upload_attachable(raw_value) + return raw_value unless raw_value.is_a?(String) + + ActiveStorage::Blob.find_signed(raw_value) || raise(UnreadableUpload, UNREADABLE_UPLOAD_MESSAGE) + end +end diff --git a/db/migrate/20260817012835_add_slug_and_published_to_forms.rb b/db/migrate/20260817012835_add_slug_and_published_to_forms.rb new file mode 100644 index 0000000000..95eb1fa048 --- /dev/null +++ b/db/migrate/20260817012835_add_slug_and_published_to_forms.rb @@ -0,0 +1,13 @@ +class AddSlugAndPublishedToForms < ActiveRecord::Migration[8.1] + def up + add_column :forms, :slug, :string unless column_exists?(:forms, :slug) + add_index :forms, :slug, unique: true unless index_exists?(:forms, :slug) + add_column :forms, :published, :boolean, default: false, null: false unless column_exists?(:forms, :published) + end + + def down + remove_index :forms, :slug if index_exists?(:forms, :slug) + remove_column :forms, :slug if column_exists?(:forms, :slug) + remove_column :forms, :published if column_exists?(:forms, :published) + end +end diff --git a/db/schema.rb b/db/schema.rb index f28a3f0114..3000855194 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -716,10 +716,13 @@ t.string "name" t.integer "owner_id" t.string "owner_type" + t.boolean "published", default: false, null: false t.string "role" t.json "sections" + t.string "slug" t.datetime "updated_at", precision: nil, null: false t.index ["form_builder_id"], name: "index_forms_on_form_builder_id" + t.index ["slug"], name: "index_forms_on_slug", unique: true end create_table "grants", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| From c1fea1be9191eaa4118a36fd9f7f3d99ebfe262a Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 16 Aug 2026 21:38:12 -0400 Subject: [PATCH 02/12] Public pretty-URL endpoint for standalone forms + form-filterable submissions index Publish an event-less Form at /f/:slug for account-free public filling; view those submissions via the form-filterable /form_submissions index. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 + .../form_submissions_controller.rb | 18 ++- app/controllers/forms_controller.rb | 2 +- app/controllers/public_forms_controller.rb | 92 +++++++++++++++ app/services/public_form_submission.rb | 110 ++++++++++++++++++ .../form_submissions_results.html.erb | 48 ++++++++ app/views/form_submissions/index.html.erb | 70 +++++------ app/views/forms/edit.html.erb | 78 +++++++++---- app/views/forms/index.html.erb | 37 ++++-- app/views/public_forms/show.html.erb | 63 ++++++++++ app/views/public_forms/thank_you.html.erb | 21 ++++ config/features.yml | 10 ++ config/routes.rb | 4 + spec/models/form_spec.rb | 50 ++++++++ spec/policies/form_policy_spec.rb | 18 +++ spec/requests/form_submissions_spec.rb | 27 ++++- spec/requests/public_forms_spec.rb | 84 +++++++++++++ spec/services/public_form_submission_spec.rb | 63 ++++++++++ spec/views/page_bg_class_alignment_spec.rb | 4 + 19 files changed, 722 insertions(+), 79 deletions(-) create mode 100644 app/controllers/public_forms_controller.rb create mode 100644 app/services/public_form_submission.rb create mode 100644 app/views/form_submissions/form_submissions_results.html.erb create mode 100644 app/views/public_forms/show.html.erb create mode 100644 app/views/public_forms/thank_you.html.erb create mode 100644 spec/requests/public_forms_spec.rb create mode 100644 spec/services/public_form_submission_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 89340ea2a7..94100bcc20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -259,6 +259,8 @@ action, or `authorize! :workshop, to: :summary?`). ### Forms +- `PublicFormSubmission` — Records a submission to a **standalone, published** `Form` filled out at its public pretty URL (`/f/:slug`, `PublicFormsController`). No event, role, or account: the respondent is find-or-created as a `Person` from the form's name/email answers (email + last-name match reuses an existing person), consent recorded once, answers stored as a `role: "public"` `FormSubmission`. Returns a `Result` (`success?`, `form_submission`, `person`, `errors`). Shares its answer/file-upload persistence with event registration via `FormAnswerPersistence` +- `FormAnswerPersistence` — Shared module (mixed into `EventRegistrationServices::PublicRegistration` and `PublicFormSubmission`) that persists each submitted field's answer onto a submission and attaches file-upload blobs to the answer's `Asset`. Single-sources the hardened file-upload path (`UnreadableUpload` on a forged/stale signed id; Asset enforces content-type/size), so every public submission flow reuses it rather than reimplementing it - `SmartFormFields` — Catalog of the `field_identifier`s that carry backend behavior and what each does when a submission arrives with it, grouped by the record they write to (person identity, profile, mailing address, phone, organization, tagging, payment, consent, CE, bulk payment), plus `ANSWER_ONLY_IDENTIFIERS` for the library questions that only store an answer. Powers the admin-only **Smart form settings** page (`FormsController#smart_form_settings`, linked from both form editors), which answers what the editor's "Field identifier" box actually does. `spec/services/smart_form_fields_spec.rb` fails when the app grows an identifier the page doesn't document — it diffs the catalog against `FormBuilderService::SECTION_FIELD_IDENTIFIERS`, the `FormField`/`OtherResponse` identifier constants, and every `field_value("…")` read in `PublicRegistration`, so **add new identifiers to the catalog when you wire one up** ### Organizations diff --git a/app/controllers/form_submissions_controller.rb b/app/controllers/form_submissions_controller.rb index 67e180197e..5218498e6c 100644 --- a/app/controllers/form_submissions_controller.rb +++ b/app/controllers/form_submissions_controller.rb @@ -1,12 +1,20 @@ class FormSubmissionsController < ApplicationController def index authorize! FormSubmission - submissions = FormSubmission.includes(:form, :event, :person) - if params[:person_id].present? - submissions = submissions.where(person_id: params[:person_id]) - @person = Person.find_by(id: params[:person_id]) + + @person = Person.find_by(id: params[:person_id]) if params[:person_id].present? + @form = Form.find_by(id: params[:form_id]) if params[:form_id].present? + + if turbo_frame_request? + submissions = FormSubmission.includes(:form, :event, :person) + submissions = submissions.where(person_id: @person.id) if @person + submissions = submissions.where(form_id: @form.id) if @form + @form_submissions = submissions.order(created_at: :desc).paginate(page: params[:page], per_page: 50) + render :form_submissions_results + else + @forms = Form.order(:name) + render :index end - @form_submissions = submissions.order(created_at: :desc) end def show diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index 6e5be9055a..90853666f4 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -147,7 +147,7 @@ def set_dashboard_event def form_params params.require(:form).permit( - :name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, + :name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published, form_fields_attributes: [ :id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, diff --git a/app/controllers/public_forms_controller.rb b/app/controllers/public_forms_controller.rb new file mode 100644 index 0000000000..1df7ddd933 --- /dev/null +++ b/app/controllers/public_forms_controller.rb @@ -0,0 +1,92 @@ +# Public, account-free pretty-URL endpoint for a standalone, published form +# (/f/:slug). Renders the form, records a submission via PublicFormSubmission, +# and shows a thank-you page. Reuses the public-registration field partials, so +# submitted answers arrive under the shared `public_registration[form_fields]` +# param namespace. +class PublicFormsController < ApplicationController + skip_before_action :authenticate_user!, only: %i[show create thank_you] + before_action :set_form + + def show + authorize! @form, to: :public_show? + @form_fields = ordered_fields + end + + def create + authorize! @form, to: :public_show? + + # Honeypot: a bot filling the hidden field is silently bounced back. + if params.dig(:public_registration, :website_url).present? + redirect_to public_form_path(@form.slug) + return + end + + @form_fields = ordered_fields + form_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {}) + + @field_errors = validate_required_fields(form_params) + if @field_errors.any? + flash.now[:alert] = "Your submission is not complete yet. Scroll down to check for any errors or missing information." + render :show, status: :unprocessable_content + return + end + + Current.source = "public_form" + result = PublicFormSubmission.call(form: @form, form_params: form_params) + + if result.success? + redirect_to thank_you_public_form_path(@form.slug), notice: "Thank you — your response has been submitted!" + else + flash.now[:alert] = result.errors.join(", ").presence || "Something went wrong. Please try again." + render :show, status: :unprocessable_content + end + end + + def thank_you + authorize! @form, to: :public_show? + end + + private + + # Scope the lookup itself to publicly-fillable forms so a draft, an event form, + # or an unknown slug 404s before the policy even runs. + def set_form + @form = Form.standalone.published.find_by!(slug: params[:slug]) + end + + def ordered_fields + @form.form_fields.reorder(position: :asc) + end + + # A file input can't be repopulated, so a form re-rendered after a validation + # error carries the already-uploaded blob's signed id in retained_uploads. An + # untouched file input still posts a blank value, so fall back to the retained + # id wherever the field itself came back empty. + def merge_retained_uploads(form_params) + retained = params.dig(:public_registration, :retained_uploads)&.to_unsafe_h || {} + return form_params if retained.blank? + + retained.each do |field_id, signed_id| + next if signed_id.blank? || form_params[field_id].present? + + form_params[field_id] = signed_id + end + form_params + end + + def validate_required_fields(form_params) + fields = @form_fields.reject(&:group_header?) + errors = FormAnswerValidator.call(fields, form_params) + + fields_by_identifier = fields.select { |f| f.field_identifier.present? }.index_by(&:field_identifier) + 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 + errors[confirm_field.id] = "must match email" if confirm_value.present? && confirm_value != email_value + end + + errors + end +end diff --git a/app/services/public_form_submission.rb b/app/services/public_form_submission.rb new file mode 100644 index 0000000000..b264879df9 --- /dev/null +++ b/app/services/public_form_submission.rb @@ -0,0 +1,110 @@ +# Records a submission to a standalone, published form filled out at its public +# pretty URL (see PublicFormsController). Unlike event registration, there is no +# event, role, or account: the respondent is identified purely from the name and +# email answers on the form, find-or-created as a Person, and their answers are +# stored as a role: "public" FormSubmission. +# +# The answer persistence (including the hardened file-upload path) is shared with +# event registration via FormAnswerPersistence. +class PublicFormSubmission + include FormAnswerPersistence + + Result = Struct.new(:success?, :form_submission, :person, :errors, keyword_init: true) + + # The role stored on submissions captured through the public standalone-form + # endpoint, distinguishing them from registration/scholarship/etc. submissions. + ROLE = "public".freeze + + # Field identifiers the form must carry to identify the respondent. A Person + # requires a first and last name, so a public form without these can't record a + # submission — the respondent gets a friendly error rather than a 500. + IDENTITY_MISSING_MESSAGE = + "This form can't accept submissions yet — it needs a name and email question. Please contact us.".freeze + + def self.call(form:, form_params:) + new(form:, form_params:).call + end + + def initialize(form:, form_params:) + @form = form + @form_params = form_params || {} + @errors = [] + end + + def call + ActiveRecord::Base.transaction do + person = find_or_create_person + return Result.new(success?: false, errors: [ IDENTITY_MISSING_MESSAGE ]) unless person + + record_mailing_list_consent(person) + + submission = FormSubmission.create!(person: person, form: @form, role: ROLE) + save_form_answers(submission) + + Result.new(success?: true, form_submission: submission, person: person, errors: []) + end + rescue FormAnswerPersistence::UnreadableUpload => e + Result.new(success?: false, errors: [ e.message ]) + rescue ActiveRecord::ValueTooLong + Result.new(success?: false, errors: [ "One of your answers is too long. Please shorten it and try again." ]) + rescue ActiveRecord::RecordInvalid => e + Result.new(success?: false, errors: [ e.message ]) + end + + private + + def field_value(identifier) + field = @form.form_fields.find_by(field_identifier: identifier) + return nil unless field + + @form_params[field.id.to_s] + end + + # Identify the respondent from the form's name/email answers. Reuses an existing + # Person on an email + last-name match (both stable identifiers) so a returning + # respondent isn't duplicated; creates one otherwise. Returns nil when there's + # no email or no name to build a Person from — a first/last name are required. + def find_or_create_person + first_name = field_value("first_name")&.strip + last_name = field_value("last_name")&.strip + email = field_value("primary_email")&.strip&.downcase + return nil if email.blank? || first_name.blank? || last_name.blank? + + find_matching_person(last_name: last_name, email: email) || Person.create!( + first_name: first_name, + last_name: last_name, + pronouns: field_value("pronouns")&.strip, + email: email, + email_type: field_value("primary_email_type")&.downcase + ) + end + + def find_matching_person(last_name:, email:) + Person + .where("LOWER(email) = ? AND LOWER(last_name) = ?", email.downcase, last_name.downcase) + .first + end + + # Consent is opt-in and recorded once. An affirmative answer stamps the time and + # the source when none is on file; a respondent who already consented is left + # untouched, and consent is never cleared from here. + def record_mailing_list_consent(person) + return if person.mailing_list_consent_at.present? + return unless Array(field_value("communication_consent")).any? { |value| value.to_s.strip.present? } + + person.update!( + mailing_list_consent_at: Time.current, + mailing_list_consent_source: "#{@form.display_name} (public form)" + ) + end + + def save_form_answers(submission) + @form_params.each do |field_id, raw_value| + field = @form.form_fields.find_by(id: field_id) + next unless field + next if field.group_header? || field.field_identifier == "confirm_email" + + persist_answer(submission, field, raw_value) + end + end +end diff --git a/app/views/form_submissions/form_submissions_results.html.erb b/app/views/form_submissions/form_submissions_results.html.erb new file mode 100644 index 0000000000..785d718b82 --- /dev/null +++ b/app/views/form_submissions/form_submissions_results.html.erb @@ -0,0 +1,48 @@ +<%= turbo_frame_tag :form_submissions_results do %> +
+ + <%= pluralize(@form_submissions.total_entries, "submission") %> + + <% if @form %> + for <%= @form.display_name %> + <% end %> +
+ + <% if @form_submissions.any? %> +
+ + + + + + + <% unless @person %><% end %> + + + + + + <% @form_submissions.each do |submission| %> + + + + + <% unless @person %><% end %> + + + + <% end %> + +
FormRoleEventPersonSubmitted
<%= submission.form&.display_name %><%= submission.role&.humanize %><%= submission.resolved_event&.name %><%= submission.person&.name %><%= submission.created_at.to_fs(:long) %> + <%= link_to "View", form_submission_path(submission, return_to: "form_submissions", person_id: submission.person_id), class: "text-blue-600 hover:underline" %> +
+
+ + + <% else %> +
+ +

No form submissions found.

+
+ <% end %> +<% end %> diff --git a/app/views/form_submissions/index.html.erb b/app/views/form_submissions/index.html.erb index 9f5fecf283..d6ea3e8a6c 100644 --- a/app/views/form_submissions/index.html.erb +++ b/app/views/form_submissions/index.html.erb @@ -1,6 +1,6 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> -
+
<% if @person %>
@@ -10,58 +10,46 @@
<% end %> -
-