Midwest::TurboTable
Overview
Midwest::TurboTable is a controller concern that adds async sorting and pagination to any midwest_table. Include it in a controller, call the turbo_table macro, wrap your table in turbo_frame_tag, and every sort link and pagination click updates only the table region — no full-page reload.
The loading overlay renders automatically while the frame is fetching, driven entirely by CSS on the turbo-frame[aria-busy] attribute.
Setup
1. Include the concern
class UsersController < ApplicationController include Midwest::TurboTable turbo_table :users, default_sort: :name, default_direction: :asc, allowed_sorts: %i[name email role created_at] def index @users = User.order(table_order).page(table_page) endend2. Wrap the table in a turbo frame
Use midwest_table_frame_id as the frame ID so the concern and the view agree on the name. All links and forms inside a <turbo-frame> automatically target that frame, so sort links and pagination navigate without a full-page reload.
<%= turbo_frame_tag midwest_table_frame_id do %> <%= midwest_table(collection: @users, caption: "Users") do |t| %> <% t.with_column "Name", key: :name, sortable: true, sorted: table_sorted?(:name), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:name) %> <% t.with_column "Email", key: :email, sortable: true, sorted: table_sorted?(:email), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:email) %> <% t.with_pagination do %> <%= midwest_pagination( current_page: table_page, total_pages: @users.total_pages, page_url: ->(p) { midwest_page_url(p) } ) %> <% end %> <% end %><% end %>No routes change is needed — sort column, sort direction, and page are all query-string parameters on the existing action.
turbo_table options
| Option | Default | Description |
|---|---|---|
frame_id: |
"#{resource_name}-table" |
ID used for turbo_frame_tag and midwest_table_frame_id |
sort_param: |
:sort |
Query-string param name for the active sort column |
direction_param: |
:dir |
Query-string param name for sort direction |
page_param: |
:page |
Query-string param name for the current page |
default_sort: |
nil |
Column sorted by when no sort param is present |
default_direction: |
:asc |
Direction used when no direction param is present |
allowed_sorts: |
nil |
Allowlist of sortable column symbols — strongly recommended in production |
per_page_param: |
:per_page |
Query-string param name for the per-page count |
default_per_page: |
nil |
Per-page count used when no per-page param is present |
per_page_options: |
[10, 25, 50] |
Allowlist of valid per-page integers |
filter_params: |
[] |
Allowlist of query-string param names accepted as filter inputs |
Helper methods
All methods are available in both the controller and its views via helper_method.
| Method | Returns | Description |
|---|---|---|
midwest_table_frame_id |
String |
The configured turbo frame ID |
table_sort_column |
`Symbol \ | nil` |
table_sort_direction |
`:asc \ | :desc` |
table_sorted?(col) |
Boolean |
Whether col is the active sort column |
table_order |
`String \ | nil` |
table_page |
Integer |
Current page number (minimum 1) |
table_per_page |
`Integer \ | nil` |
midwest_sort_url(col) |
String |
Sort URL — toggles direction if col is already active, resets page to 1 |
midwest_page_url(page) |
String |
Page URL — preserves current sort and per-page params |
midwest_per_page_url(count) |
String |
Per-page URL — resets page to 1, preserves sort params |
table_filter(key) |
`String \ | nil` |
table_filters |
Hash |
All active filter values as { key: value } |
midwest_filter_form_tag(**opts) |
HTML |
GET form targeting the frame with sort/page/per_page as hidden inputs |
midwest_clear_filter_url(key) |
String |
URL with a single filter cleared and page reset |
midwest_clear_filters_url |
String |
URL with all filters cleared and page reset |
Filtering
Declare which query-string params are allowed as filter inputs, then read them in the controller and use midwest_filter_form_tag to render a GET form that keeps sort, page, and per-page state in sync.
1. Declare filter params
class UsersController < ApplicationController include Midwest::TurboTable turbo_table :users, default_sort: :name, allowed_sorts: %i[name email role created_at], filter_params: %i[search role] def index scope = User.order(table_order).page(table_page) scope = scope.where("name ILIKE ?", "%#{table_filter(:search)}%") if table_filter(:search) scope = scope.where(role: table_filter(:role)) if table_filter(:role) @users = scope endend2. Render the filter form
midwest_filter_form_tag wraps your inputs in a GET <form> with data-turbo-frame set to the frame ID. Sort, direction, and per-page state are automatically preserved as hidden inputs so filtering never loses sort order or page size.
<%= turbo_frame_tag midwest_table_frame_id do %> <%= midwest_filter_form_tag do %> <input type="search" name="search" value="<%= table_filter(:search) %>" placeholder="Search…"> <select name="role"> <option value="">All roles</option> <option value="engineer" <%= "selected" if table_filter(:role) == "engineer" %>>Engineer</option> <option value="designer" <%= "selected" if table_filter(:role) == "designer" %>>Designer</option> </select> <button type="submit">Filter</button> <% if table_filters.any? %> <%= link_to "Clear", midwest_clear_filters_url %> <% end %> <% end %> <%= midwest_table(collection: @users, caption: "Users") do |t| %> <% t.with_column "Name", key: :name, sortable: true, sorted: table_sorted?(:name), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:name) %> <% t.with_column "Email", key: :email, sortable: true, sorted: table_sorted?(:email), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:email) %> <% t.with_column "Role", key: :role %> <% t.with_pagination do %> <%= midwest_pagination(current_page: table_page, total_pages: @users.total_pages, page_url: ->(p) { midwest_page_url(p) }) %> <% end %> <% end %><% end %>Clearing individual filters
<% table_filters.each do |key, value| %> <span> <%= value %> <%= link_to "×", midwest_clear_filter_url(key), aria: { label: "Remove #{key} filter" } %> </span><% end %>Loading state
The table's loading overlay is shown automatically while the turbo frame is fetching. No configuration is required — this is handled entirely in CSS using the aria-busy attribute that Turbo sets on the <turbo-frame> element during navigation:
turbo-frame[aria-busy] .midwest-table-loading-overlay { display: flex;}turbo-frame[aria-busy] .midwest-table-scroll { opacity: 0.5; pointer-events: none;}Security
Always set allowed_sorts: in production. Column names from params are validated against a safe-character pattern (/\A[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?\z/i) before being interpolated into the ORDER BY clause, but an explicit allowlist is the primary protection:
turbo_table :products, allowed_sorts: %i[name price stock_count created_at]Any sort param value not in the allowlist is silently ignored and the default_sort is used instead.
Per-page and jump-to controls
Pass show_per_page: true and/or show_jump_to: true to midwest_pagination. Supply turbo_frame_id: so the Stimulus controller targets the frame instead of doing a full-page navigation.
turbo_table :users, default_sort: :name, allowed_sorts: %i[name email created_at], default_per_page: 25, per_page_options: [10, 25, 50, 100]def index @users = User.order(table_order) .page(table_page) .per(table_per_page)end<%= turbo_frame_tag midwest_table_frame_id do %> <%= midwest_table(collection: @users, caption: "Users") do |t| %> <% t.with_column "Name", key: :name, sortable: true, sorted: table_sorted?(:name), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:name) %> <% t.with_pagination do %> <%= midwest_pagination( current_page: table_page, total_pages: @users.total_pages, per_page: table_per_page, per_page_options: [10, 25, 50, 100], page_url: ->(p) { midwest_page_url(p) }, per_page_url: ->(n) { midwest_per_page_url(n) }, turbo_frame_id: midwest_table_frame_id, show_per_page: true, show_jump_to: true ) %> <% end %> <% end %><% end %>The turbo_frame_id: option wires up the pagination Stimulus controller to call Turbo.visit(url, { frame: id }) instead of window.location.href, so per-page changes and jump-to navigation update only the table frame.
Pagination library integration
table_page and table_per_page return plain integers, so they work with any pagination library.
Pagy
@pagy, @users = pagy(User.order(table_order), page: table_page, items: table_per_page || 25)<% t.with_pagination do %> <%= midwest_pagination( current_page: table_page, total_pages: @pagy.pages, per_page: table_per_page, per_page_options: [10, 25, 50], page_url: ->(p) { midwest_page_url(p) }, per_page_url: ->(n) { midwest_per_page_url(n) }, turbo_frame_id: midwest_table_frame_id, show_per_page: true ) %><% end %>Kaminari
@users = User.order(table_order).page(table_page).per(table_per_page || 25)<% t.with_pagination do %> <%= midwest_pagination( current_page: table_page, total_pages: @users.total_pages, per_page: table_per_page, per_page_options: [10, 25, 50], page_url: ->(p) { midwest_page_url(p) }, per_page_url: ->(n) { midwest_per_page_url(n) }, turbo_frame_id: midwest_table_frame_id, show_per_page: true ) %><% end %>Live row updates via Turbo Streams
Individual rows can be updated in place without refreshing the whole table. Each <tr> needs a stable DOM id so Turbo Streams knows which element to replace.
Using TablePresenter (recommended)
If you are using TablePresenter, declare row_dom_id in the presenter class — see the Table Presenter docs for the full walkthrough.
Using TurboTable directly
When building the table manually with midwest_table, pass a row_dom_id: callable to the component. It receives each row object and returns the id string to stamp on the <tr>:
<%= turbo_frame_tag midwest_table_frame_id do %> <%= midwest_table( collection: @users, row_dom_id: ->(user) { dom_id(user) } ) do |t| %> <% t.with_column "Name", key: :name, sortable: true, sorted: table_sorted?(:name), sort_direction: table_sort_direction, sort_url: midwest_sort_url(:name) %> <% t.with_column "Email", key: :email %> <% end %><% end %>With row_dom_id set, each rendered row becomes:
<tr id="user_1" class="midwest-table-row">...</tr><tr id="user_2" class="midwest-table-row">...</tr>The model then broadcasts replacements targeting those ids:
class User < ApplicationRecord after_update_commit -> { broadcast_replace_later_to :users, target: dom_id(self), partial: "users/user_row", locals: { user: self } }endSubscribe outside the turbo frame so the connection survives frame navigations:
<%= turbo_stream_from :users %><%= turbo_frame_tag midwest_table_frame_id do %> <!-- table here --><% end %>Custom frame ID
If your page has multiple tables, give each one a distinct ID:
turbo_table :users, frame_id: 'users-table'turbo_table :products, frame_id: 'products-table'Or include the concern in a base controller and override midwest_table_frame_id per action:
class ReportsController < ApplicationController include Midwest::TurboTable turbo_table :reports, frame_id: 'reports-table'end