diff --git a/AGENTS.md b/AGENTS.md index d6546036aa..c01b90a869 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (78) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -410,6 +410,7 @@ end - `searchable_checkbox` — TomSelect checkbox-style multi-select - `searchable_select` — Tom Select autocomplete - `share_url` — URL sharing/copying +- `slider` — Syncs a slider form field's range handle to its hidden input, shows the picked number in a bubble above the handle, and fills the track; backs the `:slider` answer type - `sortable` — Drag-drop sorting (SortableJS); persists order via a per-row PUT (used by categories index and the registration ticket callouts editor) - `submit_once` — Disables a form's submit button after submit to block duplicate submissions; re-enables on Back/bfcache/Turbo restore - `tabs` — Tab panel navigation diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 64be221b9e..1a4052f7c4 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -177,6 +177,9 @@ application.register("share-url", ShareUrlController) import ScrollToTopController from "./scroll_to_top_controller" application.register("scroll-to-top", ScrollToTopController) +import SliderController from "./slider_controller" +application.register("slider", SliderController) + import SortableController from "./sortable_controller" application.register("sortable", SortableController) diff --git a/app/frontend/javascript/controllers/slider_controller.js b/app/frontend/javascript/controllers/slider_controller.js new file mode 100644 index 0000000000..1c12bc8265 --- /dev/null +++ b/app/frontend/javascript/controllers/slider_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus" + +// A slider form field: dragging the range handle shows the picked number in a +// bubble above it, fills the track up to that point, and writes the whole number +// to a hidden input (the submitted value). The hidden input stays blank until +// the person interacts, so an untouched required slider still fails presence +// validation — a range input on its own would always post its default. +export default class extends Controller { + static targets = ["range", "value", "bubble"] + static values = { min: Number, max: Number } + + // Thumb width, used to nudge the bubble so it stays centered over the handle + // as it travels the track. + thumbWidth = 16 + + connect() { + // A prefilled/re-rendered value lives on the hidden input — mirror it onto + // the handle and show the bubble. + if (this.valueTarget.value !== "") { + this.rangeTarget.value = this.valueTarget.value + this.render() + } else { + this.paintTrack() + } + } + + update() { + this.valueTarget.value = this.rangeTarget.value + this.render() + } + + render() { + this.bubbleTarget.textContent = this.rangeTarget.value + this.bubbleTarget.classList.remove("hidden") + this.positionBubble() + this.paintTrack() + } + + get percent() { + return (this.rangeTarget.value - this.minValue) / (this.maxValue - this.minValue) + } + + positionBubble() { + const offset = (0.5 - this.percent) * this.thumbWidth + this.bubbleTarget.style.left = `calc(${this.percent * 100}% + ${offset}px)` + } + + // Native range inputs only color the thumb, so fill the track from the left up + // to the current value. + paintTrack() { + const filled = this.percent * 100 + this.rangeTarget.style.background = + `linear-gradient(to right, var(--color-primary, #2563eb) ${filled}%, #e5e7eb ${filled}%)` + } +} diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 2980a89531..7e740f26b8 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -141,9 +141,20 @@ class FormField < ApplicationRecord :multi_select_checkbox, :group_header, :single_select_dropdown, - :file_upload + :file_upload, + :slider ] + # Bounds for the :slider answer type. It stores a whole number in this inclusive + # range (the percentage/rating sliders on the annual evaluation are all 0–100); + # per-field configurable bounds are a future enhancement. + SLIDER_MIN = 0 + SLIDER_MAX = 100 + SLIDER_STEP = 1 + # Plain digits only, so a hand-crafted "0x40" or "1_0" (both of which Integer() + # would happily parse) can't slip past as a value the range input never produces. + SLIDER_VALUE_FORMAT = /\A-?\d+\z/ + enum :input_type, [ :text_alphanumeric, :number_integer, @@ -171,6 +182,7 @@ class FormField < ApplicationRecord "single_select_radio" => "Single select radio", "single_select_dropdown" => "Single select dropdown", "multi_select_checkbox" => "Multiple select checkbox", + "slider" => "Slider (0–100)", "file_upload" => "File upload", "no_user_input" => "Informational-only" }.freeze @@ -345,6 +357,18 @@ def max_characters_error(value) "must be #{limit} #{"character".pluralize(limit)} or fewer" end + # Returns a validation error string when a submitted slider value isn't a whole + # number within the slider's bounds, or nil when it passes / does not apply. + # Blank values are left to the presence (required) check. + def slider_range_error(value) + return unless slider? + return if value.blank? + + return if value.to_s.match?(SLIDER_VALUE_FORMAT) && value.to_i.between?(SLIDER_MIN, SLIDER_MAX) + + "must be a whole number between #{SLIDER_MIN} and #{SLIDER_MAX}" + end + # True when this field's selectable options come from Sector/Category data # rather than its own stored answer options. Dynamic fields never offer "Other". def dynamic_options? diff --git a/app/services/form_answer_validator.rb b/app/services/form_answer_validator.rb index 3524055153..ade7540c7d 100644 --- a/app/services/form_answer_validator.rb +++ b/app/services/form_answer_validator.rb @@ -45,6 +45,8 @@ def error_for(field, value) "must be a whole number" elsif field.email_field? && value.to_s !~ EMAIL_FORMAT "must be a valid email address" + elsif field.slider? + field.slider_range_error(value) else field.min_words_error(value) || field.max_characters_error(value) || field.answer_inclusion_error(value) end diff --git a/app/services/form_response_aggregator.rb b/app/services/form_response_aggregator.rb index 77ed6cad9d..09e32d3e2a 100644 --- a/app/services/form_response_aggregator.rb +++ b/app/services/form_response_aggregator.rb @@ -14,7 +14,7 @@ class FormResponseAggregator FieldReport = Struct.new( :field, :label, :kind, :answered_count, :rows, :chart, :multi, :specify_rows, :responses, - :average, :total, :minimum, :maximum, :integer_valued, + :average, :total, :minimum, :maximum, :integer_valued, :percent, keyword_init: true ) @@ -118,14 +118,15 @@ def build_report(field) build_text_report(field, answers) end - # Number-typed free-form fields hold a figure worth averaging and summing - # (counts served, percentages), not free text. + # Sliders and number-typed free-form fields hold a figure worth averaging and + # summing (percentages, counts served), not free text. def numeric_field?(field) - field.number_integer? || field.number_decimal? + field.slider? || field.number_integer? || field.number_decimal? end - # An average / total / range summary of a number question. Non-numeric stray - # values are dropped rather than skewing the figures. + # An average / total / range summary of a numeric question. Sliders are + # percentages (headlined with a %, 0-100); number fields are open-ended counts. + # Non-numeric stray values are dropped rather than skewing the figures. def build_numeric_report(field, answers) values = answers.filter_map { |answer| numeric_value(field, answer.submitted_answer) } count = values.size @@ -136,7 +137,7 @@ def build_numeric_report(field, answers) total: total, average: count.zero? ? nil : total.to_f / count, minimum: values.min, maximum: values.max, - integer_valued: !field.number_decimal? + integer_valued: !field.number_decimal?, percent: field.slider? ) end diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index b9dd29d3b2..274b05d30b 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -177,6 +177,35 @@ <% end %> <% end %> + <% when "slider" %> + <%# A 0–100 slider: dragging the handle shows the picked number in a bubble + above it and stores a whole number in the hidden input. The hidden input + starts blank so an untouched required slider still fails the server's + presence check (a range input would always post its default). %> +
+ +
+ "> + +
+
+ <%= FormField::SLIDER_MIN %> + <%= (FormField::SLIDER_MIN + FormField::SLIDER_MAX) / 2 %> + <%= FormField::SLIDER_MAX %> +
+
+ <% 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 @@ -204,31 +233,31 @@ 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" + class="block w-full rounded-lg border 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 <%= error_border %> bg-white px-2 py-2" <%= "required" if required && retained_upload.nil? %>>

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

<% end %> <% if field.hint_text.present? %> -

<%= form_label_html(field.hint_text) %>

+

<%= form_label_html(field.hint_text) %>

<% end %> <% if field.free_form_text? && field.min_words.to_i.positive? %> -

Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.

+

Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.

<% end %> <% if field.free_form_text? && field.max_characters.to_i.positive? %> -

Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.

+

Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.

<% end %> <% if error %> -

+

<%= strip_tags(field.name) %> <%= error %>

<% end %> diff --git a/app/views/forms/_form_field_fields.html.erb b/app/views/forms/_form_field_fields.html.erb index c46610cffc..3d350540f7 100644 --- a/app/views/forms/_form_field_fields.html.erb +++ b/app/views/forms/_form_field_fields.html.erb @@ -68,7 +68,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 file_upload 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 slider 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/forms/_numeric_card.html.erb b/app/views/forms/_numeric_card.html.erb index 227d8cf8b1..44ad52b09f 100644 --- a/app/views/forms/_numeric_card.html.erb +++ b/app/views/forms/_numeric_card.html.erb @@ -1,7 +1,10 @@ <%# locals: (report:) %> -<%# A number question's rollup: average, total, and how many answered. Mirrors - the "Average / Total / Responses" figures on the funder summary. %> +<%# A numeric question's rollup: average, total, and how many answered. Sliders + are percentages (a % suffix and a 0-100 fill bar for the average); number + fields are open counts. Mirrors the "Average / Total / Responses" figures on + the funder summary. %> <% decimals = report.integer_valued ? 0 : 1 %> +<% suffix = report.percent ? "%" : "" %>
<%# No "N answers" link like the other cards: answered_count here counts only the answers that parse as numbers, so it would undercount the list it opened @@ -11,9 +14,15 @@ <% if report.answered_count.zero? %>

No responses yet.

<% else %> + <% if report.percent %> +
+
+
+ <% end %> +
-
<%= number_with_delimiter(report.average.round(decimals)) %>
+
<%= number_with_delimiter(report.average.round(decimals)) %><%= suffix %>
Average
@@ -27,7 +36,7 @@

- Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %> + Range <%= number_with_delimiter(report.minimum.round(decimals)) %>–<%= number_with_delimiter(report.maximum.round(decimals)) %><%= suffix %>

<% end %>
diff --git a/config/features.yml b/config/features.yml index e8e2376839..e3e1808777 100644 --- a/config/features.yml +++ b/config/features.yml @@ -3311,3 +3311,15 @@ Consultant. Every other event keeps the standard certificate of completion. action_path: /events pr_number: 2504 +- name: "Slider questions on forms" + area: registration + display_status: admin_facing + released_on: 2026-08-31 + pr_number: 2462 + summary: >- + The form builder has a new "Slider (0–100)" question type — a drag-to-set + percentage slider, for questions like "what percentage of your workshops are + one-on-one?" The picked number rides in a bubble above the handle as the + respondent drags. + pro_tips: + - "Pick it from a question's type dropdown, same as radio or checkbox. It stores a whole number from 0 to 100." diff --git a/db/seeds/dev/annual_evaluation.rb b/db/seeds/dev/annual_evaluation.rb index 3a23e3822c..faa800b787 100644 --- a/db/seeds/dev/annual_evaluation.rb +++ b/db/seeds/dev/annual_evaluation.rb @@ -5,10 +5,9 @@ # participant demographics, program impact on you, spread-the-word), plus ~20 # varied submissions so /forms/:id/results has a real report to show. # -# Percentage and count questions are number fields (input_type number_integer), -# so the results page rolls them up as average/total/responses. Once the slider -# answer type ships, the percentage questions can switch to sliders — the stored -# values and the rollup are identical either way. +# Percentage questions are sliders and the yearly-reach counts are number fields; +# both store a whole number, so the results page rolls them up the same way +# (average / total / responses), with percentages headlined as a %. # # Idempotent: the form is looked up by slug, fields by name, submissions by # (form, person), and each answer is skipped when already present. Distributions @@ -63,6 +62,11 @@ def ae_number!(form, name, **opts) ae_field!(form, name, :free_form_input_one_line, input_type: :number_integer, **opts) end +# A 0-100 percentage slider (the slider shows its own 0/50/100 scale). +def ae_slider!(form, name, **opts) + ae_field!(form, name, :slider, **opts) +end + # ── About you ──────────────────────────────────────────────────────────────── # First/last name and primary email are the person-identity fields: they save to # the Person (by field_identifier) and are logged_out_only, so once prefill lands @@ -108,8 +112,8 @@ def ae_number!(form, name, **opts) ae_field!(form, "If you offer outside the US, please share which countries", :free_form_input_one_line, width: :half) other_language = ae_field!(form, "Do you facilitate art workshops in languages other than English?", :single_select_radio, required: true, width: :half, options: YES_NO) ae_field!(form, "If you offer in other languages, please share which ones", :free_form_input_one_line, width: :half) -pct_one_on_one = ae_number!(form, "What percentage of your art workshops are one-on-one?", hint_text: "0–100%", width: :half) -pct_groups = ae_number!(form, "What percentage of your art workshops are with groups?", hint_text: "0–100%", width: :half) +pct_one_on_one = ae_slider!(form, "What percentage of your art workshops are one-on-one?", width: :half) +pct_groups = ae_slider!(form, "What percentage of your art workshops are with groups?", width: :half) ae_field!(form, "How else do you use art beyond direct client work?", :multi_select_checkbox, subtitle: "Select all that apply.", options: [ @@ -128,7 +132,7 @@ def ae_number!(form, name, **opts) reach_teens = ae_number!(form, "Teens (ages 13-17)", required: true, width: :quarter) reach_adults = ae_number!(form, "Adults (age 18-64)", required: true, width: :quarter) reach_elders = ae_number!(form, "Elders (age 65+)", required: true, width: :quarter) -pct_families = ae_number!(form, "What percentage of participants consists of families of two or more individuals?", required: true, +pct_families = ae_slider!(form, "What percentage of participants consists of families of two or more individuals?", required: true, subtitle: "Family relationships can include parents, children, siblings, grandparents, aunts, uncles, and more.") # Dynamic field: options come from the AgeRange categories and the answer resolves # to the facilitator's primary age group, exactly like the registration form. @@ -150,28 +154,28 @@ def ae_number!(form, name, **opts) # ── Your art workshop participants (ethnicity) ─────────────────────────────── ae_header!(form, "Your art workshop participants", subtitle: "What percentage of your participants are from the following ethnic backgrounds? Your total allocation across all ethnic backgrounds should add up to 100%. Please use your best estimations.") -pct_alaskan = ae_number!(form, "What percentage of your participants are Alaskan Native?", hint_text: "0–100%", width: :third) -ae_number!(form, "What percentage of your participants are American Indian or Native American?", hint_text: "0–100%", width: :third) -pct_asian = ae_number!(form, "What percentage of your participants are Asian?", hint_text: "0–100%", width: :third) -pct_black = ae_number!(form, "What percentage of your participants are Black or African American?", hint_text: "0–100%", width: :third) -pct_latinx = ae_number!(form, "What percentage of your participants are Latinx?", hint_text: "0–100%", width: :third) -ae_number!(form, "What percentage of your participants are Middle Eastern?", hint_text: "0–100%", width: :third) -ae_number!(form, "What percentage of your participants are multi-racial?", hint_text: "0–100%", width: :third) -ae_number!(form, "What percentage of your participants are Native Hawaiian or other Pacific Islander?", hint_text: "0–100%", width: :third) -pct_white = ae_number!(form, "What percentage of your participants are White?", hint_text: "0–100%", width: :third) -ae_number!(form, "What percentage of your participants are of an ethnicity not listed above?", hint_text: "0–100%", width: :half) +pct_alaskan = ae_slider!(form, "What percentage of your participants are Alaskan Native?", width: :third) +ae_slider!(form, "What percentage of your participants are American Indian or Native American?", width: :third) +pct_asian = ae_slider!(form, "What percentage of your participants are Asian?", width: :third) +pct_black = ae_slider!(form, "What percentage of your participants are Black or African American?", width: :third) +pct_latinx = ae_slider!(form, "What percentage of your participants are Latinx?", width: :third) +ae_slider!(form, "What percentage of your participants are Middle Eastern?", width: :third) +ae_slider!(form, "What percentage of your participants are multi-racial?", width: :third) +ae_slider!(form, "What percentage of your participants are Native Hawaiian or other Pacific Islander?", width: :third) +pct_white = ae_slider!(form, "What percentage of your participants are White?", width: :third) +ae_slider!(form, "What percentage of your participants are of an ethnicity not listed above?", width: :half) ae_field!(form, "For participants whose ethnic identities are not listed above, please specify their ethnicities", :free_form_input_one_line, width: :half) -pct_poverty = ae_number!(form, "What percentage of your participants are at or below the Federal Poverty Line?", +pct_poverty = ae_slider!(form, "What percentage of your participants are at or below the Federal Poverty Line?", hint_text: "See the current Federal Poverty Level Guidelines. 0–100%") # ── Participant gender identity ────────────────────────────────────────────── ae_header!(form, "Participant gender identity", subtitle: "What percentage of your participants identify as the following? Please use your best estimations.") -pct_female = ae_number!(form, "What percentage of your participants identify as female?", hint_text: "0–100%", width: :quarter) -pct_male = ae_number!(form, "What percentage of your participants identify as male?", hint_text: "0–100%", width: :quarter) -ae_number!(form, "What percentage of your participants identify as non-binary?", hint_text: "0–100%", width: :quarter) -ae_number!(form, "What percentage of your participants identify as transgender?", hint_text: "0–100%", width: :quarter) -ae_number!(form, "What percentage of your participants have a gender identity not listed above?", hint_text: "0–100%", width: :half) +pct_female = ae_slider!(form, "What percentage of your participants identify as female?", width: :quarter) +pct_male = ae_slider!(form, "What percentage of your participants identify as male?", width: :quarter) +ae_slider!(form, "What percentage of your participants identify as non-binary?", width: :quarter) +ae_slider!(form, "What percentage of your participants identify as transgender?", width: :quarter) +ae_slider!(form, "What percentage of your participants have a gender identity not listed above?", width: :half) ae_field!(form, "For participants whose gender identities are not listed above, please specify how they identify", :free_form_input_one_line, width: :half) # ── Program impact on you ──────────────────────────────────────────────────── diff --git a/db/seeds/dev/public_forms.rb b/db/seeds/dev/public_forms.rb index eea7d02a54..5183bd4f84 100644 --- a/db/seeds/dev/public_forms.rb +++ b/db/seeds/dev/public_forms.rb @@ -18,14 +18,15 @@ header: "Interested in volunteering with A Window Between Worlds? Tell us a little about yourself.", questions: [ { name: "Why do you want to volunteer with us?", answer_type: :free_form_input_paragraph }, - { name: "What days are you generally available?", answer_type: :free_form_input_one_line } + { name: "What days are you generally available?", answer_type: :free_form_input_one_line }, + { name: "How familiar are you with AWBW's workshop approach?", answer_type: :slider } ], submissions: [ { first_name: "Dana", last_name: "Volunteer", email: "dana.volunteer@example.com", answers: [ "I run art workshops for teens and want to bring AWBW's approach to my community.", - "Weekday evenings and Saturdays" ] }, + "Weekday evenings and Saturdays", "85" ] }, { first_name: "Emil", last_name: "Helper", email: "emil.helper@example.com", - answers: [ "I'm a retired social worker looking to give back.", "Weekday mornings" ] } + answers: [ "I'm a retired social worker looking to give back.", "Weekday mornings", "40" ] } ] }, { diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 71fb826f20..b3bd0a84b3 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -115,11 +115,59 @@ end end + describe "slider answer type" do + let(:form) { create(:form) } + let(:field) { build(:form_field, form: form, answer_type: :slider) } + + it "collects input but is not selectable or 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" do + expect(field.answer_type_label).to eq("Slider (0–100)") + end + + describe "#slider_range_error" do + it "is nil for a blank value (left to the required check)" do + expect(field.slider_range_error("")).to be_nil + expect(field.slider_range_error(nil)).to be_nil + end + + it "is nil for a whole number within bounds" do + expect(field.slider_range_error("0")).to be_nil + expect(field.slider_range_error("50")).to be_nil + expect(field.slider_range_error("100")).to be_nil + end + + it "flags a value outside the bounds" do + expect(field.slider_range_error("101")).to eq("must be a whole number between 0 and 100") + expect(field.slider_range_error("-1")).to eq("must be a whole number between 0 and 100") + end + + it "flags a non-integer value" do + expect(field.slider_range_error("40.5")).to eq("must be a whole number between 0 and 100") + expect(field.slider_range_error("lots")).to eq("must be a whole number between 0 and 100") + end + + it "flags a hand-crafted literal the range input can't produce" do + expect(field.slider_range_error("0x40")).to eq("must be a whole number between 0 and 100") + expect(field.slider_range_error("1_0")).to eq("must be a whole number between 0 and 100") + end + + it "does not apply to non-slider fields" do + text = build(:form_field, form: form, answer_type: :free_form_input_one_line) + expect(text.slider_range_error("101")).to be_nil + end + end + end + describe 'enums' do 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, :file_upload ]) } + :group_header, :single_select_dropdown, :file_upload, :slider ]) } it { should define_enum_for(:input_type).with_values([ :text_alphanumeric, :number_integer, :number_decimal, :date ]) } end diff --git a/spec/requests/public_forms_spec.rb b/spec/requests/public_forms_spec.rb index b7d088415d..f5afed53d3 100644 --- a/spec/requests/public_forms_spec.rb +++ b/spec/requests/public_forms_spec.rb @@ -121,6 +121,41 @@ def submission_params(first: "Sam", last: "Rivera", email: "sam@example.com") end end + describe "slider fields" do + let!(:slider_field) do + create(:form_field, form: form, name: "What percentage are one-on-one?", answer_type: :slider, required: false) + end + + def slider_params(value) + params = submission_params + params[:public_registration][:form_fields][slider_field.id.to_s] = value + params + end + + it "renders a range input for a slider question" do + get public_form_path(form.slug) + + expect(response).to have_http_status(:ok) + expect(response.body).to include('data-controller="slider"') + expect(response.body).to include('type="range"') + end + + it "persists a valid slider value" do + post public_form_path(form.slug), params: slider_params("40") + + answer = FormAnswer.find_by(form_field: slider_field) + expect(answer.submitted_answer).to eq("40") + end + + it "rejects an out-of-range slider value" do + expect { post public_form_path(form.slug), params: slider_params("150") } + .not_to change(FormSubmission, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("between 0 and 100") + end + end + describe "GET /f/:slug/thank-you" do it "renders a confirmation" do get thank_you_public_form_path(form.slug) diff --git a/spec/services/form_answer_validator_spec.rb b/spec/services/form_answer_validator_spec.rb index 65a9d0eba7..7337301003 100644 --- a/spec/services/form_answer_validator_spec.rb +++ b/spec/services/form_answer_validator_spec.rb @@ -47,6 +47,22 @@ def validate(field, value) end end + describe "slider range" do + let(:field) { create(:form_field, form: form, answer_type: :slider, required: false) } + + it "accepts a whole number within bounds" do + expect(validate(field, "75")).to eq({}) + end + + it "rejects a value outside the bounds" do + expect(validate(field, "150")).to eq(field.id => "must be a whole number between 0 and 100") + end + + it "rejects a non-integer value" do + expect(validate(field, "50.5")).to eq(field.id => "must be a whole number between 0 and 100") + end + end + describe "email format" do let(:field) { create(:form_field, form: form, field_identifier: "primary_email") } diff --git a/spec/services/form_response_aggregator_spec.rb b/spec/services/form_response_aggregator_spec.rb index 1f3ee0b933..a7203decfa 100644 --- a/spec/services/form_response_aggregator_spec.rb +++ b/spec/services/form_response_aggregator_spec.rb @@ -157,6 +157,21 @@ def number_field(name) answer_type: :free_form_input_one_line, input_type: :number_integer) end + it "averages a slider question as a percentage" do + field = create(:form_field, form: form, name: "% one-on-one", answer_type: :slider) + answer(create(:form_submission, form: form), field, "20") + answer(create(:form_submission, form: form), field, "40") + answer(create(:form_submission, form: form), field, "60") + + report = described_class.new(form).field_reports.first + + expect(report.kind).to eq(:number) + expect(report.answered_count).to eq(3) + expect(report.total).to eq(120) + expect(report.average).to eq(40.0) + expect(report.percent).to be(true) + end + it "averages and totals a whole-number field as an open count" do field = number_field("Adults served") answer(create(:form_submission, form: form), field, "10") @@ -171,6 +186,7 @@ def number_field(name) expect(report.minimum).to eq(10) expect(report.maximum).to eq(25) expect(report.integer_valued).to be(true) + expect(report.percent).to be(false) end it "drops blank and non-numeric answers from the figures" do