Хабрахабр

[Из песочницы] Реализация RESTful Table в Atlassian User Interface

О чём это вообще?

Среди инструментария, доступного разработчикам в составе этого SDK, есть подсистема для разработки веб-интерфейсов Atlassian User Interface (AUI). Для тех, кто вообще не в теме: у компании Atlassian, известной своими продуктами для обеспечения рабочих процессов (прежде всего JIRA и Confluence, но, наверное, любой айтишник без труда назовёт ещё несколько), есть также SDK для разработки плагинов к этим продуктам. А среди возможностей AUI есть так называемая RESTful Table — готовое решение для реализации интерактивной таблицы, все изменения в которой в реальном времени сохраняются на серверной стороне с помощью набора REST-сервисов.

6. Недавно мне потребовалось написать такую таблицу — до того мне этим заниматься не приходилось, посему я обратился к актуальной (AUI 7. Пришлось добирать информацию — по форумам и в исходниках самого AUI, благо последние доступны (и, кстати, также содержат хороший пример работающей RESTful-таблицы, но, к сожалению, не имеющий подробных комментариев). 2) версии официального руководства, но обнаружил, что его недостаточно. Основываться при работе, конечно, всё равно следует на официальном руководстве, но это текст, вероятно, будет полезен как дополнение… во всяком случае, пока оно не будет обновлено. Восполняющего обнаруженные пробелы руководства я в сети не обнаружил, и мне захотелось собрать воедино то, что успел накопать я, чтобы облегчить аналогичную задачу и другим, и, возможно, себе же в будущем.

Продукты и версии

При работе я использовал:

  • Java 8
  • Atlassian Plugin SDK 6.3.6 (в частности, делал сборки включённым в него экземпляром Maven 3.2.1)
  • JIRA 7.7.0 Core (плагин писался под JIRA и тестировался исключительно на этой версии)

Постановка задачи

Любое изменение контента таблицы должно синхронно фиксироваться в хранилище на стороне сервера — как правило, это база данных или другое энергонезависимое решение, но, поскольку меня в данном случае интересует таблица и её взаимодействие с серверной стороной, я ограничусь хранилищем в памяти — это позволит мне получить ранее сохранённые данные, зайдя заново на страницу с таблицей, но не сохранить их при отключении сервера или, например, переустановке плагина. Итак, мне нужно, чтобы где-то в JIRA появилась страница с таблицей, позволяющей добавлять/удалять строки, изменять содержимое имеющихся и менять строки местами.

Подготовка

На этом я не буду останавливаться подробно — в принципе это тривиальные операции; в любом случае работающий код примера доступен на Bitbucket. Предварительно я создам плагин для JIRA, содержащий одну новую страницу (пусть это будет модуль servlet, рисующий страницу в формате Apache Velocity), срабатывающий при открытии этой страницы пустой JS-скрипт (в нём и будет твориться большая часть магии) и ведущую на эту страницу ссылку в шапке JIRA.

Реализация: frontend

Прежде всего добавлю на страницу обычную таблицу HTML, которая и станет моей RESTful-таблицей Попробую действовать по официальному руководству Atlassian.

<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 () , 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-ресурсы в систему добавились, как можно видеть на странице управления плагинами, но сохраняться данные не хотят. Собираю плагин… сюрприз! В адресах, собственно, и проблема: браузер обращается по адресу вида "<your_JIRA>/plugins/servlet/rest/evt-restful-table/1. Открыв консоль браузера, легко установить причину: REST-ресурсы возвращают ошибку 404, то есть по используемым адресам их нет. 0/events-restful-table/" (можно убедиться в этом при помощи, например, плагина Atlassian REST API Browser). 0/events-restful-table/", а вот ресурсы находятся по адресам вида "<your_JIRA>/rest/evt-restful-table/1. Однако ситуация изменится, если я начну пути со слеша ("/"): в этом случае полный путь к ресурсам составляется из имени хоста и заданного пути к ресурсу. Фактически используемые таблицей пути для запросов конструируются на основе адреса текущей страницы (например, если сделать составным путь к рисующему вашу страницу сервлету, соответственно изменятся и пути для запросов). Так или иначе, просто добавить в начало каждого пути слеш недостаточно, если только у вашей JIRA базовая URL не совпадает с именем хоста. В причинах этого явления я разбираться, честно говоря, поленился; есть подозрение, что здесь дело в особенностях работы даже не AUI, а лежащего в его основе Backbone.js. Универсальным решением будет доступный (и также начинающийся со слеша) 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 строк (есть настройка, позволяющая это отключить, но я её не использовал). Он ожидает получить объект с двумя параметрами: after — путь к REST-ресурсу элемента данных, соответствующего строке, ниже которой я размещаю при перетаскивании мою, перемещаемую, и position — описание новой позиции элемента при помощи одной из четырёх констант: First, Last, Earlier или Later (по факту, правда, текущая реализация RESTful-таблицы использует только First… но обработку стоит всё-таки реализовать для всех четырёх). Для того, чтобы изменение позиции строки было отражено на сервере, нужен ещё один не упомянутый в руководстве REST-ресурс — move, принимающий информацию о деталях перемещения. Для наглядности я сделал поля 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(); });

Вот теперь всё!

Другие типы полей

Можно, конечно, добавить ещё строковых полей, но, скорее всего, в реальной таблице захочется видеть не только строки, но и что-то ещё — например, даты, чекбоксы, комбобоксы… Для примера я добавлю дату — другие поля создаются в целом аналогично. Моя таблица полнофункциональна, но толку от неё мало — фактически это просто список строк.

Для создания и редактирования даты я использую aui-date-picker, раз уж речь идёт об AUI, а для неактивной строки хватит и обычного span: Чтобы добавить в таблицу поле нестандартного вида, мне потребуется снабдить его кастомными view для создания, редактирования и чтения — соответственно в создаваемой, редактируемой и неактивной в данный момент строке.

{ 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);

Готово — в таблице появилось новое поле нужного типа.

Буду рад уточнениям и дополнениям.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть