diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index 79e9f6a459..b3f8bad6f3 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -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, diff --git a/app/controllers/public_forms_controller.rb b/app/controllers/public_forms_controller.rb index a57c74b33a..1a86a041fc 100644 --- a/app/controllers/public_forms_controller.rb +++ b/app/controllers/public_forms_controller.rb @@ -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"] diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a97da64fa7..24581ea8ed 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index f4e32409d6..c822043ef2 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -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 diff --git a/app/models/form.rb b/app/models/form.rb index c29e0768e3..18b4602857 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -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 @@ -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 diff --git a/app/models/form_submission.rb b/app/models/form_submission.rb index 76039c3e71..9f7811fe0a 100644 --- a/app/models/form_submission.rb +++ b/app/models/form_submission.rb @@ -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 @@ -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. diff --git a/app/services/other_responses/capture_from_submission.rb b/app/services/other_responses/capture_from_submission.rb index 5cdf222866..59c60bc010 100644 --- a/app/services/other_responses/capture_from_submission.rb +++ b/app/services/other_responses/capture_from_submission.rb @@ -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) diff --git a/app/services/public_form_submission.rb b/app/services/public_form_submission.rb index c215ff709f..ca95fc65b5 100644 --- a/app/services/public_form_submission.rb +++ b/app/services/public_form_submission.rb @@ -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) @@ -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, diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 0af509c3dc..8a6edd2887 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -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" %> @@ -9,7 +11,7 @@
@@ -37,7 +39,7 @@ > + <%= "required" if required %>> <%# A dropdown can't show subtext, so an option's description is folded into the label as "Name (description)". %> @@ -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? %>>

Accepted: <%= FormUploadAsset.accepted_types_label %> (max <%= FormUploadAsset.max_file_size_label %>)

<% end %> <% end %> - <% if submission.person.present? %> -
-
Submitted by
-
+
+
Submitted by
+
+ <% if submission.person.present? %> <%= submission.person.name %> <<%= submission.person.email %>> -
-
- <% end %> + <% else %> + Anonymous + <% end %> + +
<% if attendees.any? %>

Attendees

diff --git a/app/views/form_submissions/form_submissions_results.html.erb b/app/views/form_submissions/form_submissions_results.html.erb index dcc0668730..4897d62a51 100644 --- a/app/views/form_submissions/form_submissions_results.html.erb +++ b/app/views/form_submissions/form_submissions_results.html.erb @@ -44,7 +44,7 @@ <% end %> - <% unless @person %><%= submission.person&.name %><% end %> + <% unless @person %><%= submission.person&.name || content_tag(:span, "Anonymous", class: "text-gray-400 italic") %><% end %> <%# The submitted organization answer, with whether the person already has an active affiliation to an organization of that name — the diff --git a/app/views/forms/edit.html.erb b/app/views/forms/edit.html.erb index 8cd59b6e28..ddfe49eb93 100644 --- a/app/views/forms/edit.html.erb +++ b/app/views/forms/edit.html.erb @@ -133,6 +133,12 @@

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.

+ +

When on, respondents can submit without giving a name or email. Those questions still appear but aren't required.

+
@@ -140,7 +146,7 @@ <%= f.text_field :slug, placeholder: "volunteer-interest", class: "flex-1 border-0 px-3 py-2 text-sm focus:ring-0" %>
-

Lowercase letters, numbers, and hyphens. Required to publish. Must include name and email questions to identify who submitted.

+

Lowercase letters, numbers, and hyphens. Required to publish. Include name and email questions to identify who submitted — unless anonymous submissions are allowed.

<% if @form.publicly_fillable? %> diff --git a/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb b/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb index 1bd2546b8b..1e688b16af 100644 --- a/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb +++ b/app/views/notification_mailer/form_submission_confirmation_fyi.html.erb @@ -1,12 +1,14 @@ -<% profile_url = person_url(@person) %> -

New form submission

- <%= @person.full_name %> - (<%= @person.preferred_email %>) + <% if @person %> + <%= @person.full_name %> + (<%= @person.preferred_email %>) + <% else %> + Anonymous respondent + <% end %>

@@ -32,8 +34,10 @@ View submission - - View profile - + <% if @person %> + + View profile + + <% end %>

diff --git a/app/views/notification_mailer/form_submission_confirmation_fyi.text.erb b/app/views/notification_mailer/form_submission_confirmation_fyi.text.erb index 2a0c4ccc9f..22c7ad4e79 100644 --- a/app/views/notification_mailer/form_submission_confirmation_fyi.text.erb +++ b/app/views/notification_mailer/form_submission_confirmation_fyi.text.erb @@ -1,6 +1,6 @@ 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") %> @@ -8,4 +8,4 @@ New form submission <% @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 %> diff --git a/app/views/public_forms/show.html.erb b/app/views/public_forms/show.html.erb index fe7390417f..b4339951af 100644 --- a/app/views/public_forms/show.html.erb +++ b/app/views/public_forms/show.html.erb @@ -20,6 +20,13 @@
<%= render "forms/header", form: @form %> + <% if @form.allow_anonymous_submissions? %> +

+ + You can submit this form anonymously — name and email are optional. +

+ <% end %> + <% if flash[:alert] %>