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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
55 changes: 55 additions & 0 deletions app/frontend/javascript/controllers/slider_controller.js
Original file line number Diff line number Diff line change
@@ -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}%)`
}
}
26 changes: 25 additions & 1 deletion app/models/form_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
2 changes: 2 additions & 0 deletions app/services/form_answer_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions app/services/form_response_aggregator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
41 changes: 35 additions & 6 deletions app/views/events/public_registrations/_form_field.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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). %>
<div class="pt-7" data-controller="slider"
data-slider-min-value="<%= FormField::SLIDER_MIN %>"
data-slider-max-value="<%= FormField::SLIDER_MAX %>">
<input type="hidden" name="<%= field_name %>" id="<%= field_id %>" value="<%= value %>" data-slider-target="value">
<div class="relative">
<output data-slider-target="bubble"
class="pointer-events-none absolute -top-7 -translate-x-1/2 rounded-md bg-blue-600 px-2 py-0.5 text-xs font-semibold text-white shadow-sm <%= "hidden" if value.blank? %>"></output>
<input type="range"
min="<%= FormField::SLIDER_MIN %>"
max="<%= FormField::SLIDER_MAX %>"
step="<%= FormField::SLIDER_STEP %>"
value="<%= value.presence || FormField::SLIDER_MIN %>"
data-slider-target="range"
data-action="input->slider#update"
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
aria-label="<%= strip_tags(label || field.name) %>">
</div>
<div class="mt-1 flex justify-between text-xs text-gray-400">
<span><%= FormField::SLIDER_MIN %></span>
<span><%= (FormField::SLIDER_MIN + FormField::SLIDER_MAX) / 2 %></span>
<span><%= FormField::SLIDER_MAX %></span>
</div>
</div>

<% 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
Expand Down Expand Up @@ -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? %>>
<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"
class="hidden mt-3 max-h-48 w-auto rounded-lg border border-gray-200 shadow-sm"
class="mt-3 hidden max-h-48 w-auto rounded-lg border border-gray-200 shadow-sm"
alt="Selected file preview">
</div>

<% end %>

<% if field.hint_text.present? %>
<p class="rich-label text-xs text-gray-500 mt-1.5"><%= form_label_html(field.hint_text) %></p>
<p class="rich-label mt-1.5 text-xs text-gray-500"><%= form_label_html(field.hint_text) %></p>
<% end %>

<% if field.free_form_text? && field.min_words.to_i.positive? %>
<p class="text-left text-xs text-gray-500 mt-1.5">Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.</p>
<p class="mt-1.5 text-left text-xs text-gray-500">Minimum of <%= field.min_words %> <%= "word".pluralize(field.min_words) %>.</p>
<% end %>

<% if field.free_form_text? && field.max_characters.to_i.positive? %>
<p class="text-left text-xs text-gray-500 mt-1.5">Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.</p>
<p class="mt-1.5 text-left text-xs text-gray-500">Maximum of <%= field.max_characters %> <%= "character".pluralize(field.max_characters) %>.</p>
<% end %>

<% if error %>
<p class="flex items-center gap-1.5 text-xs text-red-600 mt-1.5">
<p class="mt-1.5 flex items-center gap-1.5 text-xs text-red-600">
<i class="fa-solid fa-circle-exclamation"></i><%= strip_tags(field.name) %> <%= error %>
</p>
<% 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 @@ -68,7 +68,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 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,
Expand Down
17 changes: 13 additions & 4 deletions app/views/forms/_numeric_card.html.erb
Original file line number Diff line number Diff line change
@@ -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 ? "%" : "" %>
<div class="border-primary/10 rounded-2xl border bg-white p-5 shadow-sm">
<%# 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
Expand All @@ -11,9 +14,15 @@
<% if report.answered_count.zero? %>
<p class="mt-3 text-sm text-gray-400">No responses yet.</p>
<% else %>
<% if report.percent %>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div class="h-2 rounded-full <%= DomainTheme.bg_class_for(:forms, intensity: 500) %>" style="width: <%= report.average.round.clamp(0, 100) %>%"></div>
</div>
<% end %>

<div class="mt-4 grid grid-cols-3 gap-3 text-center">
<div>
<div class="text-2xl font-bold text-gray-900 tabular-nums"><%= number_with_delimiter(report.average.round(decimals)) %></div>
<div class="text-2xl font-bold text-gray-900 tabular-nums"><%= number_with_delimiter(report.average.round(decimals)) %><%= suffix %></div>
<div class="text-xs font-semibold tracking-wide text-gray-500 uppercase">Average</div>
</div>
<div>
Expand All @@ -27,7 +36,7 @@
</div>

<p class="mt-3 text-center text-xs text-gray-400">
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 %>
</p>
<% end %>
</div>
12 changes: 12 additions & 0 deletions config/features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Loading