Skip to content

Appendix D — DataTable Component

DataTable is a Phlex component that renders sortable, paginated, optionally searchable tables. It handles two distinct data shapes through one DSL:

  • AR mode — ActiveRecord collections with Ransack search, sort, and Pagy pagination
  • Hash mode — service-shaped data (arrays of hashes) with plain server-side sort and Pagy pagination

The choice between modes is automatic. When you pass a Ransack:: Search object via q:, the component uses AR mode. When you don’t, it uses Hash mode. Same DSL either way; the component adjusts its internal behaviour to match the data.

This appendix is reference material for using the component across projects. It’s organised by mode, with a shared section on the DSL.

When to use which mode

The decision is about where the data comes from and what shape it has.

AR mode

Use when:

  • The rows are ActiveRecord records from a single model
  • Sort columns map to the model’s columns or scopes
  • Free-text search fits the Ransack matcher pattern
  • Pagination uses the standard pagy helper

This is the right shape for “list of jobs”, “list of customers”, “list of orders” — anywhere you’re displaying a collection that’s fundamentally a model’s records, perhaps with simple where/joins applied.

Hash mode

Use when:

  • The data is the result of a service object’s analytical work
  • Window functions, CTEs, aggregations, or cross-table joins produce columns that don’t map to a single AR model
  • The “rows” are conceptual entities (per-FO travel summaries, per-region SLA metrics) rather than database records

The signal: if you’re looking at the data and thinking “this isn’t really a model’s records, it’s the output of a calculation”, Hash mode fits.

In the tutorial, FoTravel.call is a clear example: the rows are “per-field-officer travel summaries over the last 90 days.” That’s not a User record (those exist), but it’s not a single model’s records either — it’s the output of a window-function query joining users with computed per-job distances.

The DSL

The shared DSL is the same in both modes. You declare:

1
2
3
4
5
6
7
8
9
render Components::DataTable.new(rows: ..., pagy: ..., q: ...) do |t|
  t.search field: :ransack_matcher        # AR mode only
  t.column :name, label: "...", sort: :sort_key, align: :left|:right do |row|
    # optional content block — return a String or render Phlex/ActionView
  end
  t.actions do |row|
    # optional rightmost actions cell
  end
end

The column declaration’s properties:

  • name — the column’s identifier. In AR mode, used as the attribute reader (row.public_send(name)); in Hash mode, used as the key into the row hash. Must be a symbol.
  • label — the human-readable header. Defaults to name.to_s.humanize.
  • sort — when set, makes the header clickable for sorting. In AR mode, must match a Ransack-recognised attribute (declared in ransackable_attributes); in Hash mode, must match a key in the row hash.
  • align:left (default) or :right. Right-align for numeric columns reads better.
  • content block (optional) — a block that takes the row and returns a String (rendered as text) or renders Phlex / ActionView components directly to the output buffer. Without a block, the component renders the value as a string.

The actions declaration takes only a block. It produces a right-most column for per-row actions (View/Edit/Delete links).

The search declaration takes a field: (the Ransack matcher to bind to) and an optional placeholder:. AR mode only — silently ignored in Hash mode.

AR mode

Setup

In your model, declare which attributes Ransack can search and sort:

1
2
3
4
5
class Job < ApplicationRecord
  def self.ransackable_attributes(_auth_object = nil)
    %w[reference customer_name scheduled_at status]
  end
end

The list explicitly enumerates safe sort/search targets. Ransack requires this since v4 to prevent unexpected exposure of internal columns.

Controller

1
2
3
4
def index
  @q = Job.visible_to(Current.user).ransack(params[:q])
  @pagy, @jobs = pagy(@q.result, items: 25)
end

@q is the Ransack::Search object. @pagy and @jobs come from Pagy’s standard helper.

View

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
render Components::DataTable.new(rows: @jobs, pagy: @pagy, q: @q) do |t|
  t.search field: :reference_or_customer_name_cont,
           placeholder: "Search jobs..."

  t.column :reference, label: "Job", sort: :reference do |job|
    link_to job.reference, job_path(job), class: "font-mono text-sm"
  end

  t.column :customer_name, label: "Customer", sort: :customer_name

  t.column :status, label: "Status" do |job|
    render Components::Badge.new(label: job.status.humanize,
                                 variant: status_variant(job))
  end

  t.column :scheduled_at, label: "Scheduled", sort: :scheduled_at do |job|
    job.scheduled_at.strftime("%-d %b · %H:%M")
  end

  t.actions do |job|
    link_to "View", job_path(job), class: "text-primary hover:underline"
  end
end

What happens:

  • Sort — clicking a sortable header navigates to a URL with Ransack’s ?q[s]=column+direction parameter; Ransack reorders the AR relation; the page rerenders
  • Search — submitting the search form navigates to a URL with Ransack’s ?q[matcher]=... parameter; the relation is filtered
  • Pagination — clicking a page number navigates to ?page=N; Pagy slices the relation

All interactions are normal links/forms. With Turbo, the page rerenders smoothly. No JavaScript controllers needed.

Common search matchers

Ransack provides matchers as predicate suffixes:

  • _cont — contains (case-insensitive substring match)
  • _eq — equals
  • _in — IN list
  • _gteq, _lteq — greater/less than or equal
  • _start, _end — starts/ends with

Combine columns with _or_:

1
field: :reference_or_customer_name_cont

Searches both reference and customer_name for the search term.

Hash mode

Setup

The service object is the source of truth. It accepts sort parameters, returns hash-shaped rows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module FoTravel
  extend self

  SORT_COLUMNS = %w[name transitions total_km avg_km].freeze
  DEFAULT_SORT = "total_km".freeze
  DEFAULT_DIR  = "desc".freeze

  def call(sort: nil, dir: nil)
    rows = fetch_rows
    apply_sort(rows, sort, dir)
  end

  private

  def fetch_rows
    # SQL query that returns array of hashes
    # ...
  end

  # Whitelist sort column to known values; default if invalid.
  # Whitelist direction to asc/desc.
  def apply_sort(rows, sort, dir)
    sort_key = SORT_COLUMNS.include?(sort) ? sort.to_sym : DEFAULT_SORT.to_sym
    direction = (dir == "asc") ? :asc : :desc

    sorted = rows.sort_by { |row| row[sort_key] }
    sorted.reverse! if direction == :desc
    sorted
  end
end

Two things to notice:

  1. Whitelist the sort column. Don’t pass user-supplied strings through to data access without validation. The whitelist also provides a sensible default when the param is unknown.

  2. The sort happens in Ruby on the result. For datasets of thousands of rows or fewer, this is fast (a few milliseconds). For larger datasets, sorting in SQL is more efficient — you interpolate the whitelisted column name into an ORDER BY clause.

Controller

1
2
3
4
5
6
7
def fo_travel
  rows = FoTravel.call(
    sort: params[:sort],
    dir:  params[:dir]
  )
  @pagy, @rows = pagy_array(rows, items: 25)
end

pagy_array is Pagy’s helper for pre-loaded arrays. Same interface as pagy, just slices the array instead of paginating an AR relation.

View

1
2
3
4
5
6
render Components::DataTable.new(rows: @rows, pagy: @pagy) do |t|
  t.column :name,        label: "Field Officer", sort: :name
  t.column :transitions, label: "Transitions",   sort: :transitions, align: :right
  t.column :total_km,    label: "Total km",      sort: :total_km,    align: :right
  t.column :avg_km,      label: "Avg km",        sort: :avg_km,      align: :right
end

No q: — that’s the signal for Hash mode. No t.search either (Hash mode doesn’t support it; if your service needs filtering, pass parameters into the service’s call).

What happens:

  • Sort — clicking a sortable header navigates to a URL with ?sort=column&dir=direction; the controller passes those to FoTravel.call; the service returns sorted rows
  • Pagination — clicking a page navigates to ?page=N; Pagy slices the array

Direction toggling

In both modes, clicking a sort header behaves intuitively:

  • First click on a column — sorts ascending (with arrow ↑)
  • Click again — toggles to descending (↓)
  • Click another column — switches to that column, ascending

The active sort column shows an arrow indicator. Inactive columns have no arrow.

In AR mode, Ransack handles this. In Hash mode, the component reads params[:sort] and params[:dir] to determine the current state and computes the next direction.

Empty states

When the rows array is empty, the component renders an empty state component instead of an empty table:

  • No search active — “No records yet”
  • Search active — “No results — try a different search.”

The empty state is Components::EmptyState (a separate component in the chassis). If your project has its own equivalent, point the component at it; the dependency is one line in the source.

Pagination details

The pagination row appears below the table when pagy: is provided. It shows:

  • Info text — “Showing 1–25 of 142 records”
  • Page nav — prev/next arrows, numbered page links, gaps for large page counts

The page nav uses Pagy’s series method, which returns an array like [1, :gap, 4, 5, "6", 7, 8, :gap, 12]. The current page is a string (non-clickable), gaps are :gap symbols (rendered as ), other entries are integers (clickable links).

If there’s only one page (or fewer rows than items-per-page), the pagination row is hidden.

Action columns

The optional rightmost column is for per-row actions:

1
2
3
4
5
t.actions do |row|
  link_to "View", path_to(row)
  link_to "Edit", edit_path_to(row)
  button_to "Delete", path_to(row), method: :delete, data: { turbo_confirm: "Sure?" }
end

The block is called with the row and runs in the component’s context, so view helpers (link_to, button_to, etc.) work.

The header label defaults to “Actions”; pass label: to customise.

Custom cell content

Without a block, a column renders the value as plain text. With a block, the block receives the row and can return either:

  • A String — rendered as text
  • Anything else — assumed to have written to the output buffer via Phlex or ActionView helpers
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Returns a String
t.column :name do |row|
  row[:name].upcase
end

# Renders a Phlex component (writes to buffer, returns nil)
t.column :status do |row|
  render Components::Badge.new(label: row[:status])
end

# Renders a Rails helper (also writes to buffer)
t.column :owner do |row|
  link_to(row.owner.name, user_path(row.owner))
end

The component handles all three cases via the block’s return value and Phlex’s buffer awareness.

Column alignment

Numeric columns generally read better right-aligned. Set align: :right to right-align both header and cell:

1
t.column :total_km, sort: :total_km, align: :right

tabular-nums is also applied via Tailwind to numeric pagination links so columns of numbers align at the digits.

Reusing across projects

DataTable is designed for reuse beyond this tutorial. Three dependencies to be aware of when porting:

  1. Tailwind classes — the component uses Tailwind utilities throughout. Adapt to your design tokens. Standard project colours like bg-surface, text-text, border-border map to your design system.

  2. Components::EmptyState — the empty state component is a peer in the chassis. If your project doesn’t have an equivalent, either copy it or replace the empty state rendering with simple inline markup.

  3. Phlex and Rails helpers — the component includes several helper modules: Pluralize, SearchFormFor, SortLink, LinkTo. These come from phlex-rails. Make sure your project has phlex-rails installed.

The Pagy and Ransack gems are required for AR mode but not Hash mode. A simpler version of the component without the AR/Ransack paths would be possible if Hash mode is all you need; the current component supports both because the chassis uses both.

When to use what

A small mental cheat sheet:

  • Listing AR records → AR mode. Get search, sort, pagination from Ransack and Pagy.
  • Showing analytical service output → Hash mode. Service controls what sort means; component renders the result.
  • Need both? → Two tables (probably on different pages). The component supports both modes; the choice is per render.