Course menuForge Kitchen

Level 2 · Lesson 1

Open the menu desk

Add a page of your own to Jira, built with Custom UI, where the head chef pastes menu-meeting notes and gets one ticket back.

About 35 min6 steps

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

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.

Atlassian docs: Custom UI (opens in a new tab)

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:

CodeWhat the kit adds
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 answer

Open 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.

Atlassian docs: Bridge API (opens in a new tab)

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.

Step 2: Add the build step

Forge serves built files, not your source. Install the bundler the kit’s script uses:

RunTerminal
npm install --save-dev esbuild

npm adds the devDependencies lines. Add the scripts lines yourself:

Editpackage.json

A 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:

RunTerminal
npm run build:ui

Run 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/…”

Step 3: One ticket on the head chef’s badge

The desk needs its own resolver, separate from the recipe card’s. Create src/desk.js:

New filesrc/desk.js

A 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();

Why: Resolver · asUser()

Three habits from here on:

  • validateMenu checks everything the desk sends before any of it is used.
  • The Jira space comes from settings(), which reads KITCHEN_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/

Step 4: Put the menu desk on the licence

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.

The menu desk sends the notes to its resolver.The head chef hands the notes over at the desk.

Three additions: the page, the function behind it, and where the built desk lives. Step through them:

Editmanifest.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
Editmanifest.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

Why: Global page (jira:globalPage) · Custom UI

Stuck? I changed manifest.yml · The menu desk page is blank

Step 5: A key to write tickets

Creating work items needs a new scope. Let the inspector add it:

RunTerminal
forge lint --fix
Editmanifest.yml

forge 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:

RunTerminal
npm run build:ui
forge deploy --approve MAJOR_VERSION_RULE
forge install --upgrade

Stuck? Deploy stops after a permission change · I added a scope or a supplier host · I changed the menu desk (Level 2)

Step 6: Send the first menu

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:

CodeMenu title
Spring menu
CodeMeeting notes
Mai: 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.
CodeWho was there
Mai, Ken, Ana

Select 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”

Compare with yours: download the code after this lesson.

Type at least two letters.