React and Atlaskit in Atlassian Server/Data center apps.

Hello!

In this article I would like to provide a solution on how to use React and Atlaskit in Atlassian Server/Data center Atlassian apps.

Intro

Currently, if you develop an app for Atlassian Sever/Data center apps all you have for building UI out of the box is vm, soy, requirejs, jquery, backbone. I wrote an article on the out of the box features.

But this technology stack is outdated. I personally would like to use up to date technology stack which includes typescript and react in my Atlassian apps. Also to make my application more portable between Server/Data Center and Cloud I would like to use atlaskit library of UI elements.

I will make this tutorial for Atlassian Jira but this kind of approach can be used for other Atlassian Server/Data Center products.

To reproduced all steps in this tutorial you would need git and Atlassian SDK.

Let’s start!

Install Maven archetype and create a new project

I created a Maven archetype to make it simpler to create a new project with all React and Atlaskit settings and dependencies.

If you do not want to use the Maven archetype you can clone already created project from this archetype and go to the Build and Run project part.

Clone the Maven archetype from my Bitbucket repository:

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

Move to the jira-react-atlaskit-archetype folder and install this archetype into your local Maven repository:

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

After the Maven archetype was installed move out of the archetype’s folder and create a new project based on this archetype:

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

You will be asked to choose the archetype you need. I have the following local Maven archetypes:

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)

We need the ru.matveev.alexey.atlas.jira:jira-react-atlaskit-archetype-archetype archetype. In my case it is number 3 that is why I provided number 3 as the answer.

Then you will be asked to enter the groupid and artifactid for your new project. I answered the following way:

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

Build and Run project

In my case the my-tutorial folder was created. We need to move to the newly created folder and package this project:

cd my-tutorial
atlas-mvn package

After the project was packaged move to the backend folder and run Atlassian Jira:

cd backend
atlas-run

Test application

After Atlassian Jira has been started go to the following url in your browser:

http://localhost:2990/jira

You need to login with the default credentials: admin:admin and go to cog-item -> Manage apps.

You can see the menu of our app in the React section. But before trying our Atlaskit elements go to System -> Logging and Profiling and add the INFO logging level for the react.atlaskit.tutorial.servlet package.

Now go back to Manage apps and click the Form menu option. You will see a form for entering data. This form was produced by the Form Atlaskit element:

Fill all text fields and push the Submit button:

Now you can open the atlassian-jira.log file and find a line like this:

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"}

It means that the data you entered in the form were caught by the backend servlet and logged into the log file.

Now let’s choose the Dynamic Table menu item in the React section. You will see the Dynamic Table Atlaskit element:

That is our application. Now let’s see how it all works!

App’s internals

Here is the folder structure of our app:

backend contains Atlassian Jira app created from Atlassian SDK.

frontend contains UI elements which will be used in our Atlassian Jira app.

pom.xml is a parent project pom which defines two modules:

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

Now let’s have a look at the frontend folder.

Frontend

The fronted folder contains files and folders which provide UI interface for our app. Here are the contents of this folder:

Let’s describe all important files and folders:

package.json – an npm configuration file and contains:

  • a list of packages your project depends on
  • versions of packages that your project can use using semantic versioning rules. We have all dependencies for typescript, atlaskit, babel and so on.

.babel.rc – configuration file for Babel. Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments. We write all our code in Typescript and we need to convert Typescript code to JavaScript, so that our Atlassian Jira app could understand what we wanted to say.

webpack.config.js – configuration file for webpack. Webpack is a static module bundler for modern JavaScript applications. When webpack processes your application, it internally builds a dependency graph which maps every module your project needs and generates one or more bundles. We need to provide a single JavaScript file with all dependencies so that Jira app could produce our UI elements.

Let’s have a look at the code for 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")
    }
};

As you can see from the first line we use atlassian-webresource-webpack-plugin. Why do we need this plugin?

After the webpack created a JavaScript file for us we need to include this file into the resource section of atlassian-plugin.xml file in our Jira app. We could do it manually, but it is better to automate it. Here are the setting for this plugin:

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')
        }),
    ],

As the result of the settings we will have a file called wr-defs.xml created in the backend/src/resources/META-INF/plugin-descriptors folder.

With locationPrefix parameter we said that JavaScript files will be placed into backend/src/resources/frontend. We will place the JavaScript files in this folder later in backend. We use this parameter here so that we could have the following line in our bundle <resource type=”download” name=”bundled.dynamictable.js” location=”frontend/bundled.dynamictable.js”/>.

Here are the contents of the wr-defs.xml file which was generated by atlassian-webresource-webpack-plugin during packaging:

<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>

As you can see we have resource sections for atlassian-plugin.xml where all our JavaScript files are defined. All we need to do is to add all these resource sections to our atlassian-plugin.xml file which is in the backend/resources folder. To accomplish it we modified the pom.xml in the backend folder:

<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>

We added <Atlassian-Scan-Folders>META-INF/plugin-descriptors</Atlassian-Scan-Folders> so that during our app installation Jira would scan the META-INF/plugin-descriptors folder for additional configuration settings for atlassian-plugin.xml.

Also we added <compressResources>false</compressResources> to disable the minification of JavaScript files. All our JavaScript files are already minified.

We defined the following entry points in the webpack.config.js file:

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

It means that webpack will scan ./src/form.js and ./src/dynamictable.js and create a separate JavaScript file for each file with all dependencies and put the two files in the frontend/dist folder.

./src/form.js and ./src/dynamictable.js contain nothing special. Most of the code I took from Atlaskit examples for the Form and Dynamic Table elements. Let’s examine the form.js file:

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

It contains just one line to import the From class from the ./js/components/Form file. Here are the contents of ./js/components/Form

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

All lines are taken from Atlaskit examples except these lines:

window.addEventListener('load', function() {
    const wrapper = document.getElementById("container");
    wrapper ? ReactDOM.render(<MyForm />, wrapper) : false;
});

I added these lines to show the MyForm in the div container after the page is loaded. We will define this div container later in the backend in a soy template.

Also pay attention to this line:

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

document.getElementById(“contextPath”).value takes the value of the contextPath input element in our soy template. I pass to this element the context path of our application from the servlet from which I call this soy template. I will talk about it later.

So, that is it for fronted. As a result of packaging the frontend module we have two JavaScript files in the frontend/dist folder and xml bundle for atlassian-plugin.xml in the backend/src/resources/META-INF/plugin-descriptors.

Let’s move to the backend.

Backend

I added the following plugins to 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 creates a file called maven.properties which contains all maven properties. I need the atlassian.plugin.key property to call the web resource correctly. I will show you later how I use it.

maven-resources-plugin takes JavaScript files from frontend/dist and places these files into backend/resources/frontend.

Next I created a web section called React and two web items called Form and Dynamic Table. I created two servlets and called these servlets from the Form and Dynamic Table web item. Here are the lines from 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>

So we have our menu items which call our servlets. Now let’s have a look at the servlets.

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


}

I define two fields resourceService and soyTemplateRenderer and initialize these fields in the constructor. resourceService is my bean to read maven.properties file. soyTemplateRenderer is a Jira bean to call soy templates.

In the doGet method I get the atlassian.plugin.key property and contextPath. As I said above I need the context path to call this servlet from the Form element on the onsubmit event. Context path contains prefix of you Atlassian Jira url. In my case /jira. Then I pass contextPath as a parameter to my soy template and call servlet.ui.form soy template.

Here is the soy file:

{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}

As you can see the templates are quite simple. All the templates do is to call required atlassian-plugin.xml web resources and define the div container where react will place all our UI elements.

This soy file was added into atlassian-plugin.xml in the web resource section:

<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>  

And that is all what should be done to use React and Atlaskit in your Atlassian apps.

Leave a Reply