From 3644f21cc3c0e849a865686838c0c1a556561cc0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:28:22 -0400 Subject: [PATCH 1/2] Add file-upload answer type to the form engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registrants can now answer a form question by uploading a file (e.g. the post-event survey's photo-of-a-creation question). File-upload answers store their blob on the existing polymorphic Asset — the same attachment, content-type validation, and image/PDF display machinery story ideas use — so no migration is needed. Direct upload submits a signed blob id as a normal param, so the form stays non-multipart. Fixes #2109. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/models/form_answer.rb | 13 +++++ app/models/form_field.rb | 4 +- .../public_registration.rb | 57 +++++++++++-------- app/services/form_answer_validator.rb | 5 ++ .../events/form_submissions/show.html.erb | 2 +- .../public_registrations/_form_field.html.erb | 22 +++++++ .../events/public_registrations/show.html.erb | 6 +- .../form_submissions/_submission.html.erb | 4 +- app/views/forms/_form_field_fields.html.erb | 2 +- .../scholarships/_form_submission.html.erb | 2 +- app/views/shared/_form_answer_value.html.erb | 22 +++++++ spec/factories/form_fields.rb | 4 ++ 12 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 app/views/shared/_form_answer_value.html.erb diff --git a/app/models/form_answer.rb b/app/models/form_answer.rb index 8210a22406..950924d50f 100644 --- a/app/models/form_answer.rb +++ b/app/models/form_answer.rb @@ -11,7 +11,20 @@ class FormAnswer < ApplicationRecord belongs_to :form_field, optional: true belongs_to :form_submission + # A file-upload answer stores its file on a polymorphic Asset (the same + # attachment/validation/display machinery story ideas use). Text answers leave + # this nil; submitted_answer still holds the filename so every text-only + # display and export shows something readable. + has_one :asset, as: :owner, dependent: :destroy + def name "#{question_name_when_answered.presence || form_field&.name}: #{submitted_answer}" end + + # The attached upload, when this answer is a file-upload answer with a file on + # file. nil for text answers or an unanswered file question. + def uploaded_file + file = asset&.file + file if file&.attached? + end end diff --git a/app/models/form_field.rb b/app/models/form_field.rb index d0634eea1f..cc6b391876 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -118,7 +118,8 @@ class FormField < ApplicationRecord :no_user_input, :multi_select_checkbox, :group_header, - :single_select_dropdown + :single_select_dropdown, + :file_upload ] enum :input_type, [ @@ -148,6 +149,7 @@ class FormField < ApplicationRecord "single_select_radio" => "Single select radio", "single_select_dropdown" => "Single select dropdown", "multi_select_checkbox" => "Multiple select checkbox", + "file_upload" => "File upload", "no_user_input" => "Informational-only" }.freeze diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index eaa10e59bf..ca723fbbdb 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -511,17 +511,42 @@ def save_form_answers(submission) next unless field next if field.group_header? || field.field_identifier == "confirm_email" - text = if raw_value.is_a?(Array) - raw_value.reject(&:blank?).join(", ") - else - raw_value.to_s - end + persist_answer(submission, field, raw_value) + 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 - record = submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(submitted_answer: text, 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 keeps the filename so text-only views, + # exports, and notifications still read. Asset enforces the content type on + # save, rolling back the whole submission on an unaccepted file. + def attach_uploaded_file(record, raw_value) + record.update!(submitted_answer: "") + return if raw_value.blank? + + asset = record.asset || record.build_asset + asset.file.attach(raw_value) + asset.save! + record.update!(submitted_answer: asset.file.filename.to_s) + 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. @@ -539,14 +564,7 @@ def save_scholarship_submission(person) next unless field next if field.group_header? - text = if raw_value.is_a?(Array) - raw_value.reject(&:blank?).join(", ") - else - raw_value.to_s - end - - record = submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(submitted_answer: text, question_name_when_answered: field.name) + persist_answer(submission, field, raw_value) end OtherResponses::CaptureFromSubmission.call(submission) @@ -566,14 +584,7 @@ def save_continuing_education_submission(person) next unless field next if field.group_header? - text = if raw_value.is_a?(Array) - raw_value.reject(&:blank?).join(", ") - else - raw_value.to_s - end - - record = submission.form_answers.find_or_initialize_by(form_field: field) - record.update!(submitted_answer: text, question_name_when_answered: field.name) + persist_answer(submission, field, raw_value) end end diff --git a/app/services/form_answer_validator.rb b/app/services/form_answer_validator.rb index aa296ace18..3524055153 100644 --- a/app/services/form_answer_validator.rb +++ b/app/services/form_answer_validator.rb @@ -36,6 +36,11 @@ def error_for(field, value) return "can't be blank" if field.required && blank_answer?(value) return if value.blank? + # A file upload arrives as a signed blob id (or an uploaded file), not text — + # the word/character/format/inclusion checks below don't apply. Its content + # type is enforced by Asset when the file is attached during submission. + return if field.file_upload? + if field.number_integer? && value.to_s !~ WHOLE_NUMBER_FORMAT "must be a whole number" elsif field.email_field? && value.to_s !~ EMAIL_FORMAT diff --git a/app/views/events/form_submissions/show.html.erb b/app/views/events/form_submissions/show.html.erb index 2db18f6126..363b7df0e9 100644 --- a/app/views/events/form_submissions/show.html.erb +++ b/app/views/events/form_submissions/show.html.erb @@ -45,7 +45,7 @@ <% section[:fields].each do |field, response| %>
<%= display_question_label(field, response) %>
-
<%= display_response_text(field, response) %>
+
<%= render "shared/form_answer_value", field: field, response: response %>
<% end %> diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index e70fb2295c..26df195aa1 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -174,6 +174,28 @@ <% end %> <% end %> + <% when "file_upload" %> + <%# Direct upload: the file streams to storage via JS and the input's value + becomes a signed blob id, submitted as a normal param — so the form needs + no multipart encoding and the persistence service attaches the blob. + Reuses the file_preview controller (image thumbnail / file-type icon). %> +
+ > +

+

Accepted: <%= Asset.accepted_types_label %>

+ +
+ <% end %> <% if field.hint_text.present? %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index 73646434db..05b12c9cc3 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -69,7 +69,7 @@ <% response = @responses[field.id] %>
<%= form_label_html(display_question_label(field, response)) %>
-
<%= display_response_text(field, response) %>
+
<%= render "shared/form_answer_value", field: field, response: response %>
<% end %> <% end %> @@ -118,7 +118,7 @@ <% response = @scholarship_responses[field.id] %>
<%= form_label_html(display_question_label(field, response)) %>
-
<%= display_response_text(field, response) %>
+
<%= render "shared/form_answer_value", field: field, response: response %>
<% end %> <% end %> @@ -169,7 +169,7 @@ <% response = @continuing_education_responses[field.id] %>
<%= form_label_html(display_question_label(field, response)) %>
-
<%= display_response_text(field, response) %>
+
<%= render "shared/form_answer_value", field: field, response: response %>
<% end %> <% end %> diff --git a/app/views/form_submissions/_submission.html.erb b/app/views/form_submissions/_submission.html.erb index d1b69b2ed3..1bd3e1eda3 100644 --- a/app/views/form_submissions/_submission.html.erb +++ b/app/views/form_submissions/_submission.html.erb @@ -22,8 +22,8 @@
<%# Resolves the sector / age-group ids stored behind the professional fields to their names; free-text ("Other: …") and plain answers pass - through unchanged. %> - <%= display_response_text(field, response) %> + through unchanged. File-upload answers render a preview + link. %> + <%= render "shared/form_answer_value", field: field, response: response %>
<% end %> diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb index 3d62f271b9..82f2f3fa3f 100644 --- a/app/views/forms/_form_field_fields.html.erb +++ b/app/views/forms/_form_field_fields.html.erb @@ -67,7 +67,7 @@
<%= f.text_area :name, required: true, rows: 1, class: "min-w-0 flex-[3_1_10rem] rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> <% - type_order = %w[free_form_input_one_line free_form_input_paragraph single_select_radio single_select_dropdown multi_select_checkbox no_user_input group_header] + type_order = %w[free_form_input_one_line free_form_input_paragraph single_select_radio single_select_dropdown multi_select_checkbox file_upload no_user_input group_header] type_options = type_order.map { |t| [ FormField::ANSWER_TYPE_LABELS[t] || t.titleize, t ] } %> <%= f.select :answer_type, type_options, diff --git a/app/views/scholarships/_form_submission.html.erb b/app/views/scholarships/_form_submission.html.erb index 2ab3fe1c97..39d2311b91 100644 --- a/app/views/scholarships/_form_submission.html.erb +++ b/app/views/scholarships/_form_submission.html.erb @@ -25,7 +25,7 @@ <% @scholarship_answers.each do |field, response| %>
<%= display_question_label(field, response) %>
-
<%= display_response_text(field, response) %>
+
<%= render "shared/form_answer_value", field: field, response: response %>
<% end %> diff --git a/app/views/shared/_form_answer_value.html.erb b/app/views/shared/_form_answer_value.html.erb new file mode 100644 index 0000000000..c17febfcfc --- /dev/null +++ b/app/views/shared/_form_answer_value.html.erb @@ -0,0 +1,22 @@ +<%# Renders one form answer's value: a file-upload answer shows an inline image + preview (or a file link for non-images) plus a download link; every other + answer type shows its resolved text. Locals: field, response. %> +<% if field.file_upload? && (uploaded = response&.uploaded_file) %> +
+ <% if uploaded.content_type.to_s.start_with?("image/") %> + <%= link_to url_for(uploaded), target: "_blank", rel: "noopener" do %> + <%= image_tag(uploaded.variable? ? uploaded.variant(:thumbnail) : uploaded, + class: "max-h-48 w-auto rounded-lg border border-gray-200 shadow-sm", + alt: uploaded.filename.to_s) %> + <% end %> + <% end %> + <%= link_to url_for(uploaded), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1.5 text-sm text-blue-700 hover:text-blue-900 underline" do %> + <%= uploaded.filename %> + <% end %> +
+<% elsif field.file_upload? %> + +<% else %> + <%= display_response_text(field, response) %> +<% end %> diff --git a/spec/factories/form_fields.rb b/spec/factories/form_fields.rb index 3446d04415..de04425090 100644 --- a/spec/factories/form_fields.rb +++ b/spec/factories/form_fields.rb @@ -13,5 +13,9 @@ required { FormField::NON_INPUT_ANSWER_TYPES.exclude?(answer_type.to_s) } # Add other attributes based on schema if needed + + trait :file_upload do + answer_type { :file_upload } + end end end From f365de26433343f449e73ff172d901b670c624a4 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 9 Aug 2026 16:34:36 -0400 Subject: [PATCH 2/2] Test the file-upload answer type across model, validator, service, and views Covers the new answer_type, its Asset-backed attachment, presence/skip behavior in FormAnswerValidator, blob attachment + filename storage + content-type rejection in PublicRegistration, and the answer-display partial's image preview + download link. --- spec/models/form_answer_spec.rb | 21 ++++++++ spec/models/form_field_spec.rb | 27 +++++++++- .../events/public_registrations_spec.rb | 18 +++++++ .../public_registration_spec.rb | 53 +++++++++++++++++++ spec/services/form_answer_validator_spec.rb | 15 ++++++ 5 files changed, 133 insertions(+), 1 deletion(-) diff --git a/spec/models/form_answer_spec.rb b/spec/models/form_answer_spec.rb index cc2b36ef6d..f06d11cf80 100644 --- a/spec/models/form_answer_spec.rb +++ b/spec/models/form_answer_spec.rb @@ -4,6 +4,27 @@ describe "associations" do it { should belong_to(:form_field).optional } it { should belong_to(:form_submission) } + it { should have_one(:asset).dependent(:destroy) } + end + + describe "#uploaded_file" do + let(:form) { create(:form) } + let(:submission) { create(:form_submission, form: form) } + let(:answer) { create(:form_answer, form_submission: submission, submitted_answer: "sample.png") } + + it "returns nil when no asset is attached" do + expect(answer.uploaded_file).to be_nil + end + + it "returns the attachment when a file is on file" do + asset = answer.build_asset + asset.file.attach(io: File.open(Rails.root.join("spec/fixtures/files/sample.png")), + filename: "sample.png", content_type: "image/png") + asset.save! + + expect(answer.reload.uploaded_file).to be_attached + expect(answer.uploaded_file.filename.to_s).to eq("sample.png") + end end describe "#name" do diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 4acece44a7..2bdda30643 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -48,6 +48,31 @@ field = build(:form_field, form: form, answer_type: :free_form_input_one_line, required: true) expect(field).to be_valid end + + it "allows a required file-upload field" do + field = build(:form_field, form: form, answer_type: :file_upload, required: true) + expect(field).to be_valid + end + end + + describe "file-upload answer type" do + let(:form) { create(:form) } + let(:field) { build(:form_field, :file_upload, form: form) } + + it "is a valid answer type" do + expect(field).to be_valid + expect(field.answer_type).to eq("file_upload") + end + + it "collects input but is neither selectable nor free-form text" do + expect(field.collects_input?).to be(true) + expect(field.selectable?).to be(false) + expect(field.free_form_text?).to be(false) + end + + it "labels the type in sentence case" do + expect(field.answer_type_label).to eq("File upload") + end end describe "field_identifier uniqueness" do @@ -94,7 +119,7 @@ it { should define_enum_for(:status).with_values([ :inactive, :active ]) } it { should define_enum_for(:answer_type).with_values([ :free_form_input_one_line, :free_form_input_paragraph, :single_select_radio, :no_user_input, :multi_select_checkbox, - :group_header, :single_select_dropdown ]) } + :group_header, :single_select_dropdown, :file_upload ]) } it { should define_enum_for(:input_type).with_values([ :text_alphanumeric, :number_integer, :number_decimal, :date ]) } end diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index 3fa397e963..fefa4745ea 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -630,6 +630,24 @@ def post_with_scholarship(scholarship_answer) expect(response.body).not_to include("Reworded after submission") end + it "renders an uploaded file answer as an image preview and download link" do + upload_field = create(:form_field, :file_upload, form: form, name: "Photo of your creation") + submission = FormSubmission.find_by(person: person, form: form) + answer = submission.form_answers.create!(form_field: upload_field, submitted_answer: "sample.png") + answer.build_asset.tap do |asset| + asset.file.attach(io: File.open(Rails.root.join("spec/fixtures/files/sample.png")), + filename: "sample.png", content_type: "image/png") + asset.save! + end + + get event_public_registration_path(event, person_id: person.id) + + expect(response).to have_http_status(:success) + expect(response.body).to include("Photo of your creation") + expect(response.body).to include("sample.png") + expect(response.body).to include(rails_blob_path(answer.uploaded_file, only_path: true)) + end + context "when the registrant filled out a separate scholarship form" do let(:scholarship_form) { create(:form, role: "scholarship") } let!(:scholarship_field) do diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 8f9a207e5f..a4000b0617 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -955,4 +955,57 @@ def register_with_additional_forms(selections) expect(FormSubmission.where(person: person, role: "scholarship")).to be_empty end end + + describe "file-upload answers" do + let!(:upload_field) do + form.form_fields.create!(name: "Photo of your creation", answer_type: :file_upload, + status: :active, position: 999) + end + + def signed_id_for(fixture, content_type) + blob = ActiveStorage::Blob.create_and_upload!( + io: File.open(Rails.root.join("spec/fixtures/files", fixture)), + filename: fixture, content_type: content_type + ) + blob.signed_id + end + + it "attaches the uploaded blob to the answer and stores its filename" do + params = base_form_params(first_name: "Pat", last_name: "Art", email: "pat@example.com").merge( + upload_field.id.to_s => signed_id_for("sample.png", "image/png") + ) + + result = described_class.call(event: event, registration_form: form, form_params: params) + + expect(result.success?).to be true + answer = result.form_submission.form_answers.find_by(form_field: upload_field) + expect(answer.uploaded_file).to be_attached + expect(answer.uploaded_file.filename.to_s).to eq("sample.png") + expect(answer.submitted_answer).to eq("sample.png") + end + + it "leaves the answer fileless when nothing was uploaded" do + params = base_form_params(first_name: "No", last_name: "File", email: "nofile@example.com").merge( + upload_field.id.to_s => "" + ) + + result = described_class.call(event: event, registration_form: form, form_params: params) + + expect(result.success?).to be true + answer = result.form_submission.form_answers.find_by(form_field: upload_field) + expect(answer.uploaded_file).to be_nil + expect(answer.submitted_answer).to eq("") + end + + it "rejects a file whose content type Asset does not accept" do + params = base_form_params(first_name: "Bad", last_name: "Type", email: "bad@example.com").merge( + upload_field.id.to_s => signed_id_for("sample.txt", "text/plain") + ) + + result = described_class.call(event: event, registration_form: form, form_params: params) + + expect(result.success?).to be false + expect(Person.find_by(email: "bad@example.com")).to be_nil + end + end end diff --git a/spec/services/form_answer_validator_spec.rb b/spec/services/form_answer_validator_spec.rb index 4969218218..936b6d2e67 100644 --- a/spec/services/form_answer_validator_spec.rb +++ b/spec/services/form_answer_validator_spec.rb @@ -77,6 +77,21 @@ def validate(field, value) end end + describe "file upload" do + it "flags a required file question with no upload" do + field = create(:form_field, :file_upload, form: form, required: true) + + expect(validate(field, "")).to eq(field.id => "can't be blank") + end + + it "accepts a signed blob id and skips the text-shaped checks" do + field = create(:form_field, :file_upload, form: form, required: true) + + # A long signed id would trip a character cap, but file uploads have none. + expect(validate(field, "a" * 5_000)).to eq({}) + end + end + describe "min words / max characters" do it "surfaces the min-words error" do field = create(:form_field, form: form, answer_type: :free_form_input_paragraph, min_words: 3)