Here's a bit of a run-down of my experiences using Web Core with Tabulator.
Motivation: In previous posts, I was trying to figure out how to get data to flow from an XData service endpoint through to a Web Core XDataWebDataset, ultimately with the goal of displaying it in a grid for the user to view/edit. The last iteration of that effort can be found here.
Unfortunately, getting a grid to work with that dataset proved to be problematic. And even when I did get an FNCGrid to show the data, it was seemingly going to be a tough haul to get the grid to be more useful in terms of editing, sorting, grouping, filtering and so on. And that's before even trying to tackle things like theming or custom controls within the grid or having the ubiquitous navigator attached. I'm sure there are examples of many of these things, but it seemed to be an uphill battle right out of the gate, and it wasn't getting any easier. And as I'm working on an app this is a reincarnation of sorts of a VCL app that has 490+ cxGrids, this seemed to be a bridge too far.
Enter Tabulator. Not something I had used previously at all. It is basically a pure javascript grid with support for all the things I was after. Sorting, filtering, grouping. Custom controls within the grid. No navigator, but the means to wire one up. This then is a not-so-brief introduction to what I've done along with a little demo video showing what it looks like.
First up is the usual links to the CDN.
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@latest/dist/js/tabulator.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabulator-tables@latest/dist/css/tabulator.min.css">
<script src="https://cdn.jsdelivr.net/npm/luxon@latest/build/global/luxon.min.js"></script>
(Side note: This is for Tabulator v5 which just very recently came out (and was one of the reasons for the delay in writing this post - some little issues needed to be ironed out by the developer). So in v5 they use luxon as a support library for dates, replacing the moment.js library used in the previous version)
Tabulator has a reasonably complex set of options that can be set to control its behaviour. For my purposes, I'm looking for typical Delphi-type grid functionality with a single row selected at a time and the ability to perform operations on a row-based sort of level. So to start with, I set up a bunch of global defaults that apply to all Tabulator tables anywhere in my project. Something like this. Must be defined before any Tabulator tables are created. I have this in the WebDataModuleCreate procedure of the DataModule that is loaded up first.
asm
Tabulator.defaultOptions.layout = "fitColumns";
Tabulator.defaultOptions.selectable = 1;
Tabulator.defaultOptions.placeholder = "No Records Found";
Tabulator.defaultOptions.index = "ID";
end;
Tabulator really likes having an ID column as the first column, so that's what I've done with all my tables. I've generally set this up so ID=1 is the first row in the table and ID=n is the last row in the table. Important for making the navigator work properly more than anything. In the queries that I generate on the server via FireDAC, it is a simple matter of just adding that as a column regardless of what the table has in it.
In DB2 this is easy enough. Other databases are either similar or can be coerced into something equivalent. Point being that it is just an arbitrary index and adding it in at query time for me is the simplest approach. Not so fun if I had to go and do this many times, but when starting out it is fine.
select
row_number() over (order by some.column) ID,
column1,
column2,
etc.
Alright. So to make a simple table, I've just dropped a TWebHTMLDiv
on a form. I've gotten into the habit of adding a separate DIV within the HTML settings for the form, though it might be redundant. In any event, setting the HTML property to something like this. Means I can adjust the WebHTMLDiv as I want and the table within it will stretch to fit. Something like that anyway.
<div id="gridSample" style="height:100%; width:100%;"></div>
In the Interface section of the form, I add a variable to reference the Tabulator table that I'm going to create.
var
tabSample: JSValue;
Then in the WebFormCreate procedure, a simple table can be created like this.
asm
var tabSampleData = [
{ ID: 1, OPTION: "Accounts" },
{ ID: 2, OPTION: "Modules" },
{ ID: 3, OPTION: "Features" },
{ ID: 4, OPTION: "Blocks" },
{ ID: 5, OPTION: "History" }
];
this.tabSample = new Tabulator("#gridSample", {
data: tabSampleData,
columns: [
{title: "ID", field: "ID", width: 70, visible: false },
{title: "Option", field: "OPTION", minWidth: 125, widthGrow: 1, bottomCalc: "count" }
]
});
end;
So in this case, a table is created with a hidden column and an Option column that stretches to fit the width of the table. A count of entries at the table appears at the bottom. All good.
The fun starts when dealing with callbacks. I tried many things to try and figure out what to put in the callback functions to call a Delphi function within the same module as the form. "this", "$mod" and so on didn't seem to work. I nominate anyone at TMS to provide a clear description of what all the options might actually be here, both for accessing functions and variables. It could be because the forms I use are created at runtime. It could be because Tabulator itself is doing something atypical. Or it could be something else entirely. What I found from poring over the generated JS code is that it is possible to call functions explicitly using a fully qualifed function call. As I want these to be centrallized anyway, I just did that and added in a table identifier so I could tell what table was calling. And then use the Delphi code to in the DataModule to call back to the functions in the form. so in the code below, my DataModule called DM1 in CoreDataModule.pas is used.
For the simple table, the callbacks look something like this. This follows the above table definition.
this.tabSample.on("rowClick", function(e,row){
row.getTable().deselectRow();
row.select();
});
this.tabSample.on("cellClick", function(e,cell){
pas.CoreDataModule.DM1.TabCellClick(101,cell.getRow().getPosition(true), cell.getColumn().getElement());
});
So in my DataModule, I have a TabCellClick function defined, and its expecting a parameter telling me what table it is, as well as what the row number is. I then use that to map back to #gridSample. Works perfectly well. So at the moment, the main thing I want to happen when a user clicks on a row is to update a navigator. It has buttons like First, Previous, Next and Last.
Note that Tabulator references rows (and columns) in a bunch of different ways. It can be the physical row in the table. Or the row being displayed currently. Or the row in the current filter. Or the row can reference the data rather than a row number. Fun times. Here, we're passing the position of the row in the table, and hopefully noting where it is in the current filter/sort.
So in the DataModule functions, we start with the TabCellClick function:
procedure TDM1.TabCellClick(Grid: Integer; Row: Integer; Column: String);
var
rowselected: Integer;
optionselected: String;
begin
if (Grid = 101) then
begin
asm
var table = Tabulator.findTable("#gridSample")[0];
if (table.getDataCount() > 0) {
var rowselected = table.getRowFromPosition(Row,true);
table.selectRow(rowselected);
optionselected = row.getCell(table.getColumn('OPTION')).getValue();
}
end;
end;
UpdateButtons(Grid, rowselected);
end;
This obviously can be more generalized. Note that we have to lookup the name of the table all the time. Probably a way around that. The latest v5 apparently does a better job of helping to identify the table from the callback functions. But this seems to work and doesn't seem to slow it down.
The UpdateButtons procedure is also in the same DataModule, and all it is doing is enabling or disabling buttons depending on whether the row selected is first or last or somewhere inbetween.
procedure DM1.UpdateButtons(Grid: Integer; Row:Integer);
var
rowcount: Integer;
begin
if (Grid = 101) then
begin
asm
var table = Tabulator.findTable(TableName)[0];
if (typeof table !== 'undefined') {
if (table !== null) {
if (table !== false) {
rowcount = table.getDataCount();
}
}
}
end;
// Row and RowCount are then used to enable/disable the buttons for
// First, Previous, Last and Next functionality. Nothing special about this
// as we know how many rows (may be zero) and what the current row is.
// Skipping this code for now.
end;
One little detail is that if you define Delphi variables that are only used in JS code, you'll get a compiler hint. And no clear way how to turn it off. So what I've done is create some dummy procedures to call which gets rid of the hints. For example, in the above, rowcount is declared but if you don't use it in a Delphi block of code, you'll get a compiler hint.
procedure TDM1.PreventCompilerHint(S: string); overload; begin end;
procedure TDM1.PreventCompilerHint(I: integer); overload; begin end;
procedure TDM1.PreventCompilerHint(J: JSValue); overload; begin end;
procedure TDM1.PreventCompilerHint(D: TDateTime); overload; begin end;
procedure TDM1.PreventCompilerHint(B: TJSArrayBuffer); overload; begin end;
procedure TDM1.PreventCompilerHint(X: TXDataClientResponse); overload; begin end;
Another compiler issue is that if you have a procedure, like TabCellClick, that is only ever called by JS, the Delphi compiler tends to optimize it out of existence. This may be an element in trying to figure out why $mod.delphifunctioncall didn't find it, but perhaps not. In any event, like the PreventCompilerHint dummy calls, I also added another one for keeping these functions around.
procedure TDM1.StopLinkerRemoval(P: Pointer); begin end;
And in the same WebDataMOduleCreate function I have calls like this:
StopLinkerRemoval( @TabCellClick );
Full credit goes to this StackOverflow Post for this idea.
Alright. All good so far. It is possible to do much, much more though. There are more callbacks for things related to editing, for example. And I've had great success with date pickers, lookup combo boxes and other things integrated into the Tabulator interface. I'm currently using it in a way where changes to rows generate XData service calls to write changes back to the database (while also changing it locally) with all kinds of error handling and tracing functions. And it is all working pretty well. This is really and truly just the tip of the iceberg.
So without further ado, here is a lttle video capture of all of this in action. Happy to provide more code samples and explanations to anyone interested. Date controls are implemented using FlatPickr and the HTML editor is using SunEditor and CodeMirror.