Last updated Nov 21, 2024

The Jira Issue Expense Tracker app - part three

In part one you created your Forge UI kit app using the Jira issue panel template. You then customised the app to display a list of expenses, and their total using test data.

Then in part two, you made changes to your app to store and retrieve data created by the user within Forge storage.

This is part three in this tutorial. Complete Part one & part two before working through this page.

In this final part of the tutorial, you will modify the app to modify and display (in addition to storing and retrieving) data created by the user within Forge storage.

Review the solution

your src/frontend/index.jsx should look like something like this:

1
2
import React, { useEffect, useState } from 'react';
import ForgeReconciler, {Box, Button, DynamicTable, Inline, Text, Textfield, xcss} from '@forge/react';
import { invoke } from '@forge/bridge';

const errorStyle = xcss({
  color: 'color.text.danger',
})

const currency = '€';

const App = () => {
  const [error, setError] = useState(null);
  const [expensesArray, setExpensesArray] = useState(null);

  useEffect(() => {
    invoke('get-all').then(setExpensesArray);
  }, []);

  let expenseDescriptionValue = null;
  let expenseAmountValue = null;

  const add = (data) => {
      setExpensesArray([...expensesArray, data])
  }
  
  const create = (data) => {
    invoke('create', {data: data,expenseDescription: expenseDescriptionValue, expenseAmount: expenseAmountValue}).then(add);
    setError(null);
  }

  const remove = (data) => {
    setExpensesArray(expensesArray.filter(t => t.id !== data.id));
  }

  const update = (data) => {
    if(data !== null) {
      setExpensesArray(expensesArray.map(t => {if(t.id === data.id) {return data} else {return t}}));
    }
  }

  const validate = (data, item) => {
    if (((data.target.id === "expense-amount") || (data.target.id === "input-expense-amount")) && isNaN(Number(data.target.value))) {
      setError("Warning: Amount must be a number")
    } else {
      if (item) {
        invoke('update', {data, item}).then(update);
        setError(null);
      } else {
        if(data.target.id === "input-expense-description") expenseDescriptionValue = data.target.value;
        if(data.target.id === "input-expense-amount") expenseAmountValue = data.target.value;
      }
    }
  }

  const inputRow = ({
    cells: [
      {
        content: <Textfield appearance="subtle"  spacing="compact" id="input-expense-description" placeholder="Add an expense +" onBlur={validate}/>,
      },
      {
        content: <Inline alignBlock="center"><Text>{currency}</Text><Textfield appearance="subtle"  spacing="compact" id="input-expense-amount" placeholder="0" onBlur={validate}/></Inline>,
      },
      {
        content: <Button appearance="subtle" spacing="compact" id="add-expense" onClick={create}>Add</Button>,
      },
    ],
  })

  const fillTable = ( expenses ) => {
    if (expenses.length > 0) {
      const rows = expenses.map((item) => ({
        cells: [
          {
            content: <Textfield appearance="subtle"  spacing="compact" id="expense-description"  defaultValue={item.description} onBlur={(event) => validate(event, item)}/>,
          },
          {
            content: <Inline alignBlock="center"><Text>{currency}</Text><Textfield appearance="subtle"  spacing="compact" id="expense-amount"  defaultValue={item.amount} onBlur={(event) => validate(event, item)}/></Inline>,
          },
          { 
            content: <Button appearance="subtle" iconBefore="trash" spacing="compact" onClick={() => invoke('delete', {item}).then(remove)}/>
          },
        ],
      }))
      rows.push(inputRow)
      return rows;
    }
    else return [inputRow];
  }

  const getTotal = (expenses) => {
    let total = 0;
    expenses.forEach(expense => {
      total += Number(expense.amount);
    });
    return total.toFixed(2);
  }

  return (
    <>
      <DynamicTable 
        caption="Expenses"
        rows={expensesArray && fillTable(expensesArray)} />
      {error && <Box xcss={errorStyle}><Text>{error}</Text></Box>}
      <Inline spread='space-between'>
        {expensesArray && <Text>Total:  {currency} {getTotal(expensesArray)}</Text>}
        {expensesArray && <Button onClick={() => invoke('delete-all').then(setExpensesArray)}>Delete All</Button>}
      </Inline>
    </>
  );
};

ForgeReconciler.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Your src/resolvers/index.js should look something like this:

1
2
import Resolver from '@forge/resolver';
import { storage } from '@forge/api';

const resolver = new Resolver();

const getUniqueId = () => '_' + Math.random().toString(36).substr(2, 9);

const getListKeyFromContext = (context) => {
  const { localId: id } = context;
  return id.split('/')[id.split('/').length - 1];
};

const getAll = async (listId) => {
  return await storage.get(listId) || [];
}

resolver.define('create', async (req) => {
    const listId = getListKeyFromContext(req.context);
    const records = await getAll(listId);
    const id = getUniqueId();
  
    const newRecord = {
      id: id,
      description:  req.payload.expenseDescription,
      amount: req.payload.expenseAmount,
    };
  
    await storage.set(listId, [...records, newRecord]);
    return newRecord;
});

resolver.define('get-all', (req) => {
  return getAll(getListKeyFromContext(req.context));
});

resolver.define('update', async (req) => {
  let updatedRecord = req.payload.item;

  if(req.payload.data.target.id === "expense-description")  {
    if(req.payload.data.target.value !== updatedRecord.description) {
      updatedRecord.description = req.payload.data.target.value;
    } else {
      return null
    }
  }

  if(req.payload.data.target.id === "expense-amount")  {
    if(req.payload.data.target.value !== updatedRecord.amount) {
      updatedRecord.amount = req.payload.data.target.value;
    } else {
      return null
    }
  }

  const listId = getListKeyFromContext(req.context);
  const records = await getAll(listId);

  let finalRecords = records.map(item => {
    if (item.id === updatedRecord.id) {
      return updatedRecord;
    }
    return item;
  })
  await storage.set(getListKeyFromContext(req.context), finalRecords);

  return updatedRecord;
});

resolver.define('delete', async (req) => {
  const listId = getListKeyFromContext(req.context);
  let records = await getAll(listId);

  records = records.filter(item => item.id !== req.payload.item.id)

  await storage.set(getListKeyFromContext(req.context), records);

  return req.payload.item;
});

resolver.define('delete-all', async (req) => {
  
  await storage.set(getListKeyFromContext(req.context), []);
  return []
});

export const handler = resolver.getDefinitions();

Your manifest.yml should look like this:

1
2
modules:
  jira:issuePanel:
    - key: jira-issue-panel-expense-tutorial
      resource: main
      resolver:
        function: resolver
      render: native
      title: Jira Expense Tracker
      icon: https://developer.atlassian.com/platform/forge/images/icons/issue-panel-icon.svg
  function:
    - key: resolver
      handler: index.handler
resources:
  - key: main
    path: src/frontend/index.jsx
app:
  runtime:
    name: nodejs22.x
  id: <unique app id - run forge register if you need to generate your own>
permissions:
  scopes:
    - storage:app

Next steps

In the final version of the app, the currency is set to Euros. You can challenge yourself to modify the app to allow the user to select their currency for each issue, and store their preference in forge storage.

Keen to keep learning? Why not browse our sample apps for jira and confluence.

Feedback

We'd love to hear from you! Provide feedback on Forge Quest

Rate this page: