Developing a JIRA add-on like it's 2016: Part Two

June 9th 2016 Andreas Knecht (guest author) in Jira, Add-ons

Welcome back! In part one of this blog series we covered all the new tools and build details required for a better way to build a JIRA add-on. If you remember the goal is to write add-ons that can be converted from server → cloud with ease. This requires we follow two guiding principles:

  • Data from the server can only be accessed via a well defined REST API. The JSON returned by this API should be standard so that it doesn't matter whether it comes from a REST resource defined in a JIRA server atlassian-plugin.xml or from a REST resource in a standalone atlassian-connect server.
  • The UI will be rendered solely on the client side, by standalone components independent of any JavaScript provided by JIRA.

Today we'll look at how some of these components are implemented on the client side using React and ES6.

React to the rescue!

Building complex UIs solely on the client side has improved significantly in recent years. Gone are the days of spaghetti JavaScript code. It's now simple to write clean re-usable components that are easy to test.

We'll take a look at a very complex part of the NPS for JIRA add-on - the reports page:

NPS reports

There's quite a lot going on, on this page:

  • We have a header with filter controls
  • A score report showing the overall NPS score and percentages per score
  • A tabbed section with details and the sentiment word cloud report
  • And an over time report at the bottom (drawn with the help of Chart js)

Lets examine the ReportsPage React component. One would think that this is quite complex, but it's not:

//imports excluded for brevity
const ReportsPage = (props) => (
  <div>
    <ReportsHeader />
    <section style={style.content}>
      <Aui.Group>
        <Aui.Item>
          <h2>{i18n.getText('survey.plugin.reports.score')}</h2>
          <ScoresReport context={props.context} />
        </Aui.Item>
        <Aui.Item>
          <Aui.Tabs>
            <Aui.Tab id="details-report" title={i18n.getText('survey.plugin.reports.details')}>
              <DetailsPanel context={props.context} />
            </Aui.Tab>
            <Aui.Tab id="sentiment-report" title={i18n.getText('survey.plugin.reports.sentiment')} lazyRendered >
              <SentimentReport context={props.context} />
            </Aui.Tab>
          </Aui.Tabs>
        </Aui.Item>
      </Aui.Group>
      <h2>{i18n.getText('survey.plugin.reports.overtime')}</h2>
      <OvertimeReport context={props.context} />
    </section>
  </div>
);

ReportsPage.propTypes = {
  context: PropTypes.object.isRequired,
};

//ignore this for now. It's Redux which we'll cover later
const mapStateToProps = (state) => ({
  context: state.context,
});

export default connect(mapStateToProps)(ReportsPage);

Even for someone not familiar with the code, it's quite easy to see what's going on here and how the reports page is broken down into a number of React sub-components:

  • ReportsHeader
  • ScoresReport
  • DetailsPanel
  • SentimentReport (the word cloud not shown in the screenshot above on the second tab)
  • OvertimeReport

The syntax may seem a little unfamiliar to some, but thanks to the power of Babel we can use all the power of ES6 and React JSX:

  • The HTML looking part is actually React JSX that gets compiled to JavaScript
  • The ReportsPage component itself is defined as a stateless function using the ES6 arrow notation
  • And finally we use ES6 exports to export this component to make it available for import in other components

Working with 3rd party librariies

We mentioned earlier that our reports use Chart JS to draw the graphs. This can be a little tricky with React since React keeps very tight control over the DOM and how it gets manipulated. Here's an example of how the NPS Doughnut component (a sub-component of the Scores Report above) works to draw the doughnut chart:

