Atlassian Jira. Admin evolution. Part 2

Part 1

In this part we will start changing our solution to make it better.

Database

As you can remember we created the user_managers table in the database using a Postgres database manager. This is a bad solution because:

  • you need to remember that this table should be created manually, if you move Jira configuration from one Jira instance to another
  • if you take a backup via Jira xml export, this table will not be present in the backup
  • a new Jira administrator will know nothing about this table unless it is written in a document and the new Jira administrator reads this document. I am myself very much against documenting solutions. Solutions must be understandable without reading documentation.

How could we create our database table in a better way?

You can find the WEB-INF/classes/entitydefs/entitymodel.xml file. This file contains the description of tables in the Jira database. Here is an example:

We can add our own table via this file. We could also define the primary key of our table, indexes but for the sake of simplicity we will use just this definition:

<entity entity-name="user_managers" table-name="user_manager" package-name="">        <field name="user" col-name="user" type="long-varchar"/> 
<field name="manager" col-name="manager" type="long-varchar"/> 
</entity> 

Also we need also to add information for our table into WEB-INF/classes/entitydefs/entitygroup.xml

<entity-group group="default" entity="user_managers"/>

Now we need to restart our Jira server. After it you can find the user_managers table in the Jira database.

Why is this way to create a table in the Jira database better than the first one?

Because right now you can take a backup of your Jira and restore your Jira without loosing the user_managers table. But what are the drawbacks?

  • We need to restart our Jira server to have a new table and this restart will make our Jira server unavailable for users.
  • To get rid of the Jira table we need to rollback changes in the entitymodel.xml and entitygroup.xml files, remove the table from Jira database and restart the Jira server. Again it will make our server unavailable for users.
  • If we want to move our configuration from one Jira Server to another we need to move our two files: entitymodel.xml and entitygroup.xml.
  • If we update our Jira Server then we need to add our new tables to the new entitymodel.xml and entitygroup.xml files. We can not simply update the new files with the old ones because we can loose changes which were introduced with the new Jira version.
  • We need to document our solution so that a new Jira admin know what to expect.

As you can see we still have many things to handle. I would not recommend this solution. We will talk later what would be a better solution.

JSP pages

Never use JSP pages. There are multiple disadvantages:

  • you can easily introduce security bugs
  • there is no API checks that is why it is time consuming to develop such a solution
  • you need to restart Jira after every change introduced to a JSP page, which makes development very annoying and long
  • You will not have errors in the logs if your JSP file ran with an error or had errors in syntax.
  • You need to document your solution to make sure you do not loose your solution while restoring your Jira, moving your Jira to another server, updating your Jira server.

Well, then what to use?

Jira project for lookup tables

Let’s remember why we needed the database table and JSP pages in the first place. We needed it to create and manage a lookup table which stores mapping between users and managers.

And a much better way which can be used in your Jira instance is to create a Jira project for the lookup table.

For example, we can create a user_managers project with issues like this:

As you can see when I create an issue in the user_managers project, I need to provide the user and the manager. And after I create this issue you can consider that a new row has been added to a table.

The summary field is absent in the screen but ss you know the summary field is required for an issue, that is why Icreated a bit more of JS and added this JS to the announcement banner.

<script>
      setInterval(function(){ 
         $("input#summary").val("mapping");
         $("input#summary").parent().css("display", "None");
      }, 3000);
</script>

Now everything should work.

Yes, I successfully created two issues:

So what do we have now? We have the user_managers project which stores information about users and managers and we can edit this project via UI. We will talk later how to access the data in this project. But for now we created a table and UI to manage it without a new Jira database table and JSP pages. And it was very fast and we did not use any black magic. Very nice!

We also had a requirement that only the lookup user could edit the data. And you have a very flexible Jira permission scheme to handle this requirement. Again very nice and clean solution.

But what is wrong with this solution?

The weakest link is the UI. UI is very robust. It is enough to manage the data but if your users want a more user friendly interface (lookup tables on other fields, wizards and so on), then you would need to change this solution or add a couple of plugins to this solution. We will talk about it later.

JavaScript: Common sense changes

Well, we have a not bad solution for our mapping table. Let’s have a look at our Javascript.

First of all we have the following problems:

  • sometimes our browser freezes
  • there is a delay between the button Ready For Work is hidden or value manager1 is set for the Approver field.

Our setInterval function causes this problems.

Let’s change setInterval function somehow. For example, to make a delay less noticeable we can decrease the waiting period for the interval function. But in this case our function will work more often which will cause more freezes to our browser. What next? We should make our setInterval smarter. Let’s say make it smarter for hiding the “Ready For Work” button. We need to hide our button only once, if the button is hidden, we should not try to hide this button again. Here is how the code looks like:

var hideActionInterval = setInterval(function(){ 
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
             clearInterval(hideActionInterval);
           } else {
             clearInterval(hideActionInterval);
           }
         }
      }, 3000);

We added clearInterval(hideActionInterval) after we made our choice to hide the button or not.

Well, can we do the same for our Approval field? No, we cant. Because if we stop our interval and after it we will change the value for the Reporter field, the new value for the Approver field will not be set. What to do?

The problem is that our approach with setInterval is completely wrong. We should execute our functions to hide the button after all elements were loaded. Same for setting a value for the Approver field. We need to set a value after all elements were loaded and after the value for the Reporter field was changed.

Ok, how to understand that all elements were loaded to a page? In jquery we could write something like this:

 $(function() {
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
           }
         }
      });

We used $(function() {}) to wait until the page has been loaded. And it worked for the “Ready for Work” button. Well, let’s do the same for the Approver field:

      $(function() {
         if ($("optgroup#reporter-group-suggested > option:selected").val()) {
            $("#customfield_10301").val("manager1");
            $("#customfield_10301").focus(function() {
                 this.blur();
            });
         }
      });

And it did not work? Why? Because when we push the create button the announcement banner is not reread and as a result nothing works. Any ideas how to fix it?

Of course, let’s add our JavaScript to the description of our Approver field. A custom field’s description is always loaded for the Create issue screen.

Ok. Let’s try again. And it did not work. This method does not work in the newest versions of Jira, but it worked before. Anyway even if it worked, do you consider it a good solution? I do not. Because your JavaScript code is now in several places in Jira. How a new administrator or you six months later are going to look for all JavaScript code? You would need to write some kind of program right? Then how would a new administrator know that you have this program? You will document it? Are you sure that your program is not buggy and will work for the new Administrator?

Too many questions with awkward answers. It seems like we are going to do something wrong.
Let’s think again. Any ideas? To search in Google? We could but still how would I know that it is a good solution?

At this point I have only one idea to look for an answer in the source code of Jira. At last I am there!

Before I looked for solutions after examining pages in the Developer Console and as a result I created all those bad solutions with JavaScript. But the right way to look for solutions in the source code. Only if you understand how Jira works, you can create a good solution! If your developer or administrator looks for solution in the Developer console, then I believe that your JavaScript solutions will be of a very low quality.

Ok. Let’s have a look at the code. Atlassian provides the source code of Atlassian Jira for users who bought a Jira license.

JavaScript: changes by the source code

If you look through the source code you will find the NEW_CONTENT_ADDED event. This event has the following reasons:

pageLoad: "pageLoad",
    inlineEditStarted: "inlineEditStarted",
    panelRefreshed: "panelRefreshed",
    criteriaPanelRefreshed: "criteriaPanelRefreshed",
    issueTableRefreshed: "issueTableRefreshed",

    //Fired when on List View, when we update the issue row with new information
    issueTableRowRefreshed: "issueTableRowRefreshed",

    //Fired when the Filters panel is opened
    filterPanelOpened: "filterPanelOpened",

    //Fired when the LayoutSwitcher has been rendered
    layoutSwitcherReady: "layoutSwitcherReady",

    //Fired when the user goes back to the search (JRADEV-18619)
    returnToSearch: "returnToSearch",

    //Fired when the Share dialog is opened
    shareDialogOpened: "shareDialogOpened",

    //Fired when the Search Filters results table has been refreshed
    filtersSearchRefreshed: "filtersSearchRefreshed",

    //Fired when the Search Dashboards results table has been refreshed
    dashboardsSearchRefreshed: "dashboardsSearchRefreshed",

    //Fired when a Tab is updated (eg: Project tabs, Manage Dashboard tabs...)
    tabUpdated: "tabUpdated",

    //Fired when a Dialog is ready to be displayed
    dialogReady: "dialogReady",

    //Fired when the Components table is ready
    componentsTableReady: "componentsTableReady",

    //Fired when a Workflow has been loaded on Project configuration
    workflowReady: "workflowReady",

    //Fired when a Workflow Header has been loaded on Project configuration
    workflowHeaderReady: "workflowHeaderReady",

    //Fired when content on the page was changed that does not fall into an alternative reason. This should be used
    //instead of creating new reasons. The NEW_CONTENT_ADDED API paradigm should be moved away from for new
    //development where possible.
    contentRefreshed: "contentRefreshed"

When the create dialog screen has been loaded the dialogReady reason is passed to the NEW_CONTENT_ADDED event and at this moment we should set the Approver field. That is why we need to subscribe to the NEW_CONTENT_ADDED event and look for the dialogReady reason. Let’s do it.

$(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {
            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
              $("#customfield_10301").val("manager1");
              $("#customfield_10301").focus(function() {
                 this.blur();
            });
            } 
        });
});

And it works correctly. We can add lines for hiding the summary filed to the same block:

      $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {
            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
              $("#customfield_10301").val("manager1");
              $("#customfield_10301").focus(function() {
                 this.blur();
              });
              $("input#summary").val("mapping");
              $("input#summary").parent().css("display", "None");
            } 
         });
      });

And everything works as expected without delays and browser freeze:

Well, now we need to implement another feature.

If the Reporter is admin then the Approver must be manager1 in all other cases the Approver must be manager2.

Let’s make changes:

$(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {
            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
              if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
                $("#customfield_10301").val("manager1");
              } else {
                $("#customfield_10301").val("manager2");
              } 
              $("#customfield_10301").focus(function() {
                 this.blur();
              });
            } 
         });
      });

And if we open the create dialog it will define the Approver correctly:

But if we change the Reporter field to user2, the Approver will not change:

Why? The problem is that the dialogReady reason works only once after the dialog was loaded and when we change the value of the Reporter field, nothing happens. What should we do?

We can go back to setInterval. But again we will have browser freezes and delays. Let’s have a look at the source code.

And I did not find anything good in the source code. That is why we will just bind the onchange event to the reporter field and set the Approver field according to the new value of the Reporter field (this line $(“#reporter”).change( function(e) {):

$(function () {         
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {
            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
               $("#reporter").change( function(e) {
                  if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
                    $("#customfield_10301").val("manager1");
                  } else {
                    $("#customfield_10301").val("manager2");
                  } 
               });
              if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
                $("#customfield_10301").val("manager1");
              } else {
                $("#customfield_10301").val("manager2");
              } 
              $("#customfield_10301").focus(function() {
                this.blur();
              }); 
            } 
         });
     });

And it works as expected. If we change the value for the Reporter field, the value for the Approver field changes accordingly.

Well, is it a good solution or bad solution? We do not know. I did not examine the complete code of Atlassian Jira. Maybe we will have an odd behaviour in some place, maybe not.

And you will have this problem with all JavaScript code. You will never know what bug and where you will get later. Moreover this bug will be difficult to solve because your JavaScript code in the banner will be very difficult to debug.

JavaScript: refactoring

Here is our JavaScript code so far:

<script>
      $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {

            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
               $("#reporter").change( function(e) {
                  if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
                    $("#customfield_10301").val("manager1");
                  } else {
                    $("#customfield_10301").val("manager2");
                  } 
               });

              if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
                $("#customfield_10301").val("manager1");
              } else {
                $("#customfield_10301").val("manager2");
              } 
              $("#customfield_10301").focus(function() {
                this.blur();
              });
              $("input#summary").val("mapping");
              $("input#summary").parent().css("display", "None");
            } 
         });
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
           }
         }
     });
