A guide to using Datatables with Stimulus
Datatables is a very handy front end package to render large datasets with ease. However, writing code to configuring multiple datatables gets repetitive. For my case, I wrote a JS class to extend Stimulus that let's me declare a new server side dateable by simply providing the URL and declaring the columns like so:
Sample HTML
<div class="card-datatable table-responsive">
<table class="table table-striped" id="example-datatable"
data-controller="example-datatable"
data-example-datatable-url-value="<%= some_datatable_path %>"
data-example-datatable-item-path-value="<%= some_index_path %>"
>
<thead>
<tr>
<th>Created At</th>
<th>Name</th>
<th>View</th>
</tr>
</thead>
</table>
</div>
You can then attach a stimulus controller as seen below. You still need a controller per dateable, but this is to significantly reduce the amount of boilerplate code written while still maintaining flexibility.
Sample JS
import { StimulusDatatable } from "controllers/datatable_controller";
// Connects to data-controller="example-datatable"
export default class extends StimulusDatatable {
config() {
const itemPath = this.itemPathValue;
return {
searching: false,
columns: [
{
data: function (row, _type, _set) {
return `<span data-controller="format--date">${row.created_at}</span>`;
}
},
{ data: "name" },
{
data: function (row, _type, _set) {
return `<div class="float-right">
<a href="${itemPath}/${row.route_token}" class="btn btn-sm btn-outline-primary">
<i class="tf-icons ti ti-chevrons-right"></i>
</a>
</div>`;
}
}
],
columnDefs: [
{ name: "created_at", targets: 0 },
{ name: "name", targets: 1 },
{ name: "view", targets: 2, orderable: false, searchable: false },
{ targets: "_all", searchable: true, orderable: true }
],
exportColumns: [0, 1],
stimulusDateRange: true
};
}
}
Stimulus Class
The actual class is below. I'd suggest reading up on data tables configuration, as I tweaked this to my specific use, such as custom rendering on the export buttons and an option date range picker, plus default search.
import { Controller } from '@hotwired/stimulus';
import jQuery from 'jquery';
import 'datatables';
/*
Note for GitHub. Check the classes used in the HTML.
It's configured for the Bootstrap theme that I use, but you'll probably need something different.
Also take note of the usualConfig() method. It's a good starting point for your own config.
*/
/*
This is the base class for all datatable controllers.
mostly pulled from https://github.com/jgorman/stimulus-datatables/blob/master/src/index.js, but heavily modified
The config that is sent must include the following:
columns: [
{ data: 'sku' },
{ data: 'name' },
{ data: 'stock' },
{
data: function (row, _type, _set) {
return `<a href="${itemPath}/${row.id}" class="btn btn-outline-primary my-0 mr-0">
<i class="tf-icons ti ti-chevrons-right"></i>
</a>`;
}
}
],
columnDefs: [
{ name: 'sku', targets: 0 },
{ name: 'name', targets: 1 },
{ name: 'stock', targets: 2 },
{ name: 'view', targets: 3, 'orderable': false, 'searchable': false },
{ targets: '_all', searchable: true, orderable: true}
],
exportColumns: [0, 1, 2, 3],
exportColumns are not part of the DataTable API, but are required for the export to work.
*/
export class StimulusDatatable extends Controller {
usualConfig() {
return {
processing: true,
serverSide: true,
paging: true,
pagingType: 'full_numbers',
searching: true,
lengthChange: true,
responsive: true,
stateSave: true,
fixedHeader: true,
order: [[0, 'asc']],
drawCallback: function () {
'use strict';
},
dom: '<"card-header flex-column flex-md-row"<"head-label text-center"><"dt-action-buttons text-end pt-3 pt-md-0"B>><"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6 d-flex justify-content-center justify-content-md-end"f>>t<"row"<"col-sm-12 col-md-6"i><"col-sm-12 col-md-6"p>>',
exportColumns: [0, 1],
stimulusDateRange: false,
tableTitle: ''
};
}
static values = {
url: String,
itemPath: String
}
// this will get overridden by the child controller
config() {}
initialize() {
// console.log("Stimulus Datatable initialized");
}
connect() {
const usualConfig = this.usualConfig();
/*
if the element does not have an id, add one
note that this will not persist between page loads and will fill up local storage if you don't add an ID.
so it's recommended to add one
*/
if (!this.element.id) {
this.element.id = `datatable-${Math.random().toString(36).substring(7)}`;
}
const elementID = this.element.id;
if (!this.isBooting()) {
return false;
}
// Register the teardown listener
document.addEventListener('turbo:before-render', this._teardown);
let config = usualConfig;
// so you can overwrite default values
Object.assign(config, this.config());
const that = this;
config.initComplete = function(_settings, _json) {
const tableInput = jQuery('div.dataTables_filter input');
tableInput.unbind();
tableInput.bind('keyup', function(e) {
if(e.keyCode === 13) {
that.dataTable.search(this.value).draw();
}
});
};
if (this.config().buttons === undefined) {
config.buttons = [
{
extend: 'collection',
className: 'btn btn-label-primary dropdown-toggle me-2',
text: '<i class="ti ti-file-export me-sm-1"></i> <span class="d-none d-sm-inline-block">Export</span>',
buttons: [{
extend: 'print',
text: '<i class="ti ti-printer me-1"></i>Print',
className: 'dropdown-item',
exportOptions: {
columns: config.exportColumns,
}
}, {
extend: 'csv',
text: '<i class="ti ti-file-text me-1"></i>Csv',
className: 'dropdown-item',
exportOptions: {
columns: config.exportColumns,
}
}, {
extend: 'excel',
text: '<i class="ti ti-file-text me-1"></i>Excel',
className: 'dropdown-item',
exportOptions: {
columns: config.exportColumns,
}
}, {
extend: 'pdf',
text: '<i class="ti ti-file-description me-1"></i>Pdf',
className: 'dropdown-item',
exportOptions: {
columns: config.exportColumns,
}
}, {
extend: 'copy',
text: '<i class="ti ti-copy me-1"></i>Copy',
className: 'dropdown-item',
exportOptions: {
columns: config.exportColumns,
}
}]
}
];
}
if (this.config().ajax === undefined) {
config.ajax = {
url: this.urlValue,
type: 'GET'
};
}
if (this.config().order === undefined) {
config.order = [[ 0, 'desc' ]];
}
if (this.config().searching === undefined) {
config.searching = usualConfig.searching;
}
if (this.config().dom === undefined || this.config().dom === '' || this.config().dom === null) {
config.dom = usualConfig.dom;
}
// pulling it from config tends to persist across pages and tables... so pull it directly from the config that we set
if (this.config().stimulusDateRange === true) {
this.stimulusDateRange = true;
} else {
this.stimulusDateRange = false;
}
const dateRangeSelectID = `${elementID}-table-date-range`;
if (this.stimulusDateRange) {
config.ajax = {
url: this.urlValue,
type: 'GET',
data: function (d) {
d.date_range = jQuery(`#${dateRangeSelectID}`).val();
}
};
// add item to config.buttons
config.buttons.push({
text: '',
className: `d-none ${elementID}-date-range-replace`
});
}
this.dataTable = jQuery(this.element).DataTable(config);
if (this.stimulusDateRange) {
// const tableBar = jQuery(`#${elementID}_wrapper > div.row > div.col-sm-12.col-md-6.d-flex.justify-content-center.justify-content-md-end`);
const replaceElement = jQuery(`.${elementID}-date-range-replace`);
const dateRangeSelectHTML = `<input type="text" id="${dateRangeSelectID}" class="form-control flatpickr-range flatpickr-input" placeholder="YYYY-MM-DD to YYYY-MM-DD" data-controller="format--date-range" readonly style="width: min-content;">`;
// the element above this element has the btn-group in the div, and it renders incorrectly. So this makes it render as if it's an individual button
replaceElement.parent().children().eq(0).removeClass('btn-group');
// if the element with the ID doesn't exist, append it
if(!document.getElementById(dateRangeSelectID)) {
// replace replaceElement with dateRangeSelectHTML
replaceElement.replaceWith(dateRangeSelectHTML);
}
const dateRangeElement = jQuery(`#${dateRangeSelectID}`);
dateRangeElement.change(function () {
if (dateRangeElement.val().includes(' to ')) {
that.dataTable.ajax.reload();
}
});
}
// if config.tableTitle is set, add it to the table
if (this.config().tableTitle !== undefined) {
if (this.config().tableTitle.length > 0) {
jQuery(`#${elementID}_wrapper div.head-label`).html(`<h5 class="card-title mb-0">${this.config().tableTitle}</h5>`);
}
}
}
isTable() {
return this.element.tagName === 'TABLE';
}
isDataTable() {
return this.element.className.includes('dataTable');
}
isPreview() {
return document.documentElement.hasAttribute('data-turbo-preview');
}
isLive() {
return this.dataTable;
}
isBooting() {
return this.isTable() && !this.isDataTable() && !this.isPreview() && !this.isLive();
}
_teardown = () => this.teardown(this);
teardown(_event) {
if (!this.isLive()){
return false;
}
document.removeEventListener('turbo:before-render', this._teardown);
this.dataTable.destroy();
this.dataTable = undefined;
return true;
}
}
// The below code probably isn't needed but I've left it in because I'm lazy.
// Connects to data-controller="datatable"
export default class Datatable extends StimulusDatatable {
static get shouldLoad() {
return false;
}
connect() {
// console.log("Datatable connected");
}
}
This code can also be found on GitHub Gists here.