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
ActiveRecordrecords 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
pagyhelper
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:
|
|
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:
|
|
The list explicitly enumerates safe sort/search targets. Ransack requires this since v4 to prevent unexpected exposure of internal columns.
Controller
|
|
@q is the Ransack::Search object. @pagy and @jobs come from
Pagy’s standard helper.
View
|
|
What happens:
- Sort — clicking a sortable header navigates to a URL with
Ransack’s
?q[s]=column+directionparameter; 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_:
|
|
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.
|
|
Two things to notice:
-
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.
-
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 BYclause.
Controller
|
|
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
|
|
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 toFoTravel.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:
|
|
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
|
|
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:
|
|
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:
-
Tailwind classes — the component uses Tailwind utilities throughout. Adapt to your design tokens. Standard project colours like
bg-surface,text-text,border-bordermap to your design system. -
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. -
Phlex and Rails helpers — the component includes several helper modules:
Pluralize,SearchFormFor,SortLink,LinkTo. These come fromphlex-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.