-
Notifications
You must be signed in to change notification settings - Fork 24
Filterable, lazy-loaded grants index + scholarship theming #1920
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,11 +4,16 @@ class GrantsController < ApplicationController | |
|
|
||
| def index | ||
| authorize! | ||
| @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? | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This a just a nit, but the pattern we've been using has been 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. |
||
|
|
||
| @grants = filter_grants(authorized_scope(Grant.all)) | ||
| .includes(:donor, scholarships: { allocation: :allocatable }) | ||
| .by_deadline | ||
| .page(params[:page]) | ||
| track_index_intent(Grant, @grants, params) | ||
| render :index_lazy | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| end | ||
|
|
||
| def show | ||
|
|
@@ -75,6 +80,38 @@ def set_form_variables | |
|
|
||
| private | ||
|
|
||
| # Narrow the index by the optional filter inputs. Each filter is a no-op when | ||
| # its param is blank or unrecognized, so combinations stack cleanly. | ||
| def filter_grants(scope) | ||
| scope = scope.where("grants.name LIKE ?", "%#{Grant.sanitize_sql_like(params[:name])}%") if params[:name].present? | ||
| scope = filter_by_donor_name(scope, params[:donor_name]) if params[:donor_name].present? | ||
|
|
||
| scope = case params[:funds] | ||
| when "available" then scope.with_funds_remaining | ||
| when "none" then scope.fully_issued | ||
| else scope | ||
| end | ||
|
|
||
| scope = scope.where(donor_type: params[:donor_type]) if Grant::DONOR_TYPES.include?(params[:donor_type]) | ||
|
|
||
| case params[:tasks] | ||
| when "completed" then scope.all_tasks_completed | ||
| when "outstanding" then scope.tasks_outstanding | ||
| else scope | ||
| end | ||
| end | ||
|
|
||
| # Match grants whose polymorphic donor (Organization or Person) name contains | ||
| # the query. Resolve matching donor ids per type, then OR the two sides so the | ||
| # other active filters on `scope` apply to both. | ||
| def filter_by_donor_name(scope, query) | ||
| like = "%#{Grant.sanitize_sql_like(query)}%" | ||
| org_ids = Organization.where("name LIKE ?", like).pluck(:id) | ||
| person_ids = Person.where("first_name LIKE :q OR last_name LIKE :q OR CONCAT(first_name, ' ', last_name) LIKE :q", q: like).pluck(:id) | ||
| scope.where(donor_type: "Organization", donor_id: org_ids) | ||
| .or(scope.where(donor_type: "Person", donor_id: person_ids)) | ||
| end | ||
|
|
||
| def set_grant | ||
| @grant = Grant.find(params[:id]) | ||
| end | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,8 +45,12 @@ export default class extends Controller { | |
| compare(rowA, rowB, index, key) { | ||
| const a = this.cellValue(rowA, index, key) | ||
| const b = this.cellValue(rowB, index, key) | ||
| const numA = parseFloat(a) | ||
| const numB = parseFloat(b) | ||
| // Strict Number(), not parseFloat(): parseFloat("2026-12-31") is 2026, which | ||
| // 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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Strict
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. needed to update this bc it wasn't sorting dates correctly
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jmilljr24 you ok w this?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This controller is out of my league. Best option is manual testing in as many places as you can. |
||
| const numB = b === "" ? NaN : Number(b) | ||
| if (!isNaN(numA) && !isNaN(numB)) return numA - numB | ||
| return a.localeCompare(b) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,31 @@ class Grant < ApplicationRecord | |
|
|
||
| scope :by_deadline, -> { order(Arel.sql("application_deadline IS NULL, application_deadline ASC")) } | ||
|
|
||
| # Total scholarship draws against a grant, as a correlated subquery. Used by the | ||
| # funds scopes so they stay flat WHERE clauses β no GROUP BY/HAVING, which would | ||
| # break will_paginate's total_entries count on the paginated index. | ||
| ALLOCATED_CENTS_SUBQUERY = | ||
| "COALESCE((SELECT SUM(scholarships.amount_cents) FROM scholarships WHERE scholarships.grant_id = grants.id), 0)".freeze | ||
|
|
||
| # Grants that still have unallocated funds (donation amount exceeds the sum of | ||
| # scholarships drawn against them). | ||
| scope :with_funds_remaining, -> { | ||
| 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}") } | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Flat |
||
|
|
||
| # Grants whose full donation has been issued as scholarships (nothing left to | ||
| # award) β the complement of with_funds_remaining. | ||
| scope :fully_issued, -> { where("grants.amount_cents <= #{ALLOCATED_CENTS_SUBQUERY}") } | ||
|
|
||
| # Task-completion filters keyed off the grant's scholarships. "Outstanding" | ||
| # means at least one scholarship still has incomplete tasks; "all completed" | ||
| # means the grant has scholarships and none are outstanding. The subqueries | ||
| # exclude grant-less scholarships (grant_id IS NULL) β a stray NULL in the | ||
| # NOT IN set below would otherwise make all_tasks_completed match nothing. | ||
| scope :tasks_outstanding, -> { | ||
| where(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) | ||
| } | ||
| scope :all_tasks_completed, -> { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π€ From Claude: Both subqueries exclude |
||
| where(id: Scholarship.where.not(grant_id: nil).select(:grant_id)) | ||
| .where.not(id: Scholarship.where(tasks_completed: false).where.not(grant_id: nil).select(:grant_id)) | ||
| } | ||
|
|
||
| # Grants offered in a scholarship's "Funded by grant" picker: every grant with | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| <%# Rendered in the page header and replaced via turbo_stream from _results when | ||
| the frame reloads, so the badge reflects the active filters. @grants is nil on | ||
| the initial full-page render (the frame fills it in moments later). %> | ||
| <span id="grants_count" class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-sm font-semibold text-green-700"><%= @grants&.total_entries %></span> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| <%# Index filters, mirroring the registrants search: a GET form driven by the | ||
| collection controller. Selects submit on change and the text boxes (grant/ | ||
| donor name) debounce-submit as you type, all into the grants_results frame β | ||
| so the old rows blur (blur-on-submit) while the next page loads. %> | ||
| <% field_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm | ||
| focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> | ||
| <% label_class = "block text-sm font-medium text-gray-700 mb-1" %> | ||
| <%= form_with url: grants_path, method: :get, | ||
| data: { controller: "collection", turbo_frame: "grants_results" }, | ||
| autocomplete: "off", | ||
| class: "space-y-4" do %> | ||
| <div class="flex flex-col md:flex-row md:flex-wrap md:items-end gap-4 mb-6"> | ||
| <div class="w-full md:flex-1"> | ||
| <%= label_tag :name, "Grant name", class: label_class %> | ||
| <%= text_field_tag :name, params[:name], class: field_class, placeholder: "Grant name" %> | ||
| </div> | ||
| <div class="w-full md:flex-1"> | ||
| <%= label_tag :donor_name, "Donor name", class: label_class %> | ||
| <%= text_field_tag :donor_name, params[:donor_name], class: field_class, placeholder: "Organization or person" %> | ||
| </div> | ||
| <div class="w-full md:w-32"> | ||
| <%= label_tag :funds, "Funds remaining", class: label_class %> | ||
| <%= select_tag :funds, | ||
| options_for_select([ [ "Available", "available" ], [ "None left", "none" ] ], params[:funds]), | ||
| include_blank: "All", | ||
| class: field_class %> | ||
| </div> | ||
| <div class="w-full md:w-32"> | ||
| <%= label_tag :donor_type, "Donor type", class: label_class %> | ||
| <%= select_tag :donor_type, | ||
| options_for_select([ [ "Organization", "Organization" ], [ "Individual", "Person" ] ], params[:donor_type]), | ||
| include_blank: "All", | ||
| class: field_class %> | ||
| </div> | ||
| <div class="w-full md:w-32"> | ||
| <%= label_tag :tasks, "Tasks completed", class: label_class %> | ||
| <%= select_tag :tasks, | ||
| options_for_select([ [ "Completed", "completed" ], [ "Outstanding", "outstanding" ] ], params[:tasks]), | ||
| include_blank: "All", | ||
| class: field_class %> | ||
| </div> | ||
| <div class="w-full md:w-auto flex items-end"> | ||
| <%= link_to "Clear filters", grants_path, class: "btn btn-utility-outline", data: { action: "collection#clearAndSubmit" } %> | ||
| </div> | ||
| </div> | ||
| <% end %> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| <%# Refresh the header count to match the filtered results (the badge lives in the | ||
| page header, outside this frame). %> | ||
| <%= turbo_stream.replace "grants_count", partial: "count" %> | ||
| <% if @grants.any? %> | ||
| <div class="overflow-x-auto bg-white rounded-xl border border-gray-200 shadow-sm animate-fade blur-on-submit"> | ||
| <table class="w-full border-collapse" data-controller="table-sort"> | ||
| <thead> | ||
| <tr class="border-b border-gray-200 bg-gray-50"> | ||
| <%# Columns are click-to-sort via the shared table-sort controller; the | ||
| index matches each cell's position in _grant.html.erb. Money/date | ||
| cells carry a numeric/ISO data-sort-value so they sort correctly. %> | ||
| <% columns = [ | ||
| { label: "Name", index: 0, align: "text-left" }, | ||
| { label: "Donor", index: 1, align: "text-left" }, | ||
| { label: "Amount", index: 2, align: "text-right" }, | ||
| { label: "Remaining", index: 3, align: "text-left" }, | ||
| { label: "Allocated", index: 4, align: "text-right" }, | ||
| { label: "Tasks completed", index: 5, align: "text-center" }, | ||
| { label: "Deadline", index: 6, align: "text-left" } | ||
| ] %> | ||
| <% columns.each do |col| %> | ||
| <th class="px-4 py-3 <%= col[:align] %>" aria-sort="none"> | ||
| <%= render "shared/sortable_header", label: col[:label], index: col[:index], | ||
| class: "text-xs font-semibold uppercase tracking-wider text-gray-500 hover:text-gray-700" %> | ||
| </th> | ||
| <% end %> | ||
| <%# Non-sortable action column for the per-row Edit link. %> | ||
| <th class="px-4 py-3 text-right"> | ||
| <span class="text-xs font-semibold uppercase tracking-wider text-gray-500">Edit</span> | ||
| </th> | ||
| </tr> | ||
| </thead> | ||
| <tbody class="divide-y divide-gray-100" data-table-sort-target="body"> | ||
| <%= render partial: "grant", collection: @grants, as: :grant %> | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
|
|
||
| <div class="pagination flex justify-center mt-12"><%= tailwind_paginate @grants %></div> | ||
| <% else %> | ||
| <div class="bg-white rounded-xl border border-dashed border-gray-300 py-12 text-center"> | ||
| <i class="fa-solid fa-hand-holding-dollar text-3xl text-gray-300" aria-hidden="true"></i> | ||
| <p class="mt-3 text-gray-500">No grants found.</p> | ||
| </div> | ||
| <% end %> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| <%# Placeholder shown while the grants_results frame loads its filtered rows. %> | ||
| <div class="overflow-x-auto bg-white rounded-xl border border-gray-200 shadow-sm"> | ||
| <table class="w-full border-collapse"> | ||
| <thead> | ||
| <tr class="border-b border-gray-200 bg-gray-50 animate-pulse"> | ||
| <% 8.times do %> | ||
| <th class="px-4 py-3"><div class="h-3 w-16 rounded bg-gray-200"></div></th> | ||
| <% end %> | ||
| </tr> | ||
| </thead> | ||
| <tbody class="divide-y divide-gray-100"> | ||
| <% 5.times do %> | ||
| <tr class="animate-pulse"> | ||
| <% 8.times do %> | ||
| <td class="px-4 py-3.5"><div class="h-4 w-20 rounded bg-gray-100"></div></td> | ||
| <% end %> | ||
| </tr> | ||
| <% end %> | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
|
|
||
| <%# Pagination skeleton, mirroring the bar placeholders on the other lazy indexes. %> | ||
| <div class="flex justify-center space-x-2 mt-12 animate-pulse"> | ||
| <% 3.times do %> | ||
| <div class="h-8 w-10 rounded bg-gray-200"></div> | ||
| <% end %> | ||
| </div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π€ From Claude: Full page renders only header/filters/skeleton; the frame's
srcrequest (turbo_frame_request?) returns the filtered rows viaindex_lazy.