Benito Serna Tips and tools for Ruby on Rails developers

Building a dynamic data grid with search and filters using rails, hotwire and ransack

September 3, 2022

Do you want to build powerful admin interfaces with little code, but you are not sure if you want to jump into a full admin solution like Active Admin, Administrate or Avo?

Here I want to show you an alternative!

A step by step guide to build a dynamic data grid with search and filters for your admin interfaces, using rails, the ransack gem and hotwire.

What are you going to build?

You are goint to build a paginated table with four filters and sortable columns.

Something like this:

Step 1: A non-dynamic paginated table

👉 You can find the full commit here.

The first step is a common rails index with the pagy gem to build the pagination.

Create the record

For the example we are going to use three columns name, category and price.

class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products do |t|
      t.string :name
      t.integer :category
      t.decimal :price

      t.timestamps
    end
  end
end

And category is going to be an enum:

class Product < ApplicationRecord
  enum :category, [:toys, :electronics, :food]
end

Declare the routes

We are going to create a products resource with only the index route.

And to make it easy to test we can define the root as a redirect to /products.

Rails.application.routes.draw do
  root to: redirect("/products")
  resources :products, only: [:index]
end

The controller

For this phase of the project we are going to paginate all products, with 10 items per page.

Once you have the setup for the pagy gem, the controller code will look like this:

class ProductsController < ApplicationController
  def index
    @pagy, @products = pagy(Product.all, items: 10)
  end
end

And then add the views…

The index.html.erb:

<h1>Products</h1>

<div class="table-container">
  <table>
    <thead>
      <tr>
        <th>Name</th>
        <th>Category</th>
        <th>Price</th>
      </tr>
    </thead>
    <tbody>
      <%= render @products %>
    </tbody>
  </table>
</div>

<%= raw pagy_nav(@pagy) %>

And the _product.html.erb partial

<%= tag.tr id: dom_id(product) do %>
  <td><%= product.name %></td>
  <td><%= product.category %></td>
  <td><%= number_to_currency product.price %></td>
<% end %>

Enough CSS

To make it look descent you can put this css on your application.css file:

@import "https://unpkg.com/open-props";

* { box-sizing: border-box; }

body {
  font-family: var(--font-sans);
  font-size: var(--font-size-1);
  margin-right: auto;
  margin-left: auto;
  padding: var(--size-5);
}

.table-container { overflow: auto; }
table { width: 100%; }
tbody { line-height: var(--font-lineheight-3); }
td, th {
  text-align: left;
  padding-bottom: var(--size-2);
  padding-right: var(--size-2);
  background-color: white;
  border-color: var(--gray-3);
  border-bottom-style: solid;
  border-bottom-width: var(--border-size-1);
}
th { font-weight: 600; }
td { padding-top: var(--size-2); }

.pagy-nav { padding: var(--size-7) 0; }
.pagy-nav .page { padding: 0 var(--size-1); }

Step 2: Order columns with sort_link

👉 You can find the full commit here.

To make the columns sortable with ransack you need to use the helper sort_link on each th from the table:

<thead>
  <tr>
    <th><%= sort_link(@q, :name) %></th>
    <th><%= sort_link(@q, :category) %></th>
    <th><%= sort_link(@q, :price) %></th>
  </tr>
</thead>

And then use the ransack method to create a Ransack::Search that you should assign to the instance variable @q.

def index
  @q = Product.ransack(params[:q])
  scope = @q.result(distinct: true)
  @pagy, @products = pagy(scope, items: 10)
end

If you want to set a default sort you can do something like this:

@q.sorts = "name asc" if @q.sorts.empty?

In the example app I put the code in a private method like this:

def index
  @q = Product.ransack(params[:q])
  scope = @q.result(distinct: true)
  set_default_sort
  @pagy, @products = pagy(scope, items: 10)
end

private

def set_default_sort
  @q.sorts = "name asc" if @q.sorts.empty?
end 

Step 3: Search with ransack

👉 You can find the full commit here.

To search with ransack you need to follow the convention defined in the documentation.

In the documentation you can also find a list with all possible matchers/predicates.

In the example we have four filters:

  1. A search_field for name_cont, that match if the name contains the given value.
  2. A select for category_eq with the list of possible categories, that match if the category equals the selected value.
  3. A search_field for price_gt, that match if the price is greater than the given value.
  4. A search_field for price_lt, that match if the price is less than the given value.

To implement it you need to write this erb.

<%= search_form_for @q do |f| %>
  <div>
    <%= f.label :name_cont %>
    <%= f.search_field :name_cont %>
  </div>

  <div>
    <%= f.label :category_eq %>
    <%= f.select :category_eq, Product.categories, include_blank: true %>
  </div>

  <div>
    <%= f.label :price_gt %>
    <%= f.search_field :price_gt %>
  </div>

  <div>
    <%= f.label :price_lt %>
    <%= f.search_field :price_lt %>
  </div>

  <div>
    <%= f.submit %>
  </div>
<% end %>

To style the form you can use this css:

 label {
   display: block;
 }

 input[type="search"],
 select {
   -webkit-appearance: none;
   -moz-appearance: none;
   background-color: white;
   border: var(--gray-3) solid var(--border-size-1);
   border-radius: var(--radius-2);
   margin-top: var(--size-2);
   padding: var(--size-2);
   height: var(--size-8);
   display: block;
   font-size: var(--font-size-1);
   width: 100%;
 }

 input[type="submit"] {
   padding: var(--size-2) var(--size-4);
   border: var(--blue-6) solid var(--border-size-1);
   background-color: var(--blue-6);
   color: white;
   border-radius: var(--radius-2);
   font-size: var(--font-size-1);
 }

 input[type="submit"]:hover {
   background-color: var(--blue-8);
 }

 form {
   display: grid;
   grid-template-columns: repeat(5, 1fr);
   gap: var(--size-2);
   align-items: flex-end;
 }

 .table-container {
   margin-top: var(--size-7);
   overflow: auto; 
 }

Step 4: Using turbo to send the form when somthing changes

👉 You can find the full commit here.

Now you can remove the button and instead send the form when something in it changes.

To do it you can create an stimulus controller that calls “requestSubmit()” when something changes.

It could be something like this:

// filters_controller.js
import { Controller } from "@hotwired/stimulus"

 export default class extends Controller {
   submit(event) {
     this.element.requestSubmit()
   }
 }

Now you can update the form to define the controller and to reference a turbo frame to update. Like this:

<%= search_form_for @q, html: {"data-controller": "filters", "data-turbo-frame": "table"} do |f| %>

Then on each control you should define an action that will call “filters#submit” and remove the submit button.

<%= search_form_for @q, html: {"data-controller": "filters", "data-turbo-frame": "table"} do |f| %>
  <div>
    <%= f.label :name_cont %>
    <%= f.search_field :name_cont, "data-action": "filters#submit" %>
  </div>

  <div>
    <%= f.label :category_eq %>
    <%= f.select :category_eq, Product.categories, {include_blank: true}, {"data-action": "filters#submit"} %>
  </div>

  <div>
    <%= f.label :price_gt %>
    <%= f.search_field :price_gt, "data-action": "filters#submit" %>
  </div>

  <div>
    <%= f.label :price_lt %>
    <%= f.search_field :price_lt, "data-action": "filters#submit" %>
  </div>
<% end %>

You will need to wrap the table and pagination in a turbo_frame_tag that match what you defined in the form, in this case you need to call it "table":

<%= turbo_frame_tag "table" do %>
  <table>
    <thead>
      <tr>
        <th><%= sort_link(@q, :name) %></th>
        <th><%= sort_link(@q, :category) %></th>
        <th><%= sort_link(@q, :price) %></th>
      </tr>
    </thead>
    <tbody>
      <%= render @products %>
    </tbody>
  </table>

  <%= raw pagy_nav(@pagy) %>
<% end %>

You can update the css of your form to take the full width and give the same space to each control:

form {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--size-2);
  align-items: flex-end;
}

Step 6: Adding a “loading” indicator

👉 You can find the full commit here.

To add a loading indicator you can use the attribute busy on the turbo-frame.

It is a boolean attribute managed by turbo. It is toggled to be present when a turbo-frame-initiated request starts, and toggled false when the request ends.

Then you can add an tag with the loading class on the turbo frame:

<%= turbo_frame_tag "table" do %>
  <p class="loading">Loading...</p>
  ...
<% end %> 

And add some css to toggle the visibility based on the busy attribute:

.loading { visibility: hidden; }
turbo-frame[busy] .loading { visibility: visible; }

Summary

This is the code that you need to build a dynamic paginated data-grid with ransack and hotwire.

On the controller you need to:

  1. Call ransack with params[:q]
  2. Pass the result to pagy
  3. And if you want, you can set a default order
class ProductsController < ApplicationController
  def index
    @q = Product.ransack(params[:q])
    scope = @q.result(distinct: true)
    set_default_sort
    @pagy, @products = pagy(scope, items: 10)
  end

  private

  def set_default_sort
    @q.sorts = "name asc" if @q.sorts.empty?
  end 
end

Then on the view you:

  1. Pass @q to the serch_form_for helper.
  2. Define the filters controller on the form.
  3. Tell the form to act on the table turbo frame.
  4. Define an action to filters#submit on each input.
  5. Add the sort_link on each th that you want.
<h1>Products</h1>

<%= search_form_for @q, html: {"data-controller": "filters", "data-turbo-frame": "table"} do |f| %>
  <div>
    <%= f.label :name_cont %>
    <%= f.search_field :name_cont, "data-action": "filters#submit" %>
  </div>

  <div>
    <%= f.label :category_eq %>
    <%= f.select :category_eq, Product.categories, {include_blank: true}, {"data-action": "filters#submit"} %>
  </div>

  <div>
    <%= f.label :price_gt %>
    <%= f.search_field :price_gt, "data-action": "filters#submit" %>
  </div>

  <div>
    <%= f.label :price_lt %>
    <%= f.search_field :price_lt, "data-action": "filters#submit" %>
  </div>
<% end %>

<%= turbo_frame_tag "table" do %>
  <p class="loading">Loading...</p>

  <table>
    <thead>
      <tr>
        <th><%= sort_link(@q, :name) %></th>
        <th><%= sort_link(@q, :category) %></th>
        <th><%= sort_link(@q, :price) %></th>
      </tr>
    </thead>
    <tbody>
      <%= render @products %>
    </tbody>
  </table>

  <%= raw pagy_nav(@pagy) %>
<% end %>

To submit the form on filters#submit you need a controller like this:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  submit(event) {
    this.element.requestSubmit()
  }
}

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.