</script>

How does it look like? This code is very difficult to understand. Let’s try to refactor it:

<script>

      function setApproverFieldByReporter() {
         if ($("optgroup#reporter-group-suggested > option:selected").val() == "admin") {
              $("#customfield_10301").val("manager1");
         } else {
              $("#customfield_10301").val("manager2");
         } 
      };

      function disableAporoverField() {
          $("#customfield_10301").focus(function() {
                this.blur();
          });
      };

      function hideSummaryField() {
          $("input#summary").val("mapping");
          $("input#summary").parent().css("display", "None");
      }

      function hideReadyForWorkButtonConditionally() {
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
           }
         }
      }

      $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {

            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {

              $("#reporter").change( function(e) {
                 setApproverFieldByReporter();
              });
              setApproverFieldByReporter();
              disableAporoverField();
              hideSummaryField();
            } 
         });
         hideReadyForWorkButtonConditionally();
     });
</script>

I moved most logic to functions and now at least we can read the flow of our program like this:

 $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {

            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {

              $("#reporter").change( function(e) {
                 setApproverFieldByReporter();
              });
              setApproverFieldByReporter();
              disableAporoverField();
              hideSummaryField();
            } 
         });
         hideReadyForWorkButtonConditionally();

We still have akward logic with bind Jira event, bind the onchange event. I will leave the refactoring of these things to you.

But event now there are only four pieces of logic in our JavaScript and our code is already difficult to read.

JavaScript: read user_managers project

Now let’s change our code. Right now we set the value of the Approver field to manager1 if the Reporter field is admin and manager2 in all other cases. But we created the user_managers project for mapping of users and managers and we need to take the manager of the Reporter from this project.

The code for this requirement will be like this:

 function setApproverFieldByReporter() {
        $.get( "http://localhost:8080/rest/api/2/search?jql=project%3DUS%20and%20%22user%22%20%3D%20" + $("#reporter").val(), function( data ) {
             $("#customfield_10301").val( data.issues[0].fields.customfield_10201.name);
       
         });        
        
         
      };
$(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {
            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {
              $("#reporter").change( function(e) {
                  setApproverFieldByReporter();
              });
              setApproverFieldByReporter();
            } 
         });
});

setApproverFieldByReporter selects issues from the user_managers project where the user field equals to $(“#reporter”).val() (which is our Reporter) and sets the Approver field with the chosen value.

Now the complete script looks like this:

<script>
     function setApproverFieldByReporter() {
        $.get( "http://localhost:8080/rest/api/2/search?jql=project%3DUS%20and%20%22user%22%20%3D%20" + $("#reporter").val(), function( data ) {
             $("#customfield_10301").val( data.issues[0].fields.customfield_10201.name);       
         });                         
      };

      function disableAporoverField() {
          $("#customfield_10301").focus(function() {
                this.blur();
          });
      };

      function hideSummaryField() {
          $("input#summary").val("mapping");
          $("input#summary").parent().css("display", "None");
      }

      function hideReadyForWorkButtonConditionally() {
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
           }
         }
      }

      $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {

            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {

              $("#reporter").change( function(e) {
                
                  setApproverFieldByReporter();
              });
              setApproverFieldByReporter();
              disableAporoverField();
              hideSummaryField();
            } 
         });
         hideReadyForWorkButtonConditionally();
     });
</script>

If you have a look at this script you will find out that checking for errors are missing. For example, what will you do if there are no managers defined for a user in the user_managers project? Or what will you do if more than 1 manager defined? And so on. If you add this logic your script will become bigger.

Test our JavaScript code

If you start testing our JavaScript code, you will see that the summary field is hidden for all projects. Why? Because we did not say that we need to hide the summary field only for the US project. I will not add this code you can do it yourself.

Also we search the manager for a reporter for all projects, we need to say to our routines that it should be done only for the AP project.

As you remember we have hidden the “Ready For Work” button for non-managers of the Reporter. But we have hidden it only in the issue view screen. But we can transition issues not only in the issue view screen but in many other places in Jira. For example, in the Issue Navigator:

As you can see the “Ready For Work” transition is there. Now you need to find all places where an issue could be transitioned and hide this transition there.

Another problem if I push the AP-2 link in the Issue Navigator the issue view screen will be open. We expect that the “Ready For Work” will be hidden, but it is not true, because our announcement banner script will not be reread. How should we fix it? Should we go back to setInterval? Or maybe we should look for an event in Jira source code?

You see there are many problems which you are going to meet while developing solutions with JavaScript. And your JavaScript code will grow bigger and bigger and you will have more and more bugs.

Readability of JavaScript code

Look at our code:

<script>
     function setApproverFieldByReporter() {
        $.get( "http://localhost:8080/rest/api/2/search?jql=project%3DUS%20and%20%22user%22%20%3D%20" + $("#reporter").val(), function( data ) {
             $("#customfield_10301").val( data.issues[0].fields.customfield_10201.name);       
         });                         
      };

      function disableAporoverField() {
          $("#customfield_10301").focus(function() {
                this.blur();
          });
      };

      function hideSummaryField() {
          $("input#summary").val("mapping");
          $("input#summary").parent().css("display", "None");
      }

      function hideReadyForWorkButtonConditionally() {
         if ($("#customfield_10301-val").text()) {
           if (JIRA.Users.LoggedInUser.userName() != $.trim($("#customfield_10301-val").text())) {
             $('#action_id_21').addClass('hidden');
           }
         }
      }

      $(function () {
         JIRA.bind(JIRA.Events.NEW_CONTENT_ADDED, function (e, $context, reason) {

            if (reason == JIRA.CONTENT_ADDED_REASON.dialogReady) {

              $("#reporter").change( function(e) {
                
                  setApproverFieldByReporter();
              });
              setApproverFieldByReporter();
              disableAporoverField();
              hideSummaryField();
            } 
         });
         hideReadyForWorkButtonConditionally();
     });
</script>

It is a disaster. We have many magic numbers and strings like customfield_10201, action_id_21, customfield_10301, which make our code not understandable. What is the JIRA.CONTENT_ADDED_REASON.dialogReady? When do we have this reason? It is not transparent.

I would say our code is a complete mess. And all code in announcement banners I have seen look like this. Do you want to make it better? How would you do it? You can not use TypeScript or modern versions of ECMAscript. Your code will be a mess anyway.

Make your JavaScript code transferable

Another problem that we can not move this code to another Jira environment because we use magic ids: customfield_10201, action_id_21, customfield_10301, and these ids will be different in that other environment. You would need to write code which would programatically define ids out of names. Do you think it is possible? No it is not possible. Because multiple custom fields can have the same name. I will talk about it in detail in next parts of this article.

You can say that custom fields can have only unique names in your Jira instance. In this case you can use Jira Rest API to get all custom fields and select custom field id by name. But again it would make your code bigger.

Conclusion on JavaScript in Announcement banner

If you use JavaScript in the announcement banner you have the following problems:

  • Poor readability of the code: you can not use modern versions of ECMAscript or Typescript, you can not divide your script in multiple scripts (you can try to do it, but nothing good will come), many magic strings, phrases, ids and so on.
  • Buggy. Very Buggy!!! Atlassian Jira developers did not develop Jira keeping in mind that somebody will be changing UI this way. That is why you can meet bugs anytime and anywhere. Even lines of your code can mix with one another and cause unpredictable behaviour
  • This code is difficult to transfer between Jira instances
  • Your code is untestable for unit tests. It is a mess to write unit test for your code. More over your libraries for tests would be pulled even if you do not mean to test your solution because you need to link the libraries in your announcement banner script
  • You can not provide a context for your javascript code which means your javascript code will be loaded everywhere the announcement banner exists, which puts more load on your Jira than needed.
  • You can not easily switch off part of your logic because everything will be intermingled in your script.

In short I would say you never should go for announcement banner JavaScript code. I will tell you about other solutions which can be used in next parts of this article.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: