Last updated Nov 21, 2024

The Jira Issue Expense app - part two

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.

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

Now, you will modify the app to store and retrieve data created by the user within Forge storage.

In this tutorial, you will use the Key-Value Store. The Key-Value Store provides simple storage for key/value pairs. It is used to persistently store data that can be retrieved later through the Query tool. It is perfect for our to do list app, as the app will just display and update a simple list.

There are quotas and limits that apply to the use of Forge storage. See the following documents to learn more:

Create a row for the user to input a new to do item

Before you get to work making the app retrieve, display, store and update data you'll need a way to create new expense items.

  1. If it isn't already running, start your tunnel by running forge tunnel
  2. Navigate to the src/frontend directory and open index.jsx.
  3. First, define what the input row of the table will look like:
    1
    2
    const inputRow = ({
      cells: [
        {
          content: <Textfield appearance="subtle"  spacing="compact" id="expense-description" placeholder="Add an expense +"/>,
        },
        {
          content: <Textfield appearance="subtle"  spacing="compact" id="expense-amount" placeholder="0"/>,
        },
        {
          content: <Button appearance="subtle" spacing="compact">Add</Button>,
        },
      ],
    })
    
  4. Now, modify the fillTable function to show the input row when the table would usually be blank:
    1
    2
      const fillTable = ( expenses ) => {
        if (expenses.length > 0) {
          ...
        } else return [inputRow];
      }
    
  5. Finally, add the input row as the last row of the table when it has existing data:
    1
    2
      const fillTable = ( expenses ) => {
        if (expenses.length > 0) {
          ...
          rows.push(inputRow)
          return rows;
        } else return [inputRow];
      }
    
  6. Save your changes to index.jsx and refresh your app to verify they're working.

Add Forge storage permissions to your manifest and ensure it is accessible through the resovler

In this section you will update your manifest to add the necessary permissions to use Forge Storage,

  1. If your tunnel is still running, close it using Control + c.

  2. Open the manifest.yml in your apps top-level directory,

  3. Append the following permissions to the end of your manifest:

    1
    2
      permissions:
        scopes:
          - storage:app
    
  4. Save the changes you made to manifest.yml

    Because the scope for your app is being changed, you'll need to take a momemnt to deploy and upgrade your installation.

  5. Navigate to and open src/resolvers/index.js.

  6. Check the storage API is being imported from @forge/api, if not, add the import statement:

    1
    2
      import {storage} from `@forge/api`;
    
  7. Save your changes to src/resolvers/index.js.

  8. Navigate to the app top-directory and run npm install.

  9. Deploy your changes with forge deploy, once deployment is complete you should see the following message:

    1
    2
    We've detected new scopes or egress URLs in your app.
    Run forge install --upgrade and restart your tunnel to put them into effect.
    
  10. From your app's top-level directory run forge install --upgrade and follow the prompts to upgrade your app installation.

Store new to do items with Forge storage

In this section you will create a new function that will call a resolver function when the user clicks the Add button that will store the values of the Add an expense + and amount text fields. Then, you'll modify the resolver function to store the new item in Forge storage.

  1. If it isn't already running, start your tunnel by running forge tunnel

  2. Navigate to and open src/resolvers/index.js.

  3. Add a new create resolver:

    1
    2
    resolver.define('create', async (req) => {
      console.log(req.payload)
      return "created";
    });
    
  4. Save your changes to src/resolvers/index.js.

  5. Navigate to and open src/frontend/index.jsx.

  6. Add a new create function that will invoke the resolver you just added:

    1
    2
    const create = (data) => {
      invoke('create', {data});
    }
    
  7. Next, update the Button in inputRow to call create when the button is pressed:

    1
    2
        content: <Button appearance="subtle" spacing="compact" id="add-expense" onClick={create}>Add</Button>,
    
  8. In your App function, create two new variables:

    1
    2
      let expenseDescriptionValue = null;
      let expenseAmountValue = null;
    
  9. Next, add a new function validate:

    1
    2
      const validate = (data) => {
        console.log(data)
        if(data.target.id === "expense-description") expenseDescriptionValue = data.target.value;
        if(data.target.id === "expense-amount") expenseAmountValue = data.target.value;
      }
    
  10. Now, update the inputRow to call validate when each of the fields loses focus:

    1
    2
      const inputRow = ({
        cells: [
          {
            content: <Textfield appearance="subtle"  spacing="compact" id="expense-description" placeholder="Add an expense +" onBlur={validate}/>,
          },
          {
            content: <Textfield appearance="subtle"  spacing="compact" id="expense-amount" placeholder="0" onBlur={validate}/>,
          },
          {
            content: <Button appearance="subtle" spacing="compact" id="add-expense" onClick={create}>Add</Button>,
          },
        ],
      })
    
  11. Update the create function to send the expense-description and expense-amount to the resolver:

    1
    2
      const create = (data) => {
        invoke('create', {data: data,expenseDescription: expenseDescriptionValue, expenseAmount: expenseAmountValue}).then(setData);
      }
    

    The create and validate functions aren't doing anything to check the input at this stage

  12. Save the changes to src/frontend/index.jsx and refresh your app in Jira after the tunnel completes the reload.

  13. Now, you can try entering some data into the Add an expense + and amount box to see what data is sent to your resolver function, for example try entering: Tolls and 7.50 in your tunnel you should see something like:

    1
    2
      data: {
        bubbles: true,
        cancelable: true,
        defaultPrevented: false,
        eventPhase: 3,
        isTrusted: true,
        target: { id: '', tagName: 'SPAN' },
        timeStamp: 1329981,
        type: 'click'
      },
      expenseDescription: 'Tolls',
      expenseAmount: '7.50'
    
  14. Navigate to and open src/resolvers/index.js.

  15. In the resolver, create a new function that creates a list key based on the localId:

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

    The localId is the unique ID for the instance of the component in the content. See Forge Resolver - Methods to learn more about the context variables. In this app, the list key ensures that data displayed in each unique instance of component is unique. So each jira ticket can have a unique to do list - rather than a global one for all jira issues.

  16. Create another new function that generates uniqueId for each list item:

    1
    2
    const getUniqueId = () => '_' + Math.random().toString(36).substr(2, 9);
    
  17. Create another new function that will get all the items in forge storage given a listId:

    1
    2
    const getAll = async (listId) => {
      return await storage.get(listId) || [];
    }
    
  18. Modify the create resolver to get the array of list items with a matching listId, create a new record based on the user input, and store the updated to do list in forge storage:

    1
    2
      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;
      });
    
  19. Create a new resolver that will get the array of list items with a matching listId and return them to the frontend, so you can check that the new items are being created:

    1
    2
    resolver.define('get-all', (req) => {
      return getAll(getListKeyFromContext(req.context));
    });
    
  20. Save your changes to src/resolvers/index.js.

  21. Navigate to and open src/frontend/index.jsx.

  22. Add a new useEffect() hook that will call the get-all resolver fron the frontend:

    1
    2
      const [expensesArray, setExpensesArray] = useState(null);
      useEffect(() => {
        invoke('get-all').then(setExpensesArray);
      }, []);
    
  23. Finally, add another new useEffect() hook that will console log expensesArray when it changes:

    1
    2
    useEffect(() => {
      console.log("Stored expenses for this issue: ")
      console.log(expensesArray)
    }, [expensesArray]);
    
  24. Save your changes to src/frontend/index.jsx and wait for the tunnel to reload your app.

  25. Open the console in your browsers developer tools, then refresh your app in the browser and add a new to do in the text box. You will see your item(s) listed in an array.

  26. Once you're happy with your changes, close the tunnel using Control + c and deploy your changes with forge deploy.

Review your changes

Your src/resolvers/index.js should look 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));
});

export const handler = resolver.getDefinitions();

Your src/frontend/index.jsx should look like this:

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

const App = () => {
  const [data, setData] = useState(null);
  const [expensesArray, setExpensesArray] = useState(null);

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

  let expenseDescriptionValue = null;
  let expenseAmountValue = null;
  
  useEffect(() => {
    console.log("Stored expenses for this issue: ")
    console.log(expensesArray)
  }, [expensesArray]);
  
  const create = (data) => {
    console.log(expenseDescriptionValue)
    console.log(expenseAmountValue)
    invoke('create', {data: data,expenseDescription: expenseDescriptionValue, expenseAmount: expenseAmountValue}).then(setData);
  }

  const validate = (data) => {
    console.log(data)
    if(data.target.id === "expense-description") expenseDescriptionValue = data.target.value;
    if(data.target.id === "expense-amount") expenseAmountValue = data.target.value;
  }

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

  const fillTable = ( expenses ) => {
    console.log(expenses)
    if (expenses.length > 0) {
      const rows = expenses.map((item) => ({
        cells: [
          {
            content: <Textfield appearance="subtle"  spacing="compact" id="expense-description" defaultValue={item.description}/>,
          },
          {
            content:  <Textfield appearance="subtle"  spacing="compact" id="expense-amount" defaultValue={item.amount}/>,
          },
          { 
            content: <Button appearance="subtle" iconBefore="trash" spacing="compact"/>
          },
        ],
      }))
      rows.push(inputRow)
      return rows;
    }
    else return [inputRow];
  }

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

  return (
    <>
      <DynamicTable 
        caption="Expenses"
        rows={fillTable(conferenceExpenses)} />
      <Inline spread='space-between'>
        <Text>Total:  ${getTotal(conferenceExpenses)}</Text>
        <Button>Delete All</Button>
      </Inline>
    </>
  );
};

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

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

Before moving to the next step, why not take a moment to try and implement the rest of the app on your own. If you get stuck, you'll find the solution in the next part.

Rate this page: