Skip to content

Repository files navigation

datatables-factory

GitHub license Build Status

A Rails engine + JavaScript library that simplifies building DataTables with AJAX, filters, checkboxes, buttons, and context menus. Works alongside ajax-datatables-rails.

Installation

Gemfile:

gem 'datatables-factory'

package.json:

{
  "dependencies": {
    "@jbox-web/datatables-factory": "^1.0.0"
  }
}

Usage

1. Ruby side — declare the table in your view

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.

2. JavaScript side — define a datatable class

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 = UsersDatatable

The 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.

3. Initialize on page load

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.

Filters

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.

Single date

<%= 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.

Number range slider

<%= 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.

Pre-populating a filter

<% dt.search_field(column_id: 2, filter_type: 'select', populate_with: 'admin') %>

Pre-applying filters from the URL

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.

Saved page past the last row

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.

Icon on a filter

<%= 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.

Select plugin

By default selects use tom-select. Pass filter_plugin: 'select2' to use Select2 instead:

<%= f.select :status, filter_plugin: 'select2' %>

HTML labels in select dropdowns

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.

Grouped options in select dropdowns

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' },
  ] }
end

Both 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.

Optional features

Checkboxes

Add dt.head_for_check_box as the first column. The module automatically:

  • renders a "select all" checkbox in the header
  • sends selected / not_selected row ids with every AJAX request
  • handles touch devices (selection restricted to the checkbox column)
  • drops the selection from the saved DataTables state, so stateSave never 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.

Context menu

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).

Buttons

dt.button extend: 'colvis', text: 'Columns'
dt.button extend: 'csv'

Namespacing

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.

HTTP method

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|
  # ...
end

The 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.

Translations

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).

CSRF

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.

Filter values and the saved state

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.

Touch keyboards

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.

Debugging

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.

Development

# 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.

About

Rails engine + JS library for building DataTables with AJAX, filters, checkboxes, buttons and context menus — pairs with ajax-datatables-rails

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages