Skip to content
Draft
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
2 changes: 1 addition & 1 deletion app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def set_dashboard_event

def form_params
params.require(:form).permit(
:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published,
:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published, :allow_anonymous_submissions,
form_fields_attributes: [
:id, :name, :answer_type, :required, :subtitle, :hint_text,
:field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy,
Expand Down
6 changes: 6 additions & 0 deletions app/controllers/public_forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ def validate_required_fields(form_params)
fields = @form_fields.reject(&:group_header?)
errors = FormAnswerValidator.call(fields, form_params)

# An anonymous-capable form leaves its identity questions optional, so drop
# their blank-required errors while keeping any format error on a filled one.
fields.each do |field|
errors.delete(field.id) if @form.optional_identity_field?(field) && form_params[field.id.to_s].blank?
end

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"]
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ def routable_path(record)
# show page.
def form_submission_link_path(submission)
event = submission.resolved_event
registration = event && submission.person.event_registrations.find_by(event: event)
registration = event && submission.person&.event_registrations&.find_by(event: event)
return event_public_registration_path(event, reg: registration.slug) if registration&.slug.present?
form_submission_path(submission)
end
Expand Down
3 changes: 2 additions & 1 deletion app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,9 @@ def form_submission_confirmation_fyi(notification)
@person = @submission.person
@answers = @submission.form_answers.includes(:form_field)

submitter = @person&.full_name || "an anonymous respondent"
mail(
subject: "#{FYI_PREFIX} New form submission: #{@form.display_name} by #{@person.full_name}"
subject: "#{FYI_PREFIX} New form submission: #{@form.display_name} by #{submitter}"
)
end

Expand Down
17 changes: 17 additions & 0 deletions app/models/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ class Form < ApplicationRecord
# submissions index scenario filter.
AGREEMENT_ROLES = %w[registration new_job reinstatement].freeze

# Name/email questions that identify the submitter. When a form accepts
# anonymous submissions these stay on the form but are never required, so a
# respondent can fill them or skip them (see PublicFormSubmission).
IDENTITY_IDENTIFIERS = %w[first_name last_name primary_email confirm_email].freeze

belongs_to :owner, polymorphic: true, optional: true
has_many :form_fields, dependent: :destroy, inverse_of: :form
has_many :event_forms, dependent: :destroy
Expand Down Expand Up @@ -58,6 +63,18 @@ def publicly_fillable?
standalone? && !event_connected? && published? && slug.present?
end

# An identity question whose answer is optional because this form accepts
# anonymous submissions β€” even when the field is flagged required.
def optional_identity_field?(field)
allow_anonymous_submissions? && field.field_identifier.in?(IDENTITY_IDENTIFIERS)
end

# Whether a submission must carry an answer for this field, accounting for
# anonymity relaxing the identity questions.
def requires_answer?(field)
field.required? && !optional_identity_field?(field)
end

private

# Blank stays nil (never ""), so the unique index tolerates the many forms with
Expand Down
8 changes: 7 additions & 1 deletion app/models/form_submission.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
class FormSubmission < ApplicationRecord
belongs_to :person
# Optional so a public form flagged allow_anonymous_submissions can record a
# submission with no identity (see PublicFormSubmission).
belongs_to :person, optional: true
belongs_to :form
belongs_to :event, optional: true
has_many :form_answers, dependent: :destroy
Expand Down Expand Up @@ -173,6 +175,10 @@ def bulk_payment?
role == "bulk_payment"
end

def anonymous?
person_id.nil?
end

# The event this submission belongs to: the directly stored one, or β€” for
# submissions created before event_id existed β€” resolved through the form's
# matching join role.
Expand Down
3 changes: 3 additions & 0 deletions app/services/other_responses/capture_from_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ def initialize(submission)
end

def call
# "Other" responses are person-owned; an anonymous submission has no owner.
return unless @submission.person

answers.each do |answer|
field_identifier = answer.form_field&.field_identifier
next unless capturable?(field_identifier)
Expand Down
21 changes: 12 additions & 9 deletions app/services/public_form_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ def initialize(form:, form_params:)
def call
ActiveRecord::Base.transaction do
person = find_or_create_person
return Result.new(success?: false, errors: [ IDENTITY_MISSING_MESSAGE ]) unless person
return Result.new(success?: false, errors: [ IDENTITY_MISSING_MESSAGE ]) unless person || @form.allow_anonymous_submissions?

record_mailing_list_consent(person)
record_mailing_list_consent(person) if person

submission = FormSubmission.create!(person: person, form: @form, role: ROLE)
save_form_answers(submission)
Expand Down Expand Up @@ -117,13 +117,16 @@ def save_form_answers(submission)
# A confirmation to the submitter and an FYI to the AWBW team, mirroring the
# event-registration flow.
def send_notifications(submission)
NotificationServices::CreateNotification.call(
noticeable: submission,
kind: :form_submission_confirmation,
recipient_role: :person,
recipient_email: submission.person.preferred_email,
notification_type: 0
)
# An anonymous submission has no submitter to confirm to; only the team FYI goes out.
if submission.person
NotificationServices::CreateNotification.call(
noticeable: submission,
kind: :form_submission_confirmation,
recipient_role: :person,
recipient_email: submission.person.preferred_email,
notification_type: 0
)
end

NotificationServices::CreateNotification.call(
noticeable: submission,
Expand Down
18 changes: 10 additions & 8 deletions app/views/events/public_registrations/_form_field.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<%# locals: (field:, value: nil, label: nil, show_option_source: false, show_dropdown_warning: false) %>
<%# locals: (field:, value: nil, label: nil, show_option_source: false, show_dropdown_warning: false, required: nil) %>
<% return if field.group_header? %>
<%# Callers (e.g. anonymous public forms) can force a field optional; defaults to the field's own setting. %>
<% required = local_assigns.fetch(:required, field.required) %>

<% error = @field_errors&.dig(field.id) %>
<% error_border = error ? "border-red-400 focus:border-red-500 focus:ring-red-500/30" : "border-gray-300 hover:border-gray-400 focus:border-blue-500 focus:ring-blue-500/30" %>
Expand All @@ -9,7 +11,7 @@
<div class="flex flex-wrap items-center gap-2 mb-1.5">
<label class="rich-label text-sm font-semibold text-gray-700" for="public_registration_form_fields_<%= field.id %>">
<%= form_label_html(label || field.name) %>
<% if field.required %>
<% if required %>
<span class="text-red-500">*</span>
<% end %>
</label>
Expand Down Expand Up @@ -37,7 +39,7 @@
<select name="<%= field_name %>"
id="<%= field_id %>"
class="<%= input_classes %>"
<%= "required" if field.required %>>
<%= "required" if required %>>
<option value="">Select a state</option>
<% us_states.each do |name, abbr| %>
<option value="<%= abbr %>" <%= "selected" if value == abbr %>><%= name %> (<%= abbr %>)</option>
Expand Down Expand Up @@ -65,7 +67,7 @@
id="<%= field_id %>"
value="<%= value %>"
class="<%= input_classes %>"
<%= "required" if field.required %>
<%= "required" if required %>
<%= raw(extra_attrs.join(" ")) %>>
<% end %>

Expand All @@ -75,7 +77,7 @@
rows="4"
class="<%= input_classes %>"
<%= "maxlength=\"#{field.effective_max_characters}\"".html_safe if field.effective_max_characters %>
<%= "required" if field.required %>><%= value %></textarea>
<%= "required" if required %>><%= value %></textarea>

<% when "single_select_radio" %>
<%# Dynamic fields (e.g. additional_sectors) source options from Sector/Category
Expand All @@ -95,7 +97,7 @@
class="text-blue-600 focus:ring-blue-500"
<%= "data-specify-option-target=\"control\"".html_safe if is_specify %>
<%= "checked" if (is_specify ? specify_option_selected?(option_label, value) : value == option_value) || (value.blank? && field.field_identifier == "interested_in_more" && option_value == "Yes") %>
<%= "required" if field.required %>>
<%= "required" if required %>>
<%= option_label %>
</span>
<% if option_description.present? %>
Expand Down Expand Up @@ -124,7 +126,7 @@
<select name="<%= field_name %>"
id="<%= field_id %>"
class="<%= input_classes %>"
<%= "required" if field.required %>>
<%= "required" if required %>>
<option value="">Select one</option>
<%# A dropdown can't show subtext, so an option's description is folded into
the label as "Name (description)". %>
Expand Down Expand Up @@ -202,7 +204,7 @@
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 && retained_upload.nil? %>>
<%= "required" if required && retained_upload.nil? %>>
<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: <%= FormUploadAsset.accepted_types_label %> (max <%= FormUploadAsset.max_file_size_label %>)</p>
<img data-file-preview-target="preview"
Expand Down
16 changes: 9 additions & 7 deletions app/views/form_submissions/_submission.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@
</div>
<% end %>
<% end %>
<% if submission.person.present? %>
<div class="py-2">
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">Submitted by</dt>
<dd class="mt-1 text-sm text-gray-900">
<div class="py-2">
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">Submitted by</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if submission.person.present? %>
<%= submission.person.name %> &lt;<%= submission.person.email %>&gt;
</dd>
</div>
<% end %>
<% else %>
<span class="text-gray-400 italic">Anonymous</span>
<% end %>
</dd>
</div>
<% if attendees.any? %>
<div class="pt-6 pb-2 border-b border-gray-200 mb-4">
<h3 class="text-lg font-semibold text-gray-800">Attendees</h3>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<span class="text-gray-300">β€”</span>
<% end %>
</td>
<% unless @person %><td class="px-4 py-3"><%= submission.person&.name %></td><% end %>
<% unless @person %><td class="px-4 py-3"><%= submission.person&.name || content_tag(:span, "Anonymous", class: "text-gray-400 italic") %></td><% end %>
<td class="px-4 py-3">
<%# The submitted organization answer, with whether the person already
has an active affiliation to an organization of that name β€” the
Expand Down
8 changes: 7 additions & 1 deletion app/views/forms/edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,20 @@
</label>
<p class="text-xs text-gray-500">Publishes the form at the public link below. A form connected to an event can't be published β€” event forms are filled out through their event.</p>

<label class="flex cursor-pointer items-center gap-2">
<%= f.check_box :allow_anonymous_submissions, class: "rounded border-gray-300 text-purple-600" %>
<span class="text-sm text-gray-800">Allow anonymous submissions (name & email become optional)</span>
</label>
<p class="text-xs text-gray-500">When on, respondents can submit without giving a name or email. Those questions still appear but aren't required.</p>

<div>
<label class="mb-1 block text-sm font-medium text-gray-700">Public link address</label>
<div class="flex max-w-lg items-center overflow-hidden rounded border border-gray-300 shadow-sm">
<span class="border-r border-gray-300 bg-gray-50 px-3 py-2 text-sm whitespace-nowrap text-gray-500"><%= "#{request.base_url}/f/" %></span>
<%= f.text_field :slug, placeholder: "volunteer-interest",
class: "flex-1 border-0 px-3 py-2 text-sm focus:ring-0" %>
</div>
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens. Required to publish. Must include name and email questions to identify who submitted.</p>
<p class="mt-1 text-xs text-gray-500">Lowercase letters, numbers, and hyphens. Required to publish. Include name and email questions to identify who submitted β€” unless anonymous submissions are allowed.</p>
</div>

<% if @form.publicly_fillable? %>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
<% profile_url = person_url(@person) %>

<p style="margin-bottom: 6px;">
New form submission
</p>

<h1 style="margin: 0;">
<strong><%= @person.full_name %></strong>
(<%= @person.preferred_email %>)
<% if @person %>
<strong><%= @person.full_name %></strong>
(<%= @person.preferred_email %>)
<% else %>
<strong>Anonymous respondent</strong>
<% end %>
</h1>

<p style="margin: 4px 0 16px; font-size: 13px; color: #6b7280;">
Expand All @@ -32,8 +34,10 @@
View submission
</a>

<a href="<%= profile_url %>"
style="display:inline-block;background:#e5e7eb;color:#111827;text-decoration:none;padding:10px 16px;border-radius:6px;font-weight:bold;">
View profile
</a>
<% if @person %>
<a href="<%= person_url(@person) %>"
style="display:inline-block;background:#e5e7eb;color:#111827;text-decoration:none;padding:10px 16px;border-radius:6px;font-weight:bold;">
View profile
</a>
<% end %>
</p>
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
New form submission

<%= @person.full_name %> (<%= @person.preferred_email %>)
<%= @person ? "#{@person.full_name} (#{@person.preferred_email})" : "Anonymous respondent" %>
<%= @form.display_name %> Β· submitted on <%= @submission.created_at
.in_time_zone("Pacific Time (US & Canada)")
.strftime("%B %-d, %Y at %-l:%M %p %Z") %>

<% @answers.each do |answer| %><% next if answer.submitted_answer.blank? %><%= answer.question_name_when_answered.presence || answer.form_field&.name %>: <%= answer.submitted_answer %>
<% end %>
View submission: <%= form_submission_url(@submission) %>
View profile: <%= person_url(@person) %>
<% if @person %>View profile: <%= person_url(@person) %><% end %>
9 changes: 8 additions & 1 deletion app/views/public_forms/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
<div class="px-6 py-7 sm:px-8">
<%= render "forms/header", form: @form %>

<% if @form.allow_anonymous_submissions? %>
<p class="mb-6 flex items-start gap-2 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
<i class="fa-solid fa-user-secret mt-0.5 text-gray-400"></i>
<span>You can submit this form anonymously β€” name and email are optional.</span>
</p>
<% end %>

<% if flash[:alert] %>
<div role="alert" class="mb-6 flex scroll-mt-24 items-start gap-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-800" data-controller="form-errors">
<i class="fa-solid fa-circle-exclamation mt-0.5 text-red-500"></i>
Expand All @@ -40,7 +47,7 @@
<% else %>
<% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) %>
<div class="<%= field.grid_span_class %>">
<%= render "events/public_registrations/form_field", field: field, value: submitted_value %>
<%= render "events/public_registrations/form_field", field: field, value: submitted_value, required: @form.requires_answer?(field) %>
</div>
<% end %>
<% end %>
Expand Down
6 changes: 3 additions & 3 deletions config/brakeman.ignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,13 @@
{
"warning_type": "Mass Assignment",
"warning_code": 105,
"fingerprint": "a46122ae2994612e9507c0c854c6d8f66039324246131f28ee068a69d9ce4024",
"fingerprint": "3eeea9bd403ae750aa697a95d36849d6597a4df73a2f0be404bba9d2ac3b5fb8",
"check_name": "PermitAttributes",
"message": "Potentially dangerous key allowed for mass assignment",
"file": "app/controllers/forms_controller.rb",
"line": 158,
"line": 165,
"link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/",
"code": "params.require(:form).permit(: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, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))",
"code": "params.require(:form).permit(:name, :role, :header, :hide_answered_person_questions, :hide_answered_form_questions, :slug, :published, :allow_anonymous_submissions, :form_fields_attributes => ([:id, :name, :answer_type, :required, :subtitle, :hint_text, :field_identifier, :section, :position, :visibility, :one_time, :width, :min_words, :max_characters, :_destroy, { :form_field_answer_options_attributes => ([:id, :option_name, :_destroy]) }]))",
"render_path": null,
"location": {
"type": "method",
Expand Down
16 changes: 16 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2103,3 +2103,19 @@
subscription form, filter the subscriptions list by organization, and see it
in the new Organization column. It's optional β€” leave it blank for a topic
that isn't tied to any organization.

- name: "Anonymous form submissions"
area: content
display_status: admin_facing
released_on: 2026-08-23
action_path: "/forms"
pr_number: 2350
summary: >-
Turn on "Allow anonymous submissions" on a public form and its name and email
questions become optional, so people can respond without identifying
themselves. Anonymous responses show as "Anonymous" in the submissions index.
pro_tips:
- The name and email questions still appear on the form β€” respondents can fill
them in or skip them.
- Anonymous responses send the team the usual FYI email but no confirmation
goes back (there's no email to send it to).
14 changes: 14 additions & 0 deletions db/migrate/20260823003323_add_anonymous_form_submissions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class AddAnonymousFormSubmissions < ActiveRecord::Migration[8.0]
def up
add_column :forms, :allow_anonymous_submissions, :boolean, default: false, null: false unless column_exists?(:forms, :allow_anonymous_submissions)
change_column_null :form_submissions, :person_id, true
end

def down
# Can't restore NOT NULL if anonymous (person-less) submissions exist; the
# feature that created them is being removed, so drop those rows first.
execute "DELETE FROM form_submissions WHERE person_id IS NULL"
change_column_null :form_submissions, :person_id, false
remove_column :forms, :allow_anonymous_submissions, if_exists: true
end
end
Loading