Всем привет!

В этой статье я бы хотел поговорить о том, как использовать React и Atlaskit в плагинах Atlassian в Server и Data Center окружениях.

Вступление


В настоящее время, если Вы будете разрабатывать плагины для продуктов Atlassian для Server и Data Center, то для разработки пользовательского интерфейса из коробки Вам доступны vm, soy, requirejs, jquery, backbone. Вот тут можно почитать мою статью, как пользоваться доступными из коробки библиотеками.

Этот стек технологий устарел и хотелось бы пользоваться чем-то по новее. В качестве стека я выбрал typescript и react. Так же для того, чтобы было проще переносить серверные плагины на клауд, я выбрал библиотеку элементов пользовательского интерфейса atlaskit.

В этой статье я буду говорить про Atlassian Jira, но этот же подход можно использовать и для других серверных и датацентровых продуктов Atlassian.

Для того, чтобы воспроизвести примеры из этой статьи необходимо установить git и Atlassian SDK.

Итак, начнем!

Устанавливаем Maven архетип и создаем новый проект


Я сделал архетип Maven для того, чтобы было проще создавать новый проект, который уже будет содержать все необходимые настройки, для создания плагина с React и Atlaskit.

Если Вы не хотите пользоваться архетипом, то можете взять уже созданный плагин из этого архетипа и перейти к части Собираем и запускаем проект.

Склонируйте архетип из моего Bitbucket репозитория:

git clone https://alex1mmm@bitbucket.org/alex1mmm/jira-react-atlaskit-archetype.git --branch v1 --single-branch

Перейдите в папку jira-react-atlaskit-archetype folder и установите этот архетип в Ваш локальный репозиторий Maven:

cd jira-react-atlaskit-archetype
atlas-mvn install

После этого перейдите в папку на уровень выше и создайти новый проект на основе этого архетипа:

cd ..
atlas-mvn archetype:generate -DarchetypeCatalog=local

Будет задан вопрос, чтобы выбрать необходимый архетип. Вот мои архетипы в локальном репозитории:

1: local -> com.cprime.jira.sil.extension:sil-extension-archetype (This is the com.cprime.jira.sil.extension:sil-extension plugin for Atlassian JIRA.)
2: local -> ru.matveev.alexey.sil.extension:sil-extension-archetype (This is the ru.matveev.alexey.sil.extension:sil-extension plugin for Atlassian JIRA.)
3: local -> ru.matveev.alexey.atlas.jira:jira-react-atlaskit-archetype-archetype (jira-react-atlaskit-archetype-archetype)

Нужно выбрать ru.matveev.alexey.atlas.jira:jira-react-atlaskit-archetype-archetype, поэтому я указал цифру 3 в качестве ответа.

Затем Вам необходимо указать groupid и artifactid для нового проекта:

Define value for property 'groupId': react.atlaskit.tutorial
Define value for property 'artifactId': my-tutorial
Define value for property 'version' 1.0-SNAPSHOT: : 
Define value for property 'package' react.atlaskit.tutorial: : 
Confirm properties configuration:
groupId: react.atlaskit.tutorial
artifactId: my-tutorial
version: 1.0-SNAPSHOT
package: react.atlaskit.tutorial
 Y: : Y

Собираем и устанавливаем проект


В моем случае новый проект находится в папке my-tutorial. Перейдем в эту папку и соберем проект:

cd my-tutorial
atlas-mvn package

После того, как проект соберется, перейдем в папку backend и запустим Atlassian Jira:

cd backend
atlas-run

Протестируем плагин


После того, как Atlassian Jira запустилась, перейдем в браузере по следующему адресу:

http://localhost:2990/jira

Вам нужно залогиниться под admin:admin и перейти шестеренка -> Manage apps.



Вы увидите меню от нашего плагина. Но прежде, чем запускать наши Atlaskit элементы, перейдите в System -> Logging and Profiling и поставьте уровень логирования INFO для пакета react.atlaskit.tutorial.servlet.



Теперь перейдем обратно в Manage apps и нажмем на меню Form. Мы увидим форму для ввода данных, которая была выведена с помощью элемента Atlaskit Form:



Заполним все текстовые поля и нажмем на кнопку Submit:



Теперь, если Вы откроете файл atlassian-jira.log, то увидите что-то наподобие такого:

2020-05-10 08:44:29,701+0300 http-nio-2990-exec-4 INFO admin 524x12509x1 15awhw2 0:0:0:0:0:0:0:1 /plugins/servlet/form [r.a.tutorial.servlet.FormServlet] Post Data: {"firstname":"Alexey","lastname":"Matveev","description":"No description","comments":"and no comments"}

Это означает, что по нажатии на кнопку Submit, наши данные были успешно переданы в сервлет, который обслуживает эту форму, и этот сервлет вывел введенные данные в лог файл.
Теперь давайте выберем меню Dynamic Table. Вы увидите элемент Atlaskit Dynamic Table:



Это все, что делает наш плагин. Теперь давайте посмотрим, как это все работает!

Плагин изнутри


Вот структура нашего плагина:



backend содержит плагин Atlassian Jira, который был создан с помощью Atlassian SDK.
frontend содержит UI элементы, которые будут использованы нашим плагином.
pom.xml pom файл, в которым определены два модуля:

    <modules>  
        <module>frontend</module>
        <module>backend</module>
    </modules>

Теперь давайте посмотрим на папку fronted.

Frontend


Папка frontend содержит следующие файлы:



Опишу основные файлы.

package.json — конфигурационный файл для npm, который содержит следующую информацию:

  • список пакетов от которых зависит наш проект.
  • версии пакетов, которые мы используем.

Мы будем использовать такие пакеты, как typescript, atlaskit, babel и другие.

.babel.rc — конфигурационный файл для Babel. Babel используется для того, чтобы перевести код на ECMAScript 2015+ в JavaScript код. Мы будем писать наш код на Typescript, поэтому нам необходимо перевести его в код JavaScript, чтобы плагин для Jira смог с ним работать.

webpack.config.js — конфигурационный файл для webpack. Webpack обрабатывает наше приложение, строит граф зависимостей и генерит бандл, который содержит весь необходимый JavaScript для работы нашего приложения. Для того, чтобы Jira плагин смог работать с нашим JavaScript, нам нужен один файл Javascript для каждой точки входа. В нашем случае точки входа это пункты меню Form и Dynamic Table.

Вот содержимое файла webpack.config.js:

const WrmPlugin = require('atlassian-webresource-webpack-plugin');
var path = require('path');module.exports = {
    module: {
        rules: [
          {
            test: /\.(js|jsx)$/,
            exclude: /node_modules/,
            use: {
                loader: "babel-loader"
            }
          }
        ]
    },
    entry: {
            'form': './src/form.js',
            'dynamictable': './src/dynamictable.js'
    },

    plugins: [
        new WrmPlugin({
            pluginKey: 'ru.matveev.alexey.atlas.jira.jira-react-atlaskit',
            locationPrefix: 'frontend/',
            xmlDescriptors: path.resolve('../backend/src/main/resources', 'META-INF', 'plugin-descriptors', 'wr-defs.xml')
        }),
    ],
    output: {
        filename: 'bundled.[name].js',
        path: path.resolve("./dist")
    }
};

Как Вы видите мы используем atlassian-webresource-webpack-plugin.

Он нужен для того, чтобы после того, как webpack создал JavaScript файл, этот файл автоматически бы добавился в качестве веб ресурса:

plugins: [
        new WrmPlugin({
            pluginKey: 'ru.matveev.alexey.atlas.jira.jira-react-atlaskit',
            locationPrefix: 'frontend/',
            xmlDescriptors: path.resolve('../backend/src/main/resources', 'META-INF', 'plugin-descriptors', 'wr-defs.xml')
        }),
    ],

В результате такой конфигурации после сборки модуля frontend будет создан файл wr-defs.xml в папке backend/src/resources/META-INF/plugin-descriptors.

Параметр locationPrefix позволяет нам задать путь нахождения файлов JavaScript в плагине Jira. В нашем случае мы указываем, что файлы будут находиться в папке backend/src/resources/frontend. Мы поместим файлы JavaScript в эту папку позже в модуле backend, но сейчас этот параметр нам позволяет получить вот такую строку в файле wr-defs.xml <resource type=«download» name=«bundled.dynamictable.js» location=«frontend/bundled.dynamictable.js»/>.

Вот содержимое файла wr-defs.xml file, который был создан в процессе сборки проекта:

<bundles>
  <web-resource key="entrypoint-form">
    <transformation extension="js">
      <transformer key="jsI18n"/>
    </transformation>
    <context>form</context>
    <dependency>com.atlassian.plugins.atlassian-plugins-webresource-plugin:context-path</dependency>
    <resource type="download" name="bundled.form.js" location="frontend/bundled.form.js"/>
  </web-resource>
  <web-resource key="entrypoint-dynamictable">
    <transformation extension="js">
      <transformer key="jsI18n"/>
    </transformation>
    <context>dynamictable</context>
    <dependency>com.atlassian.plugins.atlassian-plugins-webresource-plugin:context-path</dependency>
    <resource type="download" name="bundled.dynamictable.js" location="frontend/bundled.dynamictable.js"/>
  </web-resource>
  <web-resource key="assets-632cdd38-e80f-4a5a-ba4c-07ba7cb36e60">
    <transformation extension="js">
      <transformer key="jsI18n"/>
    </transformation>
    <transformation extension="soy">
      <transformer key="soyTransformer"/>
      <transformer key="jsI18n"/>
    </transformation>
    <transformation extension="less">
      <transformer key="lessTransformer"/>
    </transformation>
  </web-resource>
</bundles>

Как Вы видите у нас есть дополнительные секции веб ресурсов, в которых определены JavaScript файлы, созданные webpack. Все что нам осталось, это сказать Jira, чтобы при установке нашего плагина еще использовались и веб ресурсы из папки backend/src/resources/META-INF/plugin-descriptor. Для этого мы внесли следующие изменения в файл backend/pom.xml:

<plugin>
                <groupId>com.atlassian.maven.plugins</groupId>
                <artifactId>jira-maven-plugin</artifactId>
                <version>${amps.version}</version>
                <extensions>true</extensions>
                <configuration>
                    <productVersion>${jira.version}</productVersion>
                    <productDataVersion>${jira.version}</productDataVersion>
                    <compressResources>false</compressResources>
                    <enableQuickReload>true</enableQuickReload>
                    <instructions>
                        <Atlassian-Plugin-Key>${atlassian.plugin.key}</Atlassian-Plugin-Key>
                        <Export-Package></Export-Package>
                        <Import-Package>org.springframework.osgi.*;resolution:="optional", org.eclipse.gemini.blueprint.*;resolution:="optional", *</Import-Package>
                        <!-- Ensure plugin is spring powered -->
                        <Spring-Context>*</Spring-Context>
                        <Atlassian-Scan-Folders>META-INF/plugin-descriptors</Atlassian-Scan-Folders>
                    </instructions>
                </configuration>
            </plugin>

Мы добавили <Atlassian-Scan-Folders>META-INF/plugin-descriptors</Atlassian-Scan-Folders>. Это параметр как раз и скажет Jira, что нужно искать дополнительные веб ресурсы папке META-INF/plugin-descriptors.

Еще мы добавили <compressResources>false</compressResources> для того, чтобы отключить минификацию наших файлов JavaScript. Они уже были минифицированы.
Мы также определили две точки входа в наше приложение в файле webpack.config.js:

 entry: {
            'form': './src/form.js',
            'dynamictable': './src/dynamictable.js'
    },

Это означает, что webpack просканирует файлы ./src/form.js и ./src/dynamictable.js и создаст два файла JavaScript, каждый из которых это файл для одной из точек входа. Эти файлы будут созданы в папке frontend/dist.

./src/form.js и ./src/dynamictable.js не содержат ничего особенного. Большую часть кода я взял из примеров в Atlaskit.

Вот содержимое файла form.js:

import Form from "./js/components/Form";

Здесь всего лишь одна строка, которая импортирует класс из файла ./js/components/Form.js.

Вот содержимое файла ./js/components/Form.js:

import React, { Component } from 'react';
import ReactDOM from "react-dom";
import Button from '@atlaskit/button';
import TextArea from '@atlaskit/textarea';
import TextField from '@atlaskit/textfield';
import axios from 'axios';

import Form, { Field, FormFooter } from '@atlaskit/form';

export default class MyForm extends Component {
  render() {
  return (
  <div
    style={{
      display: 'flex',
      width: '400px',
      margin: '0 auto',
      flexDirection: 'column',
    }}
  >
    <Form onSubmit={data => axios.post(document.getElementById("contextPath").value + "/plugins/servlet/form", data)}>
      {({ formProps }) => (
        <form {...formProps} name="text-fields">
          <Field name="firstname" defaultValue="" label="First name" isRequired>
            {({ fieldProps }) => <TextField {...fieldProps} />}
          </Field>

          <Field name="lastname" defaultValue="" label="Last name" isRequired>
            {({ fieldProps: { isRequired, isDisabled, ...others } }) => (
              <TextField
                disabled={isDisabled}
                required={isRequired}
                {...others}
              />
            )}
          </Field>
          <Field
            name="description"
            defaultValue=""
            label="Description"
          >
            {({ fieldProps }) => <TextArea {...fieldProps} />}
          </Field>

          <Field
            name="comments"
            defaultValue=""
            label="Additional comments"
          >
            {({ fieldProps }) => <TextArea {...fieldProps} />}
          </Field>
          <FormFooter>
            <Button type="submit" appearance="primary">
              Submit
            </Button>
          </FormFooter>
        </form>
      )}
    </Form>
  </div>
);
}
}
window.addEventListener('load', function() {
    const wrapper = document.getElementById("container");
    wrapper ? ReactDOM.render(<MyForm />, wrapper) : false;
});</code></pre>
<!-- /wp:code -->Все строки взяты из примеров кроме вот этих:<!-- wp:code -->
<pre class="wp-block-code"><code>window.addEventListener('load', function() {
    const wrapper = document.getElementById("container");
    wrapper ? ReactDOM.render(<MyForm />, wrapper) : false;
});

Здесь я показываю компонент MyForm в контейнере div. Этот контейнер будет определен в soy шаблоне Jira плагина.

Также обратите внимание вот на эту строку:

onSubmit={data => axios.post(document.getElementById("contextPath").value + "/plugins/servlet/form", data)}

document.getElementById(«contextPath»).value получает значение поля с id contextPath. Я определяю это поле в soy шаблоне в Jira плагине. Значение в это поле приходит из сервлета, к которому привязан пункт меню Form. В моем случае contextPath содержит значение /jira, так как при запуске Jira из Atlassian SDK устанавливается такой путь контекста.

И это все про frontend. В результате сборки модуля frontend мы получаем два файла JavaScript в папке frontend/dist и xml с дополнительными веб ресурсами в папке backend/src/resources/META-INF/plugin-descriptors.

Теперь перейдем к бэкенду.

Backend


Я добавил вот такие плагины в файл backend/pom.xml:

<plugin>
                <groupId>org.codehaus.mojo</groupId>
                <artifactId>properties-maven-plugin</artifactId>
                <version>1.0.0</version>
                <executions>
                    <execution>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>write-project-properties</goal>
                        </goals>
                        <configuration>
                            <outputFile>${project.build.outputDirectory}/maven.properties</outputFile>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.1.0</version>
                <executions>
                    <execution>
                        <id>copy frontend files to resources</id>
                        <phase>generate-resources</phase>
                        <goals>
                            <goal>copy-resources</goal>
                        </goals>
                        <configuration>
                            <outputDirectory>src/main/resources/frontend</outputDirectory>
                            <overwrite>true</overwrite>
                            <resources>
                                <resource>
                                    <directory>../frontend/dist</directory>
                                    <includes>
                                        <include>*.*</include>
                                    </includes>
                                </resource>
                            </resources>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

properties-maven-plugin создает файл maven.properties, который содежит все свойства Maven. Мне нужно свойство atlassian.plugin.key для того, чтобы вызывать веб ресурсы из сервлетов, которые привязаны к пунктам меню нашего плагина.

maven-resources-plugin забирает файлы JavaScript из папки frontend/dist и копирует их в папку backend/resources/frontend.

Затем я создал пункты меню и сделал вызов сервлетов из этих пунктов меню.

Вот строки из файла atlassian-plugin.xml:

<servlet name="Form Servlet" i18n-name-key="form-servlet.name" key="form-servlet" class="react.atlaskit.tutorial.servlet.FormServlet"> 
    <description key="form-servlet.description">The Form Servlet Plugin</description>  
    <url-pattern>/form</url-pattern>
  </servlet>  
  <servlet name="Dynamic Table Servlet" i18n-name-key="dynamic-table-servlet.name" key="dynamic-table-servlet" class="react.atlaskit.tutorial.servlet.DynamicTableServlet"> 
    <description key="dynamic-table-servlet.description">The Dynamic Table Servlet Plugin</description>  
    <url-pattern>/dynamictable</url-pattern>
  </servlet>
  <web-section name="React Plugin" i18n-name-key="react.name" key="react" location="admin_plugins_menu" weight="1000">
    <description key="react.description">React Plugin</description>
    <label key="react.label"/>
  </web-section>
  <web-item name="from web item" i18n-name-key="form.name" key="form" section="admin_plugins_menu/react" weight="1000">
    <description key="form.description">Form</description>
    <label key="form.label"/>
    <link linkId="configuration-link">/plugins/servlet/form</link>
  </web-item>
  <web-item name="dynamic table web item" i18n-name-key="dynamictable.name" key="dynamictable" section="admin_plugins_menu/react" weight="1000">
    <description key="dynamictable.description">Dynamic Table</description>
    <label key="dynamictable.label"/>
    <link linkId="configuration-link">/plugins/servlet/dynamictable</link>
  </web-item>

Итак, у нас есть меню и сервелеты, которые из этих пунктов меню вызываются.
Теперь давайте посмотрим на сервлеты:

FormServlet.java:

public class FormServlet extends HttpServlet{
    private static final Logger log = LoggerFactory.getLogger(FormServlet.class);
    private final ResourceService resourceService;
    private final SoyTemplateRenderer soyTemplateRenderer;

    public FormServlet(@ComponentImport  SoyTemplateRenderer soyTemplateRenderer, ResourceService resourceService) {
        this.resourceService = resourceService;
        this.soyTemplateRenderer = soyTemplateRenderer;
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException
    {
        String pluginKey = this.resourceService.getProperty("atlassian.plugin.key");
        Map<String, Object> map = new HashMap<>();
        map.put("contextPath", req.getContextPath());

        String html = soyTemplateRenderer.render(pluginKey + ":jira-react-atlaskit-resources", "servlet.ui.form", map);

        resp.setContentType("text/html");
        resp.getWriter().write(html);
        resp.getWriter().close();    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        StringBuffer jb = new StringBuffer();
        String line = null;
        try {
            BufferedReader reader = req.getReader();
            while ((line = reader.readLine()) != null)
                jb.append(line);
        } catch (Exception e) { /*report an error*/ }
        log.info(String.format("Post Data: %s", jb.toString()));

        String pluginKey = this.resourceService.getProperty("atlassian.plugin.key");
        Map<String, Object> map = new HashMap<>();
        map.put("contextPath", req.getContextPath());

        String html = soyTemplateRenderer.render(pluginKey + ":jira-react-atlaskit-resources", "servlet.ui.form", map);

        resp.setContentType("text/html");
        resp.getWriter().write(html);
        resp.getWriter().close();
    }}

Я определяю две переменные resourceService и soyTemplateRenderer и инициализирую эти переменные в конструкторе класса. resourceService — бин, которые читает свойства из файла maven.properties. soyTemplateRenderer — Jira бин, который умеет вызывать soy шаблоны.
В методе doGet я получаю значение свойства atlassian.plugin.key и путь контекста. Затем я передаю путь контекста в качестве параметра в шаблон soy и вызываю шаблон soy под именем servlet.ui.form.

Вот содержимое soy файла:

{namespace servlet.ui}
/**
 * This template is needed to draw the form page.
 */
{template .form}
    {@param contextPath: string}
    {webResourceManager_requireResourcesForContext('form')}
    <html>
    <head>
        <meta charset="utf-8"/>
        <meta name="decorator" content="atl.admin">
        <meta name="admin.active.section" content="admin_plugins_menu/react">
        <title>Form Page</title>
    </head>
    <body>
    <div class="field-group hidden">
        <input class="text" type="text" id="contextPath" name="contextPath" value="{$contextPath}">
    </div>
    <div id="maincontainer">
        <div id="container">
        </div>
    </div>
    </body>
    </html>
{/template}
/**
 * This template is needed to draw the dynamic table page.
 */
{template .dynamictable}
    {webResourceManager_requireResourcesForContext('dynamictable')}
    <html>
    <head>
        <meta charset="utf-8"/>
        <meta name="decorator" content="atl.admin">
        <meta name="admin.active.section" content="admin_plugins_menu/react">
        <title>Dynamic Table Page</title>
    </head>
    <body>
    <div id="maincontainer">
        <div id="container">
        </div>
    </div>
    </body>
    </html>
{/template}

Код шаблонов достаточно прост. Я вызываю веб ресурс для пункта меню и создаю контейнер div, который будет использоваться React.

Сам soy файл я прописал в atlassian-plugin.xml:

<web-resource key="jira-react-atlaskit-resources" name="jira-react-atlaskit Web Resources"> 
    ...
    <resource type="soy" name="soyui" location="/templates/servlets.soy"/>
    ...
    <context>jira-react-atlaskit</context> 
  </web-resource> 

Это все, что нужно сделать для того, чтобы использовать React и Atlaskit в серверных и датацентровых плагинах Atlassian.