Implementing a RESTful Table in the Atlassian User Interface
What is it all about?
For those who have no clue at all: the Atlassian company , known for its products for providing workflows (primarily JIRA and Confluence, but probably any IT specialist can easily name a few more), also has an SDK for developing plugins for these products. Among the tools available to developers as part of this SDK, there is a subsystem for developing Atlassian User Interface (AUI) web interfaces. And among the capabilities of AUI there is the so-called RESTful Table - a turnkey solution for implementing an interactive table, all changes in which are saved in real time on the server side using a set of REST services.
Recently, I needed to write such a table - before that I did not have to do this, therefore I turned to the current (AUI 7.6.2) version of the official manual , but found that it was not enough. I had to get information - on the forums and in the source code of the AUI itself, since the latter are available(and, by the way, also contain a good example of a working RESTful table, but, unfortunately, without detailed comments). I did not find a guide filling up the gaps found in the network, and I wanted to put together what I managed to dig up to facilitate a similar task for others, and, possibly, for myself in the future. Based on the work, of course, it should still be based on the official guide, but this text is likely to be useful as an addition ... in any case, until it is updated.
Products and Versions
When working, I used:
- Java 8
- Atlassian Plugin SDK 6.3.6 (in particular, made assemblies an instance of Maven 3.2.1 included in it)
- JIRA 7.7.0 Core (the plugin was written under JIRA and was tested exclusively on this version)
Formulation of the problem
So, I need a page with a table somewhere in JIRA to add / remove rows, change the contents of existing ones, and swap rows. Any change in the contents of the table should be synchronously recorded in the server-side storage - as a rule, this is a database or other non-volatile solution, but since I am interested in the table and its interaction with the server side, I will limit myself to storage in memory - this will allow me to get previously saved data by going back to the page with the table, but don’t save them when the server is turned off or, for example, reinstalling the plugin.
Training
First, I will create a plug-in for JIRA containing one new page (let it be a servlet module that draws a page in Apache Velocity format), triggering an empty JS script when this page opens (most of the magic will be created in it) and leading to this page link in the JIRA header. I will not dwell on this in detail - in principle, these are trivial operations; in any case, a working example code is available on Bitbucket .
Implementation: frontend
I will try to act on the official leadership of Atlassian. First of all, I’ll add a regular HTML table to the page, which will become my RESTful table
... to the web-resource module of the JS script in the plugin descriptor (atlassian-plugin.xml) - depends on the corresponding library:
com.atlassian.auiplugin:ajs com.atlassian.auiplugin:aui-experimental-restfultable ... and in the script itself - creating, on the basis of the existing table, a minimal RESTful table with one string parameter:
AJS.$(document).ready(function () {
new AJS.RestfulTable({
autoFocus: false,
el: jQuery("#event-rt"),
allowReorder: true,
resources: {
all: "rest/evt-restful-table/1.0/events-restful-table/all",
self: "rest/evt-restful-table/1.0/events-restful-table/self"
},
columns: [
{
id: "name",
header: "Event name"
}
]
});
});Done - by assembling the plugin, you can make sure that the page really has a new table that allows you to add rows with the desired content, then edit them, delete and swap them. On the server side, these changes, of course, have not yet been fixed.
Implementation: backend
This is what I will do now. According to the manual, one REST resource is required, which provides all the data stored in the system for the table model, and another (more precisely, not one resource, but a set of them) that allows you to perform CRUD operations with one specific instance of the model. Let in this case it will be implemented as one general controller class and data model class:
@Consumes({MediaType.APPLICATION_JSON})
@Produces({MediaType.APPLICATION_JSON})
@Path("/events-restful-table/")
public class RestfulTableController {
private List storage = new ArrayList<>();
@GET
@Path("/all")
public Response getAllEvents() {
return Response.ok(storage.stream()
.sorted(Comparator.comparing(RestfulTableRowModel::getId).reversed())
.collect(Collectors.toList())).build();
}
@GET
@Path("/self/{id}")
public Response getEvent(@PathParam("id") String id) {
return Response.ok(findInStorage(id)).build();
}
@PUT
@Path("/self/{id}")
public Response updateEvent(@PathParam("id") String id, RestfulTableRowModel update) {
RestfulTableRowModel model = findInStorage(id);
Optional.ofNullable(update.getName()).ifPresent(model::setName);
return Response.ok(model).build();
}
@POST
@Path("/self")
public Response createEvent(RestfulTableRowModel model) {
model.setId(generateNewId());
storage.add(model);
return Response.ok(model).build();
}
@DELETE
@Path("/self/{id}")
public Response deleteEvent(@PathParam("id") String id) {
storage.remove(findInStorage(id));
return Response.ok().build();
}
private RestfulTableRowModel findInStorage(String id) {
return storage.stream()
.filter(item -> item.getId() == Long.valueOf(id))
.findAny()
.orElse(null);
}
private long generateNewId() {
return storage.stream()
.mapToLong(RestfulTableRowModel::getId)
.max().orElse(0) + 1;
}
} @XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class RestfulTableRowModel {
@XmlElement(name = "id")
private long id;
@XmlElement(name = "name")
private String name;
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}The corresponding rest module in the plugin descriptor:
In the REST resource code, pay attention to the following points:
- the REST-resource methods creating and updating a record in the repository also return the result object of this record as part of the response - in general, this is quite common practice; the problem is that nothing forces me to do this if I do not know about this practice. Without this, the table will not work correctly;
- when editing a record in a table, the values of only the changed fields get to the server, instead of the rest, null comes, so I have to check their presence before writing new values to the record object in the repository. Empty values come to the server in the form of empty strings, and not null, so there is no problem distinguishing the absence of a change from the new empty value in the case of string fields - but in the case of a primitive type field, difficulties are possible;
- the record class has an id field - it is not reflected in the table in any way, but it is used to identify records; The id is generated by the server and then returned to the client as part of the created storage record object. Important: id must be such that its Javascript representation is cast to Boolean true, and not false - in particular, 0 is not good;
- records on the server are sorted (in this case, by id) - this, of course, is not necessary, but most likely you will need it for any practical task. Note that the order is inverted with respect to the natural one - thanks to this, it is possible to save the same order of records when re-outputting (say, reloading the page) that occurred directly when adding records to the table; however, the table has the option "createPosition", which allows for the value "bottom" to add new entries from the bottom, and not from the top, as in this example, and in this case, such an inversion, of course, is not needed.
Putting together a plugin ... surprise! REST resources have been added to the system, as can be seen on the plugin management page, but they do not want to save data. By opening the browser console, it’s easy to establish the reason: REST resources return a 404 error, that is, they do not exist at the addresses used. The addresses, in fact, are the problem: the browser is accessing the address of the form "
resources: {
all: AJS.contextPath() + "/rest/evt-restful-table/1.0/events-restful-table/all",
self: AJS.contextPath() + "/rest/evt-restful-table/1.0/events-restful-table/self"
},Another solution is also possible: create full, not relative URLs yourself from the application’s base URL and paths to REST resources:
resources: {
all: AJS.params.baseURL + "/rest/evt-restful-table/1.0/events-restful-table/all",
self: AJS.params.baseURL + "/rest/evt-restful-table/1.0/events-restful-table/self"
},These URLs are used without additional conversions, which is fine for me too.
I am compiling the plugin again, indicating suitable paths. Now records in the table are regularly created, edited and deleted. Everything seems to work ... huh? Not really.
Moving lines
The table should also support Drag & Drop rows (there is a setting that allows you to disable this, but I did not use it). Now, if you try to drag one of the lines somewhere, it will work ... but after reloading the page, the lines will be in the same position. In order for the line position change to be reflected on the server, we need another REST resource not mentioned in the manual - move, which receives information about the details of the movement. He expects to get an object with two parameters: after - the path to the REST resource of the data element corresponding to the line below which I place my draggable and dragged one, and position - the description of the new position of the element using one of four constants: First, Last, Earlier or Later (in fact, however, the current implementation of the RESTful table uses only First ... but processing should still be implemented for all four). Only one of the two fields can be initialized. For clarity, I made the fields of the Java model string, although this is not the most convenient solution.
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
public class MoveInfo {
@XmlElement(name = "position")
private String position;
@XmlElement(name = "after")
private String after;
public String getPosition() {
return position;
}
public void setPosition(String position) {
this.position = position;
}
public String getAfter() {
return after;
}
public void setAfter(String after) {
this.after = after;
}
}And here’s the actual method that implements my REST resource:
@POST
@Path("/self/{id}/move")
public Response moveEvent(@PathParam("id") String idString, MoveInfo moveInfo) {
long oldId = Long.valueOf(idString);
long newId;
if (moveInfo.getAfter() != null) {
String[] afterPathParts = moveInfo.getAfter().split("/");
long afterId = Long.valueOf(afterPathParts[afterPathParts.length - 1]);
newId = afterId > oldId ? afterId - 1 : afterId;
} else if (moveInfo.getPosition() != null) {
switch (moveInfo.getPosition()) {
case "First":
newId = getLastId();
break;
case "Last":
newId = 1L;
break;
case "Earlier":
newId = oldId < getLastId() ? oldId + 1 : oldId;
break;
case "Later":
newId = oldId > 1 ? oldId - 1 : oldId;
break;
default:
throw new IllegalArgumentException("Unknown position type!");
}
} else {
throw new IllegalArgumentException("Invalid move data!");
}
if (newId > oldId) {
storage.stream()
.filter(entry -> entry.getId() <= newId && entry.getId() >= oldId)
.forEach(entry -> entry.setId(entry.getId() == oldId ? newId : entry.getId() - 1));
} else if (newId < oldId) {
storage.stream()
.filter(entry -> entry.getId() >= newId && entry.getId() <= oldId)
.forEach(entry -> entry.setId(entry.getId() == oldId ? newId : entry.getId() + 1));
}
return Response.ok().build();
}Please note: it is relevant precisely for the method of sorting elements in the table, which is used in this case. To sort in the reverse order, this method will have to be changed.
The method, as you can see, does not return anything meaningful to the browser (although it can), but in general the result of the move request needs to be processed (when it returns, the REORDER_SUCCESS event not mentioned in the manual will fall, which you should subscribe to for this): without this models of the moved rows of the table will retain the old id (unfortunately, they didn’t deliver them automatically), and this will mean data out of sync in the browser and on the server, so further work with the interactive elements of the table will not lead to anything good. Therefore, in this case (although it is actually quite uneconomical), the easiest way is not to try to return data about the changes from the server and to push them to the right places, but simply make the table receive and draw all the data again. All you have to do manually
AJS.$(document).ready(function () {
AJS.TableExample = {};
AJS.TableExample.table = new AJS.RestfulTable({
// ...
});
AJS.$(document).bind(AJS.RestfulTable.Events.REORDER_SUCCESS, function () {
AJS.TableExample.table.$tbody.empty();
AJS.TableExample.table.fetchInitialResources();
});Now it’s all!
Other field types
My table is fully functional, but it is of little use - in fact, it’s just a list of rows. You can, of course, add more string fields, but most likely, in a real table you will want to see not only rows, but also something else - for example, dates, checkboxes, combo boxes ... For example, I will add a date - other fields are created in general similarly .
To add a custom view field to the table, I need to supply it with a custom view for creating, editing and reading - respectively, in the row that is being created, edited and inactive at the moment. To create and edit the date, I use aui-date-picker, since we are talking about AUI, and for an inactive line, the usual span is enough:
{
id: "date",
header: "Event date",
createView: AJS.RestfulTable.CustomCreateView.extend({
render: function (self) {
var $field = AJS.$('');
$field.datePicker({'overrideBrowserDefault': true});
return $field;
}
}),
editView: AJS.RestfulTable.CustomEditView.extend({
render: function (self) {
var $field = AJS.$('');
$field.datePicker({'overrideBrowserDefault': true});
if (!_.isUndefined(self.value)) {
$field.val(new Date(self.value).print("%Y-%m-%d"));
}
return $field;
}
}),
readView: AJS.RestfulTable.CustomReadView.extend({
render: function (self) {
var val = (!_.isUndefined(self.value)) ? new Date(self.value).print("%Y-%m-%d") : undefined;
return '' + (val ? val : '') + '';
}
})
}Accordingly, I will update the java class of the data model:
@XmlElement(name = "date")
private Date date;
public Date getDate() {
return date;
}
public void setDate(Date date) {
this.date = date;
}... and add date processing to the update method:
Optional.ofNullable(update.getDate()).ifPresent(model::setDate);Done - a new field of the desired type appears in the table.
I will be glad to clarifications and additions.