A Rails engine + JavaScript library that simplifies building DataTables with AJAX, filters, checkboxes, buttons, and context menus. Works alongside ajax-datatables-rails.
Gemfile:
gem 'datatables-factory'package.json:
{
"dependencies": {
"@jbox-web/datatables-factory": "^1.0.0"
}
}Use bootstrap_datatables_for (includes Bootstrap 5 classes) or datatables_for (bare):
<% dt = bootstrap_datatables_for(:users, source: users_path, stateSave: true) do |dt| %>
<% dt.head_for_check_box %>
<% dt.head_for :name, label: 'Name', sortable: true %>
<% dt.head_for :email, label: 'Email', sortable: true %>
<% dt.head_for :created_at, label: 'Created', sortable: true %>
<% dt.head_for :actions, label: '', sortable: false, searchable: false, colvis: false %>
<% dt.search_form do |f| %>
<%= f.text_field :name %>
<%= f.select :role %>
<%= f.render_datatable %>
<% end %>
<% end %>head_for options:
| Option | Default | Description |
|---|---|---|
label |
'' |
Column header text |
sortable |
true |
Enables column sorting |
searchable |
true |
Includes column in global search |
visible |
true |
Column visibility |
colvis |
true |
Appears in column visibility toggle |
class |
[] |
Extra CSS classes — a String or an Array |
width |
'' |
Column width |
priority |
— | Responsive priority — a lower value keeps the column visible longer when width runs out. Omitted by default, so the plugin applies its own 10000 |
The filter names passed to the form builder must match a declared column; a
filter naming an unknown column raises ArgumentError at render time rather
than silently rendering a filter that filters nothing.
import { DatatableBase } from '@jbox-web/datatables-factory'
class UsersDatatable extends DatatableBase {}
// Expose on window under the namespace the Ruby side expects
window.datatables = window.datatables || {}
window.datatables.UsersDatatable = UsersDatatableThe Ruby presenter resolves the JS class by building a dotted path from the table id and an optional namespace. For a table declared as datatables_for(:users), it looks for window.datatables.UsersDatatable.
import { DatatableBase } from '@jbox-web/datatables-factory'
document.addEventListener('DOMContentLoaded', () => {
DatatableBase.load_datatables()
})load_datatables() scans for [data-toggle=datatable] elements, resolves the class, and initializes each table.
Declare filters in the view with search_form. Filter type is set by the form builder method:
| Method | Filter type | Notes |
|---|---|---|
f.text_field :col |
Text input | Debounced search |
f.select :col |
Single select | tom-select by default |
f.multi_select :col |
Multi select | tom-select with tag removal |
f.range :col |
Number range | Min/max inputs |
f.range_slider :col |
Number range | noUiSlider over the same two inputs, hidden — bounds required |
f.date :col |
Single date | Same pickers as range_date, one input |
f.range_date :col |
Date range | jQuery UI $.datepicker by default, flatpickr on request |
Every picker and select plugin is a peer the host application provides. A declared plugin that is not loaded is reported through the logger and the filter degrades to a plain input that still filters from the keyboard — it never takes the table's initialisation down.
<%= f.date :created_at, filter_plugin_options: { dateFormat: 'dd/mm/yy' } %>Sends the raw value, on the same path as a text filter, so the server needs
nothing it does not already handle for text — matching a day against a
datetime column is the application's job. A half-typed date is never sent: the
value only travels once it parses, and the input is only marked inuse then.
filter_plugin: 'flatpickr' switches picker, exactly as for range_date.
<%= f.range_slider :age, filter_range_min: 0, filter_range_max: 120, filter_range_step: 5 %>Requires noUiSlider (optional peer
dependency) and its stylesheet. filter_range_min and filter_range_max are
mandatory — server-side processing hands the page one draw's worth of rows, so
the bounds cannot be derived from the data; without them the filter would work on
an arbitrary range without a word. filter_range_step defaults to 1 and also
sets how many decimals the values carry.
The slider drives the two inputs of f.range, which stay in the DOM and go on
carrying the value: the wire format is the same min-dtf_delim-max, so a column
already filtered by f.range needs no server change. Those inputs are hidden
while the slider is up, and become visible and editable again if noUiSlider turns
out to be missing.
The library ships no stylesheet, so sizing the slider container is up to the application:
.dtf-filter-slider { flex: 1 1 100%; min-width: 0; }Filtering happens when a handle is released, never while it is being dragged, and never when the handles are moved from code — which is what lets a saved state or a URL filter reposition them without a request.
<% dt.search_field(column_id: 2, filter_type: 'select', populate_with: 'admin') %>A link may carry the filters to apply, under the dt_filters key, keyed by column name:
/users?dt_filters[role]=admin
/users?dt_filters[role][]=admin&dt_filters[role][]=moderator # multi_select
/users?dt_filters[age][from]=20&dt_filters[age][to]=40 # range
/users?dt_filters[age]=20-dtf_delim-40 # range, delimited form
/users?dt_filters[created_at]=01/01/2024 # single date
The table is filtered on its first draw (no extra request) and every widget shows
its value. The URL wins over both the state saved by DataTables stateSave and
the defaults declared with populate_with. A column that does not exist, or that
carries no filter, is ignored.
The saved page is dropped as well: a filtered set is shorter, so the page the user was last on usually falls past the last row, and the link would land on an empty table.
The same hazard exists without any URL filter: rows deleted between two visits,
a narrowed scope or a populate_with default can all leave the restored page
past the end of the set. Whenever a server-side response reports fewer records
than the current offset, the table falls back to its first page. Nothing to
configure, and the extra request only happens in that case.
<%= f.text_field :name, icon: 'magnifying-glass' %>Prepends a <span class="input-group-text dtf-filter-icon"><i class="fa-solid fa-magnifying-glass"></i></span> inside the input group.
By default selects use tom-select. Pass filter_plugin: 'select2' to use Select2 instead:
<%= f.select :status, filter_plugin: 'select2' %>TomSelect escapes the option labels it renders. When the host builds those labels
server-side — a coloured badge, a status pill — the markup shows up as raw text.
filter_html_labels: true renders them as HTML instead:
<%= f.multi_select :tags, filter_html_labels: true %>Escaping stays the default for every other filter.
Know where those labels come from: they are the label of each entry of the
dt_filter_data_<column_id> payload your datatable returns, i.e. database rows,
not view code. So the option is only as safe as whatever built them — use a
template engine that escapes (Rails' tag helpers, Phlex, ERB), never string
concatenation.
The rendered markup is bounded even so: inline event handlers (onclick and the
rest) and href/src values whose scheme is not http, https, mailto or
tel are stripped before the label reaches the page. Badges, colours and inline
styles come through untouched; a javascript: link or an onerror does not.
That is a floor, not a substitute for escaping.
A long list reads better by sections. An entry of the dt_filter_data_<column_id>
payload that carries a group is filed under an <optgroup> of that label, which
TomSelect (and the native select) renders as a section header:
def additional_data
{ dt_dropdown_data(:name) => [
{ value: 'visit_bill.signature_done', label: 'Visit bill signed', group: 'Visit bills' },
{ value: 'mandate.signature_done', label: 'Mandate signed', group: 'Mandates' },
] }
endBoth select and multi_select honour it. Entries keep the server's order: a group
opens where its first entry stands, so sort the payload by group first. An entry
without a group (or with a blank one) stays at the top level, and a payload without any is rendered
exactly as before. The group label is set as a DOM property, never interpolated —
it is escaped like an option label, whatever filter_html_labels says.
Add dt.head_for_check_box as the first column. The module automatically:
- renders a "select all" checkbox in the header
- sends
selected/not_selectedrow ids with every AJAX request - handles touch devices (selection restricted to the checkbox column)
- drops the selection from the saved DataTables state, so
stateSavenever restores a stale selection over the one the server rendered
The last point matters when the host application keeps the selection server-side: DataTables Select stores the selected rows in the state and deselects everything before restoring them, which would silently uncheck rows the server had just checked.
Add class: 'context-menu' to the table body:
<% dt.body class: 'context-menu', data: { url: context_menu_path } %>Right-click (or long-press on touch) on any row opens #context-menu. The URL that
handles the menu actions is read from the body's data-url attribute. The module
handles row selection and touch long-press (500 ms, cancels on scroll).
dt.button extend: 'colvis', text: 'Columns'
dt.button extend: 'csv'For tables inside a module namespace:
bootstrap_datatables_for(:invoices, namespace: [:billing])Looks for window.datatables.Billing.InvoicesDatatable.
Use js_namespace to override the JS root:
bootstrap_datatables_for(:invoices, js_namespace: 'MyApp')Looks for window.MyApp.InvoicesDatatable.
The table load request is sent as POST with a JSON body by default. The method
is configurable through dtf_options['http_method']:
datatables_for(:users, opts: { dtf_options: { http_method: 'QUERY' } }) do |dt|
# ...
endThe default remains POST, so leaving the option unset preserves the current
behavior. Setting it to QUERY (the safe, cacheable method with a body defined
by RFC 10008) is an opt-in that
assumes your server and any intermediate proxies accept the method — the body
shape is unchanged. This option only affects the table load request, not toolbar
button or context-menu actions.
The engine ships English defaults for every DataTables language string, the
select-all button titles and the Filter by prefix. Override any of them by
defining the same key in your own config/locales — application load paths win
over engine ones.
Per-column filter labels are yours to provide:
en:
datatables:
filter:
first_name: "first name"When a column has no entry, the label falls back to the humanized column name
(shipping_address → Filter by shipping address).
Both requests the library issues carry the X-CSRF-Token header, read from
<meta name="csrf-token">: the table load, which is a POST, and the
select_all / reset_selection button actions, which are the state-changing
ones. Both work with the standard Rails protect_from_forgery, so make sure
csrf_meta_tags is present in your layout.
An application that already injects the token globally — through an
$.ajaxPrefilter, for instance — simply overwrites the header with the same
value.
With stateSave on, DataTables persists its state — the filter values included
— to localStorage by default, where it stays until something clears it. That
is fine for a name or a status, and not for a national ID or a phone number
typed on a shared workstation.
Opt a filter out of it:
<%= f.text_field :national_id, filter_no_state: true %>The filter then behaves like any other except that its value is never written to the state, and so never restored on the next visit.
Every filter is a text input, whatever the column holds: on a phone, a column of amounts or of file numbers opens the letter keyboard. Tell the filter what to ask for:
<%= f.text_field :mandate_number, filter_input_mode: 'numeric' %>
<%= f.range :free_capital, filter_input_mode: 'decimal' %>The value goes straight to the inputmode attribute, so any value the HTML
attribute accepts works. Numeric ranges default to numeric — their bounds are
read as integers unless told otherwise; decimal is the one to ask for when the
column carries a separator. Nothing else sets the attribute at all, which leaves
the default keyboard where a separator or a letter has to stay typable.
Pass ?dtf_debug_log=true or ?dtf_debug_dump=true in the URL to enable console logging. Only the literal true enables a flag. The dtf_options hash is forwarded to the JS side and controls verbosity.
# Ruby (prefer the binstubs in bin/)
bundle install
bin/rspec # specs run against spec/dummy (binstub sets BUNDLE_GEMFILE=spec/dummy/Gemfile)
bin/rubocop
# The system specs run against one vendored DataTables and jQuery bundle at a
# time; CI crosses every combination. Defaults: DataTables 2, jQuery 3.
DT_VERSION=3 bin/rspec
JQ_VERSION=4 bin/rspec
# Test against every supported Rails version (see Appraisals)
BUNDLE_GEMFILE=spec/dummy/Gemfile bundle exec appraisal install
BUNDLE_GEMFILE=spec/dummy/Gemfile bundle exec appraisal rspec # all of them
BUNDLE_GEMFILE=spec/dummy/Gemfile bundle exec appraisal rails_8.0 rspec # just one
# JavaScript
yarn install
yarn webpack # outputs dist/js/datatables-factory.js (minified)
yarn jest # JS unit tests, with coverage written to coverage/js/dist/js/datatables-factory.js is committed and shipped to gem and npm
consumers: rebuild it whenever src/ changes — CI rejects a stale bundle.