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

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