О чём это вообще?
Для тех, кто вообще не в теме: у компании 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);
Готово — в таблице появилось новое поле нужного типа.
Буду рад уточнениям и дополнениям.
