diff --git a/app/controllers/grants_controller.rb b/app/controllers/grants_controller.rb index f45627106e..078a34787b 100644 --- a/app/controllers/grants_controller.rb +++ b/app/controllers/grants_controller.rb @@ -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? + + @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 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 diff --git a/app/frontend/javascript/controllers/table_sort_controller.js b/app/frontend/javascript/controllers/table_sort_controller.js index d0204f42fc..b69d2ed711 100644 --- a/app/frontend/javascript/controllers/table_sort_controller.js +++ b/app/frontend/javascript/controllers/table_sort_controller.js @@ -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) + const numB = b === "" ? NaN : Number(b) if (!isNaN(numA) && !isNaN(numB)) return numA - numB return a.localeCompare(b) } diff --git a/app/models/grant.rb b/app/models/grant.rb index 330d600f25..8827b9c2e6 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -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}") } + + # 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, -> { + 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 diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb index ca2d82b4be..d7075a776d 100644 --- a/app/views/events/recipients.html.erb +++ b/app/views/events/recipients.html.erb @@ -6,12 +6,18 @@
<%# Top bar: back link + sub-nav, matching the other event pages %> -
+
<%= link_to "← Events", events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> <%= render "events/subnav", event: @event, current: :scholarships %>
-
-
@@ -171,10 +177,19 @@ <%= dollars_from_cents(scholarship.amount_cents) %> <% if scholarship.grant&.funder_name.present? %> - - - Funded by <%= scholarship.grant.funder_name %> - + <% 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 %> + + Funded by <%= scholarship.grant.funder_name %> + + <% end %> + <% else %> + + + Funded by <%= scholarship.grant.funder_name %> + + <% end %> <% end %> <%= render "scholarships/tasks_status", scholarship: scholarship %> <% if allowed_to?(:edit?, scholarship) %> diff --git a/app/views/grants/_count.html.erb b/app/views/grants/_count.html.erb new file mode 100644 index 0000000000..77b068227c --- /dev/null +++ b/app/views/grants/_count.html.erb @@ -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). %> +<%= @grants&.total_entries %> diff --git a/app/views/grants/_filters.html.erb b/app/views/grants/_filters.html.erb new file mode 100644 index 0000000000..e185206250 --- /dev/null +++ b/app/views/grants/_filters.html.erb @@ -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 %> +
+
+ <%= label_tag :name, "Grant name", class: label_class %> + <%= text_field_tag :name, params[:name], class: field_class, placeholder: "Grant name" %> +
+
+ <%= label_tag :donor_name, "Donor name", class: label_class %> + <%= text_field_tag :donor_name, params[:donor_name], class: field_class, placeholder: "Organization or person" %> +
+
+ <%= 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 %> +
+
+ <%= 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 %> +
+
+ <%= 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 %> +
+
+ <%= link_to "Clear filters", grants_path, class: "btn btn-utility-outline", data: { action: "collection#clearAndSubmit" } %> +
+
+<% end %> diff --git a/app/views/grants/_grant.html.erb b/app/views/grants/_grant.html.erb index a6fc0928ad..58a078814c 100644 --- a/app/views/grants/_grant.html.erb +++ b/app/views/grants/_grant.html.erb @@ -4,7 +4,7 @@ rather than their formatted text. %> - <%= 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" } %> <%= grant_donor_badge(grant) %> <%= grant.amount %> @@ -23,7 +23,7 @@ list on its show page. Sorts by total 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 %> @@ -31,4 +31,5 @@ <% end %> <%= grant.application_deadline %> + <%= link_to "Edit", edit_grant_path(grant), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %> diff --git a/app/views/grants/_results.html.erb b/app/views/grants/_results.html.erb new file mode 100644 index 0000000000..e0c4927ee3 --- /dev/null +++ b/app/views/grants/_results.html.erb @@ -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? %> +
+ + + + <%# 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| %> + + <% end %> + <%# Non-sortable action column for the per-row Edit link. %> + + + + + <%= render partial: "grant", collection: @grants, as: :grant %> + +
+ <%= 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" %> + + Edit +
+
+ + +<% else %> +
+ +

No grants found.

+
+<% end %> diff --git a/app/views/grants/_results_skeleton.html.erb b/app/views/grants/_results_skeleton.html.erb new file mode 100644 index 0000000000..e34e7aaca6 --- /dev/null +++ b/app/views/grants/_results_skeleton.html.erb @@ -0,0 +1,28 @@ +<%# Placeholder shown while the grants_results frame loads its filtered rows. %> +
+ + + + <% 8.times do %> + + <% end %> + + + + <% 5.times do %> + + <% 8.times do %> + + <% end %> + + <% end %> + +
+
+ +<%# Pagination skeleton, mirroring the bar placeholders on the other lazy indexes. %> +
+ <% 3.times do %> +
+ <% end %> +
diff --git a/app/views/grants/_scholarships.html.erb b/app/views/grants/_scholarships.html.erb index 7676e2b7de..8fa6b927fb 100644 --- a/app/views/grants/_scholarships.html.erb +++ b/app/views/grants/_scholarships.html.erb @@ -1,10 +1,10 @@ -
+

Scholarships (<%= scholarships.total_entries %>)

<%= 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 %> Add scholarship @@ -12,7 +12,7 @@
<% if scholarships.any? %> -
+
@@ -33,7 +33,7 @@ <% end %> diff --git a/app/views/grants/index.html.erb b/app/views/grants/index.html.erb index 8a7f6e86d0..d7a171499f 100644 --- a/app/views/grants/index.html.erb +++ b/app/views/grants/index.html.erb @@ -16,7 +16,7 @@

<%= Grant.model_name.human.pluralize %> - <%= @grants.total_entries %> + <%= render "count" %>

Funds donated by organizations and people, from which scholarships are awarded.

@@ -38,43 +38,13 @@ - <% if @grants.any? %> -
-
<% if scholarship.tasks_completed? %> - + Yes @@ -43,7 +43,7 @@ <%= 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" %>
- - - <%# 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: "Scholarships", index: 5, align: "text-center" }, - { label: "Deadline", index: 6, align: "text-left" } - ] %> - <% columns.each do |col| %> - - <% end %> - - - - <%= render partial: "grant", collection: @grants, as: :grant %> - -
- <%= 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" %> -
-
+ <%= render "filters" %> - - <% else %> -
- -

No grants found.

-
+ <%# Results load in a turbo frame so the filter form (outside the frame) can + swap just the table — blurring the old rows while the next page loads and + keeping the search inputs focused. %> + <%= turbo_frame_tag :grants_results, src: grants_path(request.query_parameters), data: { turbo: "temporary" } do %> + <%= render "results_skeleton" %> <% end %>
diff --git a/app/views/grants/index_lazy.html.erb b/app/views/grants/index_lazy.html.erb new file mode 100644 index 0000000000..02a9912ca1 --- /dev/null +++ b/app/views/grants/index_lazy.html.erb @@ -0,0 +1,5 @@ +<%# Frame response for the grants_results turbo frame: the index controller + renders this (instead of the full index) on turbo_frame_request?. %> +<%= turbo_frame_tag :grants_results do %> + <%= render "results" %> +<% end %> diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index a73b020739..cf6f8361a9 100644 --- a/app/views/scholarships/_recipient_row.html.erb +++ b/app/views/scholarships/_recipient_row.html.erb @@ -16,7 +16,7 @@ <%= scholarship.amount %> <% if scholarship.tasks_completed? %> - + Yes diff --git a/spec/models/grant_spec.rb b/spec/models/grant_spec.rb index 68888adb9b..da76d35133 100644 --- a/spec/models/grant_spec.rb +++ b/spec/models/grant_spec.rb @@ -113,6 +113,42 @@ end end + describe ".fully_issued" do + it "includes fully-allocated grants and excludes ones with funds left" do + has_funds = create(:grant, amount_cents: 100_000) + create(:scholarship, grant: has_funds, amount_cents: 40_000) + exhausted = create(:grant, amount_cents: 30_000) + create(:scholarship, grant: exhausted, amount_cents: 30_000) + + expect(Grant.fully_issued).to contain_exactly(exhausted) + end + end + + describe "task-completion scopes" do + it "separates all-completed grants from those with outstanding tasks" do + all_done = create(:grant) + create(:scholarship, grant: all_done, tasks_completed: true) + mixed = create(:grant) + create(:scholarship, grant: mixed, tasks_completed: true) + create(:scholarship, grant: mixed, tasks_completed: false) + no_scholarships = create(:grant) + + expect(Grant.all_tasks_completed).to contain_exactly(all_done) + expect(Grant.tasks_outstanding).to contain_exactly(mixed) + expect(Grant.all_tasks_completed).not_to include(no_scholarships) + end + + it "ignores grant-less scholarships when computing completion" do + # A grant-less, incomplete scholarship has a NULL grant_id. Left in the + # NOT IN subquery it would make all_tasks_completed match nothing. + create(:scholarship, grant: nil, tasks_completed: false) + all_done = create(:grant) + create(:scholarship, grant: all_done, tasks_completed: true) + + expect(Grant.all_tasks_completed).to contain_exactly(all_done) + end + end + describe ".selectable_for" do it "lists grants with funds remaining" do with_funds = create(:grant, amount_cents: 100_000) diff --git a/spec/requests/grants_spec.rb b/spec/requests/grants_spec.rb index 6761390b1f..954c2caf50 100644 --- a/spec/requests/grants_spec.rb +++ b/spec/requests/grants_spec.rb @@ -31,6 +31,10 @@ before { sign_in admin } describe "GET /index" do + # The grant rows load inside the grants_results turbo frame, so row-level + # assertions issue the frame request (Turbo-Frame header) the browser sends. + let(:frame_headers) { { "Turbo-Frame" => "grants_results" } } + it "renders a successful response" do create(:grant) get grants_url @@ -43,6 +47,77 @@ expect(response.body).to include(edit_scholarship_path(scholarship)) expect(response.body).to include("Scholarship") end + + it "renders a per-row Edit link" do + grant = create(:grant) + get grants_url, headers: frame_headers + expect(response.body).to include(edit_grant_path(grant)) + end + + it "filters by funds remaining" do + available = create(:grant, name: "Has funds", amount_cents: 100_000) + issued = create(:grant, name: "All issued", amount_cents: 30_000) + create(:scholarship, grant: issued, amount_cents: 30_000) + + get grants_url(funds: "available"), headers: frame_headers + expect(response.body).to include("Has funds") + expect(response.body).not_to include("All issued") + + get grants_url(funds: "none"), headers: frame_headers + expect(response.body).to include("All issued") + expect(response.body).not_to include("Has funds") + end + + it "filters by donor type" do + org_grant = create(:grant, name: "Org grant", donor: create(:organization)) + person_grant = create(:grant, name: "Person grant", donor: create(:person)) + + get grants_url(donor_type: "Organization"), headers: frame_headers + expect(response.body).to include("Org grant") + expect(response.body).not_to include("Person grant") + + get grants_url(donor_type: "Person"), headers: frame_headers + expect(response.body).to include("Person grant") + expect(response.body).not_to include("Org grant") + end + + it "filters by grant name" do + create(:grant, name: "Healing Arts Fund") + create(:grant, name: "Music Therapy Grant") + + get grants_url(name: "healing"), headers: frame_headers + expect(response.body).to include("Healing Arts Fund") + expect(response.body).not_to include("Music Therapy Grant") + end + + it "filters by donor name across organizations and people" do + org_grant = create(:grant, name: "Org-funded", donor: create(:organization, name: "Acme Foundation")) + person_grant = create(:grant, name: "Person-funded", donor: create(:person, first_name: "Jane", last_name: "Donor")) + create(:grant, name: "Other grant", donor: create(:organization, name: "Unrelated Inc")) + + get grants_url(donor_name: "acme"), headers: frame_headers + expect(response.body).to include("Org-funded") + expect(response.body).not_to include("Other grant") + + get grants_url(donor_name: "jane donor"), headers: frame_headers + expect(response.body).to include("Person-funded") + expect(response.body).not_to include("Other grant") + end + + it "filters by task completion" do + all_done = create(:grant, name: "All done") + create(:scholarship, grant: all_done, tasks_completed: true) + outstanding = create(:grant, name: "Has outstanding") + create(:scholarship, grant: outstanding, tasks_completed: false) + + get grants_url(tasks: "completed"), headers: frame_headers + expect(response.body).to include("All done") + expect(response.body).not_to include("Has outstanding") + + get grants_url(tasks: "outstanding"), headers: frame_headers + expect(response.body).to include("Has outstanding") + expect(response.body).not_to include("All done") + end end describe "GET /show" do