On our previous posts, we added a tool
hook to our plugin, so it adds a simple
page that can be accessed either via the Tools area or from the Manage plugins page from
the administration area.
After that, we made the controller code rendering that page use DBIC
and Koha::Object
based code.
We later added REST API routes to our plugin, on top of the Koha::FancyWord(s) classes.
We will now try to make a good use of the REST API!
Listing fancy words
The tool
controller method, is designed so it fetches all the words, and passes them to the
Template:Toolkit
based template:
my $fancy_words = Koha::FancyWords->search();
$template->param( fancy_words => $fancy_words );
REST API routes built using $c->objects->search
implement server-side pagination and the DBIC
-ish query
language out of the box. We want to take advantage of this on rendering the words table.
For the task, we will use DataTables. This library expects some specific server-side formatting on the responses, but allows us to have functions to transform the data in and out. Leveraging on that, we wrote a REST API wrapper that makes using the REST API to render tables actually fun.
With this, sorting, filtering and all the DataTables functionalities just work :-D
All we need to do is keeping the original table header, while removing the loop building the table contents using the supplied Template::Toolkit
parameter:
<table class="table table-striped" id="fancy_words_table">
<thead>
<tr>
<th>Word</th>
<th>Action</th>
</tr>
</thead>
</table>
And then, we use the wrapper following (mostly) the DataTables
documentation. We have called the
wrapped DataTable constructor .api()
:
$("#fancy_words_table").api({
"ajax": {
"url": '/api/v1/contrib/fancy/words'
},
"order": [[ 0, "asc" ]],
"columns": [
{
"data": "fancy_word",
"searchable": true,
"orderable": true
},
{
"data": function( row, type, val, meta ) {
var result = '<a class="btn btn-default btn-xs delete-fancy-word" role="button" href="#" data-fancy-word-id="'
result += row.fancy_word_id +'"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Delete")+'</a>';
return result;
},
"searchable": false,
"orderable": false
}
]
});
In the above sample code, we defined how the two table columns are built. This is standard in DataTables, but I will explain it a bit.
The first column, will take the fancy_word attribute from each returned row (array elements on the REST API) for rendering.
We use the searchable attribute, to specify that using the table search box will query on the column. For that, the wrapper will (internally) generate a DBIC
-ish query using the q
query parameter on the AJAX call:
{
"fancy_word": {
"-like": "<input>%"
}
}
The orderable attribute being set will make the column sortable, and this will be done using the REST API as well. It will use
the _order_by
query parameter we defined on the REST API (standard across all the routes), and also the _match=starts_with
query parameter.
This is actually a DataTable. This means we have the full DataTables API to play with. This includes the methods to re-fetch the data and to redraw the table. We will leverage on the following snippet very frequently:
fancy_words_table.api().ajax.reload();
We will use it every time we want to refresh the table (for example, when adding or removing items).
Removing fancy words
So we built a button for each row, for deleting the corresponding fancy word. It is now time to use the REST API so each time we click on the button, the deletion takes place.
We put the .delete-fancy-word class on each button, so the first thing we might think of, is using something like:
$(document).ready(function() {
$('.delete-fancy-word').on('click', function () {
var fancy_word_id = $(this).data('fancy-word-id');
$.ajax({
method: "DELETE",
url: "/api/v1/contrib/fancy/words/"+fancy_word_id
}).success(function() {
// refresh the datatable on success
$('#fancy_words_table').api().ajax.reload();
});
});
});
This will set an event listener on the click event on each button, on page load. YAY! When clicking on each button it will make an AJAX call to remove the fancy word specified by the fancy_word_id.
On building the button and assigning it a fancy_word_id, we leveraged on the great HTML5 data attributes and the JQuery tools to handle them.
The problem is that when the reload takes place, the buttons will be recreated, and their event listeners won’t exist anymore. To solve this, we set the event listener on the table itself like this:
// The 'delete' buttons are rendered on page load, so we need to define
// the event at higher level, so it survives the reload() call
$('#fancy_words_table').on('click', '.delete-fancy-word', function () {
var fancy_word_id = $(this).data('fancy-word-id');
$.ajax({
method: "DELETE",
url: "/api/v1/contrib/fancy/words/"+fancy_word_id
}).success(function() {
// refresh the datatable on success
window.fancy_words_table.api().ajax.reload();
});
});
Adding fancy words
We already have a modal to add new words. The main problem is it is built as a form that
- doesn’t use the REST API
- relies on the CGI controller
- refreshes the page
As we have an REST API route to add new fancy words, all we need to do is make the original form not submit anything, and add an event listener that uses the REST API to add new fancy words:
$("#new_word_confirm").on('click', function(e) {
// disable default form behaviour
e.preventDefault();
// submit the new word to the REST API
var new_word = $("#word").val();
$.ajax({
method: "POST",
url: "/api/v1/contrib/fancy/words",
data: JSON.stringify({ "fancy_word": new_word })
}).success(function() {
// refresh the datatable on success
$("#newWordModal").modal('hide');
$("#word").val('');
window.fancy_words_table.api().ajax.reload();
});
});
This method will read the input value, use an AJAX call to add the new fancy word and then clean the modal for reusing it, and reload the table.
When making the AJAX call, notice we need to stringify the data structure we are sending to the REST API.
Security concerns
Data from the REST API is not escaped, it is plain JSON. This opens the path for JavaScript injection attacks. That’s why we need to escape data accordingly.
HTML escaping
The first column in our example is rendering the fancy_word attribute directly on the browser, and needs HTML escaping. The
way to escape data is by changing the column definition and using .escapeHtml()
method like this:
{
"data": "fancy_word",
"searchable": true,
"orderable": true,
"render": function (data, type, row, meta) {
if ( type == 'display' ) {
return data.escapeHtml();
}
return data;
}
}
As we might have several columns with this situation, we might pefer to define this behaviour globally, and override it locally on each column that needs it. We would leave the first column as originally defined, and use the columnDefs declaration from DataTables to handle the data escaping:
"columnDefs": [ {
"targets": [0],
"render": function (data, type, row, meta) {
if ( type == 'display' ) {
// we want to automatically escape things for display
// if special handling is needed, remove the column from 'targets'
// and handle locally
return data.escapeHtml();
}
return data;
}
} ],
The targets array lets us specify which columns the render function will be applied to. In this case, only the first one.
URI escaping
The second column in this example, does not come directly from the results, but it build a button, using the fancy_word_id to build the delete button:
{
"data": function( row, type, val, meta ) {
var result = '<a class="btn btn-default btn-xs delete-fancy-word" role="button" href="#" data-fancy-word-id="'
result += row.fancy_word_id +'"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Delete")+'</a>';
return result;
},
"searchable": false,
"orderable": false
}
In this case, the row.fancy_word_id attribute needs URI escaping, because a link is going to be built from it. For the task
we will use the encodeURIComponent()
method:
var result = '<a class="btn btn-default btn-xs delete-fancy-word" role="button" href="#" data-fancy-word-id="'
result += encodeURIComponent(row.fancy_word_id) +'"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Delete")+'</a>';
return result;
Query parameters and HTTP servers
Sometimes, the parameters the wrapper dinamically builds for querying the DataTable are just too big for the current HTTP server configuration (or involved reverse proxies). I faced this on bug 20212, in which the searchable columns are a lot and the resulting q
query parameter gets really big.
For this situations, we can leverage on the header_filter configuration of the REST API wrapper, which will make it use the x-koha-embed request header for passing the query (instead of the q
query parameter). To use this method, we just need to add this to the DataTable configuration:
"header_filter": true
Et voilà!