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.

ここでは例として、給与情報を表示する新しいレポート タイプを作成します。このカスタム レポートは、ユーザーからパラメーターを受け入れて定義されたデータを明確に出力し、そのデータに基づいてレポートを生成します。 

結果は次のようになります。

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.

以下のサンプル POM には、今回の例に使用したレポートに必要な依存関係とプラグインが含まれています。これは参照用として作成されているため、以下に示すすべてのコードを実装することはお勧めしません。

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

  • ウィジェット パラメーター (入力)
  • ウィジェット データ (出力)
  • ウィジェット モジュール (エンジン)

ウィジェット パラメーターの実装

ウィジェット パラメーターは、レポートの生成に使用されるパラメーター (フィールド) を表します。

これを実現するには、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());
    }
}

また、atlassian-plugin.xml に insight-widget モジュール型パラメーター要素を実装する必要があります。プラグイン記述子によって、パラメーターを画面上に表示させます。

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

ウィジェット データの実装

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.

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

ウィジェット モジュールの実装

The widget module will generate the report. To achieve this implement the 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);
        }
    }
}

Insight とのインターフェイスに使用される (現時点で) 公開されているコンポーネントは、次のとおりです。

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

ウィジェット フレームワークのカスタマイズ

上記の例を見てみると、ウィジェット フレームワーク内でカスタマイズできる場所が 3 箇所あることがわかります。

  • 検証
  • クエリの構築
  • レポートの生成

これらを詳しく見ていきましょう。

パラメーターの検証

ウィジェット パラメーターが正しく作成されていることを検証することが重要です。

public void validate(@NotNull WidgetParameters) throws Exception 

 上の例では、オブジェクト属性タイプが実際のオブジェクト属性に対応していることを確認していることがわかります。たとえば、数値型として想定されていた従業員の給与属性がテキスト形式になった場合は、例外がスローされます。

IQL クエリの構築

また、ウィジェット パラメーターからクエリを構築する必要があります。このクエリはオブジェクトのフェッチに使用されます。上記の例では、次のようになります。

public String buildIQL(@NotNull WidgetParameters parameters) throws Exception 

レポートの生成

最後に、返されたオブジェクトからウィジェット データを生成できます。上記の例では、次のようになります。

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.

ウィジェット モジュールを記述子に追加する

WidgetParameters、WidgetData、WidgetModule の各クラスを実装したので、Insight 内のプラグインとしてカスタム レポート ウィジェットを登録するように記述子を変更する必要があります。

完全に機能するレポートを作成するには、ModuleType を変更してマッパーとビューの各関数を指定し、レンダラーとエクスポーターを定義する必要があります。 

すべてのラベル名は一意である必要があることにご注意ください。


ModuleType を指定する

WidgetModule を指定します。

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

レンダラー

レポートの表示方法を指定します。グラフィック表示は iFrame 内でレンダリングされます。

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

棒グラフ自体には、バックエンドによって生成されたデータを変換して表示する JS コンポーネントが含まれています。以下の例をご確認ください。

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 = function (data, parameters, baseUrl) { .. }
  • data: ウィジェット データ
  • parameters: ウィジェット パラメーター
  • baseUrl: ...
  • return: 変換されたデータ

View = function (mappedData, params, containerElementSelector){ ... }
  • mappedData: マッパーの出力
  • containerElementSelector: jsRiadaWidget と等しくなる
  • return: 無効

ビュー関数で DOM に何でも追加して、親要素が次のとおりであることを確認します。

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

ダウンロードするリソースをプラグイン記述子のタグに配置します。

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

エクスポーターを定義する

次の構造を使用して、データのエクスポーターを定義します。

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

エクスポートされるデータは WidgetData ではなく、マッパーの出力になります。

Transformer.JSON = function (mappedData) { ... }
  • mappedData: マッパーの出力
  • return: 拡張型に変換されたデータ

エクスポーターは作成されたレポートには表示されますが、プレビューには表示されません。


パラメーター

レポート パラメーター フォームに表示されるオプションは次のとおりです。キーはウィジェット パラメーターのフィールド名に対応します。

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

現在のパラメーター タイプ オプションは次のとおりです。

  • チェックボックス
  • 日付ピッカー
  • 日時ピッカー
  • IQL
  • jql
  • Number (番号)
  • objectpicker
  • objectschemapicker
  • objectsearchfilterpicker
  • objecttypeattributepicker
  • objecttypepicker
  • プロジェクト ピッカー
  • ラジオ ボタン
  • schemapicker
  • [切り替え] を選択すると、
  • simpleobjecttypepicker
  • スイッチ
  • text
  • timepicker
  • ユーザー ピッカー

依存関係は、どのタイプを返せるかについて、他のパラメーターやフィルターと関連性を持っています。

さらなる開発の可能性...

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

上の例からわかるように、入力 (ウィジェット パラメーター)、出力 (ウィジェット データ)、レポート エンジン (ウィジェット モジュール) の各関数を提供するフレームワークによって、ほぼすべてのニーズに適合するレポート/エクスポート機能を提供できます。 

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

  • ラベルなし