Intro

For more information about ionic react and to view the tutorial that got me started go to https://ionicframework.com/blog/announcing-ionic-react/

Today I am going to build a simple JIRA App using Ionic react that displays a list of currently open issues for a particular user.

Getting Started

First things first, download the latest Ionic CLI:

npm i -g ionic 

The Ionic CLI features four starter templates: blank, side menu, tabs, and conference. The first three templates produce a similar directory structure (shown below on the left). The conference template (shown below on the right) is useful for gathering design ideas, but I wouldn’t use it as a place to begin your own development as its more complex than needed.

After trying each of them I decided to use the side menu template for my App. Let’s create a new project.

ionic start --type react side-menu-react-app

Now select the side menu template.

After the CLI is finished creating the app move into the newly created directory and start the app.

ionic serve

The app is now running on port 8100. It consists of two views, a home view and a list view. I am going to leave the home view as it is and change the list view to display JIRA issues. Later I will add detail view for individual issues.

Template Source

Ionic templates use TypeScript as their language of choice. If that is a deal breaker you can remove the type annotations and change the file extensions to be .jsx instead on .tsx. Even though this is the first time I have used it, I choose to stick with the TypeScript.

The List.tsx file should contain a ListItems and a ListPage component. All of the components in the Ionic blank, side menu, and tabs template are functional components. Class based components can still be used, but functional components with react hooks methods and Ionic lifecycle methods can do everything we need for this app.

Customizing

The shape of the data returned by the JIRA api will inform what we need to do with these components. I am not going to go deep into the JIRA rest api but I am going to use <jira_host>/rest/api/2/search?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20%3D%20Unresolved%20ORDER%20BY%20updated%20DESC for my requests. This should provide a list of currently unresolved issues for the user we authenticate with in our request.

I am going to use the useIonViewWillEnter lifecycle method to make the request and the useState react hook to make the data available to my component.

import {useIonViewWillEnter} from '@ionic/react';
import React, {useState} from 'react';

import api from '../jira_api'
import {
    Issue, Issue_JSON
} from '../IssuesInterface'


const [issues, setIssues] = useState<Issue[]>([]);

useIonViewWillEnter(async () => {
        const result = await fetch(api.url, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Basic ${api.credentials}`,
            },
        });
        const data = await result.json();
        const issues = data.issues.map((issue: Issue_JSON) => ({
            'id': issue.id,
            'key': issue.key,
            'url': issue.self,
            'summary': issue.fields.summary,
            'status': issue.fields.status.name,
            'project': {
                'url': issue.fields.project.self,
                'name': issue.fields.project.name,
                'key': issue.fields.project.key,
            }
        }));
        setIssues(issues);
    });

Credentials

There’s a few things going on here that I need to point out. First I have created a new file to house my credentials. You will need to modify this file with your own credentials. This will require you create your own JIRA api key. You will also need to modify the url to contain the name of your own jira host.

export default {
    credentials: btoa('<username>:<api_key>'),
    url: '<jira_host>/rest/api/2/search?jql=assignee%20%3D%20currentUser()%20AND%20resolution%20%3D%20Unresolved%20ORDER%20BY%20updated%20DESC'
}

Interfaces

Next I created a new file for handling my interfaces. Typescript interfaces are how you declare the shape of your expected data.

export interface Issue {
    id: string
    key: string;
    url: string;
    summary: string;
    status: string;
    project: Project;
}

export interface Project {
    url: string;
    key: string;
    name: string;
}

export interface Project_JSON {
    self: string;
    key: string;
    name: string;
}

export interface Issue_JSON {
    id: string
    key: string;
    self: string;
    fields: Fields_JSON;
}

export interface Fields_JSON {
    summary: string,
    status: Status_JSON,
    project: Project_JSON
}

export interface Status_JSON {
    name: string,
    self: string,
    description: string,
}

I am not only using an interface to declare the shape of the data I plan to work with, I am also using it to help me move from the shape of the JSON returned by the JIRA api to a more usable shape. The api data I got back was quite verbose, each issue had around two hundred keys. An interface with 200 keys would have been a monster to work with! Luckily I could just declare an interface with only the keys I cared about and then work from them there.

React Hooks

Lastly, I would like to point out the call to useState

const [issues, setIssues] = useState<Issue[]>([]);

The first item returned is the piece of state that I am going to work with in my functional component and the second is a setter for that piece of state.

Update Templates

Now that I know the shape of data that we are going to work with we can update our ListItems and ListPage component.

  return (
      <IonContent>
          <IonList>
              {issues.map((issue, idx) => 
                  <IssueItem 
                      key={idx} 
                      issue={issue} 
                  />
              )}
          </IonList>
      </IonContent>
  );

return (
        <IonItem>
            <IonBadge slot="start">{issue.key}</IonBadge>
            <IonLabel>
                <p>{issue.status}</p>
            </IonLabel>
        </IonItem>
    );

Result

Now we’ve got a view that looks something like this. If you are having trouble getting data back from the JIRA api it may be due to CORS issues.

Now that we are getting data back and displaying some of it we need a way to display all the data in a way that is pleasing to the user. Due to my limited TypeScript knowledge I was unable to get this working with just react hooks using shared state. I ended up restructuring my application a bit.

Restructuring

I created a parent component called IssuesPage that would pass issues into my list component and a new details component.

Issues.tsx

import {
    IonButtons,
    IonHeader,
    IonMenuButton,
    IonPage,
    IonTitle,
    IonToolbar,
    useIonViewWillEnter
} from '@ionic/react';
import React, {useState} from 'react';
import {useHistory, useParams } from 'react-router';

import api from '../jira_api'
import {
    Issue, Issue_JSON
} from '../IssuesInterface'
import ListPage from "./List";
import DetailPage from "./Detail";

const IssuesPage: React.FC = () => {
    const [issues, setIssues] = useState<Issue[]>([]);
    const history = useHistory();
    const params = useParams();
    const showDetail = params.hasOwnProperty('id');

    useIonViewWillEnter(async () => {
        const result = await fetch(api.url, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Basic ${api.credentials}`,
            },
        });
        const data = await result.json();
        const issues = data.issues.map((issue: Issue_JSON) => ({
            'id': issue.id,
            'key': issue.key,
            'url': issue.self,
            'summary': issue.fields.summary,
            'status': issue.fields.status.name,
            'project': {
                'url': issue.fields.project.self,
                'name': issue.fields.project.name,
                'key': issue.fields.project.key,
            }
        }));
        setIssues(issues);
    });
    return (
        <IonPage>
            <IonHeader>
                <IonToolbar>
                    <IonButtons slot="start">
                        <IonMenuButton />
                    </IonButtons>
                    <IonTitle>Issues</IonTitle>
                </IonToolbar>
            </IonHeader>
             {showDetail ? <DetailPage issues={issues}/> :
                 <ListPage issues={issues} history={history} />}
        </IonPage>
    );
};

