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.
You are goint to build a paginated table with four filters and sortable columns.
Something like this:
👉 You can find the full commit here.
The first step is a common rails index with the pagy gem to build the pagination.
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
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
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
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 %>
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); }
👉 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
👉 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:
search_field
for name_cont
, that match if the name
contains the given value.select
for category_eq
with the list of possible categories, that match if the category
equals the selected value.search_field
for price_gt
, that match if the price
is greater than the given value.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;
}
👉 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;
}
👉 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; }
This is the code that you need to build a dynamic paginated data-grid with ransack and hotwire.
On the controller you need to:
params[:q]
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:
@q
to the serch_form_for
helper.filters
controller on the form.table
turbo frame.filters#submit
on each input.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()
}
}
Here I try 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 posts.