This is part 1 of the Controller Refactoring Epic.
Controllers that build filter options inline become bloated and repetitive. When the same options appear in multiple actions, you're duplicating logic that belongs elsewhere.
The Problem
class Admin::JobListingsController < ApplicationController
def index
@statuses = JobListing.statuses.keys.map { |s| [s.humanize, s] }
@locations = JobListing.active.pluck(:location).compact.uniq.sort.map { |l| [l, l] }
@departments = Department.order(:name).pluck(:name, :id)
@salary_range = JobListing.active.pluck(:salary_max).compact.minmax
# ... actual index logic
end
def filter_modal
@statuses = JobListing.statuses.keys.map { |s| [s.humanize, s] }
@locations = JobListing.active.pluck(:location).compact.uniq.sort.map { |l| [l, l] }
# Same logic, copy-pasted
end
end
Every time the filter UI needs options, you're writing database queries inline. Change how locations are formatted? Find every place you plucked them.
The Solution
# app/presenters/job_listings_filter_presenter.rb
class JobListingsFilterPresenter
def statuses
@statuses ||= JobListing.statuses.keys.map { |s| [s.humanize, s] }
end
def locations
@locations ||= JobListing.active
.pluck(:location)
.compact
.uniq
.sort
.map { |l| [l, l] }
end
def departments
@departments ||= Department.order(:name).pluck(:name, :id)
end
def salary_range
@salary_range ||= JobListing.active.pluck(:salary_max).compact.minmax
end
# Convenience for views that need everything
def to_h
{
statuses:,
locations:,
departments:,
salary_range:
}
end
end
# Controller becomes minimal
class Admin::JobListingsController < ApplicationController
def index
@filter_options = JobListingsFilterPresenter.new
# ... actual index logic
end
def filter_modal
@filter_options = JobListingsFilterPresenter.new
end
end
Why This Works
Memoization is automatic. Each method caches its result. If a view calls @filter_options.locations twice, the query runs once.
Testing is straightforward. The presenter is a plain Ruby object:
RSpec.describe JobListingsFilterPresenter do
describe "#locations" do
it "returns unique sorted locations" do
create(:job_listing, location: "Chicago")
create(:job_listing, location: "Austin")
create(:job_listing, location: "Chicago") # duplicate
expect(subject.locations).to eq [["Austin", "Austin"], ["Chicago", "Chicago"]]
end
end
end
Changes happen in one place. Need to exclude draft listings from location options? Update JobListingsFilterPresenter#locations. Every action using filter options gets the fix.
When to Use This Pattern
- Filter/search UIs with multiple option sources
- Dropdowns populated from database queries
- Any repeated view data assembly logic
The presenter isn't about complexity — it's about having one source of truth for filter options.