Skip to content

Filterable, lazy-loaded grants index + scholarship theming#1920

Open
maebeale wants to merge 1 commit into
mainfrom
maebeale/grant-description-display
Open

Filterable, lazy-loaded grants index + scholarship theming#1920
maebeale wants to merge 1 commit into
mainfrom
maebeale/grant-description-display

Conversation

@maebeale

Copy link
Copy Markdown
Collaborator

🤖 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?

  • Awarders need to find grants by name, donor, remaining funds, and task status — the flat list didn't scale.

How did you approach the change?

  • Filter + lazy turbo-frame pattern (mirrors people/users): the full page renders header + filters + a skeleton; the frame's src request returns the filtered, paginated rows so the table reloads in place.
  • Funds/task scopes use flat WHERE/correlated subqueries instead of GROUP BY, so will_paginate's count stays correct on the paginated index.
  • table-sort switched to strict Number() so ISO dates sort correctly instead of collapsing every date in a year to one value.
  • Tasks-completed indicators and the grant scholarships card adopt the fuchsia scholarships theme; card links use the grey registrants style.
  • Recipients page links out to the grants index and to each funding grant.

Anything else to add?

  • New scopes (fully_issued, tasks_outstanding, all_tasks_completed) are NULL-safe for grant-less scholarships; covered by model + request specs.

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)

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.

Comment thread app/models/grant.rb
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.

Comment thread app/models/grant.rb
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 = 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.

@maebeale maebeale marked this pull request as ready for review June 26, 2026 00:04
// 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.

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.

@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

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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants