Filterable, lazy-loaded grants index + scholarship theming#1920
Filterable, lazy-loaded grants index + scholarship theming#1920maebeale wants to merge 1 commit into
Conversation
The grants index grew past a simple list: awarders need to find grants by name, donor, remaining funds, and task completion. Convert it to the codebase's standard filter + lazy-turbo-frame pattern so filtering happens server-side and the table reloads in place without a full navigation. - Filters (grant name, donor name across orgs/people, funds remaining, donor type, tasks completed) stack cleanly as no-ops when blank. - Funds/task scopes use flat WHERE/subqueries instead of GROUP BY so will_paginate's count stays correct on the paginated index. - table-sort uses strict Number() so ISO dates no longer collapse to a year and sort correctly via localeCompare. - Per-row Edit link and _top targets so row links break out of the frame. - Tasks-completed indicators and the grant scholarships card adopt the fuchsia scholarships theme; card links use the grey registrants style. - Recipients page links to the grants index and to each funding grant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // would collapse every date in a year to one value and leave date columns | ||
| // effectively unsorted. Number() rejects such strings (NaN) so ISO dates fall | ||
| // through to localeCompare, which orders them correctly since they're padded. | ||
| const numA = a === "" ? NaN : Number(a) |
There was a problem hiding this comment.
🤖 From Claude: Strict Number() instead of parseFloat(): parseFloat("2026-12-31") is 2026, which collapsed every date in a year to one value. Number() returns NaN for ISO date strings so they fall through to localeCompare, which orders zero-padded ISO dates correctly.
| scope :tasks_outstanding, -> { | ||
| where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) | ||
| } | ||
| scope :all_tasks_completed, -> { |
There was a problem hiding this comment.
🤖 From Claude: Both subqueries exclude grant_id IS NULL. Without that guard a grant-less, incomplete scholarship would put a NULL in the NOT IN set and make all_tasks_completed match nothing. Covered by a model spec.
| left_joins(:scholarships) | ||
| .group(:id) | ||
| .having("grants.amount_cents - COALESCE(SUM(scholarships.amount_cents), 0) > 0") | ||
| scope :with_funds_remaining, -> { where("grants.amount_cents > #{ALLOCATED_CENTS_SUBQUERY}") } |
There was a problem hiding this comment.
🤖 From Claude: Flat WHERE + correlated subquery (not GROUP BY/HAVING) so will_paginate's total_entries count stays correct on the paginated index.
| @grants = authorized_scope(Grant.all) | ||
| # The full page renders only the header, filters, and an empty results frame; | ||
| # the frame's src request (turbo_frame_request?) loads the filtered rows. | ||
| return render :index unless turbo_frame_request? |
There was a problem hiding this comment.
🤖 From Claude: Full page renders only header/filters/skeleton; the frame's src request (turbo_frame_request?) returns the filtered rows via index_lazy.
| // would collapse every date in a year to one value and leave date columns | ||
| // effectively unsorted. Number() rejects such strings (NaN) so ISO dates fall | ||
| // through to localeCompare, which orders them correctly since they're padded. | ||
| const numA = a === "" ? NaN : Number(a) |
There was a problem hiding this comment.
needed to update this bc it wasn't sorting dates correctly
There was a problem hiding this comment.
This controller is out of my league. Best option is manual testing in as many places as you can.
| @grants = authorized_scope(Grant.all) | ||
| # The full page renders only the header, filters, and an empty results frame; | ||
| # the frame's src request (turbo_frame_request?) loads the filtered rows. | ||
| return render :index unless turbo_frame_request? |
There was a problem hiding this comment.
This a just a nit, but the pattern we've been using has been
if turbo_frame_request?
render :grant_results
else
render :index
end
Functionally the same but I like keeping a consistent pattern. Not that it matters, but realistically the turbo frame path will be use predominately so having it first feels better.
| .by_deadline | ||
| .page(params[:page]) | ||
| track_index_intent(Grant, @grants, params) | ||
| render :index_lazy |
There was a problem hiding this comment.
Same idea with this on matching our existing patterns. Looks like this naming convention got introduction on a couple controllers at some point, however I think using the results naming convention that was established on Resource and Workshop makes a bit more sense. Yes, we are hitting the index action but we are really only updating the "results" section on the Index page when turbo_frame_request.
🤖 PR, suggested 👤 review level: 🔬 Inspect — substantive logic: new filter scopes, controller branching, and a lazy turbo-frame flow
What is the goal of this PR and why is this important?
How did you approach the change?
srcrequest returns the filtered, paginated rows so the table reloads in place.WHERE/correlated subqueries instead ofGROUP BY, sowill_paginate's count stays correct on the paginated index.table-sortswitched to strictNumber()so ISO dates sort correctly instead of collapsing every date in a year to one value.Anything else to add?
fully_issued,tasks_outstanding,all_tasks_completed) are NULL-safe for grant-less scholarships; covered by model + request specs.