Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
52a1488
docs(casadesign): open part 2 with the follow-ups carried over from #…
Jul 29, 2026
13e959c
fix(casadesign): autosave the whole form, not three fields
Jul 29, 2026
067bb85
fix(mileage rates): repair the 500 on edit and scope the record to th…
Jul 29, 2026
02b134e
fix(a11y): clear every WCAG violation found by the whole-app axe sweep
Jul 29, 2026
798064b
fix(a11y): clear the violations that only exist below the md/lg break…
Jul 29, 2026
e24856b
test(bulk court dates): remove the navigation race that made new_spec…
Jul 29, 2026
3fc2462
test: fix the remaining system-spec flakes and stop swallowing transi…
Jul 29, 2026
79ab5a3
fix(a11y): banner icon contrast, and auto-dismiss success flashes
Jul 29, 2026
295652a
refactor(supervisors): count badges -> right-aligned numeric columns
Jul 29, 2026
812cc31
fix(supervisors): drop the rose numerals and give the drill-through a…
Jul 29, 2026
546e0b4
refactor(supervisors): one numeral style, drill-through as a row action
Jul 29, 2026
b446077
refactor(dashboard): needs-attention rows become a divided list, not …
Jul 29, 2026
a0ad85b
refactor(dashboard): needs-attention becomes a real table (list was 7…
Jul 29, 2026
821d9ab
fix(tables): one rule above the column headers, and use the link helpers
Jul 30, 2026
c126cbe
fix(banners): show and fix the active state, instead of failing silently
Jul 30, 2026
aa37485
fix(tables): converge every table on the documented separator tokens
Jul 30, 2026
437c475
fix(filters): search as you type, and keep the caret across the submit
Jul 30, 2026
e8131e1
fix(multiselect): clear the query when an item is picked
Jul 30, 2026
ad0ff48
fix(contact types): drop the "never" subtext from the case-edit options
Jul 30, 2026
a9b3e0d
refactor(court orders): move copy-from-case into its Dialog, drop the…
Jul 30, 2026
8563e02
fix(court orders): real labels on the entry row, and focus the row yo…
Jul 30, 2026
341abb1
fix(court orders): stack the entry per "Form layout", drop the per-en…
Jul 30, 2026
0c59bb9
fix(court orders): centre Delete, drop the duplicate divider, make th…
Jul 30, 2026
9f8f7dd
feat(learning hours): bound the roster total by a date range, and say…
Jul 30, 2026
7375a85
fix(casa cases): drop the rose outline on the deactivate card
Jul 30, 2026
11cb9b6
fix(casa cases): fix the hanging colon and the type mixture in the vo…
Jul 30, 2026
8d3e797
fix(assignments): legible sizes in the volunteer card, and typeaheads…
Jul 30, 2026
df1d93b
fix(filters): make the volunteer and supervisor filter selects search…
Jul 30, 2026
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
36 changes: 34 additions & 2 deletions app/assets/stylesheets/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@
TomSelect's opaque `.ts-control` paints over a plain `::after` (that stacking, not a
missing rule, is why every earlier chevron was invisible). base64 is minifier- and
parser-safe; a raw `data:` URI and a `content` glyph escape both broke in the build, and
the injected icon-font element never painted. Pixel-verified to match the single-selects
(identical 8x4 dark extent), not just checked by computed style. */
the injected icon-font element never painted. Pixel-verified against a native single-select's
`<i class="bi bi-chevron-down text-xs">` in the SAME screenshot: identical 10x6 ink extent,
20 ink pixels, mean luminance 251.4 vs 251.2. Compare the SHAPE (ink bbox + coverage), not the
darkest pixel -- darkest-pixel alone matched while the region actually held the clear x. */
.ts-wrapper::after {
content: "";
position: absolute;
Expand Down Expand Up @@ -126,6 +128,24 @@
.ts-wrapper .clear-button:hover { color: #475569 !important; }
.ts-wrapper .clear-button:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; border-radius: 0.25rem; } /* it is role=button tabindex=0, so it needs a visible focus ring */
.ts-dropdown .option[data-value=""] { display: none; }
/* In a filter bar the control sits in a row of native `py-2` selects and date inputs, which are 38px,
not the 42px form-input height -- so a bare .ts-control stood 4px taller than every field beside it
(bottoms aligned by `items-end`, tops 4px out). `ts-filter` on the <select> lands on .ts-wrapper
(TomSelect copies the select's classes onto it) and matches the bar. */
.ts-wrapper.ts-filter .ts-control { min-height: 2.375rem; }
/* A filter picker ALWAYS carries a value (the "All ..." row is a real option), so it is permanently
`.has-items`: the clear-button rules below would swap its chevron for an x on every hover/focus,
while the native selects beside it keep theirs -- and clearing "All supervisors" means nothing. The
reset is the "All ..." row itself, so drop the x and keep the caret. */
.ts-wrapper.ts-filter .clear-button { display: none !important; }
.ts-wrapper.ts-filter.has-items.focus::after,
.ts-wrapper.ts-filter.has-items:hover::after { opacity: 1; }
/* Disabled control: mirrors the native inputs' `disabled:bg-slate-100 disabled:text-slate-500`.
TomSelect adds `.disabled` to the wrapper when the <select> carries the attribute, but ships no
styling for it, so an empty-state picker looked live (a disabled ancestor fieldset makes it inert
without changing how it reads). */
.ts-wrapper.disabled .ts-control { background-color: #f1f5f9 !important; color: #64748b; }
.ts-wrapper.disabled::after { opacity: 0.5; }
/* Chips. tom-select's default theme styles these at .ts-wrapper.multi specificity
(grey), so match that specificity and use !important where tom-select does. */
.ts-wrapper.multi .ts-control > .item {
Expand Down Expand Up @@ -227,3 +247,15 @@ dialog[data-modal-target="dialog"] {
@media (max-width: 640px) {
dialog[data-modal-target="dialog"] { top: 18vh; }
}

/* ------------------------------------------------------------------
Trix toolbar. Trix ships `.trix-button-row { flex-wrap: nowrap; overflow-x: auto }`,
a scrollable region that is not itself focusable -- axe's
scrollable-region-focusable (serious) flags it, and on narrow screens the
buttons hide behind a scrollbar-less horizontal scroll. Let the row wrap
instead: no scroll container, so every button stays reachable.
------------------------------------------------------------------ */
trix-toolbar .trix-button-row {
flex-wrap: wrap;
overflow-x: visible;
}
5 changes: 4 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ def not_authorized
end
format.any do
session[:user_return_to] = nil
flash[:notice] = message
# :alert, not :notice. An authorization failure is not a success, and shared/_flashes maps
# :notice to the green success variant -- so this rendered a permission error as a success,
# and success messages now auto-dismiss, which would have quietly thrown it away.
flash[:alert] = message
redirect_to(root_url)
end
end
Expand Down
12 changes: 10 additions & 2 deletions app/controllers/banners_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def create
@banner.save!
end

redirect_to banners_path
redirect_to banners_path, **banner_created_flash
rescue
render :new, status: :unprocessable_content
end
Expand All @@ -49,7 +49,7 @@ def update
@banner.update!(banner_params)
end

redirect_to banners_path
redirect_to banners_path, **banner_created_flash(verb: "updated")
rescue
render :edit, status: :unprocessable_content
end
Expand All @@ -71,6 +71,14 @@ def banner_params
BannerParameters.new(params, current_user, browser_time_zone)
end

def banner_created_flash(verb: "created")
if @banner.active?
{notice: "Banner #{verb} and is now showing at the top of every page."}
else
{alert: "Banner #{verb}, but it is not active, so no one will see it yet. Use Activate to show it."}
end
end

def deactivate_alternate_active_banner
if banner_params[:active].to_i == 1
alternate_active_banner = current_organization.banners.where(active: true).where.not(id: @banner.id).first
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/casa_cases_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def set_casa_case
@casa_case = current_organization.casa_cases.friendly.find(params[:id])
rescue ActiveRecord::RecordNotFound
respond_to do |format|
format.html { redirect_to casa_cases_path, notice: "Sorry, you are not authorized to perform this action." }
format.html { redirect_to casa_cases_path, alert: "Sorry, you are not authorized to perform this action." }
format.json { render json: {error: "Sorry, you are not authorized to perform this action."}, status: :not_found }
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/court_dates_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def set_casa_case

def not_authorized_redirect
respond_to do |format|
format.html { redirect_to casa_cases_path, notice: "Sorry, you are not authorized to perform this action." }
format.html { redirect_to casa_cases_path, alert: "Sorry, you are not authorized to perform this action." }
format.json { render json: {error: "Sorry, you are not authorized to perform this action."}, status: :unauthorized }
format.any { head :not_found }
end
Expand Down
26 changes: 25 additions & 1 deletion app/controllers/learning_hours_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ class LearningHoursController < ApplicationController

def index
authorize LearningHour
set_period
rows = LearningHoursDashboardRowsService
.new(current_user, policy_scope(LearningHour))
.new(current_user, policy_scope(LearningHour), @period)
.perform

if current_user.volunteer?
Expand Down Expand Up @@ -78,6 +79,29 @@ def destroy

private

# The roster column is bounded by a date range the user can change. It defaults to the calendar
# year to date, which is what the column header always claimed ("Time completed this year") even
# though nothing filtered on occurred_at, so the totals were all-time.
# Learning hours cannot occur before 1989-01-01 (LearningHour validates that) or in the future, so
# clamp to that window: a hand-edited or mistyped param would otherwise put a nonsense year in the
# column header ("since February 2, 0730" -- Date.parse happily accepts it).
PERIOD_FLOOR = Date.new(1989, 1, 1)

def set_period
@period_from = parse_date(params[:from]) || Date.current.beginning_of_year
@period_to = parse_date(params[:to]) || Date.current
@period_from, @period_to = @period_to, @period_from if @period_from > @period_to
@period_from = @period_from.clamp(PERIOD_FLOOR, Date.current)
@period_to = @period_to.clamp(PERIOD_FLOOR, Date.current)
@period = @period_from..@period_to
end

def parse_date(value)
Date.parse(value.to_s)
rescue Date::Error
nil
end

def set_learning_hour
@learning_hour = LearningHour.find(params[:id])
rescue ActiveRecord::RecordNotFound
Expand Down
12 changes: 7 additions & 5 deletions app/controllers/mileage_rates_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ def index
end

def new
authorize CasaAdmin
@mileage_rate = current_organization.mileage_rates.build
authorize @mileage_rate
end

def create
authorize CasaAdmin
@mileage_rate = MileageRate.new(mileage_rate_params.merge(casa_org: current_organization))
authorize @mileage_rate
if @mileage_rate.save
redirect_to mileage_rates_path
else
Expand All @@ -26,11 +26,11 @@ def create
end

def edit
authorize CasaAdmin
authorize @mileage_rate
end

def update
authorize CasaAdmin
authorize @mileage_rate

if @mileage_rate.update(mileage_rate_params)
redirect_to mileage_rates_path
Expand All @@ -45,7 +45,9 @@ def mileage_rate_params
params.require(:mileage_rate).permit(:effective_date, :amount, :is_active)
end

# Scoped to the org: an unscoped find let an admin reach another org's rate, and a 404 is the
# right answer there rather than leaving it to the policy alone.
def set_mileage_rate
@mileage_rate = MileageRate.find(params[:id])
@mileage_rate = current_organization.mileage_rates.find(params[:id])
end
end
6 changes: 5 additions & 1 deletion app/controllers/reimbursements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ def change_complete_status
private

def apply_filters_to_query(query)
query = query.where(creator_id: params[:volunteers]) if params[:volunteers].present?
# "all" is the filter bar's explicit no-filter value. The volunteer picker is a searchable select
# and the theme hides empty-valued options in its menu, so the "All volunteers" row cannot carry
# value="" -- it would be missing from the menu the user opens to clear the filter.
volunteer = params[:volunteers].presence
query = query.where(creator_id: volunteer) if volunteer && volunteer != "all"

apply_occurred_at_filters(query)
end
Expand Down
29 changes: 16 additions & 13 deletions app/decorators/contact_type_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ class ContactTypeDecorator < Draper::Decorator
delegate_all

def hash_for_multi_select_with_cases(casa_case_ids)
if casa_case_ids.nil?
casa_case_ids = []
end

{value: object.id, text: object.name, group: object.contact_type_group.name, subtext: last_time_used_with_cases(casa_case_ids)}
end

def last_time_used_with_cases(casa_case_ids)
last_contact = last_contact_with_cases(casa_case_ids)

last_contact&.occurred_at.blank? ? "never" : "#{time_ago_in_words(last_contact.occurred_at)} ago"
casa_case_ids = [] if casa_case_ids.nil?

{
value: object.id,
text: object.name,
group: object.contact_type_group.name,
# Same recency hint the checkbox form uses, so a type that has never been logged shows no
# subtext instead of a bare "never" beside every option. `.to_s`, not the bare nil: the option
# template substitutes this through TomSelect's escape(), which turns nil into the literal
# string "null".
subtext: last_logged_hint_with_cases(casa_case_ids).to_s
}
end

# Labeled recency hint for the contact-type checkboxes. Returns nil when this type has never
# been logged for the case(s) so the form can omit the line rather than show a bare "never".
# Labeled recency hint. Returns nil when this type has never been logged for the case(s) so the
# caller can omit the line rather than show a bare "never". Used by both the contact-type
# checkboxes and the multi-select options -- they had diverged, which is how "never" survived on
# casa_cases#edit after being removed elsewhere.
def last_logged_hint_with_cases(casa_case_ids)
last_contact = last_contact_with_cases(casa_case_ids)
return if last_contact&.occurred_at.blank?
Expand Down
4 changes: 2 additions & 2 deletions app/helpers/metrics_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def metric_data_table(labels, series, caption:, foot: nil, footnote: nil)
tag.details(class: "mt-3 border-t border-slate-100 pt-2.5") do
safe_join([
tag.summary("View as table", class: "w-max cursor-pointer text-[13px] font-medium text-brand-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"),
tag.div(table, class: "mt-2.5 overflow-x-auto"),
tag.div(table, class: "mt-2.5 overflow-x-auto", tabindex: 0),
(footnote ? tag.p(footnote, class: "mt-2 text-[11px] text-slate-500") : "".html_safe)
])
end
Expand All @@ -102,7 +102,7 @@ def metric_heatmap(grid, max)
end
tag.tr(safe_join([tag.th(day, scope: "row", class: "sticky left-0 z-10 bg-white px-1 py-1 pr-2.5 text-right text-[11px] font-semibold text-slate-700")] + cells))
end
tag.div(class: "overflow-x-auto") do
tag.div(class: "overflow-x-auto", tabindex: 0) do
tag.table(class: "border-collapse") do
safe_join([
tag.caption("Case contacts created by day of week (rows) and hour of day (columns, 0 to 23). Cell shade and number both encode the count.", class: "sr-only"),
Expand Down
8 changes: 4 additions & 4 deletions app/javascript/__tests__/case_emancipations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ beforeEach(() => {
<div class="card card-container">
<div class="card-body">
<div>
<h6 class="emancipation-category no-select" id="category-under-test" data-is-open='false'>
<h2 class="emancipation-category no-select" id="category-under-test" data-is-open='false'>
<input type="checkbox" class="emancipation-category-check-box" value="1">
<label>Youth has housing.</label>
<span class="category-collapse-icon" id="icon-under-test">+</span>
</h6>
</h2>
<div
class="category-options"
id="category-options-under-test"
Expand All @@ -31,11 +31,11 @@ beforeEach(() => {
<label>With Friend</label>
</div>
</div>
<h6 class="emancipation-category no-select" id="category-control" data-is-open='false'>
<h2 class="emancipation-category no-select" id="category-control" data-is-open='false'>
<input type="checkbox" class="emancipation-category-check-box" value="1">
<label>Youth has housing.</label>
<span class="category-collapse-icon">+</span>
</h6>
</h2>
<div
class="category-options"
id="category-options-control"
Expand Down
59 changes: 59 additions & 0 deletions app/javascript/controllers/auto_dismiss_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Controller } from '@hotwired/stimulus'

// Auto-hides a transient status message. Applied to success flashes only (shared/_flashes):
// warnings and errors are left on screen, since they are often the only record of what went wrong
// and dismissing them for the user loses that.
//
// The timer pauses while the pointer is over the message or keyboard focus is inside it, so it
// cannot disappear mid-read -- that plus a delay well above a few seconds is what keeps an
// auto-hiding status message clear of WCAG 2.2.1. The message keeps its role="status", so screen
// readers announce it when it appears; removing it later is silent.
const FADE_MS = 300

export default class extends Controller {
static values = { delay: { type: Number, default: 6000 } }

connect () {
this.start()
}

disconnect () {
this.cancel()
}

start () {
this.timeout = setTimeout(() => this.hide(), this.delayValue)
}

cancel () {
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
}

// data-action hooks: hold the message while the user is reading or tabbing through it.
pause () {
this.cancel()
}

resume () {
this.cancel()
this.start()
}

hide () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.element.remove()
return
}

// Inline styles rather than a utility class: a class added from JS only works while Tailwind
// still emits it, and if it were ever dropped from the build the element would silently never
// fade *or* be removed. The removal is on a timer for the same reason -- transitionend can be
// missed, and the message must go either way.
this.element.style.transition = `opacity ${FADE_MS}ms`
this.element.style.opacity = '0'
setTimeout(() => this.element.remove(), FADE_MS + 50)
}
}
Loading
Loading