О чём это вообще?
Для тех, кто вообще не в теме: у компании Atlassian, известной своими продуктами для обеспечения рабочих процессов (прежде всего JIRA и Confluence, но, наверное, любой айтишник без труда назовёт ещё несколько), есть также SDK для разработки плагинов к этим продуктам. Среди инструментария, доступного разработчикам в составе этого SDK, есть подсистема для разработки веб-интерфейсов Atlassian User Interface (AUI). А среди возможностей AUI есть так называемая RESTful Table — готовое решение для реализации интерактивной таблицы, все изменения в которой в реальном времени сохраняются на серверной стороне с помощью набора REST-сервисов.
Недавно мне потребовалось написать такую таблицу — до того мне этим заниматься не приходилось, посему я обратился к актуальной (AUI 7.6.2) версии официального руководства, но обнаружил, что его недостаточно. Пришлось добирать информацию — по форумам и в исходниках самого AUI, благо последние доступны (и, кстати, также содержат хороший пример работающей RESTful-таблицы, но, к сожалению, не имеющий подробных комментариев). Восполняющего обнаруженные пробелы руководства я в сети не обнаружил, и мне захотелось собрать воедино то, что успел накопать я, чтобы облегчить аналогичную задачу и другим, и, возможно, себе же в будущем. Основываться при работе, конечно, всё равно следует на официальном руководстве, но это текст, вероятно, будет полезен как дополнение… во всяком случае, пока оно не будет обновлено.
Продукты и версии
При работе я использовал:
- Java 8
- Atlassian Plugin SDK 6.3.6 (в частности, делал сборки включённым в него экземпляром Maven 3.2.1)
- JIRA 7.7.0 Core (плагин писался под JIRA и тестировался исключительно на этой версии)
Постановка задачи
Итак, мне нужно, чтобы где-то в JIRA появилась страница с таблицей, позволяющей добавлять/удалять строки, изменять содержимое имеющихся и менять строки местами. Любое изменение контента таблицы должно синхронно фиксироваться в хранилище на стороне сервера — как правило, это база данных или другое энергонезависимое решение, но, поскольку меня в данном случае интересует таблица и её взаимодействие с серверной стороной, я ограничусь хранилищем в памяти — это позволит мне получить ранее сохранённые данные, зайдя заново на страницу с таблицей, но не сохранить их при отключении сервера или, например, переустановке плагина.
Подготовка
Предварительно я создам плагин для JIRA, содержащий одну новую страницу (пусть это будет модуль servlet, рисующий страницу в формате Apache Velocity), срабатывающий при открытии этой страницы пустой JS-скрипт (в нём и будет твориться большая часть магии) и ведущую на эту страницу ссылку в шапке JIRA. На этом я не буду останавливаться подробно — в принципе это тривиальные операции; в любом случае работающий код примера доступен на Bitbucket.
Реализация: frontend
Попробую действовать по официальному руководству Atlassian. Прежде всего добавлю на страницу обычную таблицу HTML, которая и станет моей RESTful-таблицей
<table id="event-rt"></table>
… в модуль web-resource JS-скрипта в дескрипторе плагина (atlassian-plugin.xml) — зависимость от соответствующей библиотеки:
<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>
… а в сам скрипт — создание на основе имеющейся table минимальной RESTful-таблицы с одним строковым параметром:
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"
}
]
});
});
Готово — собрав плагин, можно убедиться, что на странице действительно есть новая таблица, позволяющая добавлять строки с желаемым содержимым, затем редактировать их, удалять и менять местами. С серверной стороны эти изменения, понятно, пока никак не фиксируются.
Реализация: backend
Этим я теперь и займусь. Согласно руководству, требуется один REST-ресурс, предоставляющий все сохранённые в системе данные для модели таблицы, и другой (точнее не один ресурс, а их набор), позволяющий выполнять CRUD-операции с одним конкретным экземпляром модели. Пусть в данном случае это будет реализовано как один общий класс контроллера и класс модели данных:
@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;
}
}
Соответствующий модуль rest в дескрипторе плагина:
<rest name="Events RESTful Table Resource" key="events-restful-table-resource" path="/evt-restful-table" version="1.0"/>
В коде REST-ресурса обратите внимание на следующие моменты:
- создающий и обновляющий запись в хранилище методы REST-ресурса также возвращают в составе ответа результирующий объект этой записи — в целом это вполне общепринятая практика; проблема в том, что ничто не заставляет меня это сделать, если я об этой практике не знаю. Без этого таблица правильно работать не будет;
- при редактировании записи в таблице на сервер попадают значения только изменённых полей, вместо остальных приходит null, поэтому я вынужден проверить их наличие, прежде чем записывать новые значения в объект записи в хранилище. Пустые значения приходят на сервер в виде пустых строк, а не null, так что проблемы отличить отсутствие изменения от нового пустого значения в случае строковых полей не возникает — но вот в случае поля примитивного типа возможны сложности;
- в составе класса записи есть поле id — оно никак не отражается в таблице, но именно оно используется для идентификации записей; id генерируется сервером, а затем возвращается клиенту в составе объекта созданной записи хранилища. Важно: id должен быть таким, чтобы его Javascript-представление приводилось к булевому true, а не false — в частности, 0 не годится;
- записи на сервере сортируются (в данном случае по id) — это, разумеется, не обязательно, но для любой практической задачи, скорее всего, понадобится. Заметьте, что порядок инвертирован по отношению к естественному — благодаря этому удаётся сохранить при повторном выводе (скажем, перезагрузив страницу) тот порядок записей, который возникал непосредственно при добавлении записей в таблицу; впрочем, у таблицы есть опция "createPosition", позволяющая при значении "bottom" добавлять новые записи снизу, а не сверху, как в этом примере, и в этом случае подобная инверсия, понятно, не нужна.
Собираю плагин… сюрприз! REST-ресурсы в систему добавились, как можно видеть на странице управления плагинами, но сохраняться данные не хотят. Открыв консоль браузера, легко установить причину: REST-ресурсы возвращают ошибку 404, то есть по используемым адресам их нет. В адресах, собственно, и проблема: браузер обращается по адресу вида "<your_JIRA>/plugins/servlet/rest/evt-restful-table/1.0/events-restful-table/", а вот ресурсы находятся по адресам вида "<your_JIRA>/rest/evt-restful-table/1.0/events-restful-table/" (можно убедиться в этом при помощи, например, плагина Atlassian REST API Browser). Фактически используемые таблицей пути для запросов конструируются на основе адреса текущей страницы (например, если сделать составным путь к рисующему вашу страницу сервлету, соответственно изменятся и пути для запросов). Однако ситуация изменится, если я начну пути со слеша ("/"): в этом случае полный путь к ресурсам составляется из имени хоста и заданного пути к ресурсу. В причинах этого явления я разбираться, честно говоря, поленился; есть подозрение, что здесь дело в особенностях работы даже не AUI, а лежащего в его основе Backbone.js. Так или иначе, просто добавить в начало каждого пути слеш недостаточно, если только у вашей JIRA базовая URL не совпадает с именем хоста. Универсальным решением будет доступный (и также начинающийся со слеша) 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"
},
Возможно и другое решение: самому создать полные, а не относительные URL из базовой URL приложения и путей к REST-ресурсам:
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"
},
Такие URL используются без дополнительных преобразований, что мне тоже вполне подойдёт.
Собираю плагин ещё раз, указав подходящие пути. Теперь записи в таблице исправно создаются, редактируются и удаляются. Кажется, всё работает… да? Не совсем.
Перемещение строк
Таблица должна поддерживать ещё и Drag&Drop строк (есть настройка, позволяющая это отключить, но я её не использовал). Сейчас, если попробовать перетащить куда-нибудь одну из строк, это сработает… но после перезагрузки страницы строки окажутся в прежней позиции. Для того, чтобы изменение позиции строки было отражено на сервере, нужен ещё один не упомянутый в руководстве REST-ресурс — move, принимающий информацию о деталях перемещения. Он ожидает получить объект с двумя параметрами: after — путь к REST-ресурсу элемента данных, соответствующего строке, ниже которой я размещаю при перетаскивании мою, перемещаемую, и position — описание новой позиции элемента при помощи одной из четырёх констант: First, Last, Earlier или Later (по факту, правда, текущая реализация RESTful-таблицы использует только First… но обработку стоит всё-таки реализовать для всех четырёх). Инициализировано может быть лишь какое-то одно из двух полей. Для наглядности я сделал поля Java-модели строковыми, хотя это не самое удобное решение.
@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;
}
}
А вот и собственно метод, реализующий мой REST-ресурс:
@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();
}
Обратите внимание: он актуален именно для того способа сортировки элементов в таблице, который в данном случае применён. Для сортировки в обратном порядке этот метод придётся менять.
Метод, как видите, не возвращает браузеру ничего осмысленного (хотя может), но вообще-то результат запроса на перемещение нужно обработать (при его возвращении упадёт опять же не упомянутое в руководстве событие REORDER_SUCCESS, на которое и следует для этого подписаться): без этого модели перемещённых строк таблицы сохранят старые id (автоматического обновления, увы, не завезли), а это будет означать рассинхронизацию данных в браузере и на сервере, так что дальнейшая работа с интерактивными элементами таблицы ни к чему хорошему не приведёт. Поэтому в данном случае (хотя вообще-то это довольно неэкономно) проще всего не пытаться возвращать с сервера данные об изменениях и для распихивания их по нужным местам, а просто заставить таблицу получить и отрисовать все данные заново. Всё, что придётся при этом сделать вручную, — это удалить старое содержимое tbody таблицы:
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();
});
Вот теперь всё!
Другие типы полей
Моя таблица полнофункциональна, но толку от неё мало — фактически это просто список строк. Можно, конечно, добавить ещё строковых полей, но, скорее всего, в реальной таблице захочется видеть не только строки, но и что-то ещё — например, даты, чекбоксы, комбобоксы… Для примера я добавлю дату — другие поля создаются в целом аналогично.
Чтобы добавить в таблицу поле нестандартного вида, мне потребуется снабдить его кастомными view для создания, редактирования и чтения — соответственно в создаваемой, редактируемой и неактивной в данный момент строке. Для создания и редактирования даты я использую aui-date-picker, раз уж речь идёт об AUI, а для неактивной строки хватит и обычного span:
{
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>';
}
})
}
Соответственно обновлю java-класс модели данных:
@XmlElement(name = "date")
private Date date;
public Date getDate() {
return date;
}
public void setDate(Date date) {
this.date = date;
}
… и добавлю обработку даты в метод апдейта:
Optional.ofNullable(update.getDate()).ifPresent(model::setDate);
Готово — в таблице появилось новое поле нужного типа.
Буду рад уточнениям и дополнениям.
Комментарии (4)
iWord
02.05.2018 22:49Уважаемый bboogwin этот решение для «на коленке» сделанного. А то очень уже перегруженный контроллер который реализует в себе логику. Понимаю что круто писать лямбды на java, но в человеческий вид привести бы стоило следующий метод.
@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(); }
bboogwin Автор
02.05.2018 22:49Угу, там не только логика, там и данные хранятся прямо в контроллере, потому что это И ЕСТЬ «на коленке сделанное» решение. Я бы никому не посоветовал делать так при реальной разработке, но это всего лишь демонстрационный пример, и от бекенда здесь требуется только работоспособность при минимуме кода. Выносить логику в какой-то отдельный сервис в этих условиях, на мой взгляд, причин нет.
DJBlend
Возможно, я не уловил сути, но зачем вообще нужна такая таблица и почему эту задачу нельзя решить существующим плагином (insight, например)?
bboogwin Автор
Речь о случае, когда вы хотите использовать такую таблицу на какой-то странице в составе плагина, который пишете сами.