Level 2 · Lesson 1
Open the menu desk
Level 2 · Lesson 1Add a page of your own to Jira, built with Custom UI, where the head chef pastes menu-meeting notes and gets one ticket back.
Behind, or starting here? Download the code as it should be before this lesson.
The recipe card used UI Kit: Jira drew it from components you arranged. The menu desk is a whole page in Jira, with its own HTML, CSS and JavaScript. By the end of this lesson the head chef can paste the notes from a menu meeting and get one work item back, created as them.
Step 1: Unpack the back-kitchen kit
Locked until the step before it is done.
Runs in the browser · Front of house
Custom UI · your own fitted counter
Custom UI is your own web page inside a Forge app. You build it like any front end, point a resource in the manifest at the built folder, and Forge serves it in a sandboxed frame inside Jira. It cannot reach Jira or the internet directly; it talks to your functions through the bridge. Choose it when UI Kit’s components are not enough; for a panel that looks like Jira, UI Kit is simpler.
In the kitchen A counter you design and fit yourself, instead of the building’s standard furniture.
The desk’s page, its build script and a few helpers are ready-made, so this lesson can stay on Forge. Download the back-kitchen kit and unzip it into the app folder, so its folders merge with yours:
scripts/build-ui.mjs builds the desk with esbuild
static/desk/src/ the desk’s HTML, CSS and JavaScript
src/lib/config.js settings, limits and labels
src/lib/validate.js checks what the desk sends, and what the sous-chef answers
src/lib/adf.js turns text into Jira’s document format
src/lib/html.js escapes text for Confluence pages
src/lib/llmText.js reads the sous-chef’s answerOpen static/desk/src/main.js. It is ordinary browser JavaScript, apart from one import:
Runs in the browser · Front of house
Forge bridge · the hatch
@forge/bridge is the only way out of a Custom UI page. invoke() calls your resolver by name, as the recipe card does; router opens Jira pages, and view reads the context and applies Jira’s theme. Anything the page sends is untrusted input, because whoever has the page open can change it.
In the kitchen The hatch between front of house and the kitchen. Runners go through it; nothing else does.
The desk sends the form with invoke('submitMenu', …) and shows what comes back. It builds every message with text nodes, never as HTML, so nothing in a menu can turn into code on the page.
Next step: next
Step 2: Add the build step
Locked until the step before it is done.
Forge serves built files, not your source. Install the bundler the kit’s script uses:
npm install --save-dev esbuildnpm adds the devDependencies lines. Add the scripts lines yourself:
package.jsonA script that builds the desk, and esbuild to do it.
"main": "index.js", |
"license": "MIT", |
"private": true, |
Added: "scripts": { |
Added: "build:ui": "node scripts/build-ui.mjs" |
Added: }, |
"dependencies": { |
"@forge/api": "^8.2.0", |
"@forge/bridge": "^7.1.0", |
"@forge/react": "^12.3.0", |
"@forge/resolver": "^2.0.0", |
"react": "^18.2.0" |
Added: }, |
Added: "devDependencies": { |
Added: "esbuild": "^0.28.2" |
} |
} |
Why: Custom UI
Then build the desk:
npm run build:uiRun this before every deploy that changes the desk. Forget it, and Jira shows the old desk.
Stuck? I changed the menu desk (Level 2) · “Cannot find module @forge/…”
Next step: next
Step 3: One ticket on the head chef’s badge
Locked until the step before it is done.
The desk needs its own resolver, separate from the recipe card’s. Create src/desk.js:
src/desk.jsA new resolver for the desk: check the input, then create one work item as the person asking.
Added: import Resolver from '@forge/resolver'; |
Added: import api, { route } from '@forge/api'; |
Added: import { LABELS, settings } from './lib/config'; |
Added: import { paragraphs } from './lib/adf'; |
Added: import { validateMenu } from './lib/validate'; |
Added: |
Added: const resolver = new Resolver(); |
Added: |
Added: // One ticket for the whole menu, created on the head chef's own badge. |
Added: resolver.define('submitMenu', async ({ payload }) => { |
Added: const check = validateMenu(payload); |
Added: if (!check.ok) return { error: check.error }; |
Added: const { projectKey, issueType } = settings(); |
Added: const { title, notes } = check.menu; |
Added: const response = await api.asUser().requestJira(route`/rest/api/3/issue`, { |
Added: method: 'POST', |
Added: headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, |
Added: body: JSON.stringify({ |
Added: fields: { |
Added: project: { key: projectKey }, |
Added: issuetype: { name: issueType }, |
Added: summary: title, |
Added: description: paragraphs(notes), |
Added: labels: [LABELS.MENU], |
Added: }, |
Added: }), |
Added: }); |
Added: if (!response.ok) { |
Added: return { error: `Jira answered ${response.status}: ${await response.text()}` }; |
Added: } |
Added: const issue = await response.json(); |
Added: return { ticket: { key: issue.key, dish: title } }; |
Added: }); |
Added: |
Added: export const handler = resolver.getDefinitions(); |
Three habits from here on:
validateMenuchecks everything the desk sends before any of it is used.- The Jira space comes from
settings(), which readsKITCHEN_PROJECT_KEY, never from the page. - The ticket is created with
asUser(), so Jira checks the head chef may create work items there, and they are its reporter.
Stuck? I changed code in src/
Next step: next
Step 4: Put the menu desk on the licence
Locked until the step before it is done.
Runs in the browser · Front of house
Global page (jira:globalPage) · the menu desk
jira:globalPage adds a full page to Jira, listed in the Apps menu. Like the issue panel, it names a resource to show and a resolver function to answer it; unlike the panel, it is not attached to any work item, so it has no work item in its context.
In the kitchen The desk where the head chef drops the notes from the menu meeting.
Three additions: the page, the function behind it, and where the built desk lives. Step through them:
manifest.ymlChange 1 of 2- A full page in Jira’s Apps menu, served from the built folder.
- The function that answers it.
- Where the built desk lives.
render: native |
title: Recipe card |
icon: https://developer.atlassian.com/platform/forge/images/icons/issue-panel-icon.svg |
Added: jira:globalPage: |
Added: - key: menu-desk |
Added: resource: desk |
Added: resolver: |
Added: function: desk-resolver |
Added: title: Menu desk |
trigger: |
- key: new-ticket-bell |
function: bell |
manifest.ymlChange 2 of 2 handler: index.handler |
- key: bell |
handler: bell.run |
Added: - key: desk-resolver |
Added: handler: desk.handler |
resources: |
- key: main |
path: src/frontend/index.jsx |
Added: - key: desk |
Added: path: static/desk/build |
app: |
runtime: |
name: nodejs24.x |
Stuck? I changed manifest.yml · The menu desk page is blank
Next step: next
Step 5: A key to write tickets
Locked until the step before it is done.
Creating work items needs a new scope. Let the inspector add it:
forge lint --fixmanifest.ymlforge lint --fix adds this for you.
- address: api.api-ninjas.com |
scopes: |
- read:jira-work |
Added: - write:jira-work |
Why: Scope · Major version
A new scope is a new major version, as in Level 1. Build the desk, deploy, approve, upgrade:
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgradeStuck? Deploy stops after a permission change · I added a scope or a supplier host · I changed the menu desk (Level 2)
Next step: next
Step 6: Send the first menu
Locked until the step before it is done.
In Jira, open Apps and choose Menu desk. The first time, it asks you to allow access, as the recipe card did. Fill in the form:
Spring menuMai: Pad thai stays, but with less sugar in the sauce.
Ken: add tom yum soup. Can someone research a lemongrass supplier first?
Ana: green curry for the vegetarian set.
Everyone: mango sticky rice as the only dessert.Mai, Ken, AnaSelect Send to the kitchen. After a few seconds the desk shows one new ticket for the whole menu. Open it: its reporter is you, it has the label menu-desk, and the recipe card on it looks up “Spring menu”, which is not a dish. The next lessons fix both: the button should not wait, and each dish should get its own ticket.
Stuck? The menu desk page is blank · Creating a ticket fails with “Jira answered 400” · The app asks me to “Allow access” · My menu stays “waiting” or ends “failed”
Next step: next
Compare with yours: download the code after this lesson.