Create Confluence macro with Webpack frontend and Java backend

Soff1932 April 23, 2019

I want to create a responsive macro for Confluence that has a frontend written in React and packed with Webpack, as well as a REST service written in Java. I was able to create the REST service and macro from this tutorial this tutorial (using Velocity and jQuery for the frontend), and I found that you could create React components for Confluence per this tutorial. However, I now want to know how I could combine these, and deploy a React app into a standard P2 plugin and use the app inside the plugin's macro. How can I achieve this?

1 answer

1 vote
Soff1932 May 13, 2019

Okay, so I figured out how to do this. First step is that we must use Atlassian's own P2 plugin for Webpack, 'atlassian-webresource-webpack-plugin'. In our webpack.config.js, we now configure the following:

const path = require('path');
const WRMPlugin = require('atlassian-webresource-webpack-plugin');

const SRC_DIR = path.join(__dirname, 'src');
const PLUGIN_TARGET_DIR = path.join(__dirname, '..', 'target');
const OUTPUT_DIR = path.join(PLUGIN_TARGET_DIR, 'classes');

module.exports = {
mode: 'development',
entry: {
'react-macro': path.join(SRC_DIR, 'index.tsx'),
},
output: {
path: OUTPUT_DIR,
filename: '[name].js'
},
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx', '.svg', '.jpg', '.png']
},
plugins: [
new WRMPlugin({
pluginKey: 'com.example.confluence.react-macro-plugin',
contextMap: {
'react-macro': [],
},
xmlDescriptors: path.join(OUTPUT_DIR, 'META-INF', 'plugin-descriptors', 'wr-defs.xml')
}),
],
module: {
...
}
};

In this example, it is assumed that the folder containing the frontend configuration lies within the same location as the backend's src folder and pom.xml file. It can be whereever you want it to be, but you must change PLUGIN_TARGET_DIR accordingly. Under entry, we define an entrypoint called react-macro that lies within my index.tsx (a TypeScript file, obviously you can use JavaScript here too).

Under plugins, we configure the WRM plugin; the pluginKey is the full key of your plugin as configured in your pom.xml; under contextMap, we allocate react-macro to no context at all (the context can be used to automatically apply it to many pages of a category, as is common in JIRA, but we do not want that in this case; the xmlDescriptors field tells the WRM plugin where the XML descriptors for the entrypoint shall go, this should stay as-is.

The modules should be filled according to your Webpack configuration.

Through this configuration, once you run Webpack, Webpack will generate the output JavaScript file, while the WRMPlugin will generate the XML descriptors that automatically hook up our entrypoint to the plugin. Because all generate files are put into the temporary 'target' folder, performing atlas-mvn clean will remove this files, and Webpack will need to be run again. Optionally, you can configure your pom.xml to automatically execute NPM/Yarn prior to the packaging of the plugin. Example for NPM found below.

Next, we will want to use the entrypoint in the macro. To do so, we will use a simple Velocity file looking like this:

<div id="react-macro-content"></div>

Through ReactDOM, we will render our app into that diff, more on that later. Assuming the Velocity file is called react-macro.vm and is placed in the folder 'templates' under 'resources', we put the following into the execute method of the Java file for the macro:

package com.example.confluence.macro;

import com.atlassian.confluence.content.render.xhtml.ConversionContext;
import com.atlassian.confluence.macro.Macro;
import com.atlassian.confluence.util.velocity.VelocityUtils;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.atlassian.webresource.api.assembler.PageBuilderService;

import javax.inject.Inject;
import javax.inject.Named;
import java.util.Map;

@Named
public class ReactMacro implements Macro {
private final PageBuilderService pageBuilderService;

@Inject
public ReactMacro(@ComponentImport PageBuilderService pageBuilderService) {
this.pageBuilderService = pageBuilderService;
}

public String execute(Map<String, String> params, String body, ConversionContext conversionContext) {
pageBuilderService.assembler().resources().requireWebResource("com.example.confluence.react-macro-plugin:entrypoint-react-macro");
return VelocityUtils.getRenderedTemplate("templates/react-macro.vm", params);
}

public BodyType getBodyType() {
return BodyType.RICH_TEXT;
}

public OutputType getOutputType() {
return OutputType.BLOCK;
}
}

 Notice the part "com.example.confluence.react-macro-plugin:entrypoint-react-macro", what we have here is again the plugin key, followed by the name of our entry point with the prefix "entrypoint-". This is because the WRMPlugin automatically generates the XML descriptors with a web-resource containing our entrypoint with this format. If you are not familiar with the PageBuilderService, it is an Atlassian component that tells the product (here, Confluence) that we need a specific resource or plugin part for this macro, and Confluence will thus prompt the user's browser to download the JavaScript file contained in the web-resource.

Okay, now we generate a P2-compatible Webpack compile, and we can use it in our macro. What's left? One problem we will still have to solve is that Atlassian loads the JavaScript file through the HTML header, while our to-be-rendered-on div appears in the body, which is loaded later. This will cause ReactDOM to not find the div when it is loaded, and nothing will be shown. This can be resolved by configuring our index file as follows:

import * as React from 'react';
import * as ReactDOM from 'react-dom';

function render() {
ReactDOM.render(
...,
document.getElementById('react-macro-content'),
);
}

if (['complete', 'loaded', 'interactive'].includes(document.readyState) && document.body) {
render();
} else {
window.addEventListener('DOMContentLoaded', render, false);
}

What this does is it checks whether the page has already completely loaded, and if so, renders the body onto the div we defined in our Velocity file. If the page is not yet loaded, we instead add an even listener that, upon load completion, renders the page.

And that's it! Apart from the usual TypeScript, React, Webpack and NPM fun, no more configuration is needed to get your React app running in your Confluence macro. Should you want to automatically compile the frontend alongside the backend, I recommend the following setup to be added to your pom.xml (for NPM, Yarn commands may vary):

<build>
<plugins>
...
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>1.6.0</version>
<executions>
<execution>
<id>process-dependencies</id>
<phase>compile</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${frontend.path}</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>install</argument>
</arguments>
</configuration>
</execution>

<execution>
<id>process-static-resources</id>
<phase>compile</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${frontend.path}</workingDirectory>
<executable>npx</executable>
<arguments>
<argument>webpack</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
...
</plugins>
</build>
<properties>
...
<frontend.path>./frontend</frontend.path>
...
</properties>

Enjoy!

sixenvi November 12, 2019

Thanks for sharing.

kondakov.artem
I'm New Here
I'm New Here
Those new to the Atlassian Community have posted less than three times. Give them a warm welcome!
January 14, 2020

I tried to do it according to your instructions, but when i open a page with a macro, js resources are not loaded. Maybe you could lay out the source code of your example?

Volodymyr Havryliuk November 2, 2023

@kondakov.artem this line 

<Atlassian-Scan-Folders>META-INF/plugin-descriptors</Atlassian-Scan-Folders>

should be added in instruction section of confluence-maven-plugin in pom.xml.

Suggest an answer

Log in or Sign up to answer
TAGS
AUG Leaders

Atlassian Community Events