Recipe: Project Management app for App Builder | The place for Zendesk users to come together and share
Skip to main content

Recipe: Project Management app for App Builder

  • June 30, 2026
  • 0 replies
  • 55 views

Vishal Jethani

Problem Statement

The Project Management app is a ticket sidebar application that provides parent-child ticket relationship management directly within the ticket view. It allows agents to create and link child tickets to a parent ticket, enabling teams to break down complex support requests into smaller, trackable sub-tasks without leaving the active ticket.

 

Location: ticket sidebar

 

Prompt

Create a ticket_sidebar app called "Project Management" that turns any Zendesk ticket into a project parent, creates linked child tickets (one per group), groups them by a shared `external_id` and a custom field value, and lets agents view, create, link, and remove project tickets from the sidebar.

 

Define eight app parameters in the manifest so they appear on the app's installation settings screen:

- `Custom_Field_ID` (type: text, required) — labelled "Custom Field ID". Help text: "ID of the custom ticket field used to store the project identifier." Key name uses underscores and mixed case exactly as shown — readers must match character-for-character.

- `prependSubject` (type: text, optional) — labelled "Prepend Subject". Help text: "Text prepended to child ticket subjects."

- `appendSubject` (type: text, optional) — labelled "Append Subject". Help text: "Text appended to child ticket subjects."

- `settingsMap` (type: multiline, optional) — labelled "Bulk Templates". Help text: "JSON array of template objects that pre-fill bulk creation forms."

- `prefillAssignee` (type: checkbox, optional) — labelled "Prefill Assignee". Help text: "Pre-fill group and assignee from the current ticket on child creation."

- `defaultTicketType` (type: text, optional) — labelled "Default Type". Help text: "Default type for new child tickets (e.g. task)."

- `defaultTicketPriority` (type: text, optional) — labelled "Default Priority". Help text: "Default priority for new child tickets (e.g. normal)."

- `customFieldIdsToCopy` (type: text, optional) — labelled "Fields to Copy". Help text: "Comma-separated custom field IDs to copy from parent to child."

 

Read these at runtime via `zafClient.metadata()` and destructure `metadata.settings`. Do NOT use `zafClient.get('setting.*')` — that throws `Could not find handler for: "setting.<name>"` because ZAF has no `setting` handler.

 

Project linkage is carried by three things on every project ticket: tag pair `project_parent` on parent / `project_child` on each child plus a shared `project_<parentId>` tag on both; `external_id = "<parentId>"` (plain numeric string, never prefixed with "Project-"); and a custom field (ID from `Custom_Field_ID` setting) holding the label `"Project-<parentId>"`.

 

1. On load, read settings via `zafClient.metadata()`. If `metadata.settings.Custom_Field_ID` is empty, render "This app needs a Custom Field ID. Ask an admin to set one in the app settings." and stop — do not call any `ticketFields:…` API.

2. Set the sidebar height via `zafClient.invoke('resize', { width: '100%', height: '550px' })`.

3. Fetch the current ticket via `zafClient.get('ticket')` (returns `id`, `subject`, `description`, `tags`, `externalId`, `requester`, `assignee`, `type`, `priority`, `customField`). Also fetch `zafClient.get('currentUser')` to read `user.locale` — use it to pick between `en`, `es`, `pt` translations in `constants.js` (default `en`).

4. In parallel, prefetch reference data via `zafClient.request()`: `GET /api/v2/ticket_forms.json`, `GET /api/v2/ticket_fields.json`, and `GET /api/v2/groups/assignable.json`. Swallow 404 on `ticket_forms.json` (Enterprise/Suite Growth+ only) and hide the Ticket Form dropdown when the response is empty.

5. Fetch the full parent ticket via `GET /api/v2/tickets/<currentTicketId>.json` to read `external_id` and `custom_fields` (the ZAF `ticket` object does not expose `external_id` or the raw `custom_fields` array).

6. If `external_id` is set AND the current ticket's tags include `project_<external_id>`, fetch all project members via `GET /api/v2/tickets.json?external_id=<external_id>`. Classify each member: a ticket is "parent" when its tags do NOT include `project_child`, else "child". Route to the Project List view. Otherwise route to the No Project view.

7. **No Project view**: render a centred empty-state card with a primary Garden `Button` labelled "Make Project". On click, **do NOT call the parent-update PUT and do NOT create any child ticket** — instead, route to the **Project Menu view** (a pending/preview Project List with the three navigation buttons from step 10: **Create Child**, **Create Bulk**, **Add Existing**, plus a Cancel/back affordance to return to the No Project view). The current ticket is treated as a *prospective* parent at this stage — no tags, `external_id`, or custom field are written yet. Persistence of the parent (the PUT body `{"ticket": {"tags": [...existing, "project_parent", "project_<currentId>"], "external_id": "<currentId>", "custom_fields": [{"id": <Custom_Field_ID>, "value": "Project-<currentId>"}]}}` plus the live-form `zafClient.invoke('ticket.tags.add', [...])` and `zafClient.set('ticket.customField:custom_field_<Custom_Field_ID>', 'Project-<currentId>')` mirrors) happens only when the agent actually submits a create flow — folded into step 14's parent-update PUT for Create Single / Create Bulk, or run as the FIRST step of the Add Existing submit per step 16. The exact body shape and live-form mirrors are unchanged from the original wording above; only the moment of execution moves from "click Make Project" to "submit a create flow". After persistence, refetch (steps 5–6) and re-render the Project List as before.

8. **Project List view**: render two stacked Garden `Table`s — **Parent** (one row) and **Children** (zero or more rows). Hide the Parent heading when no parent is present and the Children heading when no children. Each row shows: a link to `/agent/tickets/<id>`, truncated subject with a `Tooltip` showing the full subject, and a status badge coloured by status (`new`/`open`/`pending`/`hold`/`solved`/`closed`). On each child row, render a small `IconButton` with a trash icon (aligned right) that triggers the Remove flow in step 13.

9. Use `@zendeskgarden/react-icons` for the trash icon — e.g. `import { ReactComponent as TrashStrokeIcon } from '@zendeskgarden/react-icons/svg/16/trash-stroke.svg'` — or an inline `<svg>` JSX literal. NEVER import a raw `.svg` from `@zendeskgarden/svg-icons` — App Builder's runtime does not bundle and those imports throw `TypeError: Failed to resolve module specifier`.

10. Above the Parent table, render three navigation buttons in a row: **Create Child** (opens Create Single view), **Create Bulk** (opens Create Bulk view), **Add Existing** (opens Add Existing view). If the current ticket has the `project_child` tag, disable all three buttons (a child cannot spawn its own project) and show a `Tooltip`: "This ticket is a child of another project."

11. **Create Single view**: render a form inside a light-grey panel with these fields in order — Ticket Form (`Select` from prefetched forms, default = `default: true` form or first active, hide if no forms), Requester (debounced autocomplete calling `POST /api/v2/users/autocomplete.json` with body `{name: "<term>"}`, min 3 chars, 200ms debounce; only show users tagged `corresponsable` — LATAM business rule; **prefilled on open from the current ticket's requester** using `currentTicketData.requesterName` (visible value) and `currentTicketData.requesterEmail` (underlying value) — if rendered as a Garden `Combobox`, seed the full state shape `{ inputValue: requesterName, selectionValue: requesterEmail, isExpanded: false }` per step 26; agent can clear and retype to override), Subject (`Input` prefilled as `{prependSubject} <parent.subject> {appendSubject}` using settings), Description (`Textarea`, 6 rows, prefilled from parent), Type (`Select` with options `question`/`incident`/`problem`/`task`/None, default from parent.type or `defaultTicketType`), Priority (`Select` with options `low`/`normal`/`high`/`urgent`/None, default from parent.priority or `defaultTicketPriority`), Due Date (date `Input`, shown only when Type = `task`), Group (`Input`, prefilled from parent's group name — always, regardless of `prefillAssignee`), Assignee (`Input`, prefilled from parent's assignee name — always), and Dynamic Fields (one `Input` per active text custom field on the chosen form, excluding the project custom field, each seeded from `parent.custom_fields` by ID match). Submit button "Create single ticket", Cancel button returns to Project List.

12. On Create Single submit, resolve the typed Group and Assignee names to integer IDs against the prefetched groups list and `GET /api/v2/groups/<groupId>/memberships.json`. Toast a warning and submit without the ID if no match. Call `POST /api/v2/tickets.json` with body:

    ```json

    {"ticket": {

      "ticket_form_id": <integer>,

      "subject": "<string>",

      "comment": {"body": "<string>"},

      "requester": {"email": "<string>", "name": "<name if new requester>"},

      "group_id": <integer>,

      "assignee_id": <integer>,

      "type": "<string>",

      "priority": "<string>",

      "due_at": "<ISO-8601 or null>",

      "external_id": "<parentId>",

      "tags": ["project_child", "project_<parentId>"],

      "custom_fields": [{"id": <Custom_Field_ID>, "value": "Project-<parentId>"}, ...dynamicFields, ...copiedFields]

    }}

    ```

    `copiedFields` are IDs listed in `customFieldIdsToCopy` that are NOT already shown as Dynamic Fields — read their values from `parent.custom_fields` and append silently. Then update the parent (step 14) and route to the Confirmation view.

13. **Create Bulk view**: rendered in the same light-grey form panel as Create Single (10px padding, rounded corners, 1px neutral border, fields vertically stacked with ~10px spacing, submit button bottom-right). Field order, top to bottom:

    - **Ticket Form**: Garden `Select` populated from the prefetched ticket forms (step 4), default-selected to the form whose `default: true` is set or the first active form. Writes `ticket_form_id` on every ticket this form creates. Changing the selection re-renders DynamicFields for the new form's field set and re-seeds each field from the parent ticket's matching custom field value.

    - **Lookup Relationship**: a plain static label above Requester — a section heading indicating the Requester is looked up via the Zendesk users API. Not an accordion, not interactive.

    - **Requester**: Garden `Combobox` with an async option loader — debounced ~200ms, `minLength=3`, calling `POST /api/v2/users/autocomplete.json` (the endpoint also accepts GET-with-query). Response `{users: [{id, name, email, ...}]}` — render options as `user.name`, underlying value `user.email`, skip users without an email (e.g. phone-only). Filter to users tagged `corresponsable` (LATAM business rule). If the typed value doesn't match any option on blur, surface a hidden **Requester Name** input beneath the combobox and focus it so the agent can name a brand-new requester. Prefill the combobox with the current ticket's requester email on open using Garden's full state shape `{ inputValue, selectionValue, isExpanded }`.

    - **Template selector** (above Subject, rendered only when `settingsMap` is a non-empty parsed JSON object): Garden `Select` populated from the parsed `settingsMap` — each key is a template name. Include a leading "No template" option. Selecting a template pre-selects that template's group set in the Groups multi-select AND pre-fills any template-defined custom field values into DynamicFields (template values **override** the parent-seeded defaults). When `settingsMap` is empty or unset, omit the selector entirely.

    - **DynamicFields**: same renderer as Create Single — active custom fields from the chosen ticket form, excluding the project custom field. Pre-populate each field from `parent.custom_fields` by ID match; when a template is selected, the template's field values take precedence over the parent-seeded values. Agent can review and edit before submitting.

    - **Groups**: Garden `Combobox` configured as **multi-select**, sourced from the prefetched assignable groups. Renders a chip-style selected area showing the chosen group names. Submitting creates **one child ticket per selected `group_id`** (integer), reusing the same Ticket Form, Requester, Subject, Description, and DynamicField values across every created ticket.

    - **Subject**: Garden `Input`, prefilled from the parent ticket's subject. If `prependSubject` is on, prefix with `Project-<parent-id> `. If `appendSubject` is on, suffix with ` Project-<parent-id>`.

    - **Description**: Garden `Textarea`, ~8 rows, resizable vertically, prefilled from the parent ticket's description.

 

    Fields explicitly **omitted** on the Bulk form (versus Create Single): **Type**, **Priority**, **Due Date**, **Assignee** — a single assignee cannot span multiple groups, and type/priority/due-date are not collected in the bulk flow.

 

    Submit button label: **"Create bulk tickets"** (right-aligned, Garden default/secondary style — white bg, 1px neutral border). Cancel button returns to Project List.

 

    On submit, iterate every selected `group_id` from the Groups multi-select and `POST /api/v2/tickets.json` once per group. Payload per ticket is the same JSON shape as the Create Single body in step 12, **minus** the omitted keys — specifically:

    ```json

    {"ticket": {

      "ticket_form_id": <integer>,

      "subject": "<string>",

      "comment": {"body": "<description>"},

      "requester": {"email": "<email>", "name": "<name if new requester>"},

      "group_id": <integer — varies per iteration>,

      "external_id": "<parentId>",

      "tags": ["project_child", "project_<parentId>"],

      "custom_fields": [{"id": <Custom_Field_ID>, "value": "Project-<parentId>"}, ...dynamicFields, ...copiedFields]

    }}

    ```

    Only `group_id` varies across iterations; every other field is identical across the batch. `copiedFields` are IDs listed in `customFieldIdsToCopy` that are NOT already rendered as DynamicFields — read their values from `parent.custom_fields` and append silently so they propagate even when not visible. Fire the per-group POSTs in parallel (e.g. `Promise.all`) — they are independent.

 

    After all per-group POSTs settle, run the parent-update flow (step 14) exactly **once** regardless of how many children were created, then route to the Confirmation view listing every created ticket as a linked `#<id>` with the project label. On a per-ticket failure, preserve already-succeeded results in the confirmation list so the agent sees partial progress; show an error notification built from `response.description` plus `response.details.requester[0].description` when present.

14. After any create batch, update the **parent** ticket once via `PUT /api/v2/tickets/<parentId>.json` (idempotent) — add tags `["project_parent", "project_<parentId>"]`, set `external_id = "<parentId>"`, set `status = "hold"` (puts the parent on hold while children are worked), and set the project custom field to `"Project-<parentId>"`. Mirror on the live form via `zafClient.invoke('ticket.tags.add', [...])` and `zafClient.set('ticket.customField:custom_field_<Custom_Field_ID>', 'Project-<parentId>')`.

15. **Confirmation view**: render the created tickets as a list of `#<id>` links with subjects, plus a "View Project List" button that refetches (steps 5–6) and routes back.

16. **Add Existing view**: render a single `Input` labelled "Ticket ID's" with placeholder "List of ticket IDs", plus a "Add tickets to project" submit button and a Cancel button. On submit, split the input on commas and whitespace, coerce each piece to integer, discard non-numeric entries. FIRST mark the current ticket as parent (same PUT + live-form updates as step 14) so the parent is tagged even if every child fails validation. Then for each ID: `GET /api/v2/tickets/<id>.json`; if `status === "closed"` toast "<id> is closed" and skip; if tags include `project_child` toast "<id> is member of another project <external_id>" and skip; otherwise `PUT /api/v2/tickets/<id>.json` with body `{"ticket": {"tags": [...existing, "project_child", "project_<parentId>"], "external_id": "<parentId>", "custom_fields": [{"id": <Custom_Field_ID>, "value": "Project-<parentId>"}]}}`. After processing all IDs, auto-switch to Project List and refetch.

17. **Remove flow** (trash icon on child row): `PUT /api/v2/tickets/<childId>.json` with body `{"ticket": {"tags": <existing minus "project_child" and "project_<external_id>">, "external_id": "", "custom_fields": [{"id": <Custom_Field_ID>, "value": ""}]}}`. Use `""` not `null` to clear — the API coerces empty strings to unset for `external_id` and custom-field values. Then refetch the project list.

18. Validate the configured custom field before manipulating it: if the project field ID exists on the current ticket's form AND its value is non-empty, call `zafClient.invoke('ticketFields:custom_field_<Custom_Field_ID>.disable')`; if it exists on the form but is empty, call `.hide`; if it is not on this ticket's form, skip both. Wrap every `ticketFields:…` invoke in try/catch that swallows `APIUnavailable` — otherwise a missing field crashes initialization. The colon/dot syntax is strict: colon between `ticketFields` and the field key, dot before the method (`ticketFields:custom_field_123.disable`).

19. **Solve prevention**: after every project list load, if the current ticket is a project parent and ANY child has status `open` / `new` / `pending` / `hold`, call `zafClient.invoke('ticketFields:status.optionValues.solved.disable')` to block the Solved option. When every child is `solved` or `closed`, call `zafClient.invoke('ticketFields:status.optionValues.solved.enable')` and render a small green "Ready to solve" Garden `Tag` above the Parent table. Wrap both invokes in try/catch to swallow `APIUnavailable` (status field may not be on the current form).

20. Listen for `zafClient.on('ticket.status.changed', …)`: refetch the project ticket list (step 6) so statuses are fresh, then re-evaluate solve prevention (step 19).

21. Listen for `zafClient.on('ticket.form.id.changed', …)`: re-run the custom field visibility logic (step 18) because field availability changes when the agent switches the ticket form mid-session. Also re-render Dynamic Fields on the create forms.

22. For REST writes, use the exact body shapes: `custom_fields: [{id: integer, value: string}]` as an array of objects (NEVER object-keyed like `{"123": "…"}`); `tags: ["a", "b"]` as an array (NEVER a comma string); `external_id` as a plain numeric string (NEVER `null`, use `""` to clear); `ticket_form_id` / `group_id` / `assignee_id` / `requester_id` as integers; `comment: {body: "…"}` (NEVER `comment: {value: "…"}` — the v1 ZAF shorthand is not accepted).

23. For the Requester autocomplete, if `POST /api/v2/users/autocomplete.json` is not available in the Actions Catalog, fall back to `GET /api/v2/users/search.json?query=<term>` — same response shape `{users: [{id, name, email, …}]}`, lower relevance.

24. Use a compact single-column sidebar layout. Header is a teal grid icon with the app title. Navigation buttons are Garden secondary (white bg, 1px neutral border, ~13px label, 6–8px vertical padding). Forms sit inside a light-grey rounded panel with 1px neutral border and 10px padding. Labels are 12px, normal weight, 4px above each field. Fields span full width. Tables use Garden `size="small"`. Status badges use per-status hues from a `STATUS_HUE` map in `constants.js`.

25. Keep `currentTicketData` as a merged object of the ZAF `ticket` fields (`requesterEmail`, `requesterName`, `group_name`, `assignee_name`) plus the REST fields (`external_id`, `custom_fields`, raw `type`, `priority`). When a subsequent REST refetch runs, merge the REST response INTO the existing object — do not replace it, or the ZAF-enriched display names are wiped and the create form's prefill breaks silently.

26. Note: Group and Assignee on the Create Single form are plain text `<Input>` fields (not comboboxes) with name-to-ID resolution on submit — this is a deliberate simplification over a combobox approach. If you use a combobox anywhere (e.g. Requester), prefill it with Garden's full state shape `{ inputValue, selectionValue, isExpanded }` — setting only `selectionValue` leaves the visible input empty.

27. Note: the `corresponsable` tag filter on Requester autocomplete is a LATAM-specific business rule — remove or generalize it if deploying outside LATAM.

28. **Groups multi-select `Combobox` on Create Bulk must be wrapped in the `Field` from `@zendeskgarden/react-dropdowns`, NOT the one from `@zendeskgarden/react-forms`.** Both packages export a component named `Field` and they are not interchangeable — wrapping the Groups `Combobox` in the forms `Field` throws `this component must be rendered within a Field` at runtime and breaks Create Bulk on first render. Do NOT change any other form control — every existing `Input`, `Textarea`, `Select`, `Checkbox`, and the Requester field must keep using the forms `Field` exactly as before; switching them to `DropdownField` will ruin the working form. Scope the fix to the Groups section only by adding a second aliased import:

    ```js

    import { Field } from '@zendeskgarden/react-forms';

    import { Field as DropdownField } from '@zendeskgarden/react-dropdowns';

    ```

    Then in the Groups section (and only there) use `DropdownField` + `DropdownField.Label` around the multi-select `Combobox`.

 

Enhancement Options

Based on your organizational needs, you can extend the Project Management app to support your specific needs using App Builder.

 

Important Note: This app recipe is for the Project Management app. Any modifications to the recipe or its underlying logic may alter the core functionality of the app. Thoroughly test all changes in a non-production environment before deploying to your production workspace.

Please note: that as App Builder evolves including updates to underlying AI models, design components, and other dependencies, we cannot guarantee that this recipe will continue to function as expected over time. We recommend periodically validating the recipe against any platform updates.