Hotwire Handbook - Part 2 - Pagination - REDUX!
Turbo 7.2.0 is out and it brings support for GET requests, which greatly simplifies the way to do pagination with Hotwire. We're still leveraging the Pagy Ruby Gem, but it's now easier to implement and has potential for even more.
This is a bit delayed, oops! Turbo 7.2.0 came out on 22nd September 2022, but it's beena a busy couple of months!
There are a few other guides out there which cover this, but I've included a few additional bits I haven't seen elsewhere, and the more guides, the better right! The original pagination guide is still up and there are going to be some functionality examples there which are still relevant. Original Pagination Guide
This is using the Turbo-Rails gem version 1.4.0 which is the Rails gem for Turbo 7.3.0. We're also using; Rails (7.0.4.3), Stiumulus-Rails (1.2.1), pagy (5.10.1)
This is (the replacement for) part 2 of my Hotwire Handbook. The aim of this is to compliment the official Turbo Handbook and other amazing sources available. It's also for my own information and recollection! Part 1 covers toggle buttons, content updates and live counters. You can find Part 1 here
Contents
Bonus Links for Key Changes
- Replacing
.turbo_frame.html.erb
with.turbo_stream.html.erb
data: { turbo_stream: "" }
replacesdata: { turbo_frame: "page_handler" }
- Turbo Stream response for GET request
- Remove
turbo_frame_request_variant
from ApplicationController - Remove page handler turbo frame
- Remove
turbo_stream
fromrespond_to
method
Paginating an Index Page
We have some updated examples in this redux. We're looking at the Ryalto V4 memberships directory. In Ryalto a User belongs to an Organisation through their Membership, so for the Organisation's directory of users we iterate over all those membership objects.
This is in our MembershipsController and the initial directory action looks like this:
def directory # rubocop:disable Metrics/AbcSize
@pagy_memberships, @memberships = pagy(@organisation.memberships, items: 12)
end
The action here is nice and straight forward and will respond with the directory.html.erb
file to HTML requests, the directory.turbo_stream.erb
to turbo_stream requests (and directory.json.jbuilder
to JSON requests, but we're not touching on JSON requests in this guide).
The first change from before is that we have a directory.turbo_stream.erb
view file instead of directory.html+turbo_frame.erb
When we make the initial request to the directory path (The route is get 'directory', action: :directory, controller: 'memberships'
), we make an html request so our controller renders the directory html page at app/views/memberships/directory.html.erb
The directory page takes our @memberships
collection and iterates through each membership within a turbo frame and a div with the ID: directory
. We also render in our "pager" partial. The view looks like this:
The directory page just loads in two partials within the turbo frame. The first is within the #directory
div and the second outside of it.
# app/views/memberships/directory.html.erb
<%= turbo_frame_tag :directory_frame do %>
<div id="directory">
<%= render partial: "memberships/memberships_table", locals: { memberships: @memberships } %>
</div>
<%= render "memberships/pager_memberships", pagy_memberships: @pagy_memberships %>
<% end %>
The turbo frame means that requests form within the frame will automatically just load within the frame.
The memberships table iterates through our @memberships collection and displays the relevant data.
# app/views/memberships/memberships_table.html.erb
<% @memberships.each do |membership| %>
<% user = membership.user %>
<%= link_to user, data: { turbo_frame: "_top" }, class: "avatar-header-list" do %>
<div class="profile-card">
<div class="avatar-wrapper avatar-sm">
<% user.picture.attached? ? image_tag url_for(user.picture) : image_tag 'avatar-placeholder.png' %>
</div>
<div class="profile-details">
<h4><%= user.full_name %></h4>
<%# Other Details here %>
</div>
</div>
<% end %>
<% end %>
The pager partial provides a link to the next page of membership results. This is automatically clicked in the same way as previously with a simple javascript "Autoclick" controller.
# app/views/memberships/_pager_memberships.html.erb
<div id="pager_users" class="min-w-full my-8 flex justify-center">
<div>
<% if pagy_memberships.next %>
<%= link_to(
"Loading...",
pagy_url_for(pagy_memberships, pagy_memberships.next),
# directory_path(page: pagy_memberships.next),
class: "btn sm",
data: {
turbo_stream: "",
controller: "autoclick"
}
) %>
<% end %>
</div>
</div>
<script>
# Note: this doesn't liver in this file, it lives at the path below.
# Im just including it for completeness / simplicity
# app/javascript/controllers/autoclick_controller.js
import { Controller } from "@hotwired/stimulus"
import { useIntersection } from 'stimulus-use'
export default class extends Controller {
options = {
threshold: 1
}
connect() {
useIntersection(this, this.options)
}
appear(entry) {
this.element.click()
}
}
</script>
One subtle but key change here is the addition of turbo_stream: ""
to the data param. This turns the request from at HTML request to a turbo_stream request. Which means our response looks for the directory.turbo_stream
partial instead of the directory.html
The directory turbo stream partial contains two turbo streams, one which appends the next page of results to the bottom of the <div id=directory>
, we're still going for the infinite scrolling approach. The other replaces the loading button with updated pagy variables.
# app/views/directory.turbo_stream.erb
<%= turbo_stream_action_tag(
"append",
target: "directory",
template: %(#{ render partial: "memberships/memberships_table",
locals: { memberships: @memberships } })
) %>
<%= turbo_stream_action_tag(
"replace",
target: "pager_users",
template: %(#{render "memberships/pager_memberships",
pagy_memberships: @pagy_memberships})
) %>
And that's it. No more "clever" workarounds, just much more "boring".
Upgrading from before Turbo 7.2.0
If you're upgrading from the previous version of this guide there are some extra steps you need to take in order to remove our clever workarounds.
In our ApplicationController
we can remove the turbo_frame_request_variant
method and it's associated before_action
# Remove all this from #app/controllers/application_controller.rb
before_action :turbo_frame_request_variant
def turbo_frame_request_variant
request.variant = :turbo_frame if turbo_frame_request?
end
As we mentioned earlier, we're introducing data: { turbo_stream: "" }
to the next page links in our "pager" partials. This is replacing the turbo frame call to the _page_handler
turbo frames. An example is to data: { turbo_frame: "page_hander" }
, and then remove the associated <%= turbo_frame: 'page_hander' %>
The final thing to remove (and something I missed at first) is to remove :turbo_stream
from our respond_to in our ApplicationController. You can actually remove the respond_to
line entirely now as we're no longer modifying it from the defaults.
Introducing Filters
Previously we had implemented filters by having two paginated indexes on one page. Separated by different turbo frames. This does work, and is a viable option for some user cases. We've now migrated off this and introduced a wider range of user-selectable filters.
Adding this functionality is a nice progressive enhancement. You don't need to modify the existing structure much at all. We just introduce a form above our directory_frame
turbo frame, and then use the params which the form sends to scope the memberships which are returned to the view.
We have two filters, admins vs non-admins (which was what we had previously), and also membership categories, which is another associated table. The form, which sits above the directory_frame
looks like this:
# app/views/memberships/directory.html.erb
<%= form_tag directory_path, method: :get,
data: { controller: "filters-autoclick" },
class: "directory filters filters-wrapper" do %>
<div>
<%= label_tag(:type, "Filter By Type", class: "filter-label") %>
<%= select_tag :filter,
options_for_select([
%w[All all],
%w[Admins admins]
], params[:filter] || "all"),
data: { action: "input->filters-autoclick#applyFilter" } %>
</div>
<% if @organisation.categories.present? %>
<div>
<%= label_tag(:type, "Filter By Category", class: "filter-label") %>
<%= select_tag :category,
options_from_collection_for_select(
@organisation.categories, :id, :name, params[:category]),
multiple: true,
include_blank: "All",
data: { action: "input->filters-autoclick#applyFilter" } %>
</div>
<% end %>
<%= submit_tag 'Filter', data: { filters_autoclick_target: "submitButton" }, class: "hidden" %>
<% end %>
We the update our directory
action in our MembershipsController to reduce the scope of the memberships returned should they be present in the params. There are some additional methods here which just help out!
# app/controllers/memberships_controller.rb
class MembershipsController < ApplicationController
before_action :set_organisation, only: :directory
before_action :sanitise_params, only: :directory, if: -> { params.present? }
# GET /directory
def directory
memberships = @organisation.memberships.for_directory
memberships = memberships.search_by_user_name(params[:search]) if params[:search].present?
memberships = memberships.admins if params[:filter] == "admins"
memberships = memberships.filter_by_category(params[:category]) if params[:category].present? && @organisation.categories.exists?(params[:category])
@pagy_memberships, @memberships = pagy(memberships, items: 12)
end
private
def set_organisation
@organisation = current_user.current_organisation
end
# This method is to convert params from the web multi select from an array into a string.
def sanitise_params
params[:category] = params[:category].join if params[:category].is_a?(Array)
params[:search] = params[:search].join if params[:search].is_a?(Array)
params[:filter] = params[:filter].join if params[:filter].is_a?(Array)
end
end
We're leveraging some active record scopes on our membership model for the filtering.
# app/models/membership.rb
scope :for_directory, -> { excluding_ryalto_staff_global_admins.active }
scope :filter_by_category, ->(category_id) { where(category_id:) }
scope :admins, -> { where(organisation_admin: true).or(where(shift_admin: true)).or(where(article_admin: true)) }
pg_search_scope :search_by_user_name,
associated_against: { user: %i[first_name last_name] },
using: {
tsearch: { prefix: true }
}
As always I hope this was helpful, if you've used it and have any thoughts or feedback, please feel free to get in touch! I'd love to hear from you.