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.
About these instructions
You can use any supported combination of operating system and IDE to create this app. These instructions were written using IntelliJ IDEA 2017.3 on macOS Sierra. If you use another operating system or IDE combination, you should use the equivalent operations for your specific environment.
This tutorial was last tested with Jira 7.7.1 using the Atlassian SDK 6.3.10.
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 2git 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.
Set up the Atlassian Plugin SDK and build a project if you have not done it yet.
Navigate to the directory where you want to keep the app project and run the following SDK command:
1 2atlas-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 2cd 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 2rm -rf ./src/test/java rm -rf ./src/test/resources/
Delete the unneeded Java class files.
1 2rm -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.
In the root folder of your app, open the pom.xml
file.
Add your company or organization name and website URL to the organization
element.
1 2<organization> <name>Example Company</name> <url>http://www.example.com/</url> </organization>
Update the project description
element as follows:
1 2<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<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:
Open a Terminal and navigate to the app root folder where the pom.xml
is located.
Run atlas-create-jira-plugin-module
command.
Enter the number for the Report
module.
When prompted, enter the following.
Enter New Classname |
|
Package Name |
|
Select N
for Show Advanced Setup.
Select Y
for Add Another Plugin Module.
Enter the number for the Report
module again.
Enter the following.
Enter New Classname |
|
Package Name |
|
Select N
for Show Advanced Setup.
Select N
for Add Another Plugin Module.
Confirm your selections.
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<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<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 2jira-components/jira-core/src/main/java/com/atlassian/jira/plugin/report/impl/SingleLevelGroupByReport.java
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 and time format configured in Jira.
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 2package 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 2private final DateTimeFormatter formatter;
Add the new field as a parameter passed to the class constructor.
1 2public SingleLevelGroupByReportExtended( ... @JiraImport DateTimeFormatterFactory dateTimeFormatterFactory ) ... this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser(); ...
In the generateReportHtml()
method, add the following line:
1 2startingParams ... .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 2package 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.
Navigate to 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#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 2report.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 2atlas-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.
Before trying out your report, create a few Jira artifacts:
Go to the Overview page for your project.
In the Summary view, find your two new reports at the bottom of the list.
Click Single Level Group By Report Extended.
Select a Filter and the statistic type you want to group by, and then click Next. Your report should look something like this.
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 2package 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(); } 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.
Save and close the file.
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.
Navigate to src/main/resources/templates/reports/creation-report
and open the view.vm
file.
Replace its content with the following:
1 2<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 2report.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.Congratulations, that's it!
Have a treat!
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: