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


Для тех, кто вообще не в теме: у компании 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)


  1. DJBlend
    02.05.2018 17:09

    Возможно, я не уловил сути, но зачем вообще нужна такая таблица и почему эту задачу нельзя решить существующим плагином (insight, например)?


    1. bboogwin Автор
      02.05.2018 22:22

      Речь о случае, когда вы хотите использовать такую таблицу на какой-то странице в составе плагина, который пишете сами.


  1. 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();
    }


    1. bboogwin Автор
      02.05.2018 22:49

      Угу, там не только логика, там и данные хранятся прямо в контроллере, потому что это И ЕСТЬ «на коленке сделанное» решение. Я бы никому не посоветовал делать так при реальной разработке, но это всего лишь демонстрационный пример, и от бекенда здесь требуется только работоспособность при минимуме кода. Выносить логику в какой-то отдельный сервис в этих условиях, на мой взгляд, причин нет.