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.
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.
your src/frontend/index.jsx
should look like something like this:
1 2import 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 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)); }); 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 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
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.
We'd love to hear from you! Provide feedback on Forge Quest
Rate this page: