Skip to content
Open
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
39 changes: 38 additions & 1 deletion app/controllers/grants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Copy link
Copy Markdown
Collaborator Author

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 src request (turbo_frame_request?) returns the filtered rows via index_lazy.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

    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.


@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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 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.

end

def show
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions app/frontend/javascript/controllers/table_sort_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed to update this bc it wasn't sorting dates correctly

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jmilljr24 you ok w this?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
}
Expand Down
27 changes: 23 additions & 4 deletions app/models/grant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}") }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Flat WHERE + correlated subquery (not GROUP BY/HAVING) so will_paginate's total_entries count stays correct on the paginated index.


# 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, -> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– 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.

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
Expand Down
29 changes: 22 additions & 7 deletions app/views/events/recipients.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@

<div class="max-w-7xl mx-auto bg-white border border-gray-200 rounded-xl shadow p-4 sm:p-6">
<%# Top bar: back link + sub-nav, matching the other event pages %>
<div class="flex flex-wrap items-center justify-between gap-2 sm:gap-4 mb-2 print:hidden">
<div class="flex flex-wrap items-center justify-between gap-2 sm:gap-4 mb-6 print:hidden">
<%= link_to "← Events", events_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
<%= render "events/subnav", event: @event, current: :scholarships %>
</div>
<div class="flex justify-end mb-6 print:hidden">
<button onclick="window.print();" class="text-sm text-gray-500 hover:text-gray-700">
<div class="flex justify-end gap-3 mb-6 print:hidden">
<% if allowed_to?(:index?, Grant) %>
<%= link_to grants_path, target: "_blank", rel: "noopener", class: "btn btn-utility-outline" do %>
<i class="fa-solid fa-hand-holding-dollar"></i> Grants
<i class="fa-solid fa-arrow-up-right-from-square text-[0.6rem]"></i>
<% end %>
<% end %>
<button onclick="window.print();" class="btn btn-utility-outline">
<i class="fa-solid fa-print"></i> Print
</button>
</div>
Expand Down Expand Up @@ -171,10 +177,19 @@
<%= dollars_from_cents(scholarship.amount_cents) %>
</span>
<% if scholarship.grant&.funder_name.present? %>
<span class="inline-flex items-center gap-1.5 text-sm text-gray-600">
<i class="fa-solid fa-hand-holding-heart text-gray-400"></i>
Funded by <%= scholarship.grant.funder_name %>
</span>
<% if allowed_to?(:show?, scholarship.grant) %>
<%= link_to grant_path(scholarship.grant), target: "_blank", rel: "noopener",
class: "inline-flex items-center gap-1.5 text-sm text-gray-600 hover:text-gray-800 hover:underline" do %>
<i class="fa-solid fa-hand-holding-heart text-gray-400"></i>
Funded by <%= scholarship.grant.funder_name %>
<i class="fa-solid fa-arrow-up-right-from-square text-[0.6rem]"></i>
<% end %>
<% else %>
<span class="inline-flex items-center gap-1.5 text-sm text-gray-600">
<i class="fa-solid fa-hand-holding-heart text-gray-400"></i>
Funded by <%= scholarship.grant.funder_name %>
</span>
<% end %>
<% end %>
<%= render "scholarships/tasks_status", scholarship: scholarship %>
<% if allowed_to?(:edit?, scholarship) %>
Expand Down
4 changes: 4 additions & 0 deletions app/views/grants/_count.html.erb
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>
46 changes: 46 additions & 0 deletions app/views/grants/_filters.html.erb
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 %>
5 changes: 3 additions & 2 deletions app/views/grants/_grant.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
rather than their formatted text. %>
<tr class="group hover:bg-green-50/60 transition-colors duration-150">
<td class="px-4 py-3.5 align-middle" data-sort-value="<%= grant.object.name.to_s.downcase %>">
<%= link_to grant.name, grant_path(grant), class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow-sm transition-colors hover:border-gray-400 hover:bg-gray-50" %>
<%= link_to grant.name, grant_path(grant), class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-900 shadow-sm transition-colors hover:border-gray-400 hover:bg-gray-50", data: { turbo_frame: "_top" } %>
</td>
<td class="px-4 py-3.5 align-middle text-sm text-gray-700" data-sort-value="<%= grant.object.funder_name.to_s.downcase %>"><%= grant_donor_badge(grant) %></td>
<td class="px-4 py-3.5 align-middle text-sm font-medium text-gray-900 text-right tabular-nums" data-sort-value="<%= grant.object.amount_cents.to_i %>"><%= grant.amount %></td>
Expand All @@ -23,12 +23,13 @@
list on its show page. Sorts by total count. %>
<td class="px-4 py-3.5 align-middle text-sm text-center" data-sort-value="<%= grant.scholarships_count %>">
<% if grant.scholarships_count.positive? %>
<%= link_to grant.scholarships_link, class: "font-medium text-gray-900 tabular-nums hover:underline", title: "#{grant.completed_scholarships_count} of #{grant.scholarships_count} with tasks completed" do %>
<%= link_to grant.scholarships_link, class: "font-medium text-gray-900 tabular-nums hover:underline", title: "#{grant.completed_scholarships_count} of #{grant.scholarships_count} with tasks completed", data: { turbo_frame: "_top" } do %>
<%= grant.completed_scholarships_count %>/<%= grant.scholarships_count %>
<% end %>
<% else %>
<span class="text-gray-400">0</span>
<% end %>
</td>
<td class="px-4 py-3.5 align-middle text-sm text-gray-700 whitespace-nowrap" data-sort-value="<%= grant.object.application_deadline&.iso8601 %>"><%= grant.application_deadline %></td>
<td class="px-4 py-3.5 align-middle text-right text-sm"><%= link_to "Edit", edit_grant_path(grant), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %></td>
</tr>
45 changes: 45 additions & 0 deletions app/views/grants/_results.html.erb
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 %>
28 changes: 28 additions & 0 deletions app/views/grants/_results_skeleton.html.erb
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>
10 changes: 5 additions & 5 deletions app/views/grants/_scholarships.html.erb
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
<div class="bg-white rounded-lg shadow p-5">
<div class="<%= DomainTheme.bg_class_for(:scholarships) %> border <%= DomainTheme.border_class_for(:scholarships, intensity: 200) %> rounded-lg shadow p-5">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900">
Scholarships (<%= scholarships.total_entries %>)
</h2>
<%= link_to new_scholarship_path(grant_id: grant.id, return_to: return_to),
class: "inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:text-blue-900" do %>
class: "inline-flex items-center gap-1.5 text-sm font-medium text-gray-500 hover:text-gray-700" do %>
<i class="fa-solid fa-plus text-xs"></i>
Add scholarship
<i class="fa-solid fa-arrow-up-right-from-square text-xs text-gray-400"></i>
<% end %>
</div>

<% if scholarships.any? %>
<div class="overflow-x-auto">
<div class="overflow-x-auto bg-white rounded-lg border border-fuchsia-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
Expand All @@ -33,7 +33,7 @@
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-center">
<% if scholarship.tasks_completed? %>
<span class="inline-flex items-center gap-1 rounded-full text-xs font-medium border px-3 py-0.5 bg-green-50 text-green-700 border-green-200">
<span class="inline-flex items-center gap-1 rounded-full text-xs font-medium border px-3 py-0.5 bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200">
<i class="fa-solid fa-graduation-cap text-xs"></i>
Yes
</span>
Expand All @@ -43,7 +43,7 @@
</td>
<td class="px-4 py-2 whitespace-nowrap text-sm text-right">
<%= link_to "Edit", edit_scholarship_path(scholarship, return_to: return_to),
class: "text-blue-600 hover:text-blue-800" %>
class: "text-gray-500 hover:text-gray-700 underline" %>
</td>
</tr>
<% end %>
Expand Down
Loading