Adding a table with Infinite Scrolling

Part 14 of building a Rails 7 application

So far the user interface for the index pages looks like this:

image.png

I want to apply one of the TailwindCSS list styles to this and then allow the user to scroll content for as much as they like without using paging controls. On some pages this may not be suitable, especially if there are too many records so this may be configurable later. The inspiration for this section comes from an excellent article by David Colby, the link is included below in the Useful links section.

The first thing to do is add a way to paginate a collection of records, providing a way to set a page size and retrieve a specific page. There are a number of good gems that implement this functionality already, namely kaminari, will_paginate and pagy. I have not used pagy before so I will use this one (plus it has a good reputation in the community).

It needs to be added to my Gemfile:

# Collection pagination
gem 'pagy'

And then the controller needs to use it when returning the collection of records. Recall that I currently have a Resources concern that looks something like this:

controllers\concerns\resources.rb

module Resources
  extend ActiveSupport::Concern

  included do
    before_action :set_collection, only: %i[index]
  end

  private

  def set_collection
    @collection = resource_class.all
  end

  ...

Now I want to paginate that @collection. One way to do that would be to use pagy in the set_collection method here, but I think a better way will be to separate pagination into its own concern. Note that the Pagy::Backend concern is also included here in order to make the pagy method available.

controllers\concerns\paginated.rb

module Paginated
  extend ActiveSupport::Concern
  include Pagy::Backend

  private

  def set_collection
    @pagy, @collection = pagy(super)
  end
end

This concern can then be included into my ResourcesController:

class ResourcesController < ApplicationController
  include Resources
  include Actions
  include Urls
  include Parameters
  include Paginated
end

The super in my Paginated set_collection will call the set_collection in the Resources concern so now I have a paginated collection and I also have an @pagy which I can use to retrieve the total number of records and so on.

Because I am using ViewComponent I need to have a think about how to structure the components. Something like the following will be a good base to build from:

image.png

In the first instance I'll just be working on the blue and yellow components in the above diagram.

Before I get to that though there is an issue with TailwindCSS and the components that needs resolving. Because the html.erb files are in the components folder the TailwindCSS processor is not finding them and it therefore does not think it needs the CSS elements that are present there. Luckily, fixing this is easy. Edit the config/tailwind.config.js file to add the components to the content section:

const defaultTheme = require('tailwindcss/defaultTheme')

module.exports = {
  content: [
    './app/helpers/**/*.rb',
    './app/javascript/**/*.js',
    './app/views/**/*.{erb,haml,html,slim}',
    './app/components/**/*.{erb,html}',
  ],
  ...
}

Now I can start with the collection pager as I'll need that to make the infinite scrolling work:

app/components/collection_pager/component.rb

module CollectionPager
  class Component < ViewComponent::Base
    attr_reader :paginator, :collection_path_method

    def initialize(paginator:, collection_path_method:)
      super
      @paginator = paginator
      @collection_path_method = collection_path_method
    end
  end
end

This takes the Pagy paginator and a method name for the collection path I will be paging. By making this generic it can be re-used for all index pages. The component itself looks like the following:

app/components/collection_pager/component.html.erb

<div id="collection_pager" class="min-w-full my-8 flex justify-between">
  <div>
    <% if @paginator.next %>
      <%= link_to(
              "Load More",
              public_send(@collection_path_method, page: @paginator.next),
              class: "inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto",
              data: {
                  turbo_frame: "page_handler",
                  controller: "collection-pager--component"
              }
          ) %>
    <% end %>
  </div>
</div>

It creates a link to a button called "Load More", the URL is generated by calling the @collection_path_method providing the next page number.

The other important parts to note are the data-turbo-frame and data-controller. These are hotwire related with the first being a frame that will hold the rows as they are paged and the controller being the Stimulus code that does the paging automatically. I'll come back to these later.

Next I need a "collection" component to hold the "header", "rows" and "pager" components that are the yellow segments in my diagram above. It looks like this:

module Collection
  class Component < ViewComponent::Base
    renders_one :header, CollectionHeader::Component
    renders_one :rows, CollectionRows::Component
    renders_one :pager, CollectionPager::Component
  end
end

With a corresponding erb:

app/components/collection/component.html.erb

<div class="px-4 sm:px-6 lg:px-8">
  <div class="mt-8 flex flex-col">
    <div class="-my-2 -mx-4 sm:-mx-6 lg:-mx-8">
      <div class="inline-block min-w-full py-2 align-middle">
        <div class="shadow-sm ring-1 ring-black ring-opacity-5">
          <table class="min-w-full border-separate" style="border-spacing: 0">
            <%= header %>
            <tbody id="collection_rows" class="bg-white">
              <%= rows %>
            </tbody>
          </table>
          <%= pager %>
        </div>
      </div>
    </div>
  </div>
</div>

Now I need to add these components to the index.html.erb. In this instance I am using the "Item Sell Packs" page so the collection_path_method is item_sell_packs_path.

  <%= turbo_frame_tag "page_handler" %>
  <div id="collection" class=min-w-full">
    <%= render Collection::Component.new do |collection_component| %>
      <%= collection_component.with_header(columns: [
          { name: 'Name', classes: 'py-3.5 pl-4 pr-3 sm:pl-6 lg:pl-8' },
          { name: 'Canonical' },
          { name: 'Created At' },
          { name: 'Updated At' }
      ]) %>
      <%= collection_component.with_rows do |rows_component| %>
        <%= render partial: 'rows', locals: { rows_component: rows_component } %>
      <% end %>
      <%= collection_component.with_pager(paginator: @pagy, collection_path_method: :item_sell_packs_path) %>
    <% end %>
  </div>

The turbo_frame_tag called "page_handler" is going to be the glue that makes this work. This will be shown a bit later.

An important thing to note here is that the rows are extracted as a partial. The reason for doing this is later when I come to using turbo streams to generate the next chunk of rows to add to the table I need it to be completely isolated, I do not want any of the rest of the table to be re-rendered (i.e. the headings or the pager). It also means I don't have to replicate the row content in multiple places.

Here is what this _rows.html.erb partial looks like:

<%= @collection.each do |resource| %>
  <%= rows_component.with_row do %>
    <tr>
      <td class="whitespace-nowrap border-b border-gray-200 py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 lg:pl-8">
        <%= resource.name %>
      </td>
      <td class="whitespace-nowrap border-b border-gray-200 px-3 py-4 text-sm text-gray-500">
        <!-- Enabled: "bg-indigo-600", Not Enabled: "bg-gray-200" -->
        <button type="button" class="<%= resource.canonical? ? 'bg-indigo-600' : 'bg-gray-200' %> relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" role="switch" aria-checked="false">
          <span class="sr-only">Canonical</span>
          <!-- Enabled: "translate-x-5", Not Enabled: "translate-x-0" -->
          <span aria-hidden="true" class="<%= resource.canonical? ? 'translate-x-5' : 'translate-x-0' %> pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200"></span>
        </button>
      </td>
      <td class="whitespace-nowrap border-b border-gray-200 px-3 py-4 text-sm text-gray-500">
        <%= time_ago_in_words(resource.created_at, include_seconds: true) %> ago
      </td>
      <td class="relative whitespace-nowrap border-b border-gray-200 py-4 pr-4 pl-3 text-right text-sm font-medium sm:pr-6 lg:pr-8">
        <a href="#" class="text-indigo-600 hover:text-indigo-900">Edit<span class="sr-only">, <%= resource.name %></span></a>
      </td>
    </tr>
  <% end %>
<% end %>

It iterates through the @collection set by the controller and adds to the rows slot on the CollectionRows::Component. A row is simply a <tr> tag and all the <td>s containing data that I want for this particular page. The collection rows component looks like this:

app/components/collection_rows/component.rb

module CollectionRows
  class Component < ViewComponent::Base
    renders_many :rows
  end
end

And the corresponding erb:

app/components/collection_rows/component.html.erb

<% rows.each do |row| %>
  <%= row %>
<% end %>

At this point the page looks something like this:

image.png

Clicking the Load More button will not work at this point. I need to enable Turbo Frame variants, so that I can render different content from the index action in response to a Turbo Frame request. I can do this using a controller concern:

app/controllers/concerns/turbo_frame_variants.rb

module TurboFrameVariants
  extend ActiveSupport::Concern

  included do
    before_action :turbo_frame_request_variant
  end

  private

  def turbo_frame_request_variant
    request.variant = :turbo_frame if turbo_frame_request?
  end
end

And then include that in my controller:

class ResourcesController < ApplicationController
  include Resources
  include Actions
  include Urls
  include Parameters
  include Paginated
  include TurboFrameVariants
end

Now when clicking the "Load More" button it will look to render a turbo_frame variant of the index page. So that looks like the this:

app/views/item_sell_packs/index.html+turbo_frame.erb

<%= turbo_frame_tag 'page_handler' do %>
  <%= turbo_stream_action_tag(
          'append',
          target: 'collection_rows',
          template: %(#{render CollectionRows::Component.new do |component| render partial: 'rows', locals: { rows_component: component } end})
      ) %>
  <%= turbo_stream_action_tag(
          'replace',
          target: 'collection_pager',
          template: %(#{render CollectionPager::Component.new(paginator: @pagy, collection_path_method: :item_sell_packs_path)})
      ) %>
<% end %>

This works by targeting the page_handler turbo frame I added into the index.html.erb file earlier, and it does two things:

  • Performs a turbo stream action to append the next set of records returned to the collection_rows div. It does this by rendering a new CollectionRows::Component passing in the rows partial.
  • Performs a turbo stream action to replace the old pager with the now incorrect URL with a new pager component that will generate a new URL for the next page.

After this is in place clicking the "Load More" button works as intended, retrieving the next page of rows and adding it to the existing set, all without requiring a full page reload.

The final piece of the puzzle is making this automatic as the user scrolls so they do not need to click the "Load More" button. To do this only requires a small amount of Stimulus code. I want to keep the stimulus controller code together with the component that uses it. In order to do that they need to be made visible to the Rails asset pipeline, as normally it is expecting these Stimulus controllers to live under app/javascript/controllers.

First add the following to config/application.rb:

    # For component sidecar js
    initializer 'app_assets', after: 'importmap.assets' do
      Rails.application.config.assets.paths << Rails.root.join('app')
    end

    # Sweep importmap cache for components
    config.importmap.cache_sweepers << Rails.root.join('app/components')

Configure the asset pipeline:

app/assets/config/manifest.js

...
//= link_tree ../../components .js

And then pin the components javascript in the config/importmap.rb:

pin_all_from 'app/components', under: 'components'

Lastly I'll lazy load the components:

app/javascript/controllers/index.js

// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
lazyLoadControllersFrom("components", application)

On to the controller itself. Note that the file name is important here as that is how the correct controller is found. Recall that the pager had the following setting for the controller:

data: { 
  turbo_frame: "page_handler",
  controller: "collection-pager--component"
}

The controller looks like the following:

app/components/collection_pager/component_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()
  }
}

The appear() method is called whenever the "Load More" button appears on screen, with the aid of the useIntersection method. So the button is automatically clicked as the user scrolls. In order to use the stimulus-use library that needs to be pinned:

bin/importmap pin stimulus-use

And that's it, there is infinite page loading without any user interaction other than scrolling the page. Here it is in action:

hotwired.dev

colby.so/posts/pagination-and-infinite-scro..

github.com/github/view_component/issues/1064