//imports excluded for brevity
class NPSDoughnutChart extends Component {
  componentDidMount() {
    this.config = {
      type: 'doughnut',
      data: { //chart js doughnut config options excluded for brevity };

    this.doughnutChart = new Chart(this.canvas, this.config);
  }

  componentDidUpdate() {
    this.config.data.datasets[0].data = this.getData();
    this.doughnutChart.update();
  }

  getData() {
    const npsInfo = this.props.npsInfo;
    if (npsInfo.total !== 0) {
      return [npsInfo.promoters.count, npsInfo.passives.count, npsInfo.detractors.count];
    }
    return [1, 1, 1];
  }

  render() {
    const score = Math.round(this.props.npsInfo.score * 100);

    return (
      <div className="nps-score-doughnut" style={Object.assign({}, style.container, this.props.style)}>
        <div ref={(c) => { this.innerContainer = c; }} style={style.innerContainer}>
          <canvas ref={(el) => { this.canvas = el; }} width="100%" height="100%" />
          <h2 style={style.score}>{score}</h2>
        </div>
      </div>
    );
  }
}
NPSDoughnutChart.propTypes = {
  npsInfo: PropTypes.shape({
    score: PropTypes.number.isRequired,
    responses: PropTypes.number.isRequired,
    promoters: PropTypes.object.isRequired,
    passives: PropTypes.object.isRequired,
    detractors: PropTypes.object.isRequired,
  }),
  style: PropTypes.object,
};

export default NPSDoughnutChart;

Lets take a look at the render() function first:

  • It renders a canvas element and assigns a React ref to this.canvas
  • It also renders the h2 with the total score. We're also using inline styles for our components which is recommended by React. The style.score comes from an imported module that simply defines all the style attributes for the h2.

Once render() has been executed React will call componentDidMount() when the rendered content is inserted into the real DOM from the shadow DOM. In this method, we can now initialise all our Chart JS chart options and initialize the Chart using the ref to the canvas DOM element.

The only other thing we need to do is to ensure we update the chart via componentDidUpdate() whenever this component receives new props that may change the chart (for example if a filter is changed in the ReportsHeader).

That's it!

Testing

Traditionally JIRA add-ons have been tested with Web-driver tests and qunit tests in the browser. Both of these approaches tend to be quite slow since they require JIRA to be running and a browser to be setup. This is not so much of a problem while developing, but can be a pain to setup and slow to run during CI.

For the NPS plugin, front-end code is tested using a lightweight JavaScript testing framework named Mocha JS. We also use Sinon JS for mocking. Here's an example of a test to ensure that the overtime chart can handle various responses from the server:

//imports excluded for brevity

const sampleResponse = {
  start: 1453123055079,
  end: 1453727855079,
  prettyDate: '25/Jan/16',
  jql: '/issues/?jql=project+%3D+ASDF+AND+cf%5B10000%5D+is+not+EMPTY+' +
  'AND+created+%3E%3D+%222016-01-18+20%3A17%22+AND+created+%3C%3D+%222016-01-25+20%3A17%22',
  npsScore: -0.45833337,
  responseCount: 24,
};

function renderReport(resolve) {
  const context = {
    baseUrl: '',
    jql: 'project=\'TEST\'',
    projectKey: 'TEST',
    surveyId: 'sample-survey-id',
  };
  return TestUtils.renderIntoDocument(<OvertimeReport context={context} ajaxDataRenderResolve={resolve} />);
}

describe('Over time Report', () => {
  afterEach(() => {
    fetchMock.restore();
  });

  //other tests testing error responses excluded for brevity

  it('Data from server renders chart', () => {
    let el;
    fetchMock.mock('^/rest/nps/1.0/reports/TEST/sample-survey-id/overtime', {
      status: 200,
      body: { data: [sampleResponse] },
    });
    return new Promise((resolve) => {
      el = renderReport(resolve);
    }).then(() => {
      const canvas = TestUtils.scryRenderedDOMComponentsWithTag(el, 'canvas');
      expect(canvas.length).to.be(1);
    });
  });
});

Due to the async nature of the REST call, we need to use Promises that get resolved by the component itself via the ajaxDataRenderResolve prop before we can run assertions. This is a bit of a smell and we'll see later how Redux can help us to remove it.

A few other interesting things to note:

  • We use fetchMock to mock REST calls to the server
  • React TestUtils allow us to render and query React components under test

Build integration

Running these tests is incredibly fast since we don't execute them in the browser, but in jsdom - a node implementation of the DOM. This requires a bit of setup which is done in the test-setup module. Looking at our test package.json script from part one we can see how we require this test-setup module:

"test": "./node_modules/.bin/mocha --compilers js:babel-core/register \"./src/**/*test.js\" --colors --require test-setup",

This module simply contains setup for jsdom and we also configure our polyfills:

var jsdom = require('jsdom').jsdom;

global.document = jsdom('<!doctype html><html><body></body></html>');
global.window = global.document.defaultView;
global.navigator = global.window.navigator;

require('babel-polyfill');
require('isomorphic-fetch');

You can now run tests from the command line with npm run test. There's also a Mocha runner for IntelliJ. In part one of this blog series, you can also see the test-maven script we invoke from Maven which uses Mocha test reporters to produce a junit test result xml file that can be parsed by Bamboo.

I18n

There's a number of React i18n frameworks available. However most seemed too complex or not quite the right fit for our purpose. JIRA already does a lot of heavy lifting in terms of i18n for client-side resources through its web-resource transforms and the availability of AJS.I18n.getText('some.key').

So we decided on a simple solution. We provide global i18nStrings module in our web-pack configuration (see part one for the full config):

externals: {
  i18nStrings: 'require("jira/nps/i18n")',
},

This requires a module defined by a standard JIRA web-resource that has transforms applied. The contents of this module are:

define("jira/nps/i18n", function () {
    var i18nPrefixes = $$i18nPrefixes("survey.plugin");
    return extend(i18nPrefixes, {
        "common.words.save": AJS.I18n.getText("common.words.save"),
        //... keys excluded for brevity
        "user.picker.no.permission": AJS.I18n.getText("user.picker.no.permission")
    });
});

$$i18nPrefixes is a 'special' function that actually gets transformed by our own web-resource transformer into a JSON object containing all the i18nized key → values for the 'survey.plugin' prefix. It's just some syntactic sugar to save typing all the individual keys. This approach would have to be re-implemented in a different way in an atlassian-connect add-on for cloud.

Then finally we provide an i18n module to all our React components for import:

import I18nHelper from './i18nHelper';

let i18nStrings = {};
try {
  i18nStrings = require('i18nStrings');
} catch (e) {
  // this is mainly here so that the unit tests compile. Otherwise it could be
  // an *import i18nStrings from "i18nStrings"*.
  // there doesn't seem to be a good way to 'inject' a global 'i18nStrings' via
  // mocha/babel when running tests
}

const i18n = new I18nHelper(i18nStrings);

export default i18n;

The I18nHelper we import provides the same getText(key, args) API as AJS.I18n.getText(). Now in our React components we can simply call:

import i18n from './i18n';

const translatedText = i18n.getText('survey.plugin.title');

Enter Redux

Earlier when looking at unit tests we discovered a bit of a smell in one of our components - the fact that our NPS Dougnut chart was deeply aware of state and was making REST calls. This made testing less than ideal and our component overly complex. Ideally with React most components should simply be 'dumb' stateless functions rendering the passed in props and not aware of state.

Redux, a predictable state container for JavaScript aims to solve this problem. Explaining Redux goes far beyond the scope for this blog and is unnecessary since Redux's documentation is incredible!

Lets take a look at how using Redux can make another complex part of the NPS for JIRA add-on much simpler by looking at the ConfigForm:

config form

There's a few things that need to be managed here:

  • Loading the initial data for the form (issue types, the form data itself)
  • Saving and deleting
  • Errors returned by the server when saving

This is what the NPSAdmin React component that renders this page looks like:

//imports excluded for brevity
const NPSAdmin = (props) => {
  if (props.deleteSurvey.deleted) {
    window.location.reload();
    return <div />;
  }

  if (props.configData.unexpectedError) {
    window.alert(i18n.getText('survey.plugin.unexpected.error'));
    window.location.reload();
    return <Spinner />;
  }

  if (props.configData.isLoading) {
    return <div className="admin-loading"><Spinner /></div>;
  }

  return (
    <ConfigForm
      baseUrl={props.configData.context.baseUrl}
      surveyInfo={props.configData.surveyInfo}
      issueTypes={props.configData.issueTypes}
      onSave={(survey) => props.handleSave(props.configData.context, survey)}
      onDelete={() => props.handleDelete(props.configData.context)}
      saveSuccess={props.saveSurvey.success}
      errorCollection={props.saveSurvey.errorCollection}
    />);
};
const mapStateToProps = (state) => ({ ...state });

const mapDispatchToProps = (dispatch) => ({
  handleDelete: (context) => {
    const shouldDelete = window.confirm(i18n.getText('survey.plugin.config.form.delete.survey.confirm'));
    if (shouldDelete) {
      dispatch(deleteSurvey(context));
    }
  },
  handleSave: (context, survey) => {
    dispatch(saveSurvey(context, survey));
  },
});

export default connect(mapStateToProps, mapDispatchToProps)(NPSAdmin);

It's a dumb stateless function. That makes it very easy to unit test: we can just pass in different props. No need to deal with asynchronous tests and Promise callbacks like in our NPS Doughnut chart example.

The interesting part as far as Redux is concerned is at the bottom. We wrap the NPSAdmin component using a call to connect() and pass in two mapping functions for mapping state to props and dispatching actions.

The mapStateToProps function is invoked whenever state changes in our Redux store. mapDispatchToProps will dispatch the appropriate actions whenever the ConfigForm triggers its onSave or onDelete handlers.

Actions

Lets take a brief look at what the save action looks like:

//...other actions excluded for brevity

export const SURVEY_SAVE_ERRORS = 'SURVEY_SAVE_ERRORS';
export function surveySaveErrors(ex) {
  return (dispatch) => {
    if (ex.response && ex.response.status === 400) {
      return ex.response.json().then((errorCollection) =>
        dispatch({
          type: SURVEY_SAVE_ERRORS,
          errors: errorCollection,
        })
      );
    }

    dispatch(unexpectedError());
    return Promise.resolve();
  };
}

export function saveSurvey(context, newSurvey) {
  return (dispatch) =>
    fetch(`${encodeURI(context.baseUrl)}/rest/nps/1.0/${encodeURI(context.projectKey)}/surveys/${encodeURI(context.surveyId)}`, {
      method: 'PUT',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      credentials: 'same-origin',
      body: JSON.stringify(newSurvey),
    }).then(checkForError).then(() => {
      dispatch(surveySavedSuccessfully(newSurvey));
      dispatch(hideSavedSuccessfully());
    }).catch((ex) => {
      dispatch(surveySaveErrors(ex));
    });
}

This is now where we handle calls to the server and dispatch new actions depending on the result. Testing this is a lot easier since Redux provides a few helpers (redux-mock-store).

Testing

We could just test our dumb NPSAdmin component by passing in different props. However another test is to ensure our NPSAdmin component reacts correctly to actions dispatched on a real Redux store:

//imports excluded for brevity

function renderAdmin() {
  return renderStatefullyIntoDocument(<Provider store={store}><NPSAdmin /></Provider>);
}

let store;
let sandbox;
const context = { baseUrl: '/jira', projectKey: 'TEST', surveyId: 'sample-survey-id' };

describe('NPS Admin', () => {
  beforeEach(() => {
    store = createStore(adminApp);
    sandbox = sinon.sandbox.create();
  });

  //other tests excluded for brevity 
  it('is loading by default', () => {
    const el = renderAdmin();

    const titleField = TestUtils.scryRenderedDOMComponentsWithClass(el, 'title-field');
    expect(titleField.length).to.be(0);
    const spinner = TestUtils.scryRenderedDOMComponentsWithClass(el, 'admin-loading');
    expect(spinner.length).to.be(1);
  });

  it('renders config form when data received', () => {
    const el = renderAdmin();

    store.dispatch({
      type: RECEIVED_SURVEY_CONFIG,
      issueTypes: [],
      surveyAdmin: SAMPLE_SURVEY_RESP,
      context,
    });

    const titleField = TestUtils.scryRenderedDOMComponentsWithClass(el, 'title-field');
    expect(titleField.length).to.be(1);
  });
});

This is a much better test than what we saw previously with the NPS Doughnut component.

That's all for now folks

Once again we covered a lot in this part. We looked at the anatomy of a few different React components and how being able to use ES6 creates very clean, easy to maintain code. We also showed how to test and internationalise these components. Finally we were able to make a complex part of the app much easier to maintain and unit test with the help of Redux!

We hope you enjoyed this blog series on how to write JIRA add-ons using a new development process that should lend itself to producing re-usable UI components for both server and cloud, using some of the latest tools available in web development!

We're sure there's many suggestions and improvements for this approach. Please let us know in the comments below!