Tutorial - Creating a JIRA report

Applicable:

This tutorial applies to JIRA 5.0 - 7.1.0.

Level of experience:

This is an advanced tutorial. You should have completed at least one intermediate tutorial before working through this tutorial. See the list of tutorials in DAC.

Time estimate:

It should take you approximately 1 hour to complete this tutorial.

On this page:

Overview of the tutorial

This tutorial shows you how to create custom JIRA reports. In this tutorial, you'll add two reports:

  • Single Level Group By Extended report
  • Issue Creation report 

The Creation Report displays a histogram of issues created over a specified time and broken into certain intervals of time.

The Single Level Group By Extended report builds on an existing report in JIRA. The existing report looks like this:

When you're done, you'll have a new report that looks like this:

Notice the assignee and last modified date in the output.

Your completed plugin will consist of the following components:

  • Java classes encapsulating the plugin logic.
  • Resources for display of the plugin user interface (UI).
  • A plugin descriptor to present the plugin UI in JIRA.

When you have finished, all these components will be packaged in a single JAR file.

About these Instructions

You can use any supported combination of OS and IDE to create this plugin. These instructions were written using IntelliJ IDEA on Ubuntu Linux. If you are using another OS or IDE combination, you should use the equivalent operations for your specific environment.

This tutorial was last tested with JIRA 6.0.4.

Prerequisite knowledge

To complete this tutorial, you need to know the following: 

  • The basics of Java development: classes, interfaces, methods, how to use the compiler, and so on.
  • How to create an Atlassian plugin project using the Atlassian Plugin SDK.
  • The basics of using and administering JIRA.
  • This tutorial also involves creating Apache Velocity templates. To extend the tutorial code, you should have a good handle on how Velocity templates work.

Plugin Source

We encourage you to work through this tutorial. If you want to skip ahead or check your work when you are done, you can find the plugin source code on Atlassian Bitbucket. Bitbucket serves a public Git repository containing the tutorial's code. To clone the repository, issue the following command:

$ git clone https://atlassian_tutorial@bitbucket.org/atlassian_tutorial/jira-report-plugin.git

Alternatively, you can download the source using the Downloads page here: https://bitbucket.org/atlassian_tutorial/jira-report-plugin

Step 1. Create the Plugin Project

In this step, you'll use the Atlassian Plugin SDK to generate the scaffolding for your plugin project. The Atlassian Plugin SDK automates much of the work of plugin development for you. It includes commands for creating a plugin and adding modules to the plugin.

  1. If you have not already set up the Atlassian Plugin SDK, do that now: Set up the Atlassian Plugin SDK and Build a Project.
  2. In the directory where you want to put the plugin project, enter the following SDK command:

    atlas-create-jira-plugin
    
  3. Choose 1 for JIRA 5 when asked which version of JIRA you want to create the plugin for. 
  4. As prompted, enter the following information to identify your plugin:

    group-id

    com.atlassian.plugins.tutorial.jira

    artifact-id

    jira-report-plugin

    version

    1.0-SNAPSHOT

    package

    com.atlassian.plugins.tutorial.jira

  5. Confirm your entries when prompted.

The SDK finishes up and generates a directory for you with the initial project files, including a POM (Project Object Model definition file), stub source code, and resources.

Step 2. Review and tweak the generated stub code

It's a good idea to familiarise yourself with the project configuration file, known as the POM (Project Object Model definition file), and resource files. In this section, you will review and tweak the pom.xml file and the plugin descriptor file. Open your plugin project in your favorite IDE and follow along in the next sections.

Add plugin metadata to the POM

The POM is located at the root of your project and declares the project dependencies and other information. Add metadata about your plugin and your company or organisation as follows:

  1. Edit the pom.xml file in the root folder of your plugin.
  2. Add your company or organization name and website to the organization element:

    <organization>
        <name>Example Company</name>
        <url>http://www.example.com/</url>
    </organization>
    
  3. Update the project description element as follows:

    <description>Extends JIRA issue reports.</description>
    
  4. Remove the commenting from around the dependency element for the jira-core artifact, including it in your project.
    This tutorial extends an existing JIRA report that relies on APIs in the JIRA core package. So while you normally won't need to do this, you do for this tutorial.
  5. Save and close the file.

Review the generated plugin descriptor

Your stub code contains a plugin descriptor file atlassian-plugin.xml. This is an XML file that identifies the plugin to the host application (JIRA) and defines the required plugin functionality. Open the descriptor file. You can find it under the src/main/resources directory of your project home.

You should see something like this (comments removed):  

<atlassian-plugin key="${project.groupId}.${project.artifactId}" 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>
    <resource type="i18n" name="i18n" location="jira-report-plugin"/>
    <web-resource key="jira-report-plugin-resources" name="jira-report-plugin Web Resources">
        <dependency>com.atlassian.auiplugin:ajs</dependency>
        <resource type="download" name="jira-report-plugin.css" location="/css/jira-report-plugin.css"/>
        <resource type="download" name="jira-report-plugin.js" location="/js/jira-report-plugin.js"/>
        <resource type="download" name="images/" location="/images"/>
        <context>jira-report-plugin</context>
    </web-resource>
    <component key="myPluginComponent" class="com.atlassian.plugins.tutorial.jira.MyPluginComponentImpl" public="true">
        <interface>com.atlassian.plugins.tutorial.jira.MyPluginComponent</interface>
    </component>
    <component-import key="applicationProperties" interface="com.atlassian.sal.api.ApplicationProperties" />
</atlassian-plugin>

Step 3. Add the plugin modules

Now you will use the plugin module generator (another atlas command) to generate the stub code for modules for the plugin. For your modules, add two report modules as follows:

  1. Open a command window and go to the plugin root folder (where the pom.xml is located).
  2. Run atlas-create-jira-plugin-module.

  3. Enter the number for the Report module.
  4. As prompted, enter the following:

    Enter New Classname

    SingleLevelGroupByReportExtended

    Package Name

    com.atlassian.plugins.tutorial.jira.reports

    Choose N for Show Advanced Setup.

  5. Choose Y for Add Another Plugin Module.
  6. Again enter the number for the Report module.
  7. Enter the following:

    Enter New Classname

    CreationReport

    Package Name

    com.atlassian.plugins.tutorial.jira.reports

  8. Choose N for Show Advanced Setup.
  9. Choose N for Add Another Plugin Module.
  10. Confirm your choices.

The SDK generates the code files for the modules, and adds them to the plugin descriptor. It also adds other resources, such as Velocity files and i18n resource files.

Step 4. Add module properties

Properties are the configurable fields that the plugin exposes in the JIRA UI. We'll add a few properties to the module definition:

  1. Open the file src/main/resources/atlassian-plugin.xml.

  2. Uncomment the properties element under the Single Level Group By Report Extended module and replace the default property elements it contains with these two new ones: 

    <report name="Single Level Group By Report Extended"...
       ...
       <properties>
             <property>
                 <key>filterid</key>
                 <name>report.singlelevelgroupby.filterId</name>
                 <description>report.singlelevelgroupby.filterId.description</description>
                 <type>filterpicker</type>
                 <i18n>false</i18n>
             </property>
             <property>
                 <key>mapper</key>
                 <name>report.singlelevelgroupby.mapper</name>
                 <description>report.singlelevelgroupby.mapper.description</description>
                 <type>select</type>
                 <values class="com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator" />
             </property>
        </properties>
    

    We're adding two new properties, a filter picker and a selector for the statistics type by which to group the result.

  3. Similarly, uncomment and replace the properties element under the Single Level Group By Report Extended module with the following:

    <report name="Creation Report"...
       ... 
       <properties>
             <property>
                 <key>projectid</key>
                 <name>report.issuecreation.projectid.name</name>
                 <description>report.issuecreation.projectid.description</description>
                 <type>filterprojectpicker</type>
             </property>
             <property>
                 <key>startDate</key>
                 <name>report.issuecreation.startdate</name>
                 <description>report.issuecreation.startdate.description</description>
                 <type>date</type>
             </property>
             <property>
                 <key>endDate</key>
                 <name>report.issuecreation.enddate</name>
                 <description>report.issuecreation.enddate.description</description>
                 <type>date</type>
             </property>
             <property>
                 <key>interval</key>
                 <name>report.issuecreation.interval</name>
                 <description>report.issuecreation.interval.description</description>
                 <type>long</type>
                 <default>3</default>
             </property>
       </properties>    

    Here we've added:

    • projectid allows users to choose the project or filter used to generate the report. The projects that are available in a given JIRA instance are retrieved through JIRA's filterprojectpicker facility.
    • startDate sets the start of the time period included in the report.
    • endDate sets the end of the time period for the report.
    • interval specifies the time interval used to divide the overall time period. This is the histogram interval, in other words.

So far, we've been working on two modules at once, each of which corresponds to separate reports in JIRA. From here, let's take them one at a time, starting with the Single Level Group By Extended report.

Step 5. Write the Single Level Group By Extended report code

When you used the SDK to create the modules, it gave you the stub code files for the reports. The stub code is very simple: just a constructor and a few imports. We'll build on it now.

For our first report, we're extending a report delivered with JIRA, the SingleLevelGroupByReport class. If you have access to the JIRA source code, you can find the source code for the original at this location:

jira-components/jira-core/src/main/java/com/atlassian/jira/plugin/report/impl/SingleLevelGroupByReport.java

As a reminder, our goal is to include the time of last update for each issue in the report output. It should be rendered in the appropriate date/time format configured in JIRA. The view template gets values to display in JIRA through the parameter map passed by the module code. Thus, we'll modify the parameter map generated by the original JIRA report to add the OutLookDateManager's OutlookDate object to the Velocity template.

  1. Open the source file for the report that the SDK gave us, SingleLevelGroupByReportExtended.java. The file is under the project home at:
    src/main/java/com/atlassian/plugins/tutorial/jira/reports

  2. Replace its contents with the following. This is the source code of the original report, but tweaked to account for a different classname and package.

    package com.atlassian.plugins.tutorial.jira.reports;
    
    import com.atlassian.core.util.map.EasyMap;
    import com.atlassian.crowd.embedded.api.User;
    import com.atlassian.jira.bc.JiraServiceContext;
    import com.atlassian.jira.bc.JiraServiceContextImpl;
    import com.atlassian.jira.bc.filter.SearchRequestService;
    import com.atlassian.jira.bc.issue.search.SearchService;
    import com.atlassian.jira.exception.PermissionException;
    import com.atlassian.jira.issue.CustomFieldManager;
    import com.atlassian.jira.issue.IssueFactory;
    import com.atlassian.jira.issue.index.IssueIndexManager;
    import com.atlassian.jira.issue.search.ReaderCache;
    import com.atlassian.jira.issue.search.SearchException;
    import com.atlassian.jira.issue.search.SearchProvider;
    import com.atlassian.jira.issue.search.SearchRequest;
    import com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator;
    import com.atlassian.jira.issue.statistics.StatisticsMapper;
    import com.atlassian.jira.issue.statistics.StatsGroup;
    import com.atlassian.jira.issue.statistics.util.OneDimensionalDocIssueHitCollector;
    import com.atlassian.jira.plugin.report.impl.AbstractReport;
    import com.atlassian.jira.security.JiraAuthenticationContext;
    import com.atlassian.jira.util.SimpleErrorCollection;
    import com.atlassian.jira.web.FieldVisibilityManager;
    import com.atlassian.jira.web.action.ProjectActionSupport;
    import com.atlassian.jira.web.bean.FieldVisibilityBean;
    import com.atlassian.jira.web.bean.PagerFilter;
    import com.atlassian.jira.web.util.OutlookDateManager;
    import com.atlassian.util.profiling.UtilTimerStack;
    import com.opensymphony.util.TextUtils;
    import org.apache.commons.lang.StringUtils;
    import org.apache.log4j.Logger;
    import org.apache.lucene.search.Collector;
    import java.util.Map;
    
    public class SingleLevelGroupByReportExtended extends AbstractReport
    {
        private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);
    
        private final SearchProvider searchProvider;
        private final JiraAuthenticationContext authenticationContext;
        private final SearchRequestService searchRequestService;
        private final IssueFactory issueFactory;
        private final CustomFieldManager customFieldManager;
        private final IssueIndexManager issueIndexManager;
        private final SearchService searchService;
        private final FieldVisibilityManager fieldVisibilityManager;
        private final ReaderCache readerCache;
    
        public SingleLevelGroupByReportExtended(final SearchProvider searchProvider, final JiraAuthenticationContext authenticationContext,
                                                final SearchRequestService searchRequestService, final IssueFactory issueFactory,
                                                final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
                                                final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
                                                final ReaderCache readerCache )
        {
            this.searchProvider = searchProvider;
            this.authenticationContext = authenticationContext;
            this.searchRequestService = searchRequestService;
            this.issueFactory = issueFactory;
            this.customFieldManager = customFieldManager;
            this.issueIndexManager = issueIndexManager;
            this.searchService = searchService;
            this.fieldVisibilityManager = fieldVisibilityManager;
            this.readerCache = readerCache;
        }
    
        public StatsGroup getOptions(SearchRequest sr, User user, StatisticsMapper mapper) throws PermissionException
        {
    
            try
            {
                return searchMapIssueKeys(sr, user, mapper);
            }
            catch (SearchException e)
            {
                log.error("Exception rendering " + this.getClass().getName() + ".  Exception " + e.getMessage(), e);
                return null;
            }
        }
    
        public StatsGroup searchMapIssueKeys(SearchRequest request, User searcher, StatisticsMapper mapper)
                throws SearchException
        {
            try
            {
                UtilTimerStack.push("Search Count Map");
                StatsGroup statsGroup = new StatsGroup(mapper);
                Collector hitCollector = new OneDimensionalDocIssueHitCollector(mapper.getDocumentConstant(), statsGroup,
                        issueIndexManager.getIssueSearcher().getIndexReader(), issueFactory,
                        fieldVisibilityManager, readerCache);
                searchProvider.searchAndSort((request != null) ? request.getQuery() : null, searcher, hitCollector, PagerFilter.getUnlimitedFilter());
                return statsGroup;
            }
            finally
            {
                UtilTimerStack.pop("Search Count Map");
            }
        }
    
        public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception
        {
            String filterId = (String) params.get("filterid");
            if (filterId == null)
            {
                log.error("Single Level Group By Report run without a project selected (JRA-5042): params=" + params);
                return "<span class='errMsg'>No search filter has been selected. Please "
                        + "<a href=\"IssueNavigator.jspa?reset=Update&amp;pid="
                        + TextUtils.htmlEncode((String) params.get("selectedProjectId"))
                        + "\">create one</a>, and re-run this report. See also "
                        + "<a href=\"http://jira.atlassian.com/browse/JRA-5042\">JRA-5042</a></span>";
            }
            String mapperName = (String) params.get("mapper");
            final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
            final JiraServiceContext ctx = new JiraServiceContextImpl(authenticationContext.getLoggedInUser());
            final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));
    
            final Map startingParams;
            try
            {
                startingParams = EasyMap.build(
                        "action", action,
                        "statsGroup", getOptions(request, authenticationContext.getLoggedInUser(), mapper),
                        "searchRequest", request,
                        "mapperType", mapperName,
                        "customFieldManager", customFieldManager,
                        "fieldVisibility", new FieldVisibilityBean(),
                        "searchService", searchService,
                        "portlet", this);
    
            }
            catch (PermissionException e)
            {
                log.error(e, e);
                return null;
            }
        }
    
        public void validate(ProjectActionSupport action, Map params)
        {
            super.validate(action, params);
            String filterId = (String) params.get("filterid");
            if (StringUtils.isEmpty(filterId))
            {
                action.addError("filterid", action.getText("report.singlelevelgroupby.filter.is.required"));
            }
            else
            {
                validateFilterId(action,filterId);
            }
        }
    
        private void validateFilterId(ProjectActionSupport action, String filterId)
        {
            try
            {
                JiraServiceContextImpl serviceContext = new JiraServiceContextImpl(
                        action.getLoggedInUser(), new SimpleErrorCollection());
                SearchRequest searchRequest = searchRequestService.getFilter(serviceContext, new Long(filterId));
                if (searchRequest == null)
                {
                    action.addErrorMessage(action.getText("report.error.no.filter"));
                }
            }
            catch (NumberFormatException nfe)
            {
                action.addError("filterId", action.getText("report.error.filter.id.not.a.number", filterId));
            }
        }
    }

    As mentioned, this is simply the original report. Next, add the code that presents the time of last update to the report.

  3. Under the existing field declarations for the class, add a new field:

    private final OutlookDateManager outlookDateManager;
  4. Also add the new field as a parameter passed to the class constructor.

    public SingleLevelGroupByReportExtended( ... 
                                             final OutlookDateManager outlookDateManager )
       ...
       this.outlookDateManager =  outlookDateManager;
       ...
  5. In the generateReportHtml() method, add the following line. It should appear within the try block in which the code assigns values to the parameter map.

    startingParams.put("outlookDate", outlookDateManager.getOutlookDate(authenticationContext.getLocale()));
    return descriptor.getHtml("view", startingParams);

    This code appends an additional parameter to the parameter map created by the method.

  6. Save your changes.

The class as modified should look something like this:

package com.atlassian.plugins.tutorial.jira.reports;

import com.atlassian.core.util.map.EasyMap;
import com.atlassian.crowd.embedded.api.User;
import com.atlassian.jira.bc.JiraServiceContext;
import com.atlassian.jira.bc.JiraServiceContextImpl;
import com.atlassian.jira.bc.filter.SearchRequestService;
import com.atlassian.jira.bc.issue.search.SearchService;
import com.atlassian.jira.exception.PermissionException;
import com.atlassian.jira.issue.CustomFieldManager;
import com.atlassian.jira.issue.IssueFactory;
import com.atlassian.jira.issue.index.IssueIndexManager;
import com.atlassian.jira.issue.search.ReaderCache;
import com.atlassian.jira.issue.search.SearchException;
import com.atlassian.jira.issue.search.SearchProvider;
import com.atlassian.jira.issue.search.SearchRequest;
import com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator;
import com.atlassian.jira.issue.statistics.StatisticsMapper;
import com.atlassian.jira.issue.statistics.StatsGroup;
import com.atlassian.jira.issue.statistics.util.OneDimensionalDocIssueHitCollector;
import com.atlassian.jira.plugin.report.impl.AbstractReport;
import com.atlassian.jira.security.JiraAuthenticationContext;
import com.atlassian.jira.util.SimpleErrorCollection;
import com.atlassian.jira.web.FieldVisibilityManager;
import com.atlassian.jira.web.action.ProjectActionSupport;
import com.atlassian.jira.web.bean.FieldVisibilityBean;
import com.atlassian.jira.web.bean.PagerFilter;
import com.atlassian.jira.web.util.OutlookDateManager;
import com.atlassian.util.profiling.UtilTimerStack;
import com.opensymphony.util.TextUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.apache.lucene.search.Collector;
import java.util.Map;

public class SingleLevelGroupByReportExtended extends AbstractReport
{
    private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);

    private final SearchProvider searchProvider;
    private final JiraAuthenticationContext authenticationContext;
    private final SearchRequestService searchRequestService;
    private final IssueFactory issueFactory;
    private final CustomFieldManager customFieldManager;
    private final IssueIndexManager issueIndexManager;
    private final SearchService searchService;
    private final FieldVisibilityManager fieldVisibilityManager;
    private final ReaderCache readerCache;
    private final OutlookDateManager outlookDateManager;

    public SingleLevelGroupByReportExtended(final SearchProvider searchProvider, final JiraAuthenticationContext authenticationContext,
                                            final SearchRequestService searchRequestService, final IssueFactory issueFactory,
                                            final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
                                            final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
                                            final ReaderCache readerCache, final OutlookDateManager outlookDateManager )
    {
        this.searchProvider = searchProvider;
        this.authenticationContext = authenticationContext;
        this.searchRequestService = searchRequestService;
        this.issueFactory = issueFactory;
        this.customFieldManager = customFieldManager;
        this.issueIndexManager = issueIndexManager;
        this.searchService = searchService;
        this.fieldVisibilityManager = fieldVisibilityManager;
        this.readerCache = readerCache;
        this.outlookDateManager =  outlookDateManager;
    }

    public StatsGroup getOptions(SearchRequest sr, User user, StatisticsMapper mapper) throws PermissionException
    {

        try
        {
            return searchMapIssueKeys(sr, user, mapper);
        }
        catch (SearchException e)
        {
            log.error("Exception rendering " + this.getClass().getName() + ".  Exception " + e.getMessage(), e);
            return null;
        }
    }

    public StatsGroup searchMapIssueKeys(SearchRequest request, User searcher, StatisticsMapper mapper)
            throws SearchException
    {
        try
        {
            UtilTimerStack.push("Search Count Map");
            StatsGroup statsGroup = new StatsGroup(mapper);
            Collector hitCollector = new OneDimensionalDocIssueHitCollector(mapper.getDocumentConstant(), statsGroup,
                    issueIndexManager.getIssueSearcher().getIndexReader(), issueFactory,
                    fieldVisibilityManager, readerCache);
            searchProvider.searchAndSort((request != null) ? request.getQuery() : null, searcher, hitCollector, PagerFilter.getUnlimitedFilter());
            return statsGroup;
        }
        finally
        {
            UtilTimerStack.pop("Search Count Map");
        }
    }

    public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception
    {
        String filterId = (String) params.get("filterid");
        if (filterId == null)
        {
            log.error("Single Level Group By Report run without a project selected (JRA-5042): params=" + params);
            return "<span class='errMsg'>No search filter has been selected. Please "
                    + "<a href=\"IssueNavigator.jspa?reset=Update&amp;pid="
                    + TextUtils.htmlEncode((String) params.get("selectedProjectId"))
                    + "\">create one</a>, and re-run this report. See also "
                    + "<a href=\"http://jira.atlassian.com/browse/JRA-5042\">JRA-5042</a></span>";
        }
        String mapperName = (String) params.get("mapper");
        final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
        final JiraServiceContext ctx = new JiraServiceContextImpl(authenticationContext.getLoggedInUser());
        final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));

        final Map startingParams;
        try
        {
            startingParams = EasyMap.build(
                    "action", action,
                    "statsGroup", getOptions(request, authenticationContext.getLoggedInUser(), mapper),
                    "searchRequest", request,
                    "mapperType", mapperName,
                    "customFieldManager", customFieldManager,
                    "fieldVisibility", new FieldVisibilityBean(),
                    "searchService", searchService,
                    "portlet", this);
            startingParams.put("outlookDate", outlookDateManager.getOutlookDate(authenticationContext.getLocale()));
            return descriptor.getHtml("view", startingParams);
        }
        catch (PermissionException e)
        {
            log.error(e, e);
            return null;
        }
    }

    public void validate(ProjectActionSupport action, Map params)
    {
        super.validate(action, params);
        String filterId = (String) params.get("filterid");
        if (StringUtils.isEmpty(filterId))
        {
            action.addError("filterid", action.getText("report.singlelevelgroupby.filter.is.required"));
        }
        else
        {
            validateFilterId(action,filterId);
        }
    }

    private void validateFilterId(ProjectActionSupport action, String filterId)
    {
        try
        {
            JiraServiceContextImpl serviceContext = new JiraServiceContextImpl(
                    action.getLoggedInUser(), new SimpleErrorCollection());
            SearchRequest searchRequest = searchRequestService.getFilter(serviceContext, new Long(filterId));
            if (searchRequest == null)
            {
                action.addErrorMessage(action.getText("report.error.no.filter"));
            }
        }
        catch (NumberFormatException nfe)
        {
            action.addError("filterId", action.getText("report.error.filter.id.not.a.number", filterId));
        }
    }
}

Step 6. Create the view template for the Single Level Group By report

Next, edit the report view template to display the assignee and the issue's last update time. This version differs from the original JIRA report in two places where we loop over the issue result set to display the time.

Let's update the resource files that the SDK gave us.

  1. Open the template for the Single Level Group By Extended report: src/main/resources/templates/reports/single-level-group-by-report-extended/view.vm
  2. Replace the placeholder content with the following:

    #enable_html_escaping()
    #if ($searchRequest)
        #set ($urlPrefix = "${req.contextPath}/secure/IssueNavigator.jspa?reset=true")
    #end
    <table width="100%" class="aui" id="single_groupby_report_table">
        <thead>
            <tr>
                <th colspan="6">
                    <h2>$i18n.getText('report.singlelevelgroupby.mapper.filterid.name'): $textutils.htmlEncode($searchRequest.name)</h2>
                </th>
            </tr>
        </thead>
        <tbody>
            #foreach ($option in $statsGroup.entrySet())
                #set ($issues = $option.value)
                #set ($graphModel = $statsGroup.getResolvedIssues($option.key))
                <tr>
                    <th colspan="6" class="stat-heading">
                        <div class="stat-progress">
                            <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
                            #percentageGraphDiv ($graphModel)
                            #if ($issues.size() > 0)
                                <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
                            #end
                        </div>
                        <h3>#statHeading ($mapperType $option.key $customFieldManager "${urlPrefix}$!searchService.getQueryString($user, $statsGroup.getMapper().getSearchUrlSuffix($option.key, $searchRequest).getQuery())")</h3>
                    </th>
                </tr>
                #if ($issues.size() > 0)
                    #foreach ($issue in $issues)
                    <tr>
                        <td width="5%">&nbsp;</td>
                        #issueLineItem ($issue)
                        <td nowrap>
                            #if($issue.getAssignee())
                                $issue.getAssignee().getDisplayName()
                            #else
                                $i18n.getText('common.concepts.unassigned')
                            #end</td>
                        <td nowrap>$outlookDate.format($issue.getUpdated())</td>
                    </tr>
                    #end
                #else
                    <tr>
                        <td colspan="6">
                            <span class="subText">$action.getText("common.concepts.noissues").</span>
                        </td>
                    </tr>
                #end
            #end
            ## Render the Irrelevant issues if there are any
            #if($statsGroup.getIrrelevantIssues().size() > 0)
                #set ($issues = $statsGroup.getIrrelevantIssues())
                #set ($graphModel = $statsGroup.getIrrelevantResolvedIssues())
                <tr>
                    <th colspan="6">
                        <div class="stat-progress">
                            <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
                            #percentageGraphDiv ($graphModel)
                            #if ($issues.size() > 0)
                                <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
                            #end
                        </div>
                        <h3><span title="$i18n.getText('common.concepts.irrelevant.desc')">$i18n.getText('common.concepts.irrelevant')</span></h3>
                    </th>
                </tr>
                #if ($issues.size() > 0)
                    #foreach ($issue in $issues)
                    <tr>
                         <td width="5%">&nbsp;</td>
                         #issueLineItem ($issue)
                         <td nowrap>
                             #if($issue.getAssignee())
                                 $issue.getAssignee().getDisplayName()
                             #else
                                 $i18n.getText('common.concepts.unassigned')
                             #end</td>
                         <td nowrap>$outlookDate.format($issue.getUpdated())</td>
                     </tr>
                     #end
                #else
                    <tr>
                        <td colspan="6">
                            <span class="subText">$action.getText("common.concepts.noissues").</span>
                        </td>
                    </tr>
                #end
            #end
        </tbody>
    </table>

    This template code is identical to the template for the original report, but with an added table cell in the output that contains our date.

  3. Open the properties file, src/main/resources/SingleLevelGroupByReportExtended.properties.

  4. Replace its contents with the following:

    report.singlelevelgroupby.label.extended = Single Level Group By Report Extended
    report.singlelevelgroupby.filterId = Filter
    report.singlelevelgroupby.filterId.description = Select a filter to display
    report.singlelevelgroupby.mapper = Statistic Type
    report.singlelevelgroupby.mapper.description = Select a field to group by
    report.singlelevelgroupby.mapper.filterid.name = Filter
    report.singlelevelgroupby.description = This report allows you to display issues grouped by a certain field

    These are the text strings that will appear in the UI generated by the report.

  5. Save and close the file.

That gives us enough to try it out in JIRA. You'll do that next.

Step 7. Start up JIRA and try the report

See what we've got so far by starting up JIRA:

  1. At the command line, change to the project root directory (where the POM is located), and enter the following command:

    atlas-run

    Give the SDK a few minutes to download the JIRA files and start it up. If you run into build errors, make sure you have enabled the dependency for the jira-core artifact in the project POM.

  2. Once JIRA finishes starting up, open the JIRA interface in a browser with the default credentials, admin/admin.
  3. Before trying out your report, create a few JIRA artifacts first:
    • Create a project. The first time your start JIRA, a wizard prompts you to create one.
    • Create a few test issues in the project. To give your report a property to group by, choose different assignees or issue types for your issues. 
    • Also create at least one filter. You can do this from the search page. For more help, see the documentation on JIRA filters.  
  4. When ready to run the report, go to the Overview page for your project.
  5. In the Summary view, scroll down and notice your two new reports at the bottom of the list.
  6. Click Single Level Group By Report Extended
  7. Select a Filter and the statistic type you want to group by, and click Next. Your report should look something like this:

While you're at it, take a look at how the other custom report, Creation Report, looks in JIRA. We have our user input fields, thanks to the properties you added to the plugin descriptor, but there are only placeholders for labels and if you click Next, you get a blank page. You'll work on that next.

Meanwhile, you can leave JIRA running and load your plugin changes with QuickReload.

Step 8. Code the Creation Report

To make the Creation Report do something a little more useful:

  1. Open the Creation Report source code file at:
    src/main/java/com/atlassian/plugins/tutorial/jira/reports/CreationReport.java

  2. Replace its contents with the following.

    package com.atlassian.plugins.tutorial.jira.reports;
    
    import com.atlassian.jira.plugin.report.impl.AbstractReport;
    import com.atlassian.jira.web.action.ProjectActionSupport;
    import com.atlassian.core.util.DateUtils;
    import com.atlassian.jira.issue.search.SearchException;
    import com.atlassian.jira.issue.search.SearchProvider;
    import com.atlassian.jira.jql.builder.JqlQueryBuilder;
    import com.atlassian.jira.project.ProjectManager;
    import com.atlassian.jira.util.I18nHelper;
    import com.atlassian.jira.util.ParameterUtils;
    import com.atlassian.jira.web.bean.I18nBean;
    import com.atlassian.jira.web.util.OutlookDate;
    import com.atlassian.jira.web.util.OutlookDateManager;
    import com.atlassian.query.Query;
    import com.atlassian.crowd.embedded.api.User;
    import org.apache.log4j.Logger;
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    
    public class CreationReport extends AbstractReport
    {
        private static final Logger log = Logger.getLogger(CreationReport.class);
        private static final int MAX_HEIGHT = 200;
        private Long DEFAULT_INTERVAL = new Long(7);
        private long maxCount = 0;
        private Collection<Long> openIssueCounts = new ArrayList<Long>();
        private Collection<Date> dates = new ArrayList<Date>();
        private final SearchProvider searchProvider;
        private final OutlookDateManager outlookDateManager;
        private final ProjectManager projectManager;
        public CreationReport(SearchProvider searchProvider, OutlookDateManager outlookDateManager, ProjectManager projectManager)
        {
            this.searchProvider = searchProvider;
            this.outlookDateManager = outlookDateManager;
            this.projectManager = projectManager;
        }
     
        public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception
        {
            User remoteUser = action.getRemoteUser();
            I18nHelper i18nBean = new I18nBean(remoteUser);
            Long projectId = ParameterUtils.getLongParam(params, "selectedProjectId");
            Date startDate = ParameterUtils.getDateParam(params, "startDate", i18nBean.getLocale());
            Date endDate = ParameterUtils.getDateParam(params, "endDate", i18nBean.getLocale());
            Long interval = ParameterUtils.getLongParam(params, "interval");
            if (interval == null || interval.longValue() <= 0)
            {
                interval = DEFAULT_INTERVAL;
                log.error(action.getText("report.issuecreation.default.interval"));
            }
            getIssueCount(startDate, endDate, interval, remoteUser, projectId);
            List<Number> normalCount = new ArrayList<Number>();
            if (maxCount != MAX_HEIGHT && maxCount > 0)
            {
                for (Long asLong : openIssueCounts)
                {
                    Float floatValue = new Float((asLong.floatValue() / maxCount) * MAX_HEIGHT);
                    // Round it back to an integer
                    Integer newValue = new Integer(floatValue.intValue());
                    normalCount.add(newValue);
                }
            }
            if (maxCount < 0)
                action.addErrorMessage(action.getText("report.issuecreation.error"));
            Map<String, Object> velocityParams = new HashMap<String, Object>();
            velocityParams.put("startDate", startDate);
            velocityParams.put("endDate", endDate);
            velocityParams.put("openCount", openIssueCounts);
            velocityParams.put("normalisedCount", normalCount);
            velocityParams.put("dates", dates);
            velocityParams.put("maxHeight", new Integer(MAX_HEIGHT));
            velocityParams.put("outlookDate", outlookDateManager.getOutlookDate(i18nBean.getLocale()));
            velocityParams.put("projectName", projectManager.getProjectObj(projectId).getName());
            velocityParams.put("interval", interval);
            return descriptor.getHtml("view", velocityParams);
        }
        private long getOpenIssueCount(User remoteUser, Date startDate, Date endDate, Long projectId) throws SearchException
        {
            JqlQueryBuilder queryBuilder = JqlQueryBuilder.newBuilder();
            Query query = queryBuilder.where().createdBetween(startDate, endDate).and().project(projectId).buildQuery();
            return searchProvider.searchCount(query, remoteUser);
        }
        private void getIssueCount(Date startDate, Date endDate, Long interval, User remoteUser, Long projectId) throws SearchException
        {
            long intervalValue = interval.longValue() * DateUtils.DAY_MILLIS;
            Date newStartDate;
            long count = 0;
            while (startDate.before(endDate))
            {
                newStartDate = new Date(startDate .getTime() + intervalValue);
                if (newStartDate.after(endDate))
                    count = getOpenIssueCount(remoteUser, startDate, endDate, projectId);
                else
                    count = getOpenIssueCount(remoteUser, startDate, newStartDate, projectId);
                if (maxCount < count)
                    maxCount = count;
                openIssueCounts.add(new Long(count));
                dates.add(startDate);
                startDate = newStartDate;
            }
        }
    
        public void validate(ProjectActionSupport action, Map params)
        {
            User remoteUser = action.getRemoteUser();
            I18nHelper i18nBean = new I18nBean(remoteUser);
            Date startDate = ParameterUtils.getDateParam(params, "startDate", i18nBean.getLocale());
            Date endDate = ParameterUtils.getDateParam(params, "endDate", i18nBean.getLocale());
            Long interval = ParameterUtils.getLongParam(params, "interval");
            Long projectId = ParameterUtils.getLongParam(params, "selectedProjectId");
            OutlookDate outlookDate = outlookDateManager.getOutlookDate(i18nBean.getLocale());
            if (startDate == null || !outlookDate.isDatePickerDate(outlookDate.formatDMY(startDate)))
                action.addError("startDate", action.getText("report.issuecreation.startdate.required"));
            if (endDate == null || !outlookDate.isDatePickerDate(outlookDate.formatDMY(endDate)))
                action.addError("endDate", action.getText("report.issuecreation.enddate.required"));
            if (interval == null || interval.longValue() <= 0)
                action.addError("interval", action.getText("report.issuecreation.interval.invalid"));
            if (projectId == null)
                action.addError("selectedProjectId", action.getText("report.issuecreation.projectid.invalid"));
            if (startDate != null && endDate != null && endDate.before(startDate))
            {
                action.addError("endDate", action.getText("report.issuecreation.before.startdate"));
            }
        }
    }


    The code retrieves the parameters specified by the user. It then gets the relevant issue counts from the configured time range, divided into the time intervals specified by the user. The issue counts are normalized, giving the user a balanced histogram. Finally, the relevant details are passed to the Velocity template.

    For more details on what the code is doing, look at code comments included in the source code in the JIRA Report BitBucket repository.

  3. Save and close the file.

Step 9. Develop the Creation Report UI

If you tried the report in JIRA now, you would still get a blank page. To see a report, you need to code a presentation template, along with text strings that will appear in the UI. To do so: 

  1. Open the Creation Report velocity template file for editing: src/main/resources/templates/reports/creation-report/view.vm.
  2. Replace its content with the following:

    <div style="padding: 5px">
    <!-- Display the report configuration -->
    <h4>
        $i18n.getText('report.issuecreation.project'): $projectName | 
        $i18n.getText('report.issuecreation.duration'): $outlookDate.formatDMY($startDate) - $outlookDate.formatDMY($endDate) |
        $i18n.getText('report.issuecreation.interval'): $interval $i18n.getText('report.issuecreation.interval.days')
    </h4>
    <br />
    <table style="width: 100%; border: 0; background-color: lightgrey">
        <!-- Create a row to display the bars-->
        <tr valign="bottom" style="background-color: white; padding: 1px">
            #foreach ($normalCount in $normalisedCount)
                <td height="$maxHeight" align="center">
                #if ($normalCount == 0)
                    &nbsp;
                #else
                    <img src="${baseurl}/images/bluepixel.gif" width="12" height="$normalCount">
                #end
                </td>
            #end
        </tr>
        <!-- Have one row for the issue count -->
        <tr style="background-color: #eee; padding: 1px">
            #foreach ($count in $openCount)
                <td align="center"><b>$count</b></td>
            #end
        </tr>
        <!-- And one row to display the date -->
        <tr style="background-color: #eee; padding: 1px">
            #foreach ($date in $dates)
                <td align="center"><b>$outlookDate.formatDMY($date)</b></td>
            #end
        </tr>
    </table>
    </div>
  3. Add the following text strings to the properties file

    report.issuecreation.label = Issue Creation Report
    report.issuecreation.name = Issue Creation Report
    report.issuecreation.projectid.name = Project
    report.issuecreation.projectid.description = Select the project to display report on.
    report.issuecreation.description = Report displaying a histogram of issues opened over a specified period.
    report.issuecreation.startdate = Start Date
    report.issuecreation.startdate.description = Graph all issues created after this date.
    report.issuecreation.enddate = End Date
    report.issuecreation.enddate.description = Graph all issues created before this date.
    report.issuecreation.interval = Interval
    report.issuecreation.interval.days = days
    report.issuecreation.interval.description = Specify the interval (in days) for the report.
    report.issuecreation.startdate.required = A valid "Start Date" is required to generate this report.
    report.issuecreation.enddate.required = A valid "End Date" is required to generate this report.
    report.issuecreation.interval.invalid = The interval must be a number greater than 0.
    report.issuecreation.before.startdate = The "End Date" must be after the "Start Date".
    report.issuecreation.error = Error occurred generating Issue Creation Report.
    report.issuecreation.projectid.invalid = Please select a valid project.
    report.issuecreation.default.interval = The interval specified is invalid - using default interval.
    report.issuecreation.duration = Duration
    report.issuecreation.project = Project
  4. Save and close the file.

Step 10. View the finished product

Now try the Creation Report again and see what we've got:

  1. Start up JIRA again using atlas-run or, if you left JIRA running, simply rebuild your plugin. As mentioned, QuickReload will automatically reinstall your plugin for you.
  2. At the bottom of the project summary page, click the Creation Report link.
  3. Supply dates that make a range that encompasses the date on which you created the issues, and long enough to give you a few intervals to look at (keeping in mind the default interval of 3 days).
  4. Click Next
    This time, we have a report!

Next Steps

Our report output UI is pretty basic, to put it politely. As a next step, try improving the report output. You can use the reports supplied with JIRA as a model.

For more information on topics covered in this tutorial, see:

Congratulations, that's it

Have a chocolate!

Was this page helpful?

Have a question about this article?

See questions about this article

Powered by Confluence and Scroll Viewport