Rails 7.1.x - Dynamic Tables with Turbo Morph

Very Simple and easy Dynamic Tables

I recently learned that Rails 7.1+ has some delightful features that make it easy to render dynamic tables without JavaScript. This is an exploration of using these new features.

The cool thing is that morph updates without a full page reload - so its fast! and very easy to setup.

This code can be found at: https://github.com/btihen-dev/rails_morph_tables

Quick Summary

By adding this config in our head we enable Morph:

# app/views/layouts/application.html.erb
  <head>
    ...

    <meta name="turbo-refresh-method" content="morph">
    <meta name="turbo-refresh-scroll" content="preserve">
    <%= turbo_refreshes_with method: :morph, scroll: :preserve  %>
    <%= yield :head %>

    ...
  </head>

Then we can add: , data: { turbo_action: 'replace' } to links (like sorting links or form submit urls). Then morph will automatically update only changes without a full page reload and it doesn’t reset our page location.

The following article shows how to do this with sorting links and a filter/search form - this article is a summary of Dynamic Table Sorting with Morph and expands on Dynamic Filtering (with a form input) using Turbo morph.

Getting Started

Initially, I had a little problem with esbuild - partly because I didn’t start rails with bin/dev procfile. See the Appendix to be sure esbuild works and builds the proper files.

With import-maps you can start rails with just: bin/rails, but with esbuild be sure to use bin/dev!

# install rails 7.1.x
rails _7.1.3.2_ new morph_tables -T -j esbuild -d postgresql --css=bootstrap
# rails _7.1.3.2_ new morph_tables -T -d postgresql --css=bootstrap
cd morph_tables

# build the models
bin/rails g scaffold Species species_name
bin/rails g scaffold Character nick_name first_name \
            last_name given_name gender species:references
bin/rails g scaffold Company company_name
bin/rails g scaffold Job role company:references
bin/rails g model PersonJob start_date:date end_date:date \
            character:references job:references

# Add data to the seeds file see:
# https://github.com/btihen-dev/rails_morph_tables/blob/main/db/seeds.rb

bin/rails db:create
bin/rails db:migrate
bin/rails db:seed

git add .
git commit -m "add generated code"

# start rails and test
bin/dev

NOTE: So far this code only works when using import maps and not when using esbuild. When I find a solution I will update this article or write a new one about these features with esbuild.

Basic Setup Table Sort

This section is a summary of the article Dynamic Table Sorting with Morph, but repeated hear to build the base app and is an adaptation of the article: https://www.colby.so/posts/turbo-8-refresh-sorting

Lets make the character index page our home (root) page by adding root "characters#index" to the end of our routes:

#
Rails.application.routes.draw do
  resources :jobs
  resources :companies
  resources :characters
  resources :species
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Defines the root path route ("/")
  root "characters#index"
end

tidy models:

Character

# app/models/character.rb
class Character < ApplicationRecord
  belongs_to :species

  has_many :person_jobs, dependent: :destroy
  has_many :jobs, through: :person_jobs
  has_many :companies, through: :jobs

  normalizes :first_name, :nick_name, :last_name, :given_name,
             with: ->(value) { value.strip }

  validates :first_name,
            uniqueness: { scope: :last_name,
                          message: "first_name and last_name already exists" }
  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :species, presence: true
  validates :gender, presence: true
  validates :gender, inclusion: { in: %w[male female] }
end

Company

# app/models/company.rb
class Company < ApplicationRecord
  has_many :jobs, dependent: :destroy
  has_many :person_jobs, through: :jobs
  has_many :characters, through: :person_jobs

  normalizes :company_name,  with: ->(value) { value.strip }

  validates :company_name, presence: true
  validates :company_name, uniqueness: true
end

Job

# app/models/job.rb
class Job < ApplicationRecord
  belongs_to :company

  has_many :person_jobs, dependent: :destroy
  has_many :people, through: :person_jobs

  normalizes :role, :title, :company, with: ->(value) { value.strip }

  validates :company, presence: true
  validates :role, presence: true
  validates :role,
            uniqueness: { scope: :company_id,
                          message: "role and company already exists" }
end

PersonJob

# app/models/person_job.rb
class PersonJob < ApplicationRecord
  belongs_to :character
  belongs_to :job

  has_one :company, through: :job

  validates :job, presence: true
  validates :person, presence: true
  validates :start_date, presence: true
  validates :person,
            uniqueness: { scope: [ :job, :start_date ],
                          message: "person and job with start_date already exists" }
end

Species

