Rate this page:
Applicable: | This tutorial applies to Jira 7.1.0 and later. |
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. |
This tutorial shows you how to create custom Jira reports. In this tutorial, you'll add two reports:
The Single Level Group By Report Extended builds on an existing report in Jira. The existing report looks like this.
When you're done, you have a new report that looks like this.
Notice that Assignee and Updated fields appear in the output.
The Creation Report displays a histogram of issues created over a specified time and broken into certain intervals of time.
Your completed app will consist of the following components:
When you finish, all these components will be packaged in a single JAR file.
To complete this tutorial, you need to know the following:
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 app source code on Atlassian Bitbucket.
To clone the repository, run the following command:
1
git clone https://atlassian_tutorial@bitbucket.org/atlassian_tutorial/jira-report-plugin.git
Alternatively, you can download the source using the Downloads page here: bitbucket.org/atlassian_tutorial/jira-report-plugin
In this step, you'll use the Atlassian Plugin SDK to generate the scaffolding for your app project. The Atlassian Plugin SDK automates much of the work of app development for you. It includes commands for creating an app and adding modules to the app.
Navigate to the directory where you want to keep the app project and run the following SDK command:
1
atlas-create-jira-plugin
To identify your app, enter the following information.
group-id |
|
artifact-id |
|
version |
|
package |
|
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.
Navigate to the directory created by SDK.
1
cd jira-report-plugin
Delete the test directories.
Setting up testing for your app isn't part of this tutorial. Run the following commands to delete the generated test skeleton:
1 2
rm -rf ./src/test/java
rm -rf ./src/test/resources/
Delete the unneeded Java class files.
1
rm -rf ./src/main/java/com/atlassian/plugins/tutorial/jira/*
It's a good idea to familiarize yourself with the project configuration file (that is pom.xml
) and resource files. In this section,
you will review and tweak the pom.xml
file and the app descriptor file.
Open your app project in your favorite IDE and follow instructions in the next sections.
The POM is located at the root of your project and declares the project dependencies and other information. In this step you add metadata about your app and your company or organization to the file.
pom.xml
file.Add your company or organization name and website URL to the organization
element.
1 2 3 4
<organization>
<name>Example Company</name>
<url>http://www.example.com/</url>
</organization>
Update the project description
element as follows:
1
<description>Extends Jira issue reports.</description>
Remove the commenting from around the dependency
element for the jira-core
artifact, this will include dependency 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.
Save and close the file.
Your stub code contains an app descriptor file atlassian-plugin.xml
. This is an XML file that identifies the app
to the host application (Jira) and defines the required app functionality.
Under the src/main/resources
directory of your project home, open the descriptor file.
You should see something like this (comments removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<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>
<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>
</atlassian-plugin>
Now you will use the plugin module generator (another atlas
command) to generate the stub code for modules for the app.
For your modules, add two report modules as follows:
pom.xml
is located.atlas-create-jira-plugin-module
command.Report
module.When prompted, enter the following.
Enter New Classname |
|
Package Name |
|
Select N
for Show Advanced Setup.
Y
for Add Another Plugin Module.Report
module again.Enter the following.
Enter New Classname |
|
Package Name |
|
Select N
for Show Advanced Setup.
N
for Add Another Plugin Module.The SDK generates the code files for the modules and adds them to the app descriptor. It also adds other resources, such as Velocity files and i18n resource files.
Report module properties are the configurable fields that the app exposes in the Jira UI. We'll add a few properties to the module definition.
Navigate to src/main/resources
and open the atlassian-plugin.xml
file.
Under the Single Level Group By Report Extended
module, uncomment the properties
element and replace the default
property
elements it contains with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
``` xml
<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.
Under the Creation Report
module, uncomment and replace the properties
element with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
<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 the following:
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 filterprojectpicker
facility.startDate
— sets the start of the time period included in the report.endDate
— sets the end of the time period in the report.interval
— specifies the time interval used to divide the overall time period. In other words, this is the histogram interval.So far we've been working on two modules at once, each of them corresponds to separate reports in Jira. Now let's take them one at a time, starting with the Single Level Group By Report Extended.
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 extend a report delivered with Jira, the SingleLevelGroupByReport
class. If you have an
access to the Jira source code, you can find the source code for the original at this location:
1
jira-components/jira-core/src/main/java/com/atlassian/jira/plugin/report/impl/SingleLevelGroupByReport.java
The view template gets values to display in Jira using the parameter map that is passed by the module code. So, to add the DateTimeFormatter object to the Velocity template, we'll modify the parameter map generated by the original Jira report .
Under the project home at src/main/java/com/atlassian/plugins/tutorial/jira/reports
,
open the SingleLevelGroupByReportExtended.java
file.
Replace its contents with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165
package com.atlassian.plugins.tutorial.jira.reports;
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.datetime.DateTimeFormatter;
import com.atlassian.jira.datetime.DateTimeFormatterFactory;
import com.atlassian.jira.datetime.DateTimeStyle;
import com.atlassian.jira.exception.PermissionException;
import com.atlassian.jira.issue.CustomFieldManager;
import com.atlassian.jira.issue.IssueFactory;
import com.atlassian.jira.issue.fields.FieldManager;
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.project.ProjectManager;
import com.atlassian.jira.user.ApplicationUser;
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.PagerFilter;
import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import com.atlassian.util.profiling.UtilTimerStack;
import com.google.common.collect.ImmutableMap;
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.Arrays;
import java.util.Map;
@Scanned
public class SingleLevelGroupByReportExtended extends AbstractReport {
private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);
@JiraImport
private final SearchProvider searchProvider;
@JiraImport
private final SearchRequestService searchRequestService;
@JiraImport
private final IssueFactory issueFactory;
@JiraImport
private final CustomFieldManager customFieldManager;
@JiraImport
private final IssueIndexManager issueIndexManager;
@JiraImport
private final SearchService searchService;
@JiraImport
private final FieldVisibilityManager fieldVisibilityManager;
@JiraImport
private final FieldManager fieldManager;
@JiraImport
private final ProjectManager projectManager;
@JiraImport
private final ReaderCache readerCache;
public SingleLevelGroupByReportExtended(final SearchProvider searchProvider,
final SearchRequestService searchRequestService, final IssueFactory issueFactory,
final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
final ReaderCache readerCache,
final FieldManager fieldManager,
final ProjectManager projectManager) {
this.searchProvider = searchProvider;
this.searchRequestService = searchRequestService;
this.issueFactory = issueFactory;
this.customFieldManager = customFieldManager;
this.issueIndexManager = issueIndexManager;
this.searchService = searchService;
this.fieldVisibilityManager = fieldVisibilityManager;
this.readerCache = readerCache;
this.fieldManager = fieldManager;
this.projectManager = projectManager;
}
public StatsGroup getOptions(SearchRequest sr, ApplicationUser user, StatisticsMapper mapper) throws PermissionException {
try {
return searchMapIssueKeys(sr, user, mapper);
} catch (SearchException e) {
log.error("Exception rendering " + this.getClass().getName() + ". Exception \n" + Arrays.toString(e.getStackTrace()));
return null;
}
}
public StatsGroup searchMapIssueKeys(SearchRequest request, ApplicationUser 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, fieldManager, projectManager);
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.info("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&pid="
+ TextUtils.htmlEncode((String) params.get("selectedProjectId"))
+ "\">create one</a>, and re-run this report.";
}
String mapperName = (String) params.get("mapper");
final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
final JiraServiceContext ctx = new JiraServiceContextImpl(action.getLoggedInUser());
final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));
try {
final Map startingParams = ImmutableMap.builder()
.put("action", action)
.put("statsGroup", getOptions(request, action.getLoggedInUser(), mapper))
.put("searchRequest", request)
.put("mapperType", mapperName)
.put("customFieldManager", customFieldManager)
.put("fieldVisibility", fieldVisibilityManager)
.put("searchService", searchService)
.put("portlet", this).build();
return descriptor.getHtml("view", startingParams);
} catch (PermissionException e) {
log.error(e.getStackTrace());
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));
}
}
}
This is simply the original report with Atlassian Spring Scanner annotations. Next, we'll add the code that presents the time of last update to the report.
Under the existing field declarations for the class, add a new field.
1
private final DateTimeFormatter formatter;
Add the new field as a parameter passed to the class constructor.
1 2 3 4 5
public SingleLevelGroupByReportExtended( ...
@JiraImport DateTimeFormatterFactory dateTimeFormatterFactory )
...
this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
...
In the generateReportHtml()
method, add the following line:
1 2 3 4
startingParams
...
.put("formatter", formatter).build();
return descriptor.getHtml("view", startingParams);
It should appear within the try
block in which the code assigns values to the parameter map.
This code appends an additional parameter to the parameter map created by the method.
Save your changes.
The modified class should look something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169
package com.atlassian.plugins.tutorial.jira.reports;
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.datetime.DateTimeFormatter;
import com.atlassian.jira.datetime.DateTimeFormatterFactory;
import com.atlassian.jira.datetime.DateTimeStyle;
import com.atlassian.jira.exception.PermissionException;
import com.atlassian.jira.issue.CustomFieldManager;
import com.atlassian.jira.issue.IssueFactory;
import com.atlassian.jira.issue.fields.FieldManager;
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.project.ProjectManager;
import com.atlassian.jira.user.ApplicationUser;
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.PagerFilter;
import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import com.atlassian.util.profiling.UtilTimerStack;
import com.google.common.collect.ImmutableMap;
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.Arrays;
import java.util.Map;
@Scanned
public class SingleLevelGroupByReportExtended extends AbstractReport {
private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);
@JiraImport
private final SearchProvider searchProvider;
@JiraImport
private final SearchRequestService searchRequestService;
@JiraImport
private final IssueFactory issueFactory;
@JiraImport
private final CustomFieldManager customFieldManager;
@JiraImport
private final IssueIndexManager issueIndexManager;
@JiraImport
private final SearchService searchService;
@JiraImport
private final FieldVisibilityManager fieldVisibilityManager;
@JiraImport
private final FieldManager fieldManager;
@JiraImport
private final ProjectManager projectManager;
@JiraImport
private final ReaderCache readerCache;
private final DateTimeFormatter formatter;
public SingleLevelGroupByReportExtended(final SearchProvider searchProvider,
final SearchRequestService searchRequestService, final IssueFactory issueFactory,
final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
final ReaderCache readerCache,
final FieldManager fieldManager,
final ProjectManager projectManager,
@JiraImport DateTimeFormatterFactory dateTimeFormatterFactory) {
this.searchProvider = searchProvider;
this.searchRequestService = searchRequestService;
this.issueFactory = issueFactory;
this.customFieldManager = customFieldManager;
this.issueIndexManager = issueIndexManager;
this.searchService = searchService;
this.fieldVisibilityManager = fieldVisibilityManager;
this.readerCache = readerCache;
this.fieldManager = fieldManager;
this.projectManager = projectManager;
this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
}
public StatsGroup getOptions(SearchRequest sr, ApplicationUser user, StatisticsMapper mapper) throws PermissionException {
try {
return searchMapIssueKeys(sr, user, mapper);
} catch (SearchException e) {
log.error("Exception rendering " + this.getClass().getName() + ". Exception \n" + Arrays.toString(e.getStackTrace()));
return null;
}
}
public StatsGroup searchMapIssueKeys(SearchRequest request, ApplicationUser 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, fieldManager, projectManager);
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.info("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&pid="
+ TextUtils.htmlEncode((String) params.get("selectedProjectId"))
+ "\">create one</a>, and re-run this report.";
}
String mapperName = (String) params.get("mapper");
final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
final JiraServiceContext ctx = new JiraServiceContextImpl(action.getLoggedInUser());
final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));
try {
final Map startingParams = ImmutableMap.builder()
.put("action", action)
.put("statsGroup", getOptions(request, action.getLoggedInUser(), mapper))
.put("searchRequest", request)
.put("mapperType", mapperName)
.put("customFieldManager", customFieldManager)
.put("fieldVisibility", fieldVisibilityManager)
.put("searchService", searchService)
.put("portlet", this)
.put("formatter", formatter).build();
return descriptor.getHtml("view", startingParams);
} catch (PermissionException e) {
log.error(e.getStackTrace());
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));
}
}
}
In this step, we 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.
src/main/resources/templates/reports/single-level-group-by-report-extended
and open the view.vm
file.Replace the placeholder content with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
#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%"> </td>
#issueLineItem ($issue)
<td nowrap class="assignee">
#if($issue.getAssignee())
$issue.getAssignee().getDisplayName()
#else
$i18n.getText('common.concepts.unassigned')
#end</td>
<td nowrap class="last-updated"> $formatter.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%"> </td>
#issueLineItem ($issue)
<td nowrap class="assignee">
#if($issue.getAssignee())
$issue.getAssignee().getDisplayName()
#else
$i18n.getText('common.concepts.unassigned')
#end</td>
<td nowrap class="last-updated">$formatter.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.
Navigate to src/main/resources
and open the SingleLevelGroupByReportExtended.properties
file.
Replace its contents with the following:
1 2 3 4 5 6 7
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.
Save and close the file.
That gives us enough to try it out in Jira. You'll do that in the next step.
Open a Terminal and navigate to the project root directory (where the POM is located), and then run the following command:
1
atlas-run
Give the SDK a few minutes to download the Jira files and start the instance. If you run into build errors, make sure you
have enabled the dependency for the jira-core
artifact in the project POM.
Open the local Jira instance and log in with the default admin/admin credentials.
Take a look at how the Creation Report looks in Jira. Due to the properties you added to the app descriptor, we have our user input fields. But, there are only placeholders for labels, and if you click Next, you get a blank page. You'll work on that in the next step.
Meanwhile, you can leave Jira running and load your app changes with QuickReload.
In this step we make the Creation Report do something a little more useful.
Navigate to src/main/java/com/atlassian/plugins/tutorial/jira/reports
and open the CreationReport.java
file.
Replace its contents with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
package com.atlassian.plugins.tutorial.jira.reports;
import com.atlassian.core.util.DateUtils;
import com.atlassian.jira.datetime.DateTimeFormatter;
import com.atlassian.jira.datetime.DateTimeFormatterFactory;
import com.atlassian.jira.datetime.DateTimeStyle;
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.plugin.report.impl.AbstractReport;
import com.atlassian.jira.project.ProjectManager;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.util.ParameterUtils;
import com.atlassian.jira.web.action.ProjectActionSupport;
import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import com.atlassian.query.Query;
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;
@Scanned
public class CreationReport extends AbstractReport {
private static final Logger log = Logger.getLogger(CreationReport.class);
private static final int MAX_HEIGHT = 360;
private long maxCount = 0;
private Collection<Long> openIssuesCounts = new ArrayList<>();
private Collection<String> formattedDates = new ArrayList<>();
@JiraImport
private final SearchProvider searchProvider;
@JiraImport
private final ProjectManager projectManager;
private final DateTimeFormatter formatter;
private Date startDate;
private Date endDate;
private Long interval;
private Long projectId;
public CreationReport(SearchProvider searchProvider, ProjectManager projectManager,
@JiraImport DateTimeFormatterFactory dateTimeFormatterFactory) {
this.searchProvider = searchProvider;
this.projectManager = projectManager;
this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception {
//action.getLoggedInUser() since Jira 7.0.
//getLoggedInApplicationUser() since Jira 5.2
fillIssuesCounts(startDate, endDate, interval, action.getLoggedInUser(), projectId);
List<Number> issueBarHeights = new ArrayList<>();
if (maxCount > 0) {
openIssuesCounts.forEach(issueCount ->
issueBarHeights.add((issueCount.floatValue() / maxCount) * MAX_HEIGHT)
);
}
Map<String, Object> velocityParams = new HashMap<>();
velocityParams.put("startDate", formatter.format(startDate));
velocityParams.put("endDate", formatter.format(endDate));
velocityParams.put("openCount", openIssuesCounts);
velocityParams.put("issueBarHeights", issueBarHeights);
velocityParams.put("dates", formattedDates);
velocityParams.put("maxHeight", MAX_HEIGHT);
velocityParams.put("projectName", projectManager.getProjectObj(projectId).getName());
velocityParams.put("interval", interval);
return descriptor.getHtml("view", velocityParams);
}
private long getOpenIssueCount(ApplicationUser user, 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, user);
}
private void fillIssuesCounts(Date startDate, Date endDate, Long interval, ApplicationUser user, Long projectId) throws SearchException {
long intervalValue = interval * DateUtils.DAY_MILLIS;
Date newStartDate;
long count;
while (startDate.before(endDate)) {
newStartDate = new Date(startDate.getTime() + intervalValue);
if (newStartDate.after(endDate))
count = getOpenIssueCount(user, startDate, endDate, projectId);
else
count = getOpenIssueCount(user, startDate, newStartDate, projectId);
if (maxCount < count)
maxCount = count;
openIssuesCounts.add(count);
formattedDates.add(formatter.format(startDate));
startDate = newStartDate;
}
}
public void validate(ProjectActionSupport action, Map params) {
try {
startDate = formatter.parse(ParameterUtils.getStringParam(params, "startDate"));
} catch (IllegalArgumentException e) {
action.addError("startDate", action.getText("report.issuecreation.startdate.required"));
log.error("Exception while parsing startDate");
}
try {
endDate = formatter.parse(ParameterUtils.getStringParam(params, "endDate"));
} catch (IllegalArgumentException e) {
action.addError("endDate", action.getText("report.issuecreation.enddate.required"));
log.error("Exception while parsing endDate");
}
interval = ParameterUtils.getLongParam(params, "interval");
projectId = ParameterUtils.getLongParam(params, "selectedProjectId");
if (interval == null || interval <= 0) {
action.addError("interval", action.getText("report.issuecreation.interval.invalid"));
log.error("Invalid interval");
}
if (projectId == null || projectManager.getProjectObj(projectId) == null){
action.addError("selectedProjectId", action.getText("report.issuecreation.projectid.invalid"));
log.error("Invalid projectId");
}
if (startDate != null && endDate != null && endDate.before(startDate)) {
action.addError("endDate", action.getText("report.issuecreation.before.startdate"));
log.error("Invalid dates: start date should be before end date");
}
}
}
```
The code retrieves the parameters specified by the user. Then it gets the relevant issue counts from the configured time range;
they are 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.
If you try the report in Jira now, you will 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.
src/main/resources/templates/reports/creation-report
and open the view.vm
file.Replace its content with the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
<div style="padding: 5px">
<!-- Display the report configuration -->
<h4 id="creation-report-parameters">
$i18n.getText('report.issuecreation.project'): $projectName |
$i18n.getText('report.issuecreation.duration'):$startDate - $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 ($issueBarHeight in $issueBarHeights)
<td height="$maxHeight" align="center">
#if ($issueBarHeight > 0)
<img src="${baseurl}/images/bluepixel.gif" width="12" height="$issueBarHeight">
#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>$date</b></td>
#end
</tr>
</table>
</div>
Add the following text strings to the properties file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
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
Save and close the file.
Now try the Creation Report again and see what we've got.
atlas-run
command or, if you left Jira running, simply rebuild your app with atlas-package
command. As mentioned,
QuickReload will automatically reinstall your app for you.Click Next.
This time, we have a report.
Our report output UI is pretty basic. 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:
Rate this page: