Kitchen Duty Planning JS Controller

Some time has passed since I worked on this Plugin tutorial - it is mid 2018 now - and we need to catch up to some things.

What has changed since 2017?

1. State and future of AUI library:

  • AtlasKit library is based on ReactJS and is used for Cloud Plugin Development.
  • AUI library is based on JQuery and is used for Server Plugin Development.
  • AUI Library has now a more "closed-source" License, meaning you cannot use AUI version greater 7 for non-Atlassian products. If you have a website or standalone product using AUI for your UI you should check the license terms.
  • That means we will continue using AUI for our plugin. And even though we could hit it with NPM, React, Angular, Webpack and all that fancy shenanigans, we will write oldschool ES5 JavaScript code that directly executes in all browsers including ol' grandpa IE11. I leave the crazy JavaScript build chain stuff up to you. The mechanisms explained in the tutorial are the same for both ways.

2. AUI Redesign + JIRA version upgrade

  • We now compile against JIRA 7.10 and use Atlassian SDK version 6.3.10 therefore upgrade your Atlassian SDK. Also are we now compiling against Java 8. Update your pom.xml accordingly.
  • JIRA Software underwent a redesign now looks like this. But all components of AUI just work the same way.
pom.xml
<project>
    ...
    <build>
        <plugins>
            ...
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.3</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <properties>
        <jira.version>7.10.0</jira.version>
        <amps.version>6.3.15</amps.version>
        ...
    </properties>
</project>

What we want to achieve

Basically we want to first have the user select the week. Then we want to show a multi-select textfield containing existing users for that week and beeing able to add or remove users for that week. That means the template of the user selection loads conditionally by the week number.

There are now multiple soy templates as shown in the graphic above which we will later use from our JavaScript code to render the planning page.

src/main/resources/templates-soy/.../kitchen-duty-planning.soy
{namespace JIRA.Templates.KDP}

{template .planningPage} 
    <form class="aui" id="kdp-user-select-form">
        <div id="kdp-planning-page-week-container"></div>
        <div id="kdp-planning-page-week-users-container"></div>
    </form>
{/template}

{template .planningPageWeek} 
    <h4 class="kdp-step-headline">1. Select Week</h4>
    <div class="kdp-step-content">
        <input
          class="aui-date-picker aui-button"
          id="week-picker"
          type="date"
          max="2050-01-25"
          min="2011-12-25"
        />
    </div>
{/template}

{template .planningPageWeekUsers} 
    {@param? week: int} 
    <h4 class="kdp-step-headline">
        2. Kitchen Duty for Week&nbsp;
        {if $week != null } 
            <aui-badge class="aui-badge-primary">
                <span id="week-label">{week}</span>
            </aui-badge>
        {else}
            <aui-badge>
                <span id="week-label">...</span>
            </aui-badge>
        {/if}
    </h4>
    <div class="kdp-step-content">
        {if $week == null } 
            <div>- no week selected -</div>
        {else}
            <div>Users on kitchen duty for selected week:</div>
            <div
              id="kdp-user-select"
              name="kdp-user-select"
              class="kdp-aui-select"
              multiple=""
              placeholder="start typing a username ..."
            ></div>
            <br/><br/>
            <input
              class="button submit"
              type="submit"
              id="kdp-user-select-save-button"
              value="save"
            />
        {/if}
    </div>
{/template}
planningPage is the base template we inject into the page that has two divs with ids we later use to inject the other two rendered templates.
planningPageWeek is the template for the week selection. This template is only rendered once on page load.
planningPageWeekUsers is the template for the user to week selection. This template is re-rendered multiple times since it is a parameterized soy template.
week parameter is used to pass the week number to the template.
conditionally render the template for week being null (=no week selected) or week greater zero (=week selected).
when the week is null display a message, otherwise render the AUI Select 2 field for the user selection.

We will later use the templates from our JavaScript code but for now we need again do something in Java.

REST API Endpoint

In step four we implemented logic to persist only one user for a week. We need to change that to multiple users per week because of the way the AUI Select 2 works.

Therefore I have changed the endpoints to do so. Basically I added just some for-each loops and some missing activeObjects.flush(entity) statements that update the ID of the object after creating a new entity. With the AUI Select 2 user field you can remove and add users. The HTTP PUT Request always contains a full list of users therefore we do not need the explicit delete-endpoint anymore. We simply update the relationships in our @PUT endpoint.

src/main/java/com/codeclou/kitchen/duty/rest/.../KitchenDutyPlanningResource.java
package io.codeclou.kitchen.duty.rest;
...

@Named
@Path("/planning")
public class KitchenDutyPlanningResource {
...


    @GET
    @Path("/week/{weekNumber}/users")
    @Produces({MediaType.APPLICATION_JSON})
    @AnonymousAllowed
    public Response getUsersForWeek(@PathParam("weekNumber") final Integer weekNumber) {
        Week week = 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;
            }
        });
        List<KitchenDutyPlanningResourceUserModel> 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(new KitchenDutyPlanningResourceUserModel(userToWeek.getUser().getID(), userToWeek.getUser().getName()));
                }
            }
        }
        return Response.ok(users).build();
    }

    /*
     * Add the Users to the Week
     */
    @PUT
    @Path("/week/{weekNumber}/users")
    @Produces({MediaType.APPLICATION_JSON})
    @AnonymousAllowed
    public Response addUserToWeek(@PathParam("weekNumber") final Integer weekNumber,
                                  final List<KitchenDutyPlanningResourceUserModel> userParams) {
        activeObjects.executeInTransaction(new TransactionCallback<Void>() {
            @Override
            public Void doInTransaction() {
                //
                // WEEK 
                //
               Week week = KitchenDutyActiveObjectHelper.findUniqueWeek(activeObjects, weekNumber);
                if (week == null) {
                    week = activeObjects.create(Week.class, new DBParam("WEEK", weekNumber));
                    week.save();
                    activeObjects.flush(week);
                }

                //
                // CLEANUP EXISTING RELATIONSHIPS 
                //
                UserToWeek[] existingRelationships = KitchenDutyActiveObjectHelper.findAllRelationships(activeObjects, week);
                if (existingRelationships != null) {
                    for (UserToWeek existingRelationship : existingRelationships) {
                        activeObjects.delete(existingRelationship);
                        activeObjects.flush(existingRelationship);
                    }
                }

                //
                // USER
                //
                for (KitchenDutyPlanningResourceUserModel userParam : userParams) {
                    User user = KitchenDutyActiveObjectHelper.findUniqueUser(activeObjects, userParam.getUsername());
                    if (user == null) {
                        user = activeObjects.create(User.class, new DBParam("NAME", userParam.getUsername()));
                        user.save();
                        activeObjects.flush(user);
                    }
                    //
                    // Establish ManyToMany Relationship 
                    //
                    UserToWeek relationship = KitchenDutyActiveObjectHelper.findRelationship(activeObjects, user, week);
                    if (relationship == null) {
                        relationship = activeObjects.create(UserToWeek.class);
                        relationship.setUser(user);
                        relationship.setWeek(week);
                        relationship.save();
                        activeObjects.flush(relationship);
                    }
                }

                return null;
            }
        });
        return Response.ok().build();
    }
We now query the UserToWeek objects by Week, to get all UserToWeek objects from which we get the all the users assigned to the week.
Now we wrap all users in a List of KitchenDutyPlanningResourceUserModel which procudes the JSON [ { "id": 1, "username": "linda" }, { "id": 2, "username": "bob" } ]
First we get the Week object or create it if it does not exist. The activeObjects.flush(entity) ensures that the ID gets updated for the week.
Next we remove all users from that week. Why? Let's say bob and steve have been assigned to that week before. Someone edited the users for that week and now it is steve and linda. So how can we tell that we need to remove bob when we do not even have the information about that? We cannot. Therefore we simply remove all users from that week to add the desginated users to that week later.
Assign the designated users to the week. This way we do not need the DELETE endpoint anymore since we simply always first remove all users for a week and then assign the new userlist to the week.

Having done that we can move on again to our frontend code.

Moment JS and AUI Preparations

Since we want to calcuclate the week number from our date selected in the date picker we need some libraries to do so. Date calculations in JavaScript are best done with MomentJS. We need to add the dependency for the AUI date picker to our atlassian-plugin.xml and put a copy of moment.min.js inside src/main/resources/js/3rdparty/.

src/main/resources/.../atlassian-plugin.xml
<web-resource key="kitchen-duty-plugin-resources--planning-page" name="kitchen-duty-plugin Web Resources for Planning Page">
  <dependency>com.atlassian.auiplugin:ajs</dependency>
  <dependency>com.atlassian.auiplugin:aui-select2</dependency>
  <dependency>com.atlassian.auiplugin:aui-experimental-soy-templates</dependency>
  <dependency>com.atlassian.auiplugin:aui-date-picker</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="kitchen-duty-planning-soy.js"
            location="templates-soy/kitchen-duty-planning.soy"/>
  <resource type="download" name="kitchen-duty-plugin--planning-page-controller.js"
            location="/js/kitchen-duty-plugin--planning-page-controller.js"/> 
  <context>kitchen-duty-plugin</context>
</web-resource>

JS Controller

We edit our existing Planning Page JS-Controller file in /src/main/resources/js/kitchen-duty-plugin--planning-page-controller.js. I have extended the existing code to load and save the users per week. This JS code now uses the soy templates defined above.

src/main/resources/js/.../kitchen-duty-plugin--planning-page-controller.js
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
        });
    });
};

var initUserSearch = function(weekNumber) { 
    // Init SOY template
    var planningPageWeekUsersTemplate = JIRA.Templates.KDP.planningPageWeekUsers({
        week: weekNumber 
    });
    AJS.$('#kdp-planning-page-week-users-container').html(planningPageWeekUsersTemplate); 

    // Init actions
    var auiUserSelectOptions = {
        ajax: {
            url: function () { return window.KDPrestUrl + '/user/search'; },
            dataType: 'json',
            delay: 250,
            data: function (searchTerm) { return { query: searchTerm }; },
            results: function (data) { return { results: data }; },
            cache: true
        },
        minimumInputLength: 1,
        tags: 'true'
    };
    AJS.$('#kdp-user-select').auiSelect2(auiUserSelectOptions);

    // Load initial values from REST API and set for aui-select
    if (weekNumber !== null) { 
        AJS.$.ajax({
            url: window.KDPrestUrl + '/planning/week/' + weekNumber + '/users',
            dataType: 'json',
            success: function(users) {
                var selectedUserList = [];
                if (users !== null) { 
                    users.forEach(function(user) {
                        selectedUserList.push({ id: user.username, text: user.username });
                    });
                    AJS.$('#kdp-user-select').select2('data', selectedUserList);
                }
            }
        });
    }

    // Save users on save button click
    AJS.$('#kdp-user-select-form').off(); // remove previous listeners
    AJS.$('#kdp-user-select-form').submit(function (e) {
        e.preventDefault();
        var selectedUserList = [];
        AJS.$(AJS.$('#kdp-user-select').select2('data')).each(function () {
            // we need to transform the JSON sent to the Endpoint since it
            // has to be in specific format
            selectedUserList.push({ username: this.text });
        });
        AJS.$.ajax({ 
            url: window.KDPrestUrl + '/planning/week/' + weekNumber + '/users',
            type: 'PUT',
            contentType: 'application/json',
            data: JSON.stringify(selectedUserList),
            processData: false,
            success: function() {
                showSuccessFlag('Saved users for Week ' + weekNumber);
            },
            error: function() {
                showErrorFlag('Failed to save users for Week ' + weekNumber);
            }
        });
    });
};

var initWeekPicker = function() { 
    // Init SOY template
    var planningPageWeekTemplate = JIRA.Templates.KDP.planningPageWeek();
    AJS.$('#kdp-planning-page-week-container').html(planningPageWeekTemplate);

    // Init actions
    AJS.$('#week-picker').off(); // remove previous listeners
    AJS.$('#week-picker').datePicker({'overrideBrowserDefault': true});
    AJS.$('#week-picker').change(function() {
        var week = moment(AJS.$('#week-picker').val()).week(); 
        initUserSearch(week);
    });
};

AJS.toInit(function(){
    AJS.log('KDP: Planning Page Controller initializing ...');
    var baseUrl = AJS.params.baseURL;
    var restUrl = baseUrl + '/rest/kitchenduty/1.0';
    window.KDPrestUrl = restUrl;

    // set locale for moment-js so that week starts on sunday
    // and week numbers are correctly calculated
    moment.locale('en', {
        week: {
            dow: 0, // Sunday (0) is the first day of the week
            doy: 1  // Week that contains Jan 1st is the first week of the year.
        }
    });
    console.log('Week starts at: ' + moment().startOf('week').format('dddd'));
    console.log('Current moment locale: ' + moment().locale());

    // Init Base SOY template 
    var planningPageTemplate = JIRA.Templates.KDP.planningPage();
    AJS.$('#kdp-planning-page-container').html(planningPageTemplate);

    // Init child templates 
    initWeekPicker();
    initUserSearch(null);
});
initUserSearch function renders the planningPageWeekUsers soy template for the week number passed as parameter.
week soy template parameter is passed to template from JavaScript function parameter.
The rendered planningPageWeekUsers soy template is injected into the DOM.
If the weekNumber variable is not null (meaning user has selected a date), then load existing users for that week from our REST Endpoint via HTTP GET.
If we get a non empty userlist from the API (meaning there are already users assigned for that week), we build us a custom array to pass to the AUI Select 2 field. With the select2 function on the DOM element of the AUI Select 2 you can pass in initial data like so: AJS.$('#id').select2('data', [ { id: 'foo', text: 'foo' } ]);
When the form is submitted (meaning user clicked save button), then we first build ourselves a userlist in the format the API can handle selectedUserList.push({ username: this.text });. Then we send the userlist via HTTP PUT to our Endpoint for the selected week.
initWeekPicker function renders the planningPageWeek soy template. This template is only rendered once on page load. It initializes the AUI Date picker for our textfield.
This is the real magic now. We register a change listener to our date picker field, meaning that once the user selects a date the function is called. Inside that function we get the selected date from the date picker and pass it to the MomentJS week() function which returns us the week number for that date. And once we have the week number we trigger a re-render of the planningPageWeekUsers template for that week.
In our AJS.toInit() function (which is executed after the DOM has loaded), we render the base planningPage soy template.
Right after that we initially render the child templates. First the initWeekPicker() and secondly the initUserSearch() with a null parameter, telling the function that the user has not yet selected a date.

That's it. Now we can run our plugin with atlas-run and when navigating to http://localhost:2990/jira/secure/KitchenDutyPlanningWebworkAction.jspa it should work as you can see below. We are completely done implementing the Kitchen Duty Planning Page.

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

The graphic shows marked green which components we implemented in this section.