# app/models/species.rb
class Species < ApplicationRecord
  has_many :characters, dependent: :destroy

  normalizes :species_name, with: ->(value) { value.strip }

  validates :species_name, presence: true
  validates :species_name, uniqueness: true
end

update the index of the Character controller to avoid an N+1 query for our table:

# app/controllers/characters_controller.rb
  def index
    query = Character
            .includes(:species)
            .includes(person_jobs: { job: :company })
    if params[:column].present?
      # @characters = query.order("#{params[:column]}").all
      @characters = query.order("#{params[:column]} #{params[:direction]}").all
    else
      @characters = query.all
    end
  end

now let’s make some helpers for our dynamic table:

# app/helpers/characters_helper.rb
module CharactersHelper
  def sort_link(column:, label:)
    direction = column == params[:column] ? future_direction : 'asc'
    link_to(
      "#{label} #{sort_arrow_for(column)}".html_safe,
      characters_path(column: column, direction: direction)
    )
  end

  def future_direction = params[:direction] == 'asc' ? 'desc' : 'asc'

  def sort_arrow
    case params[:direction]
    when 'asc' then tag.i(class: "bi bi-arrow-up")
    when 'desc' then tag.i(class: "bi bi-arrow-down")
    else tag.i(class: "bi bi-arrow-down-up")
    end
  end

  def sort_arrow_for(column)
    params[:column] == column ? sort_arrow : tag.i(class: "bi bi-arrow-down-up")
  end
end

Let’s convert the people index view into a table view app/views/people/index.html.erb

# app/views/characters/index.html.erb
<p style="color: green"><%= notice %></p>

<% content_for :title, "Characters" %>
<div class="container text-center">

  <div class="row justify-content-start">
    <div class="col-9">
      <h1>Characters</h1>
      <%= render "characters", characters: @characters %>
    </div>

    <div class="col-3">
      <%= link_to "New", new_character_path, class: "mt-5 sticky-top btn btn-primary" %>
    </div>
  </div>
</div>
# app/views/characters/_characters.html.erb
<table class="table table-striped table-hover">
  <thead class="sticky-top">
    <tr class="table-primary">
      <th scope="col">
        <%= sort_link(column: "characters.id", label: "Id", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "first_name", label: "First Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "last_name", label: "Last Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "gender", label: "Gender", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "species.species_name", label: "Species", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        Company
      </th>
    </tr>
  </thead>

  <tbody class="scrollable-table">
    <div id="characters">
    <% characters.each do |character| %>
      <tr id="<%= dom_id(character) %>">
        <th scope="row"><%= link_to "#{character.id}", edit_character_path(character) %></th>
        <td><%= character.first_name %></td>
        <td><%= character.last_name %></td>
        <td><%= character.gender %></td>
        <td><%= character.species.species_name %></td>
        <td class="text-start">
          <ul class="list-unstyled">
            <% character.person_jobs.each  do |person_job| %>
              <li>
                <b><%= person_job.job.company.company_name %></b><br>
                &nbsp; - <%= person_job.job.role %><br>
                &nbsp; &nbsp;
                <em>
                  (<%= person_job.start_date.strftime("%e %b '%y") %> -
                    <%= person_job.end_date&.strftime("%e %b '%y") || 'present' %> )
                </em>
              </li>
            <% end %>
          </ul>
        </td>
      </tr>
    <% end  %>
    </div>
  </tbody>

</table>

start rails:

bin/dev

go to: http://localhost:3030/people and be sure this table looks reasonable.

now let’s commit this.

git add .
git commit -m "basic people table added"

Fix Scroll Reset

To fix scroll reset we need to enable new Turbo 8 features - morph dom and keeping scroll location:

# app/views/layouts/application.html.erb
  <head>
    <title>EsbuildTables</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <meta name="turbo-refresh-method" content="morph">
    <meta name="turbo-refresh-scroll" content="preserve">
    <%= turbo_refreshes_with method: :morph, scroll: :preserve  %>
    <%= yield :head %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
  </head>

you can add this feature more surgically, this enables these features everywhere.

Now we need to inform our helper about the morph-dom by adding data: { turbo_action: 'replace' } to our path so the helper now looks

# app/helpers/characters_helper.rb
module CharactersHelper
  def sort_link(column:, label:)
    direction = column == params[:column] ? future_direction : 'asc'
    link_to(
      "#{label} #{sort_arrow_for(column)}".html_safe,
      characters_path(column: column, direction: direction),
      data: { turbo_action: 'replace' }
    )
  end

  def future_direction = params[:direction] == 'asc' ? 'desc' : 'asc'

  def sort_arrow
    case params[:direction]
    when 'asc' then tag.i(class: "bi bi-arrow-up")
    when 'desc' then tag.i(class: "bi bi-arrow-down")
    else tag.i(class: "bi bi-arrow-down-up")
    end
  end

  def sort_arrow_for(column)
    return sort_arrow if params[:column] == column

    tag.i(class: "bi bi-arrow-down-up")
  end
end

now when you sort a column it doesn’t reset the scroll location.

Add Filter (submit with button)

This section is built up the article: https://www.colby.so/posts/filtering-tables-with-rails-and-hotwire, but adapted to use the simplicity of Turbo 8 Morph instead of the full hotwire features.

Add a form to submit our filter text in the company header with:

      <th scope="col">
        Company
        <%= form_with url: characters_path, method: :get do |form| %>
          <%= form.button "Filter", class: "btn btn-light btn-sm" %>
          <%= form.text_field :company_name,
                placeholder: "company name",
                value: params[:company_name],
                class: "form-control form-control-sm" %>
        <% end  %>
      </th>

now the table can be updated to look like:

# app/views/characters/_characters.html.erb
<table class="table table-striped table-hover">
  <thead class="sticky-top">
    <tr class="table-primary">
      <th scope="col">
        <%= sort_link(column: "characters.id", label: "Id", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "first_name", label: "First Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "last_name", label: "Last Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "gender", label: "Gender", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "species.species_name", label: "Species", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        Company
        <%= form_with url: characters_path, method: :get do |form| %>
          <%= form.button "Filter", class: "btn btn-light btn-sm" %>
          <%= form.text_field :company_filter, # field sent back to controller
                placeholder: "company name",
                value: params[:company_filter], # holds our value for user to view
                class: "form-control form-control-sm" %>
        <% end  %>
      </th>
    </tr>
  </thead>

  <tbody class="scrollable-table">
    <div id="characters">
    <% characters.each  do |character| %>
      <tr id="<%= dom_id(character) %>">
        <th scope="row"><%= link_to "#{character.id}", edit_character_path(character) %></th>
        <td><%= character.first_name %></td>
        <td><%= character.last_name %></td>
        <td><%= character.gender %></td>
        <td><%= character.species.species_name %></td>
        <td class="text-start">
          <ul class="list-unstyled">
            <% character.person_jobs.each  do |person_job| %>
              <li>
                <b><%= person_job.job.company.company_name %></b><br>
                &nbsp; - <%= person_job.job.role %><br>
                &nbsp; &nbsp;
                <em>
                  (<%= person_job.start_date.strftime("%e %b '%y") %> -
                    <%= person_job.end_date&.strftime("%e %b '%y") || 'present' %> )
                </em>
              </li>
            <% end %>
          </ul>
        </td>
      </tr>
    <% end  %>
    </div>
  </tbody>

</table>

now we need to receive it in our controller - so we update index with an filter:

    # filter by company name if present
    @company_filter = params[:company_filter]

    if @company_filter.present?
      query = query
              .joins(person_jobs: { job: :company })
              .where('companies.company_name ilike ?', "%#{@company_filter}%")
    end

Now index will look like

# app/controllers/characters_controller.rb
  def index
    @company_filter = params[:company_filter]
    query = Character
            .includes(:species)
            .includes(person_jobs: { job: :company })

    # sort column and direction if present
    query = query.order("#{params[:column]} #{params[:direction]}") if params[:column].present?

    # filter by company name if present
    if @company_filter.present?
      query = query
              .joins(person_jobs: { job: :company })
              .where('companies.company_name ilike ?', "%#{@company_filter}%")
    end
    @characters = query.all
  end

This works well, but we loose our filter when we sort - so let’s update by adding our company_filter into our helper that builds the url:

# app/helpers/characters_helper.rb
module CharactersHelper
  def sort_link(column:, label:, company_filter:)
    direction = column == params[:column] ? future_direction : 'asc'
    link_to(
      "#{label} #{sort_arrow_for(column)}".html_safe,
      characters_path(column:, direction:, company_name: company_filter),
      data: { turbo_action: 'replace' }
    )
  end

  def future_direction = params[:direction] == 'asc' ? 'desc' : 'asc'

  def sort_arrow
    case params[:direction]
    when 'asc' then tag.i(class: "bi bi-arrow-up")
    when 'desc' then tag.i(class: "bi bi-arrow-down")
    else tag.i(class: "bi bi-arrow-down-up")
    end
  end

  def sort_arrow_for(column)
    params[:column] == column ? sort_arrow : tag.i(class: "bi bi-arrow-down-up")
  end
end

Filter without Button

create a new stimulus controller that will feed our data (after a short delay) automatically

rails g stimulus filter_form

now make it look like (400ms) is teh delay to allow the user to type before it sends a request (adjust it as you wish):

// app/javascript/controllers/filter_form_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["form"];

  filter() {
    clearTimeout(this.timeout);
    this.timeout = setTimeout(() => {
      this.formTarget.requestSubmit();
    }, 200);
  }
}

We can now adjust our form and wire it to stimulus with data: { action: "input->filter-form#filter" } and remove the button now the form looks like:

      <th scope="col">
        Company
        <%= form_with url: characters_path, method: :get,
            data: {
              controller: "filter-form", filter_form_target: "form", turbo_action: 'replace' # adding replace here keeps the scroll location correct
            } do |form| %>
          <%= form.text_field :company_filter,
              placeholder: "partial name",
              value: params[:company_filter],
              class: "form-control form-control-sm",
              autocomplete: "off",
              data: { action: "input->filter-form#filter" } %>
        <% end %>
      </th>

now the template looks like:

# app/views/characters/_characters.html.erb
<table class="table table-striped table-hover">
  <thead class="sticky-top">
    <tr class="table-primary">
      <th scope="col">
        <%= sort_link(column: "characters.id", label: "Id", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "first_name", label: "First Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "last_name", label: "Last Name", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "gender", label: "Gender", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        <%= sort_link(column: "species.species_name", label: "Species", company_filter: @company_filter) %>
      </th>
      <th scope="col">
        Company
        <%= form_with url: characters_path, method: :get,
            data: {controller: "filter-form", filter_form_target: "form", turbo_action: 'replace' # adding replace here keeps the scroll location correct
            } do |form| %>
          <%= form.text_field :company_filter,
              placeholder: "partial name",
              value: params[:company_filter],
              class: "form-control form-control-sm",
              autocomplete: "off",
              data: { action: "input->filter-form#filter" } %> ## connects with the js
        <% end %>
      </th>
    </tr>
  </thead>

  <tbody class="scrollable-table">
    <div id="characters">
    <% characters.each do |character| %>
      <tr id="<%= dom_id(character) %>">
        <th scope="row"><%= link_to "#{character.id}", edit_character_path(character) %></th>
        <td><%= character.first_name %></td>
        <td><%= character.last_name %></td>
        <td><%= character.gender %></td>
        <td><%= character.species.species_name %></td>
        <td class="text-start">
          <ul class="list-unstyled">
            <% character.person_jobs.each  do |person_job| %>
              <li>
                <b><%= person_job.job.company.company_name %></b><br>
                &nbsp; - <%= person_job.job.role %><br>
                &nbsp; &nbsp;
                <em>
                  (<%= person_job.start_date.strftime("%e %b '%y") %> -
                    <%= person_job.end_date&.strftime("%e %b '%y") || 'present' %> )
                </em>
              </li>
            <% end %>
          </ul>
        </td>
      </tr>
    <% end  %>
    </div>
  </tbody>

</table>

Resources

Adding Bootstrap to an existing project

Bootstrap Icond

Rails Table Articles

Rails Hotwire

APPENDIX

when using esbuild you should see the following two files and you should NOT see import-map files or config!

David Colby who wrote: https://www.colby.so/posts/turbo-8-refresh-sorting and https://www.colby.so/posts/filtering-tables-with-rails-and-hotwire

writes:

For reference, Procfile.dev looks like this for me on a fresh esbuild + bootstrap install:

web: env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn build --watch
css: yarn watch:css

And package.json looks like this:

{
  "name": "app",
  "private": true,
  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "@hotwired/turbo-rails": "^8.0.4",
    "@popperjs/core": "^2.11.8",
    "autoprefixer": "^10.4.19",
    "bootstrap": "^5.3.3",
    "bootstrap-icons": "^1.11.3",
    "esbuild": "^0.20.2",
    "nodemon": "^3.1.0",
    "postcss": "^8.4.38",
    "postcss-cli": "^11.0.0",
    "sass": "^1.76.0"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets",
    "build:css:compile": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules",
    "build:css:prefix": "postcss ./app/assets/builds/application.css --use=autoprefixer --output=./app/assets/builds/application.css",
    "build:css": "yarn build:css:compile && yarn build:css:prefix",
    "watch:css": "nodemon --watch ./app/assets/stylesheets/ --ext scss --exec \"yarn build:css\""
  },
  "browserslist": [
    "defaults"
  ]
}
Bill Tihen
Bill Tihen
Developer, Data Enthusiast, Educator and Nature’s Friend

very curious – known to explore knownledge and nature