Interactive HOTWired paginated deletes

When you have a lot of records, it makes sense to paginate the table. That way, instead of loading thousands of records, you can load a subset, with better performance, and allow your user to move through the pages as leisure. It’s also better for usability, because it’s easier to go back to a particular page rather than scroll through a long list.

I like to use Basecamp’s geared_pagination gem to manage the pagination, offsets, and current page. I’ve found that when I want to delete a particular record, I could the HOTWire or javascript to remove the individual row. But it wouldn’t update the page counts, or make add in another record. This was a usability problem when I had to delete lots of rows. I would eventually remove all the rows on the page, and then I had to refresh to pull more records from the database.

I realized I can use Turbo Frames to replace the entire table, which will update the page counts, and add data back on to the page. Here is how it works below:

Interactive deletes on a paginated table using HOTWire

Here’s how it all works. This tutorial assumes Rails 7, and HOTWire configured with the turbo-rails gem.

The Controller

I’m going to use a Book model, but you can really use any of your records. Here is the books_controller.rb:

# frozen_string_literal: true

class BooksController < ApplicationController
  def index
    set_page_and_extract_portion_from Book.all
  end

  def destroy
    @book = Book.find params[:id]
    @book.destroy

    set_page_and_extract_portion_from Book.all
  end
end

The index method will use Geared pagination’s method to extract a selection from all the Book records. The destroy method is going to destroy the record, ad you would expect, and then it’s going to call the pagination method again. This will bring up the records we’ll use in the Turbo Stream to replace the table.

The Views

Since there are two actions in the controller, we’ll use two corresponding erb files. First, here is a partial that will be shared between the two actions, _book_records.html.erb:

<% unless page.first? %>
  <%= link_to "Prev", books_path(page: page.number - 1 ) %>
<% end %>
<span>
  Showing page <%= page.number %> of <%= page.recordset.page_count %>
  (<%= page.recordset.records_count %> total)
</span>
<% unless page.last? %>
  <%= link_to "Next", books_path(page: page.next_param) %>
<% end %>
<table class="table">
  <thead>
    <tr>
      <th>Title</th>
      <th>Author</th>
      <th>Publisher</th>
      <th>Category</th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <% page.records.each do |book| %>
      <tr>
        <td><%= book.title %></td>
        <td><%= book.author %></td>
        <td><%= book.publisher %></td>
        <td><%= book.category %></td>
        <td><%= link_to "Delete", book_path(book, page: page.number), data: { 'turbo-method': :delete, 'turbo-confirm': "Are you sure?" } %></td>
      </tr>
    <% end %>
  </tbody>
</table> 

The first part of this partial is the pagination code that moves the page back and forth. The recordset count will show how many records are left, so we can confirm that a Book was deleted successfully. The Delete link will include the current page in the request so the paginate method will load the correct set of records. It also uses the turbo-method and the turbo-confirm data attributes, which are different from the traditional Rails UJS data annotations. They behave the same, but will be used by Turbo instead of Rails UJS.

The index.html.erb will have a turbo_frame_tag so that Turbo knows which section to replace. The action is supposed to update the page’s URL so that when the page reloads, it shows the proper page of records.

<h1 class="title">Paginate Through Books</h1>
<%= turbo_frame_tag "books", action: "advance" do %>
  <%= render partial: 'book_records', locals: {page: @page} %>
<% end %>

The destroy.turbo_stream.erb will use the replace command to reload the page of records. There is no need to remove the destroyed Book, because it is no longer in the database and won’t appear.

<%= turbo_stream.replace "books" do %>
  <%= turbo_frame_tag "books", action: "advance" do %>
    <%= render partial: 'book_records', locals: {page: @page} %>
  <% end %>
<% end %>

Notice how both actions use the book_records partial and pass the @page variable as a local parameter.

Amazing Interactivity

One of the great things about HOTWire is how very little changes in the controllers and views. By rethinking how the destroy method is used, we can get a very interactive app without having to build any front end JavaScript.

Leave a Reply