export default IssuesPage;

List.tsx

import {
    IonBadge,
    IonContent,
    IonItem,
    IonLabel,
    IonList,
} from '@ionic/react';
import React from 'react';
import {History} from 'history';

import {Issue} from '../IssuesInterface';

interface ListPageProps {
    issues: Issue[],
    history: History
}

const ListPage: React.FC<ListPageProps> = ({issues, history}) => {
    const navigateToIssue = (s: string) => () => {
        history.push(`/home/issues/${s}`)
    };

  return (
      <IonContent>
          <IonList>
              {issues.map((issue, idx) =>
                  <IssueItem
                      key={idx}
                      issue={issue}
                      navFunc={navigateToIssue}
                  />
              )}
          </IonList>
      </IonContent>
  );
};

const IssueItem: React.FC<{ 
    issue: Issue, navFunc: (s: string) => any 
}> =
    ({issue, navFunc}) => {
        return (
            <IonItem onClick={navFunc(issue.id)}>
                <IonBadge slot="start">{issue.key}</IonBadge>
                <IonLabel>
                    <p>{issue.status}</p>
                </IonLabel>
            </IonItem>
        );
};

export default ListPage;

Details.tsx

import React from "react";
import {
    IonBadge,
    IonContent,
    IonItem,
    IonCard,
    IonCardContent,
    IonCardHeader,
} from "@ionic/react";
import { useParams } from "react-router-dom";

import {Issue} from '../IssuesInterface';

interface Params {
    id: string
}


const DetailPage: React.FC<{issues: Issue[]}> = ({issues}) => {
    const params = useParams<Params>();
    const {id} = params;
    const issue: Issue = issues.filter(i => i.id === id)[0];
    return (
        <IonContent>
            {issue ?
                <IonCard>
                    <IonCardHeader>
                        <IonBadge>{issue.key}</IonBadge>
                    </IonCardHeader>
                    <IonCardContent>
                        <h2>
                           {issue.project.name} - {issue.project.key} 
                        </h2>
                        <p>Status: {issue.status}</p>
                        <p>Summary: {issue.summary}</p>
                    </IonCardContent>
                </IonCard>
             :
                <IonItem>Issue Not Found</IonItem>
            }
        </IonContent>
    );
};

export default DetailPage;

App.tsx

import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import { IonApp, IonRouterOutlet, IonSplitPane } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { AppPage } from './declarations';

import Menu from './components/Menu';
import Home from './pages/Home';
import Issues from "./pages/Issues";
import { home, cube} from 'ionicons/icons';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/padding.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/display.css';

/* Theme variables */
import './theme/variables.css';

const appPages: AppPage[] = [
  {
    title: 'Home',
    url: '/home',
    icon: home
  },
  {
    title: 'Issues',
    url: '/home/issues',
    icon: cube
  }
];

const App: React.FC = () => (
  <IonApp>
    <IonReactRouter>
      <IonSplitPane contentId="main">
        <Menu appPages={appPages} />
        <IonRouterOutlet id="main">
          <Route path="/home" component={Home} exact={true} />
          <Route path="/home/issues" component={Issues} exact/>
          <Route path="/home/issues/:id" component={Issues} exact/>
          <Route exact path="/" render={() => <Redirect to="/home" />} 
          />
        </IonRouterOutlet>
      </IonSplitPane>
    </IonReactRouter>
  </IonApp>
);

export default App;

Here is the finished detail view:

The End

All the code is available here. This was all pretty new to me, but the more I worked with it the more I enjoyed it! I hope you learned something. Thanks for reading.