Photo by Compare Fibre on Unsplash
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:
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:
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:
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 newCollectionRows::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: