Kitchen Duty Overview Page

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.

BaseResource and AuthGuards for KitchenDutyPlanningResource Endpoint

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().

src/main/java/com/codeclou/kitchen/duty/rest/.../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.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.

src/main/java/com/codeclou/kitchen/duty/rest/.../RestError.java
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.

src/main/java/com/codeclou/kitchen/duty/rest/.../KitchenDutyPlanningResource.java
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();
    }

}
Class needs to extend BaseResource now.
Constructor now needs to do dependency injection for UserManager and ActiveObjects.
AuthGuard to ensure user is logged in. Otherwise error HTTP 401 Unauthorized is served.
AuthGuard to ensure user is administrator. Otherwise error HTTP 403 Forbidden is served.
Why not just remove @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.

src/main/java/com/codeclou/kitchen/duty/impl/.../MyPluginComponentImpl.java
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;
    ...
}

 

KitchenDutyActiveObjectHelper - or howto move data access spaghetti code somewhere else

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.

src/main/java/com/codeclou/kitchen/duty/ao/.../KitchenDutyActiveObjectHelper.java
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.
Note that we never return null for lists. It is always convenient in the upper layers of our code (e.g. the REST Endpoints) to not always have to null check everything.

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.

 

Kitchen Duty Overview Page REST Resource

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.

desired event response for march 2018
[
  {
    "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.

src/main/java/com/codeclou/kitchen/duty/rest/.../KitchenDutyOverviewPageResource.java
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();
    }

}
The URL should map to /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.
Get all weeks in that month.
Query the database and get existing Week Object.
Get the users for that week (nullsafe = return empty list if no users found)
Map the user objects to stringified usernames.
Now we build our event list as 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.

src/main/java/com/codeclou/kitchen/duty/rest/.../KitchenDutyOverviewPageMonthDutyModel.java
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.

src/main/java/com/codeclou/kitchen/duty/rest/.../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.

src/test/java/ut/com/codeclou/kitchen/duty/rest/.../BaseResourceTest.java
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).

curl --user admin:admin http://localhost:2990/jira/rest/kitchenduty/1.0/overview_page/year/2018/month/3
[
  {
    "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.

Kitchen Duty Overview Page Webwork Action and WebItems

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.

src/main/resources/.../atlassian-plugin.xml
<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>
First we need a link to the Overview Page from the side navigation when we are on the Planning Page. You can see it in the screenshot above.
We define a dedicated web-resource section to provide CSS, JS and Soy resources for the Overview Page.
We reuse the moment-js library we already used on the planning page since the full calendar library depends on moment-js.
Next we put a copy of fullcalendar.min.js to src/main/resources/js/3rdparty.
The full calendar also needs a copy of fullcalendar.min.css to src/main/resources/css/3rdparty.
For the Overview Page we also define a dedicated soy template in src/main/resources/templates-soy/kitchen-duty-overview.soy. We will create the file later in full detail.
The Overview Page has its own JS-controller in src/main/resources/js/kitchen-duty-plugin--overview-page-controller.js. We will create the file later in full detail.
The actual page is defined as a web work module. The roles-required="use" ensures only logged in users can view the page.
The module contains the actual action to the KitchenDutyOverviewPageWebworkAction.java.
As usual we define a velocity template for our web work action in src/main/resources/templates/kitchen-duty-overview-page-webwork-module/kitchen-duty-overview-page-success.vm.
Lastly we define a web-item with the link to the Overview Page in the JIRA top navigation bar.

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.

src/main/resources/.../kitchen-duty-plugin.properties
# 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.

src/main/java/com/codeclou/kitchen/duty/webwork/.../KitchenDutyOverviewPageWebworkAction.java
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.

src/main/resources/templates/kitchen-duty-overview-page-webwork-module/.../kitchen-duty-overview-page-success.vm
<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.

Kitchen Duty Overview Page JS Controller and Soy Templates

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.

src/main/resources/templates-soy/.../kitchen-duty-overview.soy
{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.

src/main/resources/js/.../kitchen-duty-plugin--overview-page-controller.js
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);
                }
            });
        }
    });
});
Load the overviewPage soy template and inject it into the DOM.
Initialize the full calendar in the div with id kdp-calendar.
We set the defaultView to month.
We also want the week numbers displayed.
Via the Events Function we fill the calendar with our kitchen duty 'events' representing each week.
The 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.
We do the same to get the current month displayed in the calendar.
Via ajax GET request we call our /overview_page/year/:year/month/:month REST Endpoint with the month and year we get from the calendar api.
We iterate over the rawEvents which are in the form of [ { "week": 10, "start": "2018-03-04", "end": "2018-03-10", "users": [ "bob", "steve" ] } ].
Since we need to build full calendar event objects in form of [ { "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.
As 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.

src/main/resources/js/.../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.

You have completed Step 6. Good Job! You can check out this step's solution on GitHub

The tutorial is at its end. You have completed all steps. Congratulations!