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:
Before you get to work making the app retrieve, display, store and update data you'll need a way to create new expense items.
forge tunnel
src/frontend
directory and open index.jsx
.1 2const 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>, }, ], })
fillTable
function to show the input row when the table would usually be blank:
1 2const fillTable = ( expenses ) => { if (expenses.length > 0) { ... } else return [inputRow]; }
1 2const fillTable = ( expenses ) => { if (expenses.length > 0) { ... rows.push(inputRow) return rows; } else return [inputRow]; }
index.jsx
and refresh your app to verify they're working.In this section you will update your manifest to add the necessary permissions to use Forge Storage,
If your tunnel is still running, close it using Control + c.
Open the manifest.yml
in your apps top-level directory,
Append the following permissions to the end of your manifest:
1 2permissions: scopes: - storage:app
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.
Navigate to and open src/resolvers/index.js
.
Check the storage API is being imported from @forge/api
, if not, add the import statement:
1 2import {storage} from `@forge/api`;
Save your changes to src/resolvers/index.js
.
Navigate to the app top-directory and run npm install
.
Deploy your changes with forge deploy
, once deployment is complete you should see the following message:
1 2We've detected new scopes or egress URLs in your app. Run forge install --upgrade and restart your tunnel to put them into effect.
From your app's top-level directory run forge install --upgrade
and follow the prompts to upgrade your app installation.
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.
If it isn't already running, start your tunnel by running forge tunnel
Navigate to and open src/resolvers/index.js
.
Add a new create
resolver:
1 2resolver.define('create', async (req) => { console.log(req.payload) return "created"; });
Save your changes to src/resolvers/index.js
.
Navigate to and open src/frontend/index.jsx
.
Add a new create
function that will invoke the resolver you just added:
1 2const create = (data) => { invoke('create', {data}); }
Next, update the Button in inputRow
to call create
when the button is pressed:
1 2content: <Button appearance="subtle" spacing="compact" id="add-expense" onClick={create}>Add</Button>,
In your App
function, create two new variables:
1 2let expenseDescriptionValue = null; let expenseAmountValue = null;
Next, add a new function validate
:
1 2const 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; }
Now, update the inputRow
to call validate
when each of the fields loses focus:
1 2const 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>, }, ], })
Update the create
function to send the expense-description
and expense-amount
to the resolver:
1 2const 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
Save the changes to src/frontend/index.jsx
and refresh your app in Jira after the tunnel completes the reload.
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 2data: { bubbles: true, cancelable: true, defaultPrevented: false, eventPhase: 3, isTrusted: true, target: { id: '', tagName: 'SPAN' }, timeStamp: 1329981, type: 'click' }, expenseDescription: 'Tolls', expenseAmount: '7.50'
Navigate to and open src/resolvers/index.js
.
In the resolver, create a new function that creates a list key based on the localId
:
1 2const 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.
Create another new function that generates uniqueId for each list item:
1 2const getUniqueId = () => '_' + Math.random().toString(36).substr(2, 9);
Create another new function that will get all the items in forge storage given a listId:
1 2const getAll = async (listId) => { return await storage.get(listId) || []; }
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 2resolver.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; });
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 2resolver.define('get-all', (req) => { return getAll(getListKeyFromContext(req.context)); });
Save your changes to src/resolvers/index.js
.
Navigate to and open src/frontend/index.jsx
.
Add a new useEffect()
hook that will call the get-all
resolver fron the frontend:
1 2const [expensesArray, setExpensesArray] = useState(null); useEffect(() => { invoke('get-all').then(setExpensesArray); }, []);
Finally, add another new useEffect()
hook that will console log expensesArray
when it changes:
1 2useEffect(() => { console.log("Stored expenses for this issue: ") console.log(expensesArray) }, [expensesArray]);
Save your changes to src/frontend/index.jsx
and wait for the tunnel to reload your app.
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.
Once you're happy with your changes, close the tunnel using Control + c and deploy your changes with forge deploy
.
Your src/resolvers/index.js
should look like this:
1 2import 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 2import 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 2modules: 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
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: