From cdfea5d831b285ba05079891d40b516ea452314a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 31 Aug 2026 15:51:17 -0400 Subject: [PATCH 1/6] =?UTF-8?q?Add=20a=20slider=20(0=E2=80=93100)=20form-f?= =?UTF-8?q?ield=20answer=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The annual evaluation asks many "what percentage…" questions that read best as a drag-to-set slider. The form builder had no numeric/slider input, so add one as a first-class answer type the seed and admins can use. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- app/frontend/javascript/controllers/index.js | 3 ++ .../controllers/slider_controller.js | 43 ++++++++++++++++++ app/models/form_field.rb | 24 +++++++++- app/services/form_answer_validator.rb | 2 + .../public_registrations/_form_field.html.erb | 36 +++++++++++++++ app/views/forms/_form_field_fields.html.erb | 2 +- config/features.yml | 11 +++++ spec/models/form_field_spec.rb | 45 ++++++++++++++++++- spec/requests/public_forms_spec.rb | 35 +++++++++++++++ spec/services/form_answer_validator_spec.rb | 16 +++++++ 11 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 app/frontend/javascript/controllers/slider_controller.js diff --git a/AGENTS.md b/AGENTS.md index d6546036aa..6a7e5565c2 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` — Keeps a slider form field's range handle and number box in step (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..a59abee87b --- /dev/null +++ b/app/frontend/javascript/controllers/slider_controller.js @@ -0,0 +1,43 @@ +import { Controller } from "@hotwired/stimulus" + +// Keeps a slider form field's range handle and its number box in step: dragging +// the handle fills the number box, typing a number moves the handle (clamped to +// the field's bounds), and the range track fills up to the current value. The +// number input carries the submitted value; the range is display/entry only. +export default class extends Controller { + static targets = ["range", "number"] + static values = { min: Number, max: Number } + + connect() { + // A prefilled/re-rendered value lives on the number box — mirror it onto the + // handle. An untouched field leaves the number box blank so "required" still + // catches a non-answer. + if (this.numberTarget.value !== "") this.rangeTarget.value = this.numberTarget.value + this.paint() + } + + rangeChanged() { + this.numberTarget.value = this.rangeTarget.value + this.paint() + } + + numberChanged() { + if (this.numberTarget.value === "") { + this.rangeTarget.value = this.minValue + this.paint() + return + } + const clamped = Math.min(Math.max(Number(this.numberTarget.value), this.minValue), this.maxValue) + this.numberTarget.value = clamped + this.rangeTarget.value = clamped + this.paint() + } + + // Fills the track from the left up to the handle, since native range inputs + // only color the thumb. + paint() { + const percent = ((this.rangeTarget.value - this.minValue) / (this.maxValue - this.minValue)) * 100 + this.rangeTarget.style.background = + `linear-gradient(to right, var(--color-primary, #2563eb) ${percent}%, #e5e7eb ${percent}%)` + } +} diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 2980a89531..bb84894358 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -141,9 +141,17 @@ 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 + enum :input_type, [ :text_alphanumeric, :number_integer, @@ -171,6 +179,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 +354,19 @@ 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? + + number = Integer(value.to_s, exception: false) + return if number && number.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/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index b9dd29d3b2..d3f681a77f 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -177,6 +177,42 @@ <% end %> <% end %> + <% when "slider" %> + <%# A 0–100 slider whose handle and a synced number box drive one hidden value. + The number box lets someone type an exact figure; the controller keeps the + two in step and stores a whole number. %> +
+
+ + > +
+
+ <%= 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 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/config/features.yml b/config/features.yml index e8e2376839..0099db38bc 100644 --- a/config/features.yml +++ b/config/features.yml @@ -3311,3 +3311,14 @@ 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 + 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/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 71fb826f20..7e7c6d077a 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -115,11 +115,54 @@ 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 "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..c0d456b929 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 and number 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") } From e6560dbd55c8750b039532f8c09e9befceea91d4 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 31 Aug 2026 15:52:26 -0400 Subject: [PATCH 2/6] Record slider feature PR number in the features catalog Co-Authored-By: Claude Opus 4.8 (1M context) --- config/features.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/features.yml b/config/features.yml index 0099db38bc..e3e1808777 100644 --- a/config/features.yml +++ b/config/features.yml @@ -3315,6 +3315,7 @@ 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 From e454e8f81c6549371b0eddb2da47be59677e4698 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 31 Aug 2026 16:07:20 -0400 Subject: [PATCH 3/6] Slider: drop the number box, show a live value bubble on the handle Dragging shows the picked number in a bubble above the handle instead of a separate number input; the value is written to a hidden field. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/slider_controller.js | 68 +++++++++++-------- .../public_registrations/_form_field.html.erb | 41 +++++------ 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/app/frontend/javascript/controllers/slider_controller.js b/app/frontend/javascript/controllers/slider_controller.js index a59abee87b..1c12bc8265 100644 --- a/app/frontend/javascript/controllers/slider_controller.js +++ b/app/frontend/javascript/controllers/slider_controller.js @@ -1,43 +1,55 @@ import { Controller } from "@hotwired/stimulus" -// Keeps a slider form field's range handle and its number box in step: dragging -// the handle fills the number box, typing a number moves the handle (clamped to -// the field's bounds), and the range track fills up to the current value. The -// number input carries the submitted value; the range is display/entry only. +// 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", "number"] + 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 number box — mirror it onto the - // handle. An untouched field leaves the number box blank so "required" still - // catches a non-answer. - if (this.numberTarget.value !== "") this.rangeTarget.value = this.numberTarget.value - this.paint() + // 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() + } } - rangeChanged() { - this.numberTarget.value = this.rangeTarget.value - this.paint() + update() { + this.valueTarget.value = this.rangeTarget.value + this.render() } - numberChanged() { - if (this.numberTarget.value === "") { - this.rangeTarget.value = this.minValue - this.paint() - return - } - const clamped = Math.min(Math.max(Number(this.numberTarget.value), this.minValue), this.maxValue) - this.numberTarget.value = clamped - this.rangeTarget.value = clamped - this.paint() + 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)` } - // Fills the track from the left up to the handle, since native range inputs - // only color the thumb. - paint() { - const percent = ((this.rangeTarget.value - this.minValue) / (this.maxValue - this.minValue)) * 100 + // 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) ${percent}%, #e5e7eb ${percent}%)` + `linear-gradient(to right, var(--color-primary, #2563eb) ${filled}%, #e5e7eb ${filled}%)` } } diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index d3f681a77f..274b05d30b 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -178,33 +178,26 @@ <% end %> <% when "slider" %> - <%# A 0–100 slider whose handle and a synced number box drive one hidden value. - The number box lets someone type an exact figure; the controller keeps the - two in step and stores a whole number. %> -