📜 ⬆️ ⬇️

Implementing a RESTful Table in the Atlassian User Interface

What is it all about?


For those who are not in the topic at all: Atlassian , known for its workflow solutions (primarily JIRA and Confluence, but probably any IT specialist can easily name a few more), also has an SDK for developing plug-ins for these products. Among the tools available to developers as part of this SDK, there is a subsystem for developing web interfaces Atlassian User Interface (AUI). And among the features of AUI is the so-called RESTful Table - a ready-made 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.


I recently needed to write such a table - before that I didn’t have to do this, so 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, not having detailed comments). I didn’t find the missing leadership gaps in the network, and I wanted to put together what I had to dig in to facilitate a similar task to others, and perhaps to myself in the future. Building on work, of course, still follows the official manual, 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:



Formulation of the problem


So, I need to have a page with a table somewhere in JIRA that allows you to add / delete rows, change the contents of existing ones and change rows in places. Any change in the content of the table should be synchronously recorded in the storage on the server side - 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, re-entering the page with the table, but not saving it when the server is disconnected or, for example, reinstalling the plugin.


Training


I’ll first create a plugin for JIRA that contains one new page (let it be a servlet module that paints a page in Apache Velocity format), an empty JS script triggered by opening this page (and most of the magic will happen 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, add the usual HTML table to the page, which will become my RESTful table


<table id="event-rt"></table> 

... in the JS script web-resource module in the plug-in descriptor (atlassian-plugin.xml) - dependency on the corresponding library:


 <web-resource key="events-restful-table-script" name="events-restful-table-script"> <resource type="download" name="events-restful-table.js" location="/js/events-restful-table.js"/> <dependency>com.atlassian.auiplugin:ajs</dependency> <dependency>com.atlassian.auiplugin:aui-experimental-restfultable</dependency> </web-resource> 

... and in the script itself - the creation of a minimal RESTful table with one string parameter based on the existing table:


 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 does have a new table that allows you to add lines with the desired content, then edit them, delete and swap places. On the server side, these changes, of course, have not yet been recorded.


Implementation: backend


This is what I'll 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 (or more precisely not one resource, but their set), which allows performing CRUD operations with one specific instance of the model. Suppose that in this case it will be implemented as one common controller class and data model class:


 @Consumes({MediaType.APPLICATION_JSON}) @Produces({MediaType.APPLICATION_JSON}) @Path("/events-restful-table/") public class RestfulTableController { private List<RestfulTableRowModel> 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:


 <rest name="Events RESTful Table Resource" key="events-restful-table-resource" path="/evt-restful-table" version="1.0"/> 

In the REST resource code, note the following points:



Collecting plugin ... surprise! REST resources have been added to the system, as you can see on the plugin management page, but they don’t want to save the data. Having opened the browser console, it is easy to establish the reason: REST resources return a 404 error, that is, they are not available at the addresses used. In the addresses, in fact, the problem: the browser accesses the address of the form "<your_JIRA> /plugins/servlet/rest/evt-restful-table/1.0/events-restful-table/", but the resources are located at the addresses of the form <your_JIRA > /rest/evt-restful-table/1.0/events-restful-table/ "(you can verify this with, for example, the Atlassian REST API Browser plugin). The actual paths used by the table for requests are constructed based on the address of the current page (for example, if you compile the path to the servlet that paints your page, the paths for the requests will change accordingly). However, the situation will change if I start the path with a slash ("/"): in this case, the full path to the resources is made up of the host name and the specified path to the resource. The reasons for this phenomenon, I understand, frankly, lazy; there is a suspicion that the point here is the peculiarities of the work, not even the AUI, but the underlying Backbone.js. Either way, just adding a slash to the beginning of each path is not enough, unless your JIRA has a base URL that matches the host name. The universal solution will be accessible (and also starting with a slash) context path:

 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 the full URLs rather than relative URLs from the base URL of the application and the paths to the 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" }, 

Such URLs are used without additional conversions, which is also quite suitable for me.


I collect the plugin again, indicating the appropriate path. Now the records in the table are regularly created, edited and deleted. It seems that everything works ... yes? Not really.


Moving rows


The table should also support Drag & Drop rows (there is a setting that allows you to disable it, but I did not use it). Now, if you try dragging 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 change in the position of the string to be reflected on the server, another REST resource not mentioned in the manual is needed - move, which receives information about the movement details. It expects to receive 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 drag and drop position while dragging, and position - the description of the new position of the element using one of the four constants: First, Last, Earlier or Later (in fact, however, the current implementation of the RESTful table uses only First ... but the processing should still be implemented for all four). Only one of two fields can be initialized. For clarity, I made the Java model fields 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 is 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 specifically for the method of sorting elements in the table, which in this case is applied. 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 may), but in general the result of the transfer request needs to be processed (when it is returned, the REORDER_SUCCESS event not mentioned in the manual will fall, for which you should subscribe) models of the moved rows of the table will keep the old id (automatic updates, alas, they did not deliver), and this will mean that the data in the browser and on the server are out of sync, so further work with interactive table elements will not lead to anything good. Therefore, in this case (although, generally, it is rather uneconomical), the easiest way is not to try to return data about changes from the server and push them to the right places, but simply get the table to retrieve and redraw all the data again. All you have to do manually is to delete the old contents of the tbody table:


 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 that's it!


Other field types


My table is fully functional, but there is little use for it — in fact, it’s just a list of strings. You can, of course, add more string fields, but, most likely, in the real table you want to see not only the rows, but also something else - for example, dates, checkboxes, combo boxes ... For example, I will add the date - other fields are created in general, similarly .


To add a non-standard view field to the table, I will need to provide it with a custom view for creating, editing and reading, respectively, in the row being created, edited and inactive. To create and edit a date, I use aui-date-picker, since this is an 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.$('<input type="date" class="text aui-date-picker" name="date" />'); $field.datePicker({'overrideBrowserDefault': true}); return $field; } }), editView: AJS.RestfulTable.CustomEditView.extend({ render: function (self) { var $field = AJS.$('<input type="date" class="text aui-date-picker" name="date">'); $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 '<span data-field-name="date">' + (val ? val : '') + '</span>'; } }) } 

Accordingly, I will update the java-class data model:


 @XmlElement(name = "date") private Date date; public Date getDate() { return date; } public void setDate(Date date) { this.date = date; } 

... and add date handling to the update method:


 Optional.ofNullable(update.getDate()).ifPresent(model::setDate); 

Done - a new field of the desired type has appeared in the table.


I will be glad to clarify and supplement.


')

Source: https://habr.com/ru/post/354740/


All Articles