Before we continue any further we need to clean up some technical debt. I just realised the REST API Endpoints are not secured also the database spaghetti code really bugs me out. Therefore here are some completely unrelated but necessary things we need to do.
Since we will get more Endpoints now for our Overview Page we will have common code. Therefore we will create a base-class all endpoints extend from.
We move the common code there. The first thing is the dependencies to UserManager
and ActiveObjects
.
Next are the two AuthGuard methods we will use to secure every endpoint isUserLoggedIn()
and isUserNotAdmin()
.
Lastly we need some convenience methods to serve certain HTTP Error codes as JSON - therefore we now have getUnauthorizedErrorResponse()
and getForbiddenErrorResponse()
.
package io.codeclou.kitchen.duty.rest;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.sal.api.user.UserProfile;
import javax.ws.rs.core.Response;
import java.time.LocalDate;
import java.time.temporal.ChronoField;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.List;
public class BaseResource {
protected com.atlassian.sal.api.user.UserManager userManager;
protected ActiveObjects activeObjects;
protected Boolean isUserLoggedIn() {
UserProfile user = userManager.getRemoteUser();
if (user != null) {
return true;
} else {
return false;
}
}
public Boolean isUserNotAdmin() {
UserProfile user = userManager.getRemoteUser();;
return (user == null || !userManager.isAdmin(user.getUserKey()));
}
protected Response getUnauthorizedErrorResponse() {
return Response.serverError()
.entity(new RestError(
RestError.errorText401,
401001,
Response.Status.UNAUTHORIZED.getStatusCode()))
.status(Response.Status.UNAUTHORIZED)
.build();
}
protected Response getForbiddenErrorResponse() {
return Response.serverError()
.entity(new RestError(
RestError.errorText403,
403001,
Response.Status.FORBIDDEN.getStatusCode()))
.status(Response.Status.FORBIDDEN)
.build();
}
}
You might have already seen that we use a class RestError
. This is a simple pojo to serve our errors as JSON.
package io.codeclou.kitchen.duty.rest;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
@XmlRootElement(name = "status")
@XmlAccessorType(XmlAccessType.FIELD)
public class RestError {
public static final String errorText401 = "Unauthorized (no user is authenticated).";
public static final String errorText403 = "Permission Denied (insufficient rights).";
@XmlElement(name = "statusCode")
private Integer statusCode;
@XmlElement(name = "subCode")
private Integer subCode;
@XmlElement
private String message;
public RestError() { }
public RestError(String message, Integer subCode, Integer statusCode) {
this.setMessage(message);
this.setStatusCode(statusCode);
this.setSubCode(subCode);
}
public Integer getStatusCode() {
return statusCode;
}
public void setStatusCode(Integer statusCode) {
this.statusCode = statusCode;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public Integer getSubCode() {
return subCode;
}
public void setSubCode(Integer subCode) {
this.subCode = subCode;
}
}
Ok now we need our existing KitchenDutyPlanningResource
to extend the BaseResource
and use the AuthGuards.
package io.codeclou.kitchen.duty.rest;
...
@Named
@Path("/planning")
public class KitchenDutyPlanningResource extends BaseResource {
@Inject
public KitchenDutyPlanningResource(ActiveObjects activeObjects,
UserManager userManager) {
this.activeObjects = activeObjects;
this.userManager = userManager;
}
public KitchenDutyPlanningResource() {
}
@GET
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response getUsersForWeek(@PathParam("weekNumber") final Integer weekNumber) {
// AUTHENTICATION
if (!this.isUserLoggedIn()) {
return getUnauthorizedErrorResponse();
}
// AUTHORIZATION
if (this.isUserNotAdmin()) {
return getForbiddenErrorResponse();
}
// BUSINESS LOGIC
...
}
@PUT
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response addUserToWeek(@PathParam("weekNumber") final Integer weekNumber,
final List<KitchenDutyPlanningResourceUserModel> userParams) {
// AUTHENTICATION
if (!this.isUserLoggedIn()) {
return getUnauthorizedErrorResponse();
}
// AUTHORIZATION
if (this.isUserNotAdmin()) {
return getForbiddenErrorResponse();
}
// BUSINESS LOGIC
...
}
@DELETE
@Path("/week/{weekNumber}/users")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response deleteUserFomWeek(@PathParam("weekNumber") final Integer weekNumber,
final KitchenDutyPlanningResourceUserModel userParam) {
// AUTHENTICATION
if (!this.isUserLoggedIn()) {
return getUnauthorizedErrorResponse();
}
// AUTHORIZATION
if (this.isUserNotAdmin()) {
return getForbiddenErrorResponse();
}
// BUSINESS LOGIC
...
}
@GET
@Path("/user/{username}/weeks")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response getWeeksForUser(@PathParam("username") final String username) {
// AUTHENTICATION
if (!this.isUserLoggedIn()) {
return getUnauthorizedErrorResponse();
}
// AUTHORIZATION
if (this.isUserNotAdmin()) {
return getForbiddenErrorResponse();
}
// BUSINESS LOGIC
...
}
@GET
@Path("/health")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response health() {
return Response.ok("ok").build();
}
}
BaseResource
now.
UserManager
and ActiveObjects
.
@AnonymousAllowed
? You can do that - then JIRA will do the authentication and error handling and most likely send you HTML errors instead of JSON.
Just do Authentication and Authorization the way you see it fits best for you. This is just one way to do so, but it is also risky if you forget the AuthGuards for some methods.
You should always have automated REST API tests ensure your plugin is really secure.
You might have noticed that we use a UserManager
that comes from the SAL API. We have already learned that we need to add a @ComponentImport
for that.
package io.codeclou.kitchen.duty.impl;
...
@ExportAsService ({MyPluginComponent.class})
@Named ("myPluginComponent")
public class MyPluginComponentImpl implements MyPluginComponent {
@ComponentImport
protected com.atlassian.sal.api.user.UserManager userManager;
...
}
We have previously seen our REST Endpoints full of ugly transactional data access code.
We want to move that stuff somewhere else and have clean REST Endpoints that just call some methods and do not care about transactions.
The code simply moves into KitchenDutyActiveObjectHelper
. There it can be ugly and we do not have to see it all the time.
package io.codeclou.kitchen.duty.ao;
...
import java.util.ArrayList;
import java.util.List;
public class KitchenDutyActiveObjectHelper {
....
//
// TRANSACTIONAL
//
public static Week getWeekByWeekNumberInTransaction(ActiveObjects activeObjects, Long weekNumber) {
return activeObjects.executeInTransaction(new TransactionCallback<Week>() {
@Override
public Week doInTransaction() {
Week[] weeks = activeObjects.find(Week.class, Query.select().where("WEEK = ?", weekNumber));
if (weeks != null && weeks.length > 0) {
return weeks[0];
}
return null;
}
});
}
public static List<User> getUsersAssignedToWeekInTransaction(ActiveObjects activeObjects, Week week) {
List<User> users = new ArrayList<>();
if (week != null) {
UserToWeek[] relationships = activeObjects.executeInTransaction(new TransactionCallback<UserToWeek[]>() {
@Override
public UserToWeek[] doInTransaction() {
return KitchenDutyActiveObjectHelper.findAllRelationships(activeObjects, week);
}
});
if (relationships != null) {
for (UserToWeek userToWeek : relationships) {
users.add(userToWeek.getUser());
}
}
}
return users;
}
}
getWeekByWeekNumberInTransaction()
gives us either an object of Week
or null
for a certain week number.
getUsersAssignedToWeekInTransaction()
gives us either a list of List<User>
or and empty list for a certain week.
These two methods are needed for our Overview Page REST Endpoint. If you are brave enough you can refactor the Planning Page REST Endpoint in the same way and move
the code to KitchenDutyActiveObjectHelper
.
We now need a REST Endpoint to give us all weeks with users that have kitchen duty for the whole month.
But since we checked the Events Object Spec of Full Calendar we know that for a valid event we need the start and the end date. We want an event representing a week which means sunday is the start date and saturday the end date. It is possible to calculate that in JavaScript but I decided to do it in Java.
[
{
"week": 10,
"start": "2018-03-04",
"end": "2018-03-10",
"users": [ ]
},
{
"week": 11,
"start": "2018-03-11",
"end": "2018-03-17",
"users": [ "admin" ]
},
...
]
Since we moved the data access code to our ActiveObjectsHelper our Endpoint looks very clean now.
package io.codeclou.kitchen.duty.rest;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.plugins.rest.common.security.AnonymousAllowed;
import com.atlassian.sal.api.user.UserManager;
import io.codeclou.kitchen.duty.ao.KitchenDutyActiveObjectHelper;
import io.codeclou.kitchen.duty.ao.User;
import io.codeclou.kitchen.duty.ao.Week;
...
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Named
@Path("/overview_page")
public class KitchenDutyOverviewPageResource extends BaseResource {
@Inject
public KitchenDutyOverviewPageResource(ActiveObjects activeObjects,
UserManager userManager) {
this.activeObjects = activeObjects;
this.userManager = userManager;
}
public KitchenDutyOverviewPageResource() { }
@GET
@Path("/year/{year}/month/{month}")
@Produces({MediaType.APPLICATION_JSON})
@AnonymousAllowed
public Response getUsersForWeek(@PathParam("year") final Long year,
@PathParam("month") final Long month) {
// AUTHENTICATION
if (!this.isUserLoggedIn()) {
return getUnauthorizedErrorResponse();
}
// BUSINESS LOGIC
List<KitchenDutyOverviewPageMonthDutyModel> responseList = new ArrayList<>();
List<Long> weekNumbersInMonth = getWeeksOfMonth(year, month);
for (Long weekNumber : weekNumbersInMonth) {
Week week = KitchenDutyActiveObjectHelper
.getWeekByWeekNumberInTransaction(activeObjects, weekNumber);
List<User> usersForWeek = KitchenDutyActiveObjectHelper
.getUsersAssignedToWeekInTransaction(activeObjects, week);
List<String> usernames = usersForWeek
.stream()
.map(user -> user.getName())
.collect(Collectors.toList());
responseList.add(new KitchenDutyOverviewPageMonthDutyModel(
weekNumber,
getFirstDayOfWeekOfYear(year, weekNumber).toString(),
getLastDayOfWeekOfYear(year, weekNumber).toString(),
usernames)
);
}
return Response.ok(responseList).build();
}
}
/year/{year}/month/{month}
so that we can query for e.g. /year/2018/month/8
and get all weeks for that month. We always get 5 weeks.
KitchenDutyOverviewPageMonthDutyModel
objects with week, start-date and end-date.
getFirstDayOfWeekOfYear()
gets you the date of the first day of the week (sunday).
getLastDayOfWeekOfYear()
gets you the date of the last day of the week (saturday).
The pojo for the week-events with start and end-date. The rest will be mapped in the JS controller.
package io.codeclou.kitchen.duty.rest;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.List;
/*
* We want this entity to be directly usable as full calendar event object
* https://fullcalendar.io/docs/event-object
*/
@XmlRootElement(name = "duty")
@XmlAccessorType(XmlAccessType.FIELD)
public class KitchenDutyOverviewPageMonthDutyModel {
@XmlElement
private Long week;
@XmlElement
private String start; /* Date String - First day of week (Sunday) */
@XmlElement
private String end; /* Date String - Last day of week (Monday) */
@XmlElement
private List<String> users;
public KitchenDutyOverviewPageMonthDutyModel(Long week, String start, String end, List<String> users) {
this.start = start;
this.end = end;
this.week = week;
this.users = users;
}
public Long getWeek() {
return week;
}
public void setWeek(Long week) {
this.week = week;
}
public List<String> getUsers() {
return users;
}
public void setUsers(List<String> users) {
this.users = users;
}
public String getStart() {
return start;
}
public void setStart(String start) {
this.start = start;
}
public String getEnd() {
return end;
}
public void setEnd(String end) {
this.end = end;
}
}
The date calculation helpers getWeeksOfMonth()
, getFirstDayOfWeekOfYear()
and getLastDayOfWeekOfYear()
are defined in BaseResource.java
.
package io.codeclou.kitchen.duty.rest;
import com.atlassian.activeobjects.external.ActiveObjects;
import com.atlassian.sal.api.user.UserProfile;
import javax.ws.rs.core.Response;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.Month;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalField;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class BaseResource {
...
// See unit test
public static List<Long> getWeeksOfMonth(Long year, Long month) {
List<Long> weeks = new ArrayList<>();
weeks.add(getWeekOfMonth(year, month, 1L));
weeks.add(getWeekOfMonth(year, month, 2L));
weeks.add(getWeekOfMonth(year, month, 3L));
weeks.add(getWeekOfMonth(year, month, 4L));
weeks.add(getWeekOfMonth(year, month, 5L));
return weeks;
}
protected static Long getWeekOfMonth(Long year, Long month, Long weekInMonth) {
// https://docs.oracle.com/javase/8/docs/api/java/time/temporal/WeekFields.html
// For locale en_US weeks start on sunday
WeekFields weekFields = WeekFields.of(Locale.forLanguageTag("en_US"));
LocalDate origin = LocalDate.of(1970, 1, 1);
LocalDate reference = origin
.with(weekFields.weekBasedYear(), year)
.with(ChronoField.YEAR, year)
.with(ChronoField.MONTH_OF_YEAR, month)
.with(ChronoField.ALIGNED_DAY_OF_WEEK_IN_MONTH, 1)
.with(ChronoField.ALIGNED_WEEK_OF_MONTH, weekInMonth);
return (long) reference.get(weekFields.weekOfYear());
}
public static LocalDate getFirstDayOfWeekOfYear(Long year, Long week) {
// https://docs.oracle.com/javase/8/docs/api/java/time/temporal/WeekFields.html
WeekFields weekFields = WeekFields.of(Locale.forLanguageTag("en_US"));
return LocalDate.now()
.with(weekFields.weekBasedYear(), year)
.with(ChronoField.ALIGNED_WEEK_OF_YEAR, week)
.with(ChronoField.DAY_OF_WEEK, 1).minusDays(1);
}
public static LocalDate getLastDayOfWeekOfYear(Long year, Long week) {
return getFirstDayOfWeekOfYear(year, week).plusDays(6);
}
}
getWeeksOfMonth()
returns five weeks with week numbers
for given year and month.
getWeekOfMonth()
returns the week number
for given year, month and weekInMonth. Ok if it sounds confusing look
at the Unit Test. But simply said this methods converts
week 1-5 of a specific month to the week-of-year-number.
getFirstDayOfWeekOfYear()
returns the first day of the week (sunday)
for a given year and week.
getLastDayOfWeekOfYear()
returns the last day of the week (saturday)
for a given year and week.
The Unit Test for the date calculation helpers looks like this. Just simple asserts, nothing special.
package ut.io.codeclou.kitchen.duty.rest;
import io.codeclou.kitchen.duty.rest.BaseResource;
import org.junit.Test;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import static org.hamcrest.core.Is.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
public class BaseResourceTest {
@Test
public void testGetWeeksOfMonth__weekStartsInPreviousMonth() {
// Sunday to Saturday:
// Week 31 July 29, 2018 => August 4, 2018
// Week 32 August 5, 2018 => August 11, 2018
// Week 33 August 12, 2018 => August 18, 2018
// Week 34 August 19, 2018 => August 25, 2018
// Week 35 August 26, 2018 => Sept, 1, 2018
{
List<Long> weeks = BaseResource.getWeeksOfMonth(2018L, 8L);
assertThat(weeks, is(Arrays.asList(31L, 32L, 33L, 34L, 35L)));
}
// Sunday to Saturday:
// Week 5 January 28, 2018 => February 3, 2018
// Week 6 February 4, 2018 => February 10, 2018
// Week 7 February 11, 2018 => February 17, 2018
// Week 8 February 18, 2018 => February 24, 2018
// Week 9 February 25, 2018 => March 3, 2018
{
List<Long> weeks = BaseResource.getWeeksOfMonth(2018L, 2L);
assertThat(weeks, is(Arrays.asList(5L, 6L, 7L, 8L, 9L)));
}
// Sunday to Saturday:
// Week 18 April 29, 2018 => May 5, 2018
// Week 19 May 6, 2018 => May 12, 2018
// Week 20 May 13, 2018 => May 19, 2018
// Week 21 May 20, 2018 => May 26, 2018
// Week 22 May 27, 2018 => June 2, 2018
{
List<Long> weeks = BaseResource.getWeeksOfMonth(2018L, 5L);
assertThat(weeks, is(Arrays.asList(18L, 19L, 20L, 21L, 22L)));
}
}
@Test
public void testGetWeeksOfMonth__weekStartsExactlyInMonth() {
// Sunday to Saturday:
// Week 19 May 1, 2016 => May 7, 2016
// Week 20 May 8, 2016 => May 14, 2016
// Week 21 May 15, 2016 => May 21, 2016
// Week 22 May 22, 2016 => May 28, 2016
// Week 23 May 29, 2016 => June 4, 2016
{
List<Long> weeks = BaseResource.getWeeksOfMonth(2016L, 5L);
assertThat(weeks, is(Arrays.asList(19L, 20L, 21L, 22L, 23L)));
}
// Sunday to Saturday:
// Week 27 July 1, 2018 => July 7, 2018
// Week 28 July 8, 2018 => July 14, 2018
// Week 29 July 15, 2018 => July 21, 2018
// Week 30 July 22, 2018 => July 28, 2018
// Week 31 July 29, 2018 => Aug. 4, 2018
{
List<Long> weeks = BaseResource.getWeeksOfMonth(2018L, 7L);
assertThat(weeks, is(Arrays.asList(27L, 28L, 29L, 30L, 31L)));
}
}
@Test
public void testGetFirstDayOfWeekOfMonth() {
LocalDate date = BaseResource.getFirstDayOfWeekOfYear(2018L, 32L);
assertEquals("2018-08-05", date.toString());
LocalDate date2 = BaseResource.getFirstDayOfWeekOfYear(2018L, 31L);
assertEquals("2018-07-29", date2.toString());
LocalDate date3 = BaseResource.getFirstDayOfWeekOfYear(2018L, 27L);
assertEquals("2018-07-01", date3.toString());
}
@Test
public void testGetLastDayOfWeekOfMonth() {
LocalDate date = BaseResource.getLastDayOfWeekOfYear(2018L, 32L);
assertEquals("2018-08-11", date.toString());
LocalDate date2 = BaseResource.getLastDayOfWeekOfYear(2018L, 31L);
assertEquals("2018-08-04", date2.toString());
}
}
Ok if we now do a curl on the Endpoint it looks like this. We always get five weeks within the month (or with minor overlap).
[
{
"week": 10,
"start": "2018-03-04",
"end": "2018-03-10",
"users": [ ]
},
{
"week": 11,
"start": "2018-03-11",
"end": "2018-03-17",
"users": [ "admin" ]
},
{
"week": 12,
"start": "2018-03-18",
"end": "2018-03-24",
"users": [ ]
},
{
"week": 13,
"start": "2018-03-25",
"end": "2018-03-31",
"users": [ ]
},
{
"week": 14,
"start": "2018-04-01",
"end": "2018-04-07",
"users": [ ]
}
]
The code for our endpoint is complete now. Let's move on.
Ok now again comes the ugly XML part. We need a dedicated page that can be accessed by every logged in user to display the calendar. And we need a Web Item link in the top JIRA navigation bar.
Also we want to link to the Overview Page from the Planning Page to make navigating easy.
<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
...
<web-item key="admin_kitchen_duty_overview_webitem"
name="admin_kitchen_duty_overview_webitem"
section="admin_plugins_menu/admin_kitchen_duty_planning_section"
weight="14" i18n-name-key="kitchen-duty-plugin.admin.overview.page.web.item.name">
<label key="kitchen-duty-plugin.admin.overview.page.web.item.name"/>
<link linkId="admin_kitchen_duty_overview_webitem_link">/secure/KitchenDutyOverviewPageWebworkAction.jspa</link>
</web-item>
...
<!-- ====================== -->
<!-- OVERVIEW PAGE -->
<!-- ====================== -->
<web-resource key="kitchen-duty-plugin-resources--overview-page"
name="kitchen-duty-plugin Web Resources for Overview Page">
<dependency>com.atlassian.auiplugin:ajs</dependency>
<dependency>com.atlassian.auiplugin:aui-experimental-soy-templates</dependency>
<transformation extension="soy">
<transformer key="soyTransformer">
<functions>com.atlassian.confluence.plugins.soy:soy-core-functions</functions>
</transformer>
</transformation>
<resource type="download" name="momentjs.js"
location="/js/3rdparty/moment-2.22.2.min.js"/>
<resource type="download" name="fullcalendar.js"
location="/js/3rdparty/fullcalendar-3.9.0.min.js"/>
<resource type="download" name="fullcalendar.css"
location="/css/3rdparty/fullcalendar-3.9.0.min.css"/>
<resource type="download" name="kitchen-duty-overview-soy.js"
location="templates-soy/kitchen-duty-overview.soy"/>
<resource type="download" name="kitchen-duty-plugin--overview-page-controller.js"
location="/js/kitchen-duty-plugin--overview-page-controller.js"/>
<context>kitchen-duty-plugin</context>
</web-resource>
<webwork1 key="kitchen-duty-overview-page-webwork-module"
name="Kitchen Duty Overview Page Webwork Module"
i18n-name-key="kitchen-duty-overview-page-webwork-module.name"
roles-required="use">
<description
key="kitchen-duty-overview-page-webwork-module.description"
>The Kitchen Duty Overview Page Webwork Module Plugin</description>
<actions>
<action name="io.codeclou.kitchen.duty.webwork.KitchenDutyOverviewPageWebworkAction"
alias="KitchenDutyOverviewPageWebworkAction">
<view
name="kitchen-duty-overview-page-success"
>/templates/kitchen-duty-overview-page-webwork-module/kitchen-duty-overview-page-success.vm</view>
</action>
</actions>
</webwork1>
<web-item key="user_kitchen_duty_overview_webitem"
name="user_kitchen_duty_overview_webitem"
section="system.top.navigation.bar"
weight="60"
i18n-name-key="kitchen-duty-plugin.user.overview.page.web.item.name">
<label key="kitchen-duty-plugin.user.overview.page.web.item.name"/>
<link
linkId="user_kitchen_duty_overview_webitem_link"
>/secure/KitchenDutyOverviewPageWebworkAction.jspa</link>
</web-item>
...
</atlassian-plugin>
src/main/resources/js/3rdparty
.
src/main/resources/css/3rdparty
.
src/main/resources/templates-soy/kitchen-duty-overview.soy
.
We will create the file later in full detail.
src/main/resources/js/kitchen-duty-plugin--overview-page-controller.js
.
We will create the file later in full detail.
roles-required="use"
ensures only logged in users
can view the page.
KitchenDutyOverviewPageWebworkAction.java
.
src/main/resources/templates/kitchen-duty-overview-page-webwork-module/kitchen-duty-overview-page-success.vm
.
All these xml things have i18n keys which we need to put in our translation file. Keep in mind that encoding for property files is ISO 8859-1.
# admin
kitchen-duty-plugin.admin.overview.page.web.item.name = Overview Page
# kitchen-duty-overview-page-success.vm
kitchen-duty-plugin.user.overview.page.title = Kitchen Duty Plugin - Overview Page
# webitem
kitchen-duty-plugin.user.overview.page.web.item.name = Kitchen Duty
Now we define our web work action that displays the velocity template and loads the overview page resources.
package io.codeclou.kitchen.duty.webwork;
import com.atlassian.jira.web.action.JiraWebActionSupport;
import com.atlassian.webresource.api.assembler.PageBuilderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
@Named
public class KitchenDutyOverviewPageWebworkAction extends JiraWebActionSupport
{
private static final Logger log = LoggerFactory.getLogger(KitchenDutyOverviewPageWebworkAction.class);
@Inject
private PageBuilderService pageBuilderService;
@Override
public String execute() throws Exception {
pageBuilderService.assembler().resources()
.requireWebResource("io.codeclou.kitchen-duty-plugin:kitchen-duty-plugin-resources")
.requireWebResource("io.codeclou.kitchen-duty-plugin:kitchen-duty-plugin-resources--overview-page");
return "kitchen-duty-overview-page-success";
}
public void setPageBuilderService(PageBuilderService pageBuilderService) {
this.pageBuilderService = pageBuilderService;
}
}
Let's also create the velocity success template now.
On non-admin pages you have to define the Navigation by yourself.
Also read the doc about Page.
We simply define a div with id kdp-overview-page-container
to hook in with our JS-controller.
<html>
<head>
<title>$i18n.getText("kitchen-duty-plugin.user.overview.page.title")</title>
<meta name="decorator" content="atl.general"/>
</head>
<body>
<div id="page">
<section id="content" role="main">
<header class="aui-page-header">
<div class="aui-page-header-inner">
<div class="aui-page-header-main">
<h1>Kitchen Duty</h1>
</div>
</div>
</header>
<div class="aui-page-panel">
<div class="aui-page-panel-inner">
<div class="aui-page-panel-nav">
<nav class="aui-navgroup aui-navgroup-vertical">
<div class="aui-navgroup-inner">
<ul class="aui-nav">
<li class="aui-nav-selected"><a href="${req.contextPath}/secure/KitchenDutyOverviewPageWebworkAction.jspa">Overview Page</a></li>
<li><a href="${req.contextPath}/secure/KitchenDutyPlanningWebworkAction.jspa">Planning Page</a></li>
</ul>
</div>
</nav>
</div>
<section class="aui-page-panel-content">
<div id="kdp-overview-page-container"></div>
</section>
</div>
</div>
</section>
</div>
</body>
</html>
When clicking on 'Kitchen Duty' in the JIRA top bar now it should look like this.
Ok that was now the server-side part of the overview page. Let's move on to the client-side.
As we already did it on the planning page, we again have a JS-controller using soy-templates and some logic to display the full calendar with data from REST Endpoints on the page.
Ok to be honest we could just skip using the soy template here since we do not really need it right now. But it is always good to follow common conventions for building our pages, so that we can easily extend them in the future.
{namespace JIRA.Templates.KDPO}
/**
* Kitchen Duty Overview Page - Base Template
*/
{template .overviewPage}
<div id="kdp-overview">
<div id="kdp-calendar"></div>
</div>
{/template}
Now comes the actual controller logic to display the calendar with kitchen duty data from the REST API.
AJS.toInit(function(){
AJS.log('KDP: Overview Page Controller initializing ...');
var baseUrl = AJS.params.baseURL;
var restUrl = baseUrl + '/rest/kitchenduty/1.0';
window.KDPrestUrl = restUrl;
// Init Base SOY template
var overviewPageTemplate = JIRA.Templates.KDPO.overviewPage();
AJS.$('#kdp-overview-page-container').html(overviewPageTemplate);
AJS.$('#kdp-calendar').fullCalendar({
defaultView: 'month',
weekNumbers: true,
height: 500,
fixedWeekCount: false,
events: function(start, end, timezone, callback) {
// Full calendar always starts month with days of previous month.
// We add 10 days to get month we want.
var year = moment(start).add('days', 10).format('YYYY');
var month = moment(start).add('days', 10).format('M');
AJS.$.ajax({
url: window.KDPrestUrl + '/overview_page/year/' + year + '/month/' + month,
dataType: 'json',
success: function(rawEvents) {
var events = [];
AJS.$(rawEvents).each(function() {
var users = AJS.$(this).attr('users');
events.push({
title: users.join(', '),
start: AJS.$(this).attr('start'),
end: AJS.$(this).attr('end'),
color: users.length > 0 ? '#36B37E' : '#FFAB00',
});
});
callback(events);
}
});
}
});
});
kdp-calendar
.
defaultView
to month.
start
variable contains the start date of the calendar view.
Since we are in the month view the start date is usually some day of the previous month
therefore we add 10 days to get a date within the current month. Via moment-js we get the year.
/overview_page/year/:year/month/:month
REST Endpoint with the month and year we get from the calendar api.
rawEvents
which are in the form of
[ { "week": 10, "start": "2018-03-04", "end": "2018-03-10", "users": [ "bob", "steve" ] } ]
.
[ { "start": "2018-03-04", "end": "2018-03-10", "title": "bob, steve", "color": "#ff0000" } ]
we map our values. As title
we concatenate the users into one string.
color
we display green (#36B37E) if the week has assigned users.
And we display orange (#FFAB00) if no users are assigned to week.
Lastly - since we might use the code to display AUI Flags in both controllers - we move the common code to kitchen-duty-plugin.js
.
//
// COMMON CODE
//
var showSuccessFlag = function(message) {
require(['aui/flag'], function(flag) {
flag({
type: 'success',
title: 'Kitchen Duty Plugin',
close: 'auto',
body: message
});
});
};
var showErrorFlag = function(message) {
require(['aui/flag'], function(flag) {
flag({
type: 'error',
title: 'Kitchen Duty Plugin',
close: 'auto',
body: message
});
});
};
That's it. Now we can run our plugin with atlas-run
and when navigating to http://localhost:2990/jira/secure/KitchenDutyOverviewPageWebworkAction.jspa
it should work as you can see below. We are completely done implementing the Kitchen Duty Overview Page.
The tutorial is at its end. You have completed all steps. Congratulations!