Struggling with Javascript and esbuild (Rails 7)

I am trying to use Datatables with Rails 7 and esbuild. For that I need to

  1. Load datatables.net-bs5.js to make the Javascript constructor Datatable available
  2. Run some Javascript in the view to call the Datatable constructor.

But practically it does not work: the js is loaded but the constructor is only available in application.js, not within index.html.erb. Any idea what to do?

In application.js

import * as bootstrap from "bootstrap"
import DataTable from "datatables.net-bs5"

In index.html.erb (from DataTables example - Non-jQuery initialisation)

<script>
  document.addEventListener('DOMContentLoaded', function () {
    let table = new DataTable('#example');
  });
</script>

But every time I get the following error message

Uncaught ReferenceError: DataTable is not defined

at HTMLDocument. ((index):50:17)

Knowing that the javascript seems properly loaded and that I don’t get any error message in the rails console.

Untitled

Just to complete this, in Rails 6 with webpack, the way of working was to modify the environment.js file from the webpack config to make it available in the view.

environment.config.merge({
  output: {
    library: ['Packs', '[name]'],
    libraryTarget: 'window',
  }
})

Classes/functions in bundled packages are usually not made available in the global namespace. The “Rails Way” to solve this is to use a Stimulus controller:

import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  connect () {
    new DataTable('#example');
  }
}

Thank you but I’m not sure to understand.

You mean that my index.html.erb should contain the following?

<script>
 import { Controller } from '@hotwired/stimulus'
 
 export default class extends Controller {
   connect () {
        document.addEventListener('DOMContentLoaded', function () {
        let table = new DataTable('#example');
     });
   }
 }
</script>

No. When you are using a bundler like esbuild/webpack you cannot use classes/functions from node packages in your HTML. They simply are not available there. They are only available for code running inside your application.js file.

To solve this problem, Rails recommends the first party library Stimulus. In a nutshell, it is designed to replace eventListeners that run on DOMContentLoaded, onclick and other such events. It also will take you less than 1 hour to go through the guidebook I linked and learn it. After you go through the install process you are going to create a file called datable_controller.js and place the code I gave you there.

Then on the page you need data table, you will tell Stimulus to execute the code in that file:

<div id="dataTable" data-controller="data_table">
  <!-- HTML of the data table goes here -->
</div>

That said, while this is the “Rails Way” and will make your life easier as you add more JS code (which I’m thinking you will, since you are using a bundler like esbuild), you can ‘cheat’ and just tell esbuild to make DataTable available for you in your html. Chris from GoRails has a video for jQuery, that you can simple adjust for DataTable:

I worked a bit with Simulus but I’m not yet familiar with it… will go deeper. I’m not sure it answers my question as my target is to put the javascript in the .html.erb.

I went through this video, but the solution recommended is quite bad in my opinion: it consists in putting all the Javascript in the assets/javascript folder (application.js and ./src), which is very poor practice in the case of a big project.

Indeed, since every table needs to be initialized and customized individually (define which column has to be sortable, what is the data type, the header…) it would mean that the javascript folder would contain the initializer for maybe 50 different tables.

Actually, the part of the video I was referring to, is where he sets jquery in the window. Basically, somewhere in your application.js do this:

import DataTable from "datatables.net-bs5"
window.DataTable = DataTable

This should make DataTable available in your HTML.

Sadly it does not work :frowning:

application.js

import "@hotwired/turbo-rails"
import "./controllers"
import * as bootstrap from "bootstrap"
import DataTable from "datatables.net-bs5"

window.Datatable = Datatable

Just to complete a bit, window.DataTable makes DataTable function available in the view. :slight_smile:

ƒ (root, $) {
            if (!root) {
              root = window;
            }
            if (!$ || !$.fn.dataTable) {
              $ = require_jquery_dataTables()(root, $).$;
            }
      …

BUT, it just doesn’t do anything. I guess this means I need to export other variables through window to make it work.

I have set up a controller that initializes DataTables. This was in Stimulus 1/Rails 5.2, using DataTables as a jquery plugin. With Stimulus 3 I think there are better ways to pass data from the view to the controller.

Also, since this is an old project that used DataTables long before Stimulus was released, I’m still using DataTables as a jQuery plugin. So you’ll need to adapt the controller to what Stimulus 3 expects, and change the DataTables init from jquery plugin style to something like what you have listed above.

table_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "table", "row", "modal" ]

  connect() {
    // console.log("table controller connected");
    
    this.dt = null; // datatables instance can be plugged in

    const controllerName = `stimulus_${this.identifier}`;
    this.modalID = `${controllerName}_modal`;
    
    if (this.data.get('lazyload') !== null) {
      this.lazyload = new LazyLoad({ elements_selector: `.${this.data.get('lazyload')}` });
    }
    
    const event = new CustomEvent("table-controller-connected", {detail: {element: this.element, table: this.tableTarget}});
    // fire connected event later so controller can finish connecting events
    window.requestAnimationFrame(function() {
      window.dispatchEvent(event);
    });
  }
  
  // actions
  setupDataTable(event) {
    if (this.dt == null) {
      // DataTables is a jquery function
      this.dt = $(this.tableTarget).DataTable(event.detail.dt_options);
      // console.log("DataTable set up")
      if (this.lazyload) {
        this.lazyload.update();
      }
    }
  }
  
  replaceAllRows(event) {
    if (this.dt == null) { return; }
    this.dt.clear();
    this.dt.rows.add(event.detail.rows).draw();
    if (this.lazyload) {
      this.lazyload.update();
    }
  }
}

The controller also does other things like triggering handling click events to open an edit form in a bootstrap modal, and updating the row after updating the data, but I didn’t include that here. The setupDataTable() and replaceAllRows() functions show how you can interact with your datatable variable in the controller.

index.html.erb

<div class="table-responsive" data-controller="table" data-table-lazyload="lazy" data-action="setup-dt->table#setupDataTable">
	<table class='table table-condensed table-striped table-hover clickable-rows' id='pics_table' 
		data-target="table.table" data-action="click->table#click replace-rows->table#replaceAllRows">
		<thead>
			<tr>
				<th>Name</th>
				<th>Status</th>
				<th width="150px">Image</th>
			</tr>
		</thead>
	</table>
</div>

<script>
	
	window.addEventListener("table-controller-connected", function(event){
		console.log("table controller connected event");
		const element = event.detail.element;
		
		const dt_options = {
			lengthMenu: [ [-1, 50, 200], ['All', '50', '200'] ],
			infoCallback: function( settings, start, end, max, total, pre ) { return start +" to "+ end +" of "+ total +" thing"+ (total==1?'':'s') +" found"; },
		  fixedHeader: { headerOffset: 50 }, 
			data: rows, 
			columns: [
				{ data: 'name', render: {_: 'display', sort: 'data', filter: 'data'} }, 
				{ data: 'status', render: {_: 'display', sort: 'data', filter: 'data'} }, 
				{ data: 'image' }, 
			], 
		}
		
    const dtEvt = new CustomEvent("setup-dt", {detail: {dt_options: dt_options}});
		element.dispatchEvent(dtEvt);
	}, false);
	
	const rows = <%= render(partial: 'rows', formats: [:json]).html_safe %>;
	
</script>

Another place I use this datatable with an empty dataset; a search form populates the table with an ajax request:

  fetch(url).then(response => response.json())
    .then(data => {
  	  // console.log(data);
      const dtEvt = new CustomEvent("replace-rows", {detail: {rows: data.houses}});
      const table = document.querySelector('#pics_table');
      table.dispatchEvent(dtEvt);
    }
  )

Obviously you can use this concept with html table rows as well, but you may have to work with html for row updates too.

So the initialization process I’m using is:

  1. Controller connect() runs, fires custom event
  2. View responds to custom event, builds DataTable init options object, fires custom event to trigger controller action
  3. Controller action setupDataTable() initializes DataTable instance with view-specific options.

With Stimulus 3 you could use values to avoid this event dance if you wanted. You could then use the various values you’d provide in the markup to build a DataTables options object in the controller’s connect() function and instantiate the DataTables instance.

Thanks for sharing this… I had a look at it and tried similar with Stimulus, but basically, it does not solve anything. The challenge is not the integration of Datatables with the .erb, I do it. The challenges comes from jsbundling, which is designed to hide javascript from the view.

For info, the creator of datatables helped me on this. The solution is here

GitHub - Vorkosigan76/rails7-datatables2

Basically

  • there were typos in my html (shame on me)
  • I (strangely) needed to add parenthesis when making the DataTable available through window

The javascript libraries are not hidden from the Stimulus controllers, though. So what that controller does is access DataTables only in the controller, while still allowing you to specify the table-specific configuration options in the view.

I have several listings I’m handling similarly, with a click on the table row to open the edit form in a bootstrap modal, and updating the edited row in the table. So the controller also allows you to extract that functionality as well.

Thanks for the link to the sample database. I have a project I’d like to move away from webpack and I have some listings using the controller and some listings using DataTables from the view so it’s useful to know how to get it assigned to the global window namespace.