Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions app/models/form_answer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down Expand Up @@ -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

Expand Down
57 changes: 34 additions & 23 deletions app/services/event_registration_services/public_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions app/services/form_answer_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/form_submissions/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
<% section[:fields].each do |field, response| %>
<div class="flex gap-4 py-1.5">
<div class="w-1/3 shrink-0 text-xs text-gray-500"><%= display_question_label(field, response) %></div>
<div class="text-sm text-gray-900 whitespace-pre-line"><%= display_response_text(field, response) %></div>
<div class="text-sm text-gray-900 whitespace-pre-line"><%= render "shared/form_answer_value", field: field, response: response %></div>
</div>
<% end %>
</div>
Expand Down
22 changes: 22 additions & 0 deletions app/views/events/public_registrations/_form_field.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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). %>
<div data-controller="file-preview">
<input type="file"
name="<%= field_name %>"
id="<%= field_id %>"
accept="<%= Asset.accept_attribute %>"
data-direct-upload-url="<%= rails_direct_uploads_path %>"
data-file-preview-target="input"
data-action="change->file-preview#update"
class="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-blue-700 hover:file:bg-blue-100 rounded-lg border <%= error_border %> bg-white px-2 py-2"
<%= "required" if field.required %>>
<p data-file-preview-target="filename" class="mt-1.5 text-xs font-medium text-gray-600"></p>
<p class="mt-1 text-xs text-gray-400">Accepted: <%= Asset.accepted_types_label %></p>
<img data-file-preview-target="preview"
class="hidden mt-3 max-h-48 w-auto rounded-lg border border-gray-200 shadow-sm"
alt="Selected file preview">
</div>

<% end %>

<% if field.hint_text.present? %>
Expand Down
6 changes: 3 additions & 3 deletions app/views/events/public_registrations/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
<% response = @responses[field.id] %>
<div class="<%= field.grid_span_class %> pb-8">
<dt class="rich-label text-xs font-medium text-gray-500 uppercase tracking-wide"><%= form_label_html(display_question_label(field, response)) %></dt>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= display_response_text(field, response) %></dd>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= render "shared/form_answer_value", field: field, response: response %></dd>
</div>
<% end %>
<% end %>
Expand Down Expand Up @@ -118,7 +118,7 @@
<% response = @scholarship_responses[field.id] %>
<div class="<%= field.grid_span_class %> pb-8">
<dt class="rich-label text-xs font-medium text-gray-500 uppercase tracking-wide"><%= form_label_html(display_question_label(field, response)) %></dt>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= display_response_text(field, response) %></dd>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= render "shared/form_answer_value", field: field, response: response %></dd>
</div>
<% end %>
<% end %>
Expand Down Expand Up @@ -169,7 +169,7 @@
<% response = @continuing_education_responses[field.id] %>
<div class="<%= field.grid_span_class %> pb-8">
<dt class="rich-label text-xs font-medium text-gray-500 uppercase tracking-wide"><%= form_label_html(display_question_label(field, response)) %></dt>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= display_response_text(field, response) %></dd>
<dd class="mt-0.5 text-base font-semibold text-gray-900 whitespace-pre-line"><%= render "shared/form_answer_value", field: field, response: response %></dd>
</div>
<% end %>
<% end %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/form_submissions/_submission.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<dd class="mt-1 text-sm text-gray-900">
<%# 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 %>
</dd>
</div>
<% end %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/forms/_form_field_fields.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
<div class="flex flex-wrap items-center gap-2">
<%= 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,
Expand Down
2 changes: 1 addition & 1 deletion app/views/scholarships/_form_submission.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<% @scholarship_answers.each do |field, response| %>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-400"><%= display_question_label(field, response) %></dt>
<dd class="mt-0.5 text-sm text-gray-900 whitespace-pre-line"><%= display_response_text(field, response) %></dd>
<dd class="mt-0.5 text-sm text-gray-900 whitespace-pre-line"><%= render "shared/form_answer_value", field: field, response: response %></dd>
</div>
<% end %>
</dl>
Expand Down
22 changes: 22 additions & 0 deletions app/views/shared/_form_answer_value.html.erb
Original file line number Diff line number Diff line change
@@ -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) %>
<div class="mt-1 flex flex-col items-start gap-2">
<% 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 %>
<i class="fa-solid fa-paperclip text-xs"></i><%= uploaded.filename %>
<% end %>
</div>
<% elsif field.file_upload? %>
<span class="text-gray-400">β€”</span>
<% else %>
<%= display_response_text(field, response) %>
<% end %>
4 changes: 4 additions & 0 deletions spec/factories/form_fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 additions & 0 deletions spec/models/form_answer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion spec/models/form_field_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions spec/requests/events/public_registrations_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading