This documentation is for Insight for Server/Data Center only.

In this tutorial, we will demonstrate how to create a custom report using the Insight Widget Framework, by creating a new report type called Payroll Report. You can follow along with each step and view the attached code to see how we've built the new report type using the classes in the Widget framework.

This tutorial is geared towards developers.

The code for this project is available in our public repository here.

In this example, we are going to be creating a new report type to display payroll information. This custom report will accept parameters from the user, output clearly defined data, and generate a report based on that data. 

The finished product will look something like this:

To create the custom report, we will start by creating a new Jira plugin following the instructions provided by Atlassian. Next, we will implement the Insight Widget Framework in the basic plugin, which allows us to use the report interfaces that are exposed. Finally, we will implement the Widget Module in the plugin descriptor, which registers our custom report widget as a plugin within Insight.

Creating a new Jira plugin

You can create a new Jira plugin by following the guide provided by Atlassian. Following the example will generate a basic POM file, to which you can add the various dependencies and plugins required for the report.

The sample POM below contains the dependencies and plugins needed for our example report. This is for example purposes; you may not want to implement all of the code below.

 Example POM file...
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>com.riadalabs.jira.plugins</groupId>
    <artifactId>insight-report-payroll</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <organization>
        <name>Riada Product Development</name>
        <url>http://www.riada.se</url>
    </organization>

    <name>insight-report-payroll</name>
    <description>Example plugin that interfaces with Riada's Insight</description>

    <packaging>atlassian-plugin</packaging>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>

        <jira.version>7.12.0</jira.version>
        <amps.version>6.3.21</amps.version>
        <atlassian.plugin.key>${project.groupId}.${project.artifactId}</atlassian.plugin.key>
        <testkit.version>6.3.11</testkit.version>
        <javax.inject.version>1</javax.inject.version>
        <jsr311.api.version>1.1.1</jsr311.api.version>

        <insight.core.version>6.3.4-SNAPSHOT</insight.core.version>
        <insight.model.version>0.3.8</insight.model.version>
        <insight.iql.version>0.3.11</insight.iql.version>
        <insight.widget.version>1.0.4-SNAPSHOT</insight.widget.version>
        <insight.object.navigator.version>1.1.11-SNAPSHOT</insight.object.navigator.version>

        <atlassian.plugins.version>4.0.4</atlassian.plugins.version>
        <atlassian.spring.scanner.version>1.2.13</atlassian.spring.scanner.version>

        <!-- Test Dependencies -->
        <plugin.testrunner.version>1.2.3</plugin.testrunner.version>
        <jacoco.version>0.8.2</jacoco.version>
        <junit.version>4.10</junit.version>
        <assertj.version>3.11.1</assertj.version>
        <mockito.version>1.8.5</mockito.version>
    </properties>

    <repositories>
        <repository>
            <id>riada-repository</id>
            <url>https://repo.riada.io/repository/riada-repo/</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>com.atlassian.jira</groupId>
            <artifactId>jira-api</artifactId>
            <version>${jira.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.inject</groupId>
            <artifactId>javax.inject</artifactId>
            <version>${javax.inject.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>javax.ws.rs</groupId>
            <artifactId>jsr311-api</artifactId>
            <version>${jsr311.api.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.riadalabs.jira.plugins</groupId>
            <artifactId>insight</artifactId>
            <version>${insight.core.version}</version>
            <classifier>api</classifier>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.riadalabs</groupId>
            <artifactId>insight-core-model</artifactId>
            <version>${insight.model.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>io.riada.jira.plugins</groupId>
            <artifactId>insight-core-widget-api</artifactId>
            <version>${insight.widget.version}</version>
            <scope>provided</scope>
            <exclusions>
                <exclusion>
                    <groupId>*</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>io.riada.jira.plugins</groupId>
            <artifactId>insight-core-object-navigator</artifactId>
            <version>${insight.object.navigator.version}</version>
            <!-- Keep provided - will be unpacked (see below)! -->
            <scope>provided</scope>
            <exclusions>
                <exclusion>
                    <groupId>*</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>com.atlassian.plugins</groupId>
            <artifactId>atlassian-plugins-core</artifactId>
            <version>${atlassian.plugins.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.plugin</groupId>
            <artifactId>atlassian-spring-scanner-annotation</artifactId>
            <version>${atlassian.spring.scanner.version}</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.atlassian.plugin</groupId>
            <artifactId>atlassian-spring-scanner-runtime</artifactId>
            <version>${atlassian.spring.scanner.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!-- TEST -->
        <dependency>
            <groupId>com.atlassian.plugins</groupId>
            <artifactId>atlassian-plugins-osgi-testrunner</artifactId>
            <version>${plugin.testrunner.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-all</artifactId>
            <version>${mockito.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>${assertj.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>com.atlassian.maven.plugins</groupId>
                <artifactId>maven-jira-plugin</artifactId>
                <version>${amps.version}</version>
                <extensions>true</extensions>
                <configuration>
                    <productVersion>${jira.version}</productVersion>
                    <productDataVersion>${jira.version}</productDataVersion>
<!--                    <productDataPath>${basedir}/src/test/resources/generated-test-resources.zip</productDataPath>-->

                    <pluginArtifacts>
                        <pluginArtifact>
                            <groupId>com.riadalabs.jira.plugins</groupId>
                            <artifactId>insight</artifactId>
                            <version>${insight.core.version}</version>
                        </pluginArtifact>
                    </pluginArtifacts>

                    <enableQuickReload>true</enableQuickReload>
                    <enableFastdev>false</enableFastdev>

                    <compressResources>false</compressResources>
                    <instructions>
                        <Atlassian-Plugin-Key>${atlassian.plugin.key}</Atlassian-Plugin-Key>
                        <Export-Package/>
                        <Import-Package>
                            !org.apache.batik.*;version="0.0",
                            *;resolution:="optional"
                        </Import-Package>
                        <Spring-Context>*</Spring-Context>
                    </instructions>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.atlassian.plugin</groupId>
                <artifactId>atlassian-spring-scanner-maven-plugin</artifactId>
                <version>${atlassian.spring.scanner.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>atlassian-spring-scanner</goal>
                        </goals>
                        <phase>process-classes</phase>
                    </execution>
                </executions>
                <configuration>
                    <scannedDependencies>
                        <dependency>
                            <groupId>com.atlassian.plugin</groupId>
                            <artifactId>atlassian-spring-scanner-external-jar</artifactId>
                        </dependency>
                    </scannedDependencies>
                    <verbose>false</verbose>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>${jacoco.version}</version>
                <configuration>
                    <destFile>${basedir}/target/coverage-reports/jacoco-unit.exec</destFile>
                    <dataFile>${basedir}/target/coverage-reports/jacoco-unit.exec</dataFile>
                </configuration>
                <executions>
                    <execution>
                        <id>jacoco-initialize</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>jacoco-site</id>
                        <phase>test</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-enforcer-plugin</artifactId>
                <version>3.0.0-M2</version>
                <executions>
                    <execution>
                        <id>enforce</id>
                        <goals>
                            <goal>enforce</goal>
                        </goals>
                        <configuration>
                            <rules>
                                <banDuplicatePomDependencyVersions/>
                                <requireMavenVersion>
                                    <version>[3.2.1,)</version>
                                </requireMavenVersion>
                                <requireJavaVersion>
                                    <version>1.8</version>
                                </requireJavaVersion>
                            </rules>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

After we've created our sample project, we will implement the Insight Widget Framework in our basic plugin which allows us to use the report interfaces that are exposed. The widget framework consists of 3 parts:

  • Widget Parameters (input)
  • Widget Data (output)
  • Widget Module (engine)

Implementing the Widget parameters

Widget parameters represent the parameters(fields) that will be used to generate the report.

To achieve this implement the WidgetParameters class.

 Example WidgetParameters...
package com.riadalabs.jira.plugins.insight.reports.payroll;

import com.google.common.collect.Lists;
import io.riada.jira.plugins.insight.widget.api.WidgetParameters;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

/**
 * This is what is sent from the frontend as expected inputs
 */
public class PayrollReportParameters implements WidgetParameters {

    private Value schema;
    private Value objectType;

    private Value numAttribute;
    private Value dateAttributeStartDate;
    private Value dateAttributeEndDate;

    private String period;
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    private String iql;

    public PayrollReportParameters() {
    }

    public Value getSchema() {
        return schema;
    }

    public void setSchema(Value schema) {
        this.schema = schema;
    }

    public Value getObjectType() {
        return objectType;
    }

    public void setObjectType(Value objectType) {
        this.objectType = objectType;
    }

    public Value getNumAttribute() {
        return numAttribute;
    }

    public void setNumAttribute(Value numAttribute) {
        this.numAttribute = numAttribute;
    }

    public Value getDateAttributeStartDate() {
        return dateAttributeStartDate;
    }

    public void setDateAttributeStartDate(Value dateAttributeStartDate) {
        this.dateAttributeStartDate = dateAttributeStartDate;
    }

    public Value getDateAttributeEndDate() {
        return dateAttributeEndDate;
    }

    public void setDateAttributeEndDate(Value dateAttributeEndDate) {
        this.dateAttributeEndDate = dateAttributeEndDate;
    }

    public String getPeriod() {
        return period;
    }

    public Period period() {

        return Period.from(getPeriod());
    }

    public void setPeriod(String period) {
        this.period = period;
    }

    public LocalDateTime getStartDate() {
        return startDate;
    }

    public void setStartDate(LocalDateTime startDate) {
        this.startDate = startDate;
    }

    public LocalDateTime getEndDate() {
        return endDate;
    }

    public void setEndDate(LocalDateTime endDate) {
        this.endDate = endDate;
    }

    public String getIql() {
        return iql;
    }

    public void setIql(String iql) {
        this.iql = iql;
    }

    public LocalDate determineStartDate(Boolean doesWeekBeginOnMonday) {
        return this.period() == Period.CUSTOM ? getStartDate().toLocalDate()
                : this.period().from(LocalDate.now(), doesWeekBeginOnMonday);
    }

    public LocalDate determineEndDate(Boolean doesWeekBeginOnMonday) {
        final LocalDate today = LocalDate.now();

        return this.period() == Period.CUSTOM ? Period.findEarliest(getEndDate().toLocalDate(), today)
                : this.period().to(today, doesWeekBeginOnMonday);
    }

    public List<Value> getDateAttributes() {
        return Lists.newArrayList(getDateAttributeStartDate(), getDateAttributeEndDate());
    }

    public List<Value> getNumericAttributes() {
        return Lists.newArrayList(getNumAttribute());
    }
}

You must also implement the insight-widget module type parameters element in the atlassian-plugin.xml plugin descriptor to make the parameters appear on-screen.

 Example widget module-type with parameters...
<?xml version="1.0" encoding="UTF-8"?>

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}"/>
        <param name="plugin-icon">images/pluginIcon.png</param>
        <param name="plugin-logo">images/pluginLogo.png</param>
    </plugin-info>

    <!-- add our i18n resource -->
    <resource type="i18n" name="i18n" location="insight-report-payroll"/>

    <!-- resources to be imported by reports iframe -->
    <web-resource key="insight-report-payroll" i18n-name-key="Payroll Report Resource">
        <resource type="download" name="insight-report-payroll.css"
                  location="/css/insight-report-payroll.css"/>
        <resource type="download" name="insight-report-payroll.js"
                  location="/js/insight-report-payroll.js"/>
        <resource type="download" name="chart.js"
                  location="/js/lib/chart.js"/>
        <context>insight-report-payroll</context>
    </web-resource>

    <!-- implement insight-widget module type -->
    <insight-widget key="insight.example.report.payroll"
                    class="com.riadalabs.jira.plugins.insight.reports.payroll.PayrollReport"
                    category="report"
                    web-resource-key="insight-report-payroll"
                    name="insight.example.report.payroll.name"
                    description="insight.example.report.payroll.description"
                    icon="diagram"
                    background-color="#26a9ba">
        <!-- Map and display data -->
        <renderers>
            <renderer mapper="BarChart.Mapper"
                      view="BarChart.View"
                      label="insight.example.report.view.chart.bar"/>
            <renderer mapper="AreaChart.Mapper"
                      view="AreaChart.View"
                      label="insight.example.report.view.chart.area"
                      selected="true"/>
        </renderers>
        <!-- Export data to file -->
        <exporters>
            <exporter transformer="Transformer.JSON"
                      extension="json"
                      label="insight.example.report.exporter.json"/>
        </exporters>
        <!-- Parameters to show up in report form -->
        <parameters>
            <parameter key="period"
                       label="insight.example.report.period"
                       type="switch"
                       required="true"
                       default="CURRENT_WEEK">
                <configuration>
                    <options>
                        <option label="insight.example.report.period.current.week" value="CURRENT_WEEK"/>
                        <option label="insight.example.report.period.last.week" value="LAST_WEEK"/>
                        <option label="insight.example.report.period.current.month" value="CURRENT_MONTH"/>
                        <option label="insight.example.report.period.last.month" value="LAST_MONTH"/>
                        <option label="insight.example.report.period.current.year" value="CURRENT_YEAR"/>
                        <option label="insight.example.report.period.last.year" value="LAST_YEAR"/>
                        <option label="insight.example.report.period.custom" value="CUSTOM"/>
                    </options>
                </configuration>
            </parameter>
            <parameter key="startDate"
                       type="datepicker"
                       label="insight.example.report.period.custom.start"
                       required="true">
                <configuration>
                    <dependency key="period">
                        <value>CUSTOM</value>
                    </dependency>
                </configuration>
            </parameter>
            <parameter key="endDate"
                       type="datepicker"
                       label="insight.example.report.period.custom.end"
                       required="true">
                <configuration>
                    <dependency key="period">
                        <value>CUSTOM</value>
                    </dependency>
                </configuration>
            </parameter>
            <parameter key="schema"
                       type="schemapicker"
                       label="insight.example.report.schema"
                       required="true">
            </parameter>
            <parameter key="objectType"
                       type="simpleobjecttypepicker"
                       label="insight.example.report.objecttype"
                       required="true">
                <configuration>
                    <dependency key="schema"/>
                </configuration>
            </parameter>
            <parameter key="numAttribute"
                       type="objecttypeattributepicker"
                       label="insight.example.report.attribute.numeric"
                       required="true">
                <configuration>
                    <dependency key="objectType"/>
                    <filters>
                        <value>INTEGER</value>
                        <value>DOUBLE</value>
                    </filters>
                </configuration>
            </parameter>
            <parameter key="dateAttributeStartDate"
                       type="objecttypeattributepicker"
                       label="insight.example.report.attribute.date.start"
                       required="true">
                <configuration>
                    <dependency key="objectType"/>
                    <filters>
                        <value>DATE</value>
                        <value>DATE_TIME</value>
                    </filters>
                </configuration>
            </parameter>
            <parameter key="dateAttributeEndDate"
                       type="objecttypeattributepicker"
                       label="insight.example.report.attribute.date.end"
                       required="true">
                <configuration>
                    <dependency key="objectType"/>
                    <filters>
                        <value>DATE</value>
                        <value>DATE_TIME</value>
                    </filters>
                </configuration>
            </parameter>
            <parameter key="iql"
                       type="iql"
                       label="insight.example.report.iql">
                <configuration>
                    <dependency key="schema"/>
                </configuration>
            </parameter>
        </parameters>
    </insight-widget>
</atlassian-plugin>

Implementing the Widget data

The widget data represents the form in which the report will be consumed by the front-end renderers. To implement this use the WidgetData class.

 Example WidgetData class...
package com.riadalabs.jira.plugins.insight.reports.payroll;

import com.fasterxml.jackson.annotation.JsonInclude;
import io.riada.jira.plugins.insight.widget.api.WidgetData;
import io.riada.jira.plugins.insight.widget.api.WidgetMetadata;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;

/**
 * This is what is sent to the frontend as wrapper for the generated reports output data
 */
public class PayrollReportData implements WidgetData {

    private final Map<LocalDate, List<Expenditure>> expendituresByDay;
    private final boolean hasData;
    private final boolean useIso8601FirstDayOfWeek;

    public static PayrollReportData empty() {
        return new PayrollReportData(Collections.EMPTY_MAP, false, false);
    }

    public PayrollReportData(Map<LocalDate, List<Expenditure>> expendituresByDay,
            boolean hasData,
            boolean useIso8601FirstDayOfWeek) {
        this.expendituresByDay = expendituresByDay;
        this.hasData = hasData;
        this.useIso8601FirstDayOfWeek = useIso8601FirstDayOfWeek;
    }

    @Override
    public boolean hasData() {
        return this.hasData;
    }

    @Override
    public WidgetMetadata getMetadata() {

        WidgetMetadata metadata = new WidgetMetadata(hasData(), getNotice());
        metadata.addOption("useIso8601FirstDayOfWeek", useIso8601FirstDayOfWeek);

        return metadata;
    }

    @JsonInclude (NON_EMPTY)
    public Map<LocalDate, List<Expenditure>> getExpendituresByDay() {
        return expendituresByDay;
    }
}

Implementing the Widget module

The widget module will generate the report. To achieve this implement the WidgetModule and GeneratingDataByIQLCapability classes.

 Example WidgetModule and GeneratingDataByIQLCapability classes...
package com.riadalabs.jira.plugins.insight.reports.payroll;

import com.atlassian.jira.config.properties.APKeys;
import com.atlassian.jira.config.properties.ApplicationProperties;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.google.common.collect.Maps;
import com.riadalabs.jira.plugins.insight.channel.external.api.facade.ObjectSchemaFacade;
import com.riadalabs.jira.plugins.insight.channel.external.api.facade.ObjectTypeAttributeFacade;
import com.riadalabs.jira.plugins.insight.channel.external.api.facade.ObjectTypeFacade;
import com.riadalabs.jira.plugins.insight.reports.payroll.builder.IQLBuilder;
import com.riadalabs.jira.plugins.insight.reports.payroll.builder.ReportDataBuilder;
import com.riadalabs.jira.plugins.insight.reports.payroll.validator.PayrollReportValidator;
import com.riadalabs.jira.plugins.insight.services.model.ObjectAttributeBean;
import com.riadalabs.jira.plugins.insight.services.model.ObjectBean;
import com.riadalabs.jira.plugins.insight.services.model.ObjectTypeAttributeBean;
import com.riadalabs.jira.plugins.insight.services.model.ObjectTypeBean;
import com.riadalabs.jira.plugins.insight.services.progress.model.ProgressId;
import io.riada.core.service.model.ServiceError;
import io.riada.core.service.Reason;
import io.riada.core.service.ServiceException;
import io.riada.jira.plugins.insight.widget.api.WidgetModule;
import io.riada.jira.plugins.insight.widget.api.capability.GeneratingDataByIQLCapability;
import org.jetbrains.annotations.NotNull;

import javax.annotation.Nonnull;
import javax.inject.Inject;
import javax.inject.Named;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;

@Named
public class PayrollReport implements WidgetModule<PayrollReportParameters>,
        GeneratingDataByIQLCapability<PayrollReportParameters, PayrollReportData> {

    private final ObjectSchemaFacade objectSchemaFacade;
    private final ObjectTypeFacade objectTypeFacade;
    private final ObjectTypeAttributeFacade objectTypeAttributeFacade;
    private final ApplicationProperties applicationProperties;

    @Inject
    public PayrollReport(@ComponentImport final ObjectSchemaFacade objectSchemaFacade,
                         @ComponentImport final ObjectTypeFacade objectTypeFacade,
                         @ComponentImport final ObjectTypeAttributeFacade objectTypeAttributeFacade,
                         @ComponentImport final ApplicationProperties applicationProperties) {
        this.objectSchemaFacade = objectSchemaFacade;
        this.objectTypeFacade = objectTypeFacade;
        this.objectTypeAttributeFacade = objectTypeAttributeFacade;
        this.applicationProperties = applicationProperties;
    }

    @Override
    public void validate(@NotNull PayrollReportParameters parameters) throws Exception {
        Set<ServiceError> validationErrors = PayrollReportValidator.validate(parameters, objectSchemaFacade,
                objectTypeAttributeFacade);

        if (!validationErrors.isEmpty()) {
            throw new ServiceException(validationErrors, Reason.VALIDATION_FAILED);
        }
    }

    @NotNull
    @Override
    public String buildIQL(@Nonnull PayrollReportParameters parameters) throws Exception {
        final IQLBuilder iqlBuilder = new IQLBuilder(objectTypeAttributeFacade);

        final Integer objectTypeId = parameters.getObjectType().getValue();
        final ObjectTypeBean objectTypeBean = objectTypeFacade.loadObjectTypeBean(objectTypeId);

        iqlBuilder.addObjectType(objectTypeBean)
                .addDateAttributes(parameters.getDateAttributes(), parameters)
                .addNumericAttributes(parameters.getNumericAttributes())
                .addCustomIQL(parameters.getIql());

        return iqlBuilder.build();
    }

    @NotNull
    @Override
    public PayrollReportData generate(@NotNull PayrollReportParameters parameters, List<ObjectBean> objects,
            @NotNull ProgressId progressId) {

        if (objects.isEmpty()) {
            return PayrollReportData.empty();
        }

        final boolean doesWeekBeginOnMonday = applicationProperties.getOption(APKeys.JIRA_DATE_TIME_PICKER_USE_ISO8601);

        final LocalDate startDate = parameters.determineStartDate(doesWeekBeginOnMonday);
        final LocalDate endDate = parameters.determineEndDate(doesWeekBeginOnMonday);

        final Map<Integer, String> numericAttributeNames = createAttributeIdToNameMap(parameters.getNumericAttributes());
        final LinkedHashMap<Integer, ObjectAttributeBean> dateAttributesMap = createEmptyObjectTypeAttributeIdMap(parameters.getDateAttributes());

        final ReportDataBuilder reportDataBuilder =
                new ReportDataBuilder(startDate, endDate, doesWeekBeginOnMonday, numericAttributeNames,
                        dateAttributesMap);

        return reportDataBuilder.fillData(objects)
                .build();
    }

    private Map<Integer, String> createAttributeIdToNameMap(List<Value> attributes) {
        return attributes.stream()
                .map(Value::getValue)
                .map(id -> uncheckCall(() -> objectTypeAttributeFacade.loadObjectTypeAttributeBean(id)))
                .collect(Collectors.toMap(ObjectTypeAttributeBean::getId, ObjectTypeAttributeBean::getName));
    }

    private LinkedHashMap<Integer, ObjectAttributeBean> createEmptyObjectTypeAttributeIdMap(List<Value> attributes) {
        final LinkedHashMap emptyValuedMap = Maps.newLinkedHashMap();

        attributes.stream()
                .map(Value::getValue)
                .forEach(id -> emptyValuedMap.put(id, null));

        return emptyValuedMap;
    }

    private <T> T uncheckCall(Callable<T> callable) {
        try {
            return callable.call();
        } catch (RuntimeException e) {
            throw e;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

These are the (currently) exposed components used to interface with Insight:

  • ObjectSchemaFacade
  • ObjectTypeFacade
  • ObjectTypeAttributeFacade
  • ObjectFacade
  • ConfigureFacade
  • IqlFacade
  • ObjectAttributeBeanFactory
  • ImportSourceConfigurationFacade
  • InsightPermissionFacade
  • InsightGroovyFacade
  • ProgressFacade

Customizing the widget framework

If you examine the example above, you can see there are three places that we allow for customization within the widget framework:

  • Validation
  • Query building
  • Generating the report

Let's take a closer look at each of these below.

Validating the parameters

It's important to validate that the widget parameters have been created correctly.

public void validate(@NotNull WidgetParameters) throws Exception 

 In the example above, you can see that we are making sure that the object attribute types still correspond to the actual object attributes. If, for example, the Employee has a salary attribute that was expected as a numeric type but is now textual, an exception will be thrown.

Building the IQL query

You will also need to build a query from the widget parameters. This query will be used to fetch the objects. In the above example:

public String buildIQL(@NotNull WidgetParameters parameters) throws Exception 

Generating the report

Finally, you can generate the widget data from the returned objects. In the above example:

public WidgetData generate(@NotNull WidgetParameters parameters, List<ObjectBean> objects,
            @NotNull ProgressId progressId) 

The ProgressId corresponds to the progress of the current report job. Use the ProgressFacade to extract any pertinent information.

Add the Widget module to the descriptor

Now that we've implemented classes for the WidgetParameters, WidgetData, and WidgetModule, we need to modify the descriptor to register your custom report widget as a plugin within Insight.

You will need to modify the ModuleType, specify mapper and view functions, and define renderers and exporters to create a fully-functioning report. 

Please note that all label names will need to be unique.


Specify the ModuleType

Points to the WidgetModule.

<insight-widget class="com.riadalabs.jira.plugins.insight.reports.payroll.PayrollReport"

Renderers

Specifies how the report is to be rendered. The graphical display is rendered within an iFrame.

<renderers>
    <renderer mapper="BarChart.Mapper"
              view="BarChart.View"
              label="Bar Chart"/>
</renderers>  

The Bar Chart itself includes JS components that transform and display the data generated by the backend. Example below:

 Example mapper...
var BarChart = {};

BarChart.Mapper = function (data, parameters, baseUrl) {

    var mapper = new PayrollMapper(data, parameters);

    var expenditureIn = {
        label: "IN",
        data: [],
        backgroundColor: 'rgba(255,57,57,0.5)',
        borderColor: 'rgb(255,57,57)',
        borderWidth: 1
    };

    var expenditureOut = {
        label: "OUT",
        data: [],
        backgroundColor: 'rgba(45,218,181,0.5)',
        borderColor: 'rgb(45,218,181)',
        borderWidth: 1
    };

    var expenditureTotal = {
        label: "TOTAL",
        data: [],
        backgroundColor: 'rgba(111,158,255,0.5)',
        borderColor: 'rgb(111,158,255)',
        borderWidth: 1
    };

    return mapper.asTimeSeries(expenditureIn, expenditureOut, expenditureTotal);

};

BarChart.View = function (mappedData) {

    var containingElement = document.querySelector('.js-riada-widget');
    if (!containingElement) return;

    var canvas = new Canvas("myChart");

    var canvasElement = canvas.appendTo(containingElement);

    var myChart = new Chart(canvasElement, {
        type: 'bar',
        data: mappedData,
        options: {
            scales: {
                xAxes: [{
                    ticks: {
                        beginAtZero: true
                    },
                    stacked: true
                }]
            },
            animation: {
                duration: 0
            },
            responsive: true,
            maintainAspectRatio: false
        }
    });

    return containingElement;
};

var AreaChart = {};

AreaChart.Mapper = function (data, parameters, baseUrl) {

    var mapper = new PayrollMapper(data, parameters);

    var expenditureIn = {
        label: "IN",
        data: [],
        backgroundColor: 'rgba(255,57,57,0.5)',
        steppedLine: true,
        pointRadius: 2,
    };

    var expenditureOut = {
        label: "OUT",
        data: [],
        backgroundColor: 'rgba(45,218,181,0.5)',
        steppedLine: true,
        pointRadius: 2
    };

    var expenditureTotal = {
        label: "TOTAL",
        data: [],
        backgroundColor: 'rgba(111,158,255, 0.5)',
        steppedLine: true,
        pointRadius: 2
    };

    return mapper.asTimeSeries(expenditureIn, expenditureOut, expenditureTotal);

};

AreaChart.View = function (mappedData) {

    var containingElement = document.querySelector('.js-riada-widget');
    if (!containingElement) return;

    var canvas = new Canvas("myChart");

    var canvasElement = canvas.appendTo(containingElement);

    var myChart = new Chart(canvasElement, {
        type: 'line',
        data: mappedData,
        options: {
            scales: {
                yAxes: [{
                    ticks: {
                        beginAtZero: true
                    }
                }]
            },
            elements: {
                line: {
                    tension: 0
                }
            },
            animation: {
                duration: 0
            },
            responsive: true,
            maintainAspectRatio: false
        }
    });

    return containingElement;
};

var Canvas = function (id) {
    this.id = id;

    this.appendTo = function (containingElement) {

        clearOldIfExists(containingElement);

        var canvasElement = document.createElement("canvas");
        canvasElement.id = this.id;

        containingElement.appendChild(canvasElement);

        return canvasElement;
    };

    function clearOldIfExists(containingElement) {
        var oldCanvas = containingElement.querySelector('#myChart');
        if (oldCanvas) oldCanvas.remove();
    }
};

var PayrollMapper = function (data, parameters) {
    this.data = data;
    this.parameters = parameters;

    var EXPENDITURE_IN = "IN";
    var EXPENDITURE_OUT = "OUT";
    var EXPENDITURE_TOTAL = "TOTAL";

    this.asTimeSeries = function (dataIn, dataOut, dataTotal) {
        var mappedData = {};

        if (!this.data.metadata.hasData || this.parameters.numAttribute == null) {
            return mappedData;
        }

        mappedData.labels = Object.keys(data.expendituresByDay);
        mappedData.datasets = [];

        var attributeMap = createAttributeMap(this.parameters, dataIn, dataOut, dataTotal);

        Object.entries(data.expendituresByDay).forEach(function (entry, index) {

            var expenditures = entry[1];

            if (expenditures === undefined || expenditures.length === 0) {

                Object.entries(attributeMap).forEach(function (entry) {
                    var expenditure = entry[1];

                    fillData(expenditure, EXPENDITURE_IN, 0.0);
                    fillData(expenditure, EXPENDITURE_OUT, 0.0);

                    var previousTotal = index === 0 ? 0.0 : expenditure[EXPENDITURE_TOTAL].data[index - 1];
                    fillData(expenditure, EXPENDITURE_TOTAL, previousTotal);
                });

            }

            expenditures.forEach(function (expenditure) {
                if (attributeMap.hasOwnProperty(expenditure.name)) {
                    fillData(attributeMap[expenditure.name], EXPENDITURE_IN, expenditure.typeValueMap[EXPENDITURE_IN]);
                    fillData(attributeMap[expenditure.name], EXPENDITURE_OUT, 0.0 - expenditure.typeValueMap[EXPENDITURE_OUT]);

                    var currentTotal = expenditure.typeValueMap[EXPENDITURE_IN] - expenditure.typeValueMap[EXPENDITURE_OUT];
                    var previousTotal = index === 0 ? 0.0 : attributeMap[expenditure.name][EXPENDITURE_TOTAL].data[index - 1];
                    fillData(attributeMap[expenditure.name], EXPENDITURE_TOTAL, currentTotal + previousTotal)
                }
            });

        });

        mappedData.datasets = flatMap(attributeMap);

        return mappedData;

    };

    var createAttributeMap = function (parameters, dataIn, dataOut, dataTotal) {
        var map = {};

        map[parameters.numAttribute.label] = {};

        dataIn.label = parameters.numAttribute.label + "-" + dataIn.label;
        dataOut.label = parameters.numAttribute.label + "-" + dataOut.label;
        dataTotal.label = parameters.numAttribute.label + "-" + dataTotal.label;

        map[parameters.numAttribute.label][EXPENDITURE_IN] = dataIn;
        map[parameters.numAttribute.label][EXPENDITURE_OUT] = dataOut;
        map[parameters.numAttribute.label][EXPENDITURE_TOTAL] = dataTotal;

        return map;
    };

    function fillData(expenditure, dataType, value) {
        expenditure[dataType].data.push(value);
    }

    function flatMap(attributeMap) {
        var flattened = [];
        Object.values(attributeMap).forEach(function (valuesByAttribute) {
            Object.values(valuesByAttribute).forEach(function (value) {
                flattened.push(value);
            });
        });

        return flattened;
    }

};

var Transformer = {};

Transformer.JSON = function (mappedData) {
    if(!mappedData) return null;

    mappedData.datasets.forEach(function(dataset){
       ignoringKeys(['_meta'], dataset);
    });

    return JSON.stringify(mappedData);
};

function ignoringKeys(keys, data){
    keys.forEach(function(key){
        delete data[key];
    })
}

Mapper and view functions

The mapper and view functions need to follow the signature below:

Mapper = function (data, parameters, baseUrl) { .. }
  • data: widget data
  • parameters: widget parameters
  • baseUrl: ...
  • return: transformed data

View = function (mappedData, params, containerElementSelector){ ... }
  • mappedData: the output of Mapper
  • containerElementSelector: will be equal to jsRiadaWidget
  • return: void

In the view function append whatever to the DOM, making sure the parent element is:

<div id="riada" class="js-riada-widget">

Place any resources to be downloaded in a tag in the plugin descriptor.

<web-resource key="insight-report-payroll" i18n-name-key="Payroll Report Resource">
    <resource type="download" name="insight-report-payroll.css"
              location="/css/insight-report-payroll.css"/>
    <resource type="download" name="insight-report-payroll.js"
              location="/js/insight-report-payroll.js"/>
    <context>insight-report-payroll</context>
</web-resource>

Define any exporters

Define any exporters for your data using the following structure.

<exporters>
   <exporter transformer="Transformer.JSON"
             extension="json"
             label="insight.example.report.exporter.json"/>
</exporters>

The data exported will not be the WidgetData but the output of the Mapper.

Transformer.JSON = function (mappedData) { ... }
  • mappedData: output of Mapper
  • return: data transformed to extension type

Exporters are displayed in the created report, but not the preview.


Parameters

These are the options displayed in the report parameters form. The key will correspond to the widget parameters field name.

<parameter key="numAttribute"
           type="objecttypeattributepicker"
           label="insight.example.report.attribute.numeric"
           required="true">
    <configuration>
        <dependency key="objectType"/>
        <filters>
            <value>INTEGER</value>
            <value>DOUBLE</value>
        </filters>
    </configuration>
</parameter>

The current parameter type options are:

  • checkbox
  • datepicker
  • datetimepicker
  • iql
  • jql
  • number
  • objectpicker
  • objectschemapicker
  • objectsearchfilterpicker
  • objecttypeattributepicker
  • objecttypepicker
  • projectpicker
  • radiobutton
  • schemapicker
  • select
  • simpleobjecttypepicker
  • switch
  • text
  • timepicker
  • userpicker

Dependencies are relative to other parameters and filters on what types are returnable.

More development possibilities...

It's possible to create nearly any kind of custom reporting in Insight by building a Jira plugin using the Insight Widget Framework.

As you can see in the example above, the framework that provides input (Widget parameters), output (Widget data), and reporting engine (Widget module) functions can also be used to provide reporting or exporting functionality to fit almost any need. 

We will be providing more code samples - as well as documentation - about these features in the future.