Workfront UI extension (front end)
Part of the appbuilder-workfront family. This is the front end — the app screens the user sees (the "SPA"). It registers extension points (the spots where the app appears in Workfront) and calls Runtime actions (the cloud back end) for all data.
This skill is the Workfront-specific front end (extension points, WF shared context). For generic React/Spectrum patterns (pages, forms, data tables, dialogs, navigation) and ExC Shell / AEM UI surfaces, use appbuilder-ui-scaffolder. A ready-to-edit registration example is in assets/ExtensionRegistration.example.js.
Rules
- One
register() call wires everything up; individual views use attach().
- Auth comes from
sharedContext + getWFInstanceUrl() — Workfront supplies the signed-in user and instance; don't build a login.
- Talk to the back end with
actionWebInvoke only — the browser must never call Workfront directly (/attask/api/…). Back-end code lives in workfront-actions.
- Every action replies with
{ data, error } — always check error before using data.
Extension points (in ExtensionRegistration)
register() takes id at the top level (a non-empty slug identifying the extension); the extension points go inside methods. Each item's url must map to a route in App.js, and every id must be unique.
const guestConnection = await register({
id: extensionId, // top-level id, non-empty
metadata, // from app-metadata.json (generated by a build hook)
methods: {
id: extensionId, // ⚠️ REQUIRED here too — the menu item won't render without it (see Gotchas)
mainMenu: {
getItems() { return [{ id, url: '/index.html#/route', label, icon }] }
},
secondaryNav: { // left panel, per object type
PROJECT: { getItems() { return [{ id, label, icon, url: '/route' }] } },
// register each separately: PROJECT, TASK, ISSUE, PORTFOLIO, PROGRAM
},
widgets: { // embed in a custom-form field
getItems() {
return [{
id, url: '/index.html#/widgets1', label,
dimensions: { height, width, maxHeight, maxWidth } // all optional
}]
}
},
}
})
Widget id/url/label are required; dimensions is optional.
Routing (App.js)
Add a <Route> per extension-point url:
<Route exact path="custom-application" element={<CustomApplication />} />
Shared context
sharedContext is get-only (.get(key) — not iterable). Confirmed shape from the Workfront host:
const ctx = conn?.sharedContext
const auth = ctx?.get('auth') // { imsClientId, imsOrgID, imsToken }
const user = ctx?.get('user') // { ID, email }
const host = ctx?.get('hostname') // e.g. ai-dev-arm.devtest.workfront-dev.adobe.com (no protocol)
// also: protocol; plus objCode, objID, isLoginAs, isInBulkEditing on object-scoped points
const imsToken = auth?.imsToken
const imsOrgId = auth?.imsOrgID // ⚠️ key is `imsOrgID` (capital ID) — NOT imsOrgId / imsOrg
Everything an action needs is right here — pass imsToken, imsOrgId (auth.imsOrgID), and host into actionWebInvoke. Don't call Workfront for the org: it's in auth, and a cross-origin currentUser fetch from the SPA is CORS-blocked anyway. Only set the x-gw-ims-org-id header when you have a value — Fetch turns undefined into the string "undefined" (→ 401 Org Id undefined). Widgets receive the same context.
Workers / errors
Do heavy CSV/XLSX (spreadsheet) work in Web Workers — background threads, so the screen doesn't freeze — but never call the WF API inside a worker. Show a toast (small popup notice) on failure; never expose tokens.
Gotchas (from building a real Main Menu extension)
- Main Menu item not rendering? It's the
id, and the fix is trivial. The WF template scaffolds register({ metadata, methods: { id: extensionId, mainMenu } }) with extensionId = '' in Constants.js. Two things are required for the item to appear: (1) give extensionId a non-empty value in Constants.js, and (2) keep id: extensionId under methods — that placement is exactly what the menu needs; if you remove it, the item still registers (Workfront even calls getItems) but silently never renders. Simplest working form: set extensionId, and keep id: extensionId inside methods (having it at the top level of the config too is fine). Don't be thrown off by reading @adobe/uix-guest: register(config) does new GuestServer(config.id) then guest.register(config.methods, …) and just forwards methods to the host — so from the guest source a bare methods.id looks like an ignored no-op. It isn't: Workfront's host side consumes methods.id, and that behavior isn't visible in the guest package. Verified in a live Main Menu extension — the item does not render without it. (This is easy to misdiagnose as an environment problem, or as dead code — it's neither.)
aio app init -y (or skipping the "Add a custom button to Main Menu Item" prompt) generates no menu item at all — no mainMenu block, no view component, no App.js route, no icons.js, empty extensionId. Either answer that prompt during init, or hand-add: icons.js (exporting icon1/icon2), the mainMenu block, a <Route>, and the view component.
- Calling actions: the template's
web-src/src/utils.js exports actionWebInvoke(url, headers, params, options = { method: 'POST' }). Action URLs are injected into web-src/src/config.json at build time under both "<action>" and "<package>/<action>" — import actions from '../config.json' and look up by name; never hardcode a Runtime URL.
register vs attach: the background registration frame uses register() (a GuestServer, declares the extension points); a displayed view uses attach() (a GuestUI). Both expose sharedContext.
- The deployed app's URL is the Experience Cloud shell link — not the bare CDN. Clicking the Main Menu button navigates to
…/workfront/custom-applications/<extensionId>/<menuRoute> (org- and instance-scoped); that is the app's shareable URL, and after aio app deploy you should hand it to the user (build recipe in workfront-local-testing). First segment = the registration id; second = the menu item's #/route. Two traps: reusing the app id as the second segment (…/<extensionId>/<extensionId>) loads the background registration frame, not the view; and the raw CDN …/index.html#/route renders with no host → no sharedContext. If the item registers but won't render live, suspect the Workfront environment, not the code (see workfront-local-testing).
- React Spectrum
TableView's "select all" header checkbox gives you the string 'all', not a Set. onSelectionChange for selectionMode="multiple" normally hands you a Set of row keys, and code like selectedKeys.size > 0 works fine when the user checks rows one at a time. But clicking the header's own "select all" checkbox sets selectedKeys to the literal string 'all' instead — 'all'.size is undefined, so a naive selectedKeys.size > 0 check silently evaluates to false and a bulk-action button stays disabled with no visible error. Always branch on the string case: const count = selectedKeys === 'all' ? totalItems : selectedKeys.size, and use the same branch anywhere you turn the selection into an array of ids (selectedKeys === 'all' ? items.map(i => i.id) : Array.from(selectedKeys)). This is easy to miss in testing if you only ever click individual row checkboxes.
1---2name: workfront-ui-extension3description: Use when building or editing the React/Spectrum front-end SPA of a Workfront App Builder extension. Reach for this whenever the user is: registering or changing extension points in `ExtensionRegistration` — a Main Menu button, a left-panel (`secondaryNav`) item for a specific Workfront object type (Project, Task, Issue, Portfolio, Program), or a custom-form widget with specific height and width; adding a new route in `App.js` to match an extension point URL; reading the Workfront shared context to get the current user, `objCode`, `objID`, or `hostname`; calling a Runtime action from the SPA via `actionWebInvoke`; or debugging a widget or route that renders blank after being registered. Never call Workfront or Adobe APIs directly from the SPA — all API calls belong in a Runtime action (see `workfront-actions`).4license: Apache-2.05---6# Workfront UI extension (front end)78Part of the `appbuilder-workfront` family. This is the **front end** — the app screens the user sees (the "SPA"). It registers **extension points** (the spots where the app appears in Workfront) and calls **Runtime actions** (the cloud back end) for all data.910> This skill is the **Workfront-specific** front end (extension points, WF shared context). For generic React/Spectrum patterns (pages, forms, data tables, dialogs, navigation) and ExC Shell / AEM UI surfaces, use **`appbuilder-ui-scaffolder`**. A ready-to-edit registration example is in `assets/ExtensionRegistration.example.js`.1112## Rules1314- One `register()` call wires everything up; individual views use `attach()`.15- Auth comes from `sharedContext` + `getWFInstanceUrl()` — Workfront supplies the signed-in user and instance; don't build a login.16- Talk to the back end with `actionWebInvoke` only — the browser must **never** call Workfront directly (`/attask/api/…`). Back-end code lives in `workfront-actions`.17- Every action replies with `{ data, error }` — always check `error` before using `data`.1819## Extension points (in ExtensionRegistration)2021`register()` takes **`id` at the top level** (a non-empty slug identifying the extension); the extension points go inside `methods`. Each item's `url` must map to a route in `App.js`, and every `id` must be unique.2223```js24const guestConnection = await register({25 id: extensionId, // top-level id, non-empty26 metadata, // from app-metadata.json (generated by a build hook)27 methods: {28 id: extensionId, // ⚠️ REQUIRED here too — the menu item won't render without it (see Gotchas)29 mainMenu: {30 getItems() { return [{ id, url: '/index.html#/route', label, icon }] }31 },32 secondaryNav: { // left panel, per object type33 PROJECT: { getItems() { return [{ id, label, icon, url: '/route' }] } },34 // register each separately: PROJECT, TASK, ISSUE, PORTFOLIO, PROGRAM35 },36 widgets: { // embed in a custom-form field37 getItems() {38 return [{39 id, url: '/index.html#/widgets1', label,40 dimensions: { height, width, maxHeight, maxWidth } // all optional41 }]42 }43 },44 }45})46```4748Widget `id`/`url`/`label` are required; `dimensions` is optional.4950## Routing (App.js)5152Add a `<Route>` per extension-point url:5354```jsx55<Route exact path="custom-application" element={<CustomApplication />} />56```5758## Shared context5960`sharedContext` is **get-only** (`.get(key)` — not iterable). Confirmed shape from the Workfront host:6162```js63const ctx = conn?.sharedContext64const auth = ctx?.get('auth') // { imsClientId, imsOrgID, imsToken }65const user = ctx?.get('user') // { ID, email }66const host = ctx?.get('hostname') // e.g. ai-dev-arm.devtest.workfront-dev.adobe.com (no protocol)67// also: protocol; plus objCode, objID, isLoginAs, isInBulkEditing on object-scoped points6869const imsToken = auth?.imsToken70const imsOrgId = auth?.imsOrgID // ⚠️ key is `imsOrgID` (capital ID) — NOT imsOrgId / imsOrg71```7273Everything an action needs is right here — pass `imsToken`, `imsOrgId` (`auth.imsOrgID`), and `host` into `actionWebInvoke`. **Don't call Workfront for the org**: it's in `auth`, and a cross-origin `currentUser` fetch from the SPA is CORS-blocked anyway. Only set the `x-gw-ims-org-id` header when you have a value — Fetch turns `undefined` into the string `"undefined"` (→ `401 Org Id undefined`). Widgets receive the same context.7475## Workers / errors7677Do heavy CSV/XLSX (spreadsheet) work in **Web Workers** — background threads, so the screen doesn't freeze — but never call the WF API inside a worker. Show a **toast** (small popup notice) on failure; never expose tokens.7879## Gotchas (from building a real Main Menu extension)8081- **Main Menu item not rendering? It's the `id`, and the fix is trivial.** The WF template scaffolds `register({ metadata, methods: { id: extensionId, mainMenu } })` with `extensionId = ''` in `Constants.js`. Two things are required for the item to appear: (1) give `extensionId` a **non-empty** value in `Constants.js`, and (2) **keep `id: extensionId` under `methods`** — that placement is exactly what the menu needs; if you remove it, the item still registers (Workfront even calls `getItems`) but **silently never renders**. Simplest working form: set `extensionId`, and keep `id: extensionId` inside `methods` (having it at the top level of the config too is fine). **Don't be thrown off by reading `@adobe/uix-guest`:** `register(config)` does `new GuestServer(config.id)` then `guest.register(config.methods, …)` and just *forwards* `methods` to the host — so from the guest source a bare `methods.id` looks like an ignored no-op. It isn't: **Workfront's host side consumes `methods.id`**, and that behavior isn't visible in the guest package. Verified in a live Main Menu extension — the item does not render without it. *(This is easy to misdiagnose as an environment problem, or as dead code — it's neither.)*82- **`aio app init -y` (or skipping the "Add a custom button to Main Menu Item" prompt) generates no menu item at all** — no `mainMenu` block, no view component, no `App.js` route, no `icons.js`, empty `extensionId`. Either answer that prompt during init, or hand-add: `icons.js` (exporting `icon1`/`icon2`), the `mainMenu` block, a `<Route>`, and the view component.83- **Calling actions:** the template's `web-src/src/utils.js` exports `actionWebInvoke(url, headers, params, options = { method: 'POST' })`. Action URLs are injected into `web-src/src/config.json` at build time under **both** `"<action>"` and `"<package>/<action>"` — `import actions from '../config.json'` and look up by name; never hardcode a Runtime URL.84- **`register` vs `attach`:** the background registration frame uses `register()` (a GuestServer, declares the extension points); a displayed view uses `attach()` (a GuestUI). Both expose `sharedContext`.85- **The deployed app's URL is the Experience Cloud shell link — not the bare CDN.** Clicking the Main Menu button navigates to `…/workfront/custom-applications/<extensionId>/<menuRoute>` (org- and instance-scoped); that **is** the app's shareable URL, and after `aio app deploy` you should hand it to the user (build recipe in `workfront-local-testing`). First segment = the registration `id`; second = the menu item's `#/route`. Two traps: reusing the app id as the second segment (`…/<extensionId>/<extensionId>`) loads the background registration frame, not the view; and the raw CDN `…/index.html#/route` renders with no host → no `sharedContext`. If the item registers but won't render live, suspect the Workfront environment, not the code (see `workfront-local-testing`).86- **React Spectrum `TableView`'s "select all" header checkbox gives you the string `'all'`, not a `Set`.** `onSelectionChange` for `selectionMode="multiple"` normally hands you a `Set` of row keys, and code like `selectedKeys.size > 0` works fine when the user checks rows one at a time. But clicking the header's own "select all" checkbox sets `selectedKeys` to the **literal string `'all'`** instead — `'all'.size` is `undefined`, so a naive `selectedKeys.size > 0` check silently evaluates to `false` and a bulk-action button stays disabled with no visible error. Always branch on the string case: `const count = selectedKeys === 'all' ? totalItems : selectedKeys.size`, and use the same branch anywhere you turn the selection into an array of ids (`selectedKeys === 'all' ? items.map(i => i.id) : Array.from(selectedKeys)`). This is easy to miss in testing if you only ever click individual row checkboxes.