Midwest::TablePresenter
Overview
Midwest::TablePresenter is a base class that collocates a table's column definitions, sort/filter/pagination configuration, and collection-building logic into a single object. Instead of spreading this across the controller (macro options), the view (column wiring), and the model (query scoping), you define it once and render it with midwest_table_from.
Setup
1. Create the presenter
By convention, place table presenters in app/tables/ following the naming pattern {App}::Tables::{Model}Table.
# app/tables/users_table.rbclass UsersTable < Midwest::TablePresenter model User default_sort :name default_direction :asc allowed_sorts %i[name email role created_at] filter_params %i[search role] default_per_page 25 per_page_options [10, 25, 50] column "Name", key: :name, sortable: true column "Email", key: :email, sortable: true column "Role", key: :role, sortable: true column "Created At", key: :created_at, sortable: true def apply_filters(scope) scope = scope.where("name ILIKE ?", "%#{filter(:search)}%") if filter(:search) scope = scope.where(role: filter(:role)) if filter(:role) scope endend2. Instantiate in the controller
class UsersController < ApplicationController include Midwest::TurboTable def index @table = table_presenter(UsersTable) endendtable_presenter wires the presenter with params and request so it can read query params and build URLs. Pass scope: to restrict the base query independently:
@table = table_presenter(UsersTable, scope: policy_scope(User))3. Render in the view
<%= turbo_frame_tag @table.frame_id do %> <%= midwest_table_from @table %><% end %>midwest_table_from renders a midwest_table with columns, sort state, and pagination configured from the presenter automatically.
column DSL
| Argument | Description |
|---|---|
label |
Column header text |
key: |
Method called on each row to render the cell value |
sortable: true |
Enables the sort link; sorted:, sort_direction:, and sort_url: are wired automatically |
| block | Custom cell rendering — receives (row, view) where view is the ActionView context |
Key-based cells
Pass key: and the component calls that method on every row. This is sufficient for plain text values:
column "Name", key: :name, sortable: truecolumn "Email", key: :email, sortable: truecolumn "Role", key: :roleBlock-based cells
Pass a block when you need HTML output — links, badges, formatted dates, or any component. The block receives (row, view), where view is the full ActionView context, giving access to URL helpers, content_tag, link_to, and all midwest_* view helpers:
# Link to the record's edit pagecolumn "Name" do |row, view| view.link_to row.name, view.edit_user_path(row)end# Render a Midwest badge based on the row's statecolumn "Status" do |row, view| view.midwest_badge(row.status, variant: row.active? ? :success : :neutral)end# Format a datecolumn "Joined" do |row, _view| row.created_at.strftime("%b %-d, %Y")endThe return value is rendered directly into the <td>. Return an HTML-safe string (use html_safe or a helper that already returns safe content) whenever you build markup manually:
column "Actions" do |row, view| view.content_tag(:div, class: "flex gap-2") do view.link_to("Edit", view.edit_user_path(row), class: "midwest-btn variant-ghost size-sm") + view.link_to("Delete", view.user_path(row), method: :delete, class: "midwest-btn variant-ghost size-sm color-red", data: { turbo_method: :delete, turbo_confirm: "Delete #{row.name}?" }) endendCombining key and block columns
Key-based and block-based columns are freely mixed in the same presenter:
class UsersTable < Midwest::TablePresenter model User column "Name", key: :name, sortable: true column "Email", key: :email, sortable: true column "Status" do |row, view| view.midwest_badge(row.status.humanize, variant: row.active? ? :success : :neutral) end column "Actions" do |row, view| view.link_to "Edit", view.edit_user_path(row), class: "midwest-btn variant-ghost size-sm" endendAdding columns from the view
Pass a block to midwest_table_from to append extra columns alongside the presenter-defined ones. This is useful for per-page action columns that depend on view context (e.g. policy checks) without putting them in the presenter:
<%= midwest_table_from @table do |t| %> <% t.with_column "Actions" do |row, view| %> <%= view.link_to "Edit", view.edit_user_path(row), class: "midwest-btn variant-ghost size-sm" %> <% end %><% end %>When writing the cell block directly in ERB (rather than inside a Ruby class), the view argument is available but implicit — you can call view helpers like link_to directly since you are already inside the ActionView context:
<%= midwest_table_from @table do |t| %> <% t.with_column "Actions" do |row| %> <%= link_to "Edit", edit_user_path(row), class: "midwest-btn variant-ghost size-sm" %> <% end %><% end %>Configuration options
| Method | Default | Description |
|---|---|---|
model(Class) |
nil |
ActiveRecord model used as the default base scope |
frame_id(id) |
derived from class name | Turbo frame ID (e.g. "users-table") |
default_sort(col) |
nil |
Column sorted by when no sort param is present |
default_direction(dir) |
:asc |
Direction used when no direction param is present |
allowed_sorts(*cols) |
nil |
Allowlist of sortable columns — strongly recommended |
filter_params(*keys) |
[] |
Allowlist of accepted filter param names |
default_per_page(n) |
nil |
Per-page count when no per-page param is present |
per_page_options(*opts) |
[10, 25, 50] |
Allowlist of valid per-page values |
action_model(Class) |
nil |
Model class passed to TableComponent for rich empty states |
show_jump_to(bool) |
false |
Show a page-number jump-to input in the pagination footer |
pagination_variant(sym) |
:default |
Pagination display variant passed to PaginationComponent |
row_dom_id(callable) |
nil |
Adds an id= to each <tr> for Turbo Streams row updates — pass true for Rails' dom_id convention or a lambda for custom ids |
row_href(callable) |
nil |
Makes entire rows clickable — the callable receives (row) or (row, view) and returns a navigation URL |
Instance API
| Method | Returns | Description |
|---|---|---|
collection |
`ActiveRecord::Relation \ | Array` |
sort_column |
`Symbol \ | nil` |
sort_direction |
`:asc \ | :desc` |
sorted?(col) |
Boolean |
Whether col is the active sort column |
filter(key) |
`String \ | nil` |
filters |
Hash |
All active filter values |
filterable? |
Boolean |
Whether any filter params are declared |
page |
Integer |
Current page (minimum 1) |
per_page |
`Integer \ | nil` |
frame_id |
String |
Turbo frame ID |
sort_url(col) |
String |
Sort URL for a column |
page_url(page) |
String |
Pagination URL |
per_page_url(n) |
String |
Per-page change URL |
clear_filter_url(key) |
String |
URL with one filter cleared |
clear_filters_url |
String |
URL with all filters cleared |
action_model |
`Class \ | nil` |
show_jump_to |
Boolean |
Whether to show the pagination jump-to input |
pagination_variant |
Symbol |
Pagination display variant |
row_dom_id_for(row) |
`String \ | nil` |
row_href_for(row, view) |
`String \ | nil` |
Rich empty state via action_model
Declare action_model in the presenter and midwest_table_from will automatically render a Midwest::EmptyStateComponent (inline variant) when the collection is empty — no manual empty state slot needed.
class UsersTable < Midwest::TablePresenter model User action_model User # User must include Midwest::Iconable for auto-icon column "Name", key: :name, sortable: trueendThe empty state inherits the icon, title, and body text from the model's Midwest::Iconable configuration:
class User < ApplicationRecord include Midwest::Iconable icon :personendYou can still supply an explicit empty_state slot in the block passed to midwest_table_from — the slot takes precedence over the action_model default.
Toolbar slot
Add a toolbar (filters, search, bulk actions) above the table body by yielding with_toolbar in the block passed to midwest_table_from:
<%= midwest_table_from @table do |t| %> <% t.with_toolbar do %> <%= form_with url: request.path, method: :get do |f| %> <%= f.text_field :search, value: @table.filter(:search), placeholder: "Search…" %> <% end %> <% end %> <% t.with_column "Actions" do |row, view| %> <%= view.link_to "Edit", view.edit_user_path(row) %> <% end %><% end %>The toolbar renders above the scroll wrapper and below the table's outer border, using the midwest-table-toolbar CSS class.
Pagination display options
Control how the pagination footer appears using two DSL helpers:
class UsersTable < Midwest::TablePresenter show_jump_to true # adds a "Go to page" input pagination_variant :arrows # passes :arrows to PaginationComponentendBoth values are threaded through midwest_table_from automatically — no changes to the view needed.
Auto-discovery
When you call table_presenter without an explicit class, it looks for a presenter using the naming convention {App}::Tables::{ControllerName}Table:
# In UsersController, looks for MyApp::Tables::UsersTable@table = table_presenterPass the class explicitly to opt out of convention:
@table = table_presenter(AdminUsersTable)Extending midwest_table_from
Pass a block to add columns alongside the presenter-defined ones, or to append extra slots:
<%= midwest_table_from @table, striped: true do |t| %> <% t.with_column "Actions" do |row, view| %> <%= view.link_to "Edit", view.edit_user_path(row), class: "midwest-btn variant-ghost" %> <% end %><% end %>Custom scoping
Override base_scope in the presenter for logic that should always apply, regardless of the scope: passed from the controller:
class UsersTable < Midwest::TablePresenter model User def base_scope super.where(active: true) endendOr pass scope: from the controller for per-request scoping (e.g. policy/tenant):
@table = table_presenter(UsersTable, scope: policy_scope(User).where(org: current_org))Clickable rows
Declare row_href to make entire table rows navigable. Each row gets a pointer cursor and navigates to the returned URL on click. Clicks on interactive elements within the row (links, buttons, inputs, selects) are not intercepted, so in-row controls continue to work normally.
In the presenter
The callable receives the row object. Pass a two-argument callable to access URL helpers via the view context:
class UsersTable < Midwest::TablePresenter model User # Simple path row_href ->(row) { "/users/#{row.id}" } # With URL helpers (second argument is the ActionView context) row_href ->(row, view) { view.user_path(row) } # Block form works too row_href { |row, view| view.edit_user_path(row) } column "Name", key: :name, sortable: true column "Email", key: :emailendNo changes to the view needed — midwest_table_from wires row_href automatically.
Directly on the component
When not using a presenter, pass row_href: as a component option:
<%= midwest_table(collection: @users, row_href: ->(row) { user_path(row) }) do |t| %> <% t.with_column('Name', key: :name) %> <% t.with_column('Email', key: :email) %><% end %>Keyboard and modifier support
- Cmd+click (Mac) / Ctrl+click (Windows/Linux) opens the URL in a new tab.
- When Turbo Drive is loaded, navigation uses
Turbo.visit()for SPA-style transitions. Otherwise it falls back to a standard page load.
Live row updates via Turbo Streams
Declare row_dom_id to stamp an id= attribute on every <tr>. This makes each row a named Turbo Streams target so the server can push a replace action to update a single row without touching the rest of the table.
1. Enable row ids in the presenter
Pass true to use Rails' standard dom_id convention ("user_1", "user_2", …). The rows must be ActiveRecord objects (or any object that responds to model_name and to_key):
class UsersTable < Midwest::TablePresenter model User row_dom_id true column "Name", key: :name, sortable: true column "Email", key: :emailendOr pass a lambda for a custom id format:
row_dom_id ->(row) { "user-row-#{row.id}" }2. Write a row partial
Turbo Streams replace sends the replacement HTML from the server. The replacement must be a standalone <tr> — column logic lives in the partial, not the presenter, so keep it in sync:
<%# app/views/users/_user_row.html.erb %><tr id="<%= dom_id(user) %>" class="midwest-table-row"> <td class="midwest-table-td"><%= user.name %></td> <td class="midwest-table-td"><%= user.email %></td> <td class="midwest-table-td"><%= user.role %></td></tr>3. Broadcast from the model
Use any of Rails' broadcast helpers to push the replacement after a change:
class User < ApplicationRecord after_update_commit -> { broadcast_replace_later_to :users, target: dom_id(self), partial: "users/user_row", locals: { user: self } }endbroadcast_replace_later_to enqueues the broadcast via a background job so the web request returns before the ActionCable write happens.
4. Subscribe in the view
Add a turbo_stream_from tag outside the turbo frame so the subscription persists across frame navigations:
<%= turbo_stream_from :users %><%= turbo_frame_tag @table.frame_id do %> <%= midwest_table_from @table %><% end %>Why not <turbo-frame> per row?
Browsers eject unknown elements from inside <table> during HTML parsing — a <turbo-frame> placed inside <tbody> lands outside the table in the DOM before Turbo's JavaScript can do anything with it. Turbo Streams replace operates on the already-parsed DOM via JavaScript, so targeting a <tr id="..."> works correctly.