# Forms API

## Introduction

The Forms API powers data-entry screens — creating and editing a profile, updating account details, or any other screen where the user fills in values and saves them. The **Form Screen** plugin renders the screen from a JSON form configuration, prefills it from the feed entry the user selected, and posts the result to a customer-hosted endpoint as a CloudEvent.

Three artifacts take part, and all three must agree on **field IDs**:

| Artifact | Provides | Owned by |
| --- | --- | --- |
| **Form configuration feed** | `properties[]` — one entry per field. Defines what is rendered and how it validates on device. | The form author, in Zapp |
| **Entry feed** | `extensions.form_data` on the selected entry — the values the form opens with. | The backend |
| **Submission endpoint** | Receives `data.form_data`, persists it, returns success or validation errors. | The backend |

A field ID appears in all four places: `properties[].id`, the prefilled `form_data`, the submitted `data.form_data`, and the `fieldErrors` keys of an error response. Rename it in one place and prefill, submission, or error display silently breaks.

## Editable Entries Feed

The feed that lists the records the user can edit — profiles, for example. Two things make an entry editable:

1. **`type`** — the entry type, typically `"profile-edit"`. The value itself is a recommendation, not a requirement; what matters is that it is mapped to the Form screen in the app configuration. When the user taps the entry, the app resolves the type mapping, navigates to the Form screen, and passes the entry data to it.
2. **`form_data` extension** — the entry's `extensions` object carries a `form_data` payload used to prefill the form.

```json
{
  "title": "Manage Profiles",
  "entry": [
    {
      "id": "a3JVE000005wcej2AA",
      "title": "Main Profile",
      "type": {
        "value": "profile-edit"
      },
      "extensions": {
        "form_data": {
          "profileName": "Main Profile",
          "dob": "2024-12-31",
          "parentalControls": ["allow_comment", "allow_change_picture"],
          "kidsProfile": false,
          "profileId": "a3JVE000005wcej2AA",
          "email": "profile.user@example.com",
          "profileImage": "char:1741"
        },
        "master": 1
      }
    }
  ]
}
```

:::note
**Hidden fields.** Not every key in `form_data` has to be editable. The form renders a component only for the fields declared in the form configuration. A key with no matching component — `"profileId"` above — is never shown, stays in the screen state, and is sent back unchanged on submit. This is the supported way to round-trip immutable server identifiers.
:::

### Creating a New Record

To create rather than edit, the app must reach the Form screen **without** `form_data`, or with an empty object. Two common ways:

1. **Inline with the editable entries feed** — append a "New Profile" entry as the last element. It can share the same type mapping or use a different one, such as `profile-add`, as long as it resolves to the Form screen.
2. **As a separate manual feed** — attach a feed to a distinct "Create New" button component on the list screen. In Zapp this is a Manual Feed.

```json
{
  "id": "69ef9a0d-5582-4b60-ae34-6a2fc01fbc2b",
  "title": "Profile - Create New Button",
  "type": {
    "value": "feed"
  },
  "extensions": {},
  "entry": [
    {
      "id": "e6df0cea-7305-433f-b466-1817bec74787",
      "title": "New Profile",
      "type": {
        "value": "profile-add"
      },
      "extensions": {
        "form_data": {}
      }
    }
  ]
}
```

## Form Configuration

To set up the form in your app:

1. Add the **Form Screen** plugin to your app in Zapp.
2. Create a new screen of this type.
3. In the screen configuration, open the **Data** section.
4. Assign the form structure feed — the JSON described below — to the **Form Feed** selector.

The configuration is an object with a required `properties` array. Each entry defines one field: a text input, date picker, checkbox, selector, or button.

```json
{
  "id": "fieldId",
  "type": "textInput | datePicker | checkBox | singleSelect | multiSelect | button | label",
  "preset": "PresetName",
  "options": {
    "title": "Field title"
  }
}
```

### How It Works Internally

1. **State initialization** — the screen copies the selected entry's `extensions.form_data` into its internal screen state. With no entry data, for example when creating, the state starts empty.
2. **Component instantiation** — the screen parses `properties` and instantiates one UI component per field using the declared `preset`.
3. **Data binding** — each component is bound to its value in the screen state. User input updates the state, and the state is what gets submitted.

### Form Configuration Sample

```json
{
  "properties": [
    {
      "id": "profileName",
      "type": "textInput",
      "preset": "FormTextInput",
      "options": {
        "title": "Profile Name",
        "required": true,
        "minLength": 3,
        "maxLength": 12
      }
    },
    {
      "id": "dob",
      "type": "datePicker",
      "preset": "FormTextInput",
      "options": {
        "title": "Date of Birth",
        "required": true
      }
    },
    {
      "id": "parentalControls",
      "type": "multiSelect",
      "preset": "FormMultiSelectGroup",
      "options": {
        "title": "Parental Controls",
        "required": true,
        "minItems": 1,
        "maxItems": 3,
        "items": [
          { "const": "allow_comment", "title": "Allow comments" },
          { "const": "allow_change_picture", "title": "Allow picture changes" },
          { "const": "allow_change_name", "title": "Allow name changes" }
        ]
      }
    },
    {
      "id": "kidsProfile",
      "type": "checkBox",
      "preset": "FormMultiSelectGroup",
      "options": {
        "title": "Kids Profile"
      }
    },
    {
      "id": "buttonSave",
      "type": "button",
      "preset": "FormButtonSave",
      "options": {
        "title": "Save",
        "extensions": {
          "tap_actions": {
            "actions": [
              { "type": "validateForm" },
              {
                "type": "postForm",
                "options": {
                  "data": {
                    "form_data": "@{screen/}"
                  },
                  "inflateData": true,
                  "subject": "profile_update",
                  "type": "com.applicaster.post.data.v1",
                  "url": "https://example.com/events"
                }
              },
              {
                "type": "navigateToScreen",
                "options": {
                  "typeMapping": "home-screen"
                }
              }
            ]
          }
        }
      }
    }
  ]
}
```

:::note
The save button above uses `FormButtonSave`, which the published `formTypes.ts` does not declare — it lists `FormButton`, `FormButtonCancel` and `FormButtonDelete`. Confirm the preset against your installed plugin version before copying this sample.
:::

## Field Types

| Field type | Preset | Notes |
| --- | --- | --- |
| `textInput` | `FormTextInput` | Free text input. Supports `required`, `minLength`, `maxLength`, `secure`, and numeric validation through `options.inputType: "number"`. |
| `datePicker` | `FormTextInput` | Date entry field. The form contract uses `type: "datePicker"`; the current rendering path still uses `FormTextInput` and passes `inputType: "date"` to the text field component. Values are stored and submitted as ISO 8601 strings such as `YYYY-MM-DD`; date-time strings with an offset are also accepted by validation. |
| `checkBox` | `FormMultiSelectGroup` | Boolean field. A required checkbox must be set to `true`. For server compatibility, tolerate both booleans and `"true"` / `"false"` strings in received payloads. |
| `singleSelect` | Usually `FormMultiSelectGroup`, or a custom preset such as `FormAvatarPicker` | One selected value. Items can be provided inline or through `itemsFeedURL`. |
| `multiSelect` | `FormMultiSelectGroup` | Multiple selected values. Items can be provided inline or through `itemsFeedURL`. Server prefill and submit values are arrays of selected option IDs. Supports `required`, `minItems`, and `maxItems`. |
| `button` | `FormButton`, `FormButtonCancel`, `FormButtonDelete` | Executes an action chain. See [Buttons](#buttons). |
| `label` | `""` | Static text. Confirm support against your installed plugin version before using it. |

### Options

| Option | Applies to | Notes |
| --- | --- | --- |
| `title` | All visible fields | Display title for the generated component or feed. |
| `description` | All visible fields | Secondary text. |
| `required` | Inputs, date picker, checkbox, selectors | Validated before submission when `validateForm` runs. |
| `minLength`, `maxLength` | Text input and number input | Length constraints. |
| `minItems`, `maxItems` | Multiselect | Selection-count constraints. Optional empty multiselect fields remain valid unless `required` is also set. |
| `secure` | Text input | Secure text entry. |
| `inputType` | Text input | `string`, `number`, or `date`. |
| `items` | Select fields | Inline options, each `{ "const": "<id>", "title": "<label>" }`. |
| `itemsFeedURL` | Select fields | URL that provides selectable entries. Use instead of `items`, not alongside it. |
| `extensions.tap_actions` | Buttons | Action chain executed by the button. |

### Date Picker

Use `datePicker` when the field collects a date. The submitted value is an ISO 8601 date such as `2000-05-31`. The validator also accepts ISO date-time values, for example `2024-12-31T10:30:00Z` or `2024-12-31T10:30:00+02:00`.

Empty values are handled by `required`; an optional empty date field is valid. An invalid filled value shows the form validation error `Please enter a valid date`.

Until there is a dedicated date picker component, the screen renders this field through the text input component with date input behavior. Form authors should still use `type: "datePicker"` as the stable form contract.

### Multiselect

Each option has a string `const`, and the value crossing the server boundary is an array of those `const` values.

```json
{
  "id": "parentalControls",
  "type": "multiSelect",
  "preset": "FormMultiSelectGroup",
  "options": {
    "title": "Parental Controls",
    "items": [
      { "const": "allow_comment", "title": "Allow comments" },
      { "const": "allow_change_picture", "title": "Allow picture changes" }
    ],
    "minItems": 1,
    "maxItems": 2
  }
}
```

Server prefill and the submitted payload both use the same shape:

```json
{
  "parentalControls": ["allow_comment", "allow_change_picture"]
}
```

If the user selects nothing, the submitted value is an empty array: `"parentalControls": []`.

:::caution
Avoid commas in multiselect option IDs. The screen state uses comma-delimited internal storage while the form is open, so a comma inside an option `const` splits into two selections at the server boundary. Use simple stable IDs such as `allow_comment` or `allow_change_name`.
:::

## Buttons

A button is `type: "button"` plus an action chain under `extensions.tap_actions`. **The preset is the role and the styling; the behavior is entirely in the action chain** — a delete-styled button with a save chain will save.

| Role | Preset | Chain |
| --- | --- | --- |
| Save | `FormButton` (samples above use `FormButtonSave`) | `validateForm` → `postForm` → `navigateToScreen` or `goBack` |
| Cancel | `FormButtonCancel` | `navigateToScreen` or `goBack`, optionally after `confirmDialog` |
| Delete | `FormButtonDelete` | `confirmDialog` → `sendCloudEvent` (or `postForm`) → `navigateToScreen` or `goBack` |

:::note
`goBack` returns to the previous screen instead of pushing a new one, so a save or delete does not leave the user one level deeper in the stack every time. It is supported by the plugin but not yet declared in `formTypes.ts`; `form-e2e` accepts it, and its drift test will flag the day the vendored types catch up.
:::

Available actions are `validateForm`, `postForm`, `navigateToScreen`, `goBack`, `confirmDialog`, and `sendCloudEvent`. Two rules govern every chain:

- **Actions run in order and the chain stops on failure.** When `postForm` returns field errors, nothing after it runs. Anything that must not happen on a failed submit — navigation, a success dialog — belongs after `postForm`; anything that must happen first belongs before it.
- **Only `postForm` surfaces `formError` and `fieldErrors`.** `sendCloudEvent` posts and moves on: it cannot show the user an error and it does not stop the chain. Use `postForm` wherever the server can reject the submission with a message the user needs to see — a save, above all. Use `sendCloudEvent` for a post with nothing to report back: a delete, and fire-and-forget side events such as analytics chained after a submission.

### Cancel

No `validateForm` — a half-filled form is exactly what the user is abandoning — and no `postForm`. Add `confirmDialog` only if losing edits should be confirmed. All four dialog texts are required, and dismissing the dialog stops the chain.

```json
{
  "id": "buttonCancel",
  "type": "button",
  "preset": "FormButtonCancel",
  "options": {
    "title": "Cancel",
    "extensions": {
      "tap_actions": {
        "actions": [
          {
            "type": "confirmDialog",
            "options": {
              "title": "Discard changes?",
              "message": "Your edits will not be saved.",
              "okButtonText": "Discard",
              "cancelButtonText": "Keep editing"
            }
          },
          {
            "type": "navigateToScreen",
            "options": { "typeMapping": "manage-profiles" }
          }
        ]
      }
    }
  }
}
```

### Delete

`confirmDialog` first, because deletion is not undoable. Then `sendCloudEvent`: a delete has nothing to validate and nothing for the server to reject, so it does not need `postForm`'s error surfacing. No `validateForm` either — the record is being removed, so a half-valid form must still be deletable.

Use `postForm` here instead if your endpoint can genuinely refuse a delete ("you cannot delete your last profile") and you want that message shown on the form. Mind the payload key when you switch: `postForm` sends the screen state as `data.form_data`, while `sendCloudEvent` sends the keys you declare under `data` — `formTypes.ts` declares `form`.

```json
{
  "id": "buttonDelete",
  "type": "button",
  "preset": "FormButtonDelete",
  "options": {
    "title": "Delete Profile",
    "extensions": {
      "tap_actions": {
        "actions": [
          {
            "type": "confirmDialog",
            "options": {
              "title": "Delete this profile?",
              "message": "This cannot be undone.",
              "okButtonText": "Delete",
              "cancelButtonText": "Cancel"
            }
          },
          {
            "type": "sendCloudEvent",
            "options": {
              "data": { "form": "@{screen/}" },
              "inflateData": true,
              "subject": "profile_delete",
              "type": "com.applicaster.post.data.v1",
              "url": "https://example.com/events"
            }
          },
          {
            "type": "navigateToScreen",
            "options": { "typeMapping": "manage-profiles" }
          }
        ]
      }
    }
  }
}
```

The delete payload carries the same screen state as a save — the record identifier rides along as a hidden key. The server tells the two apart by `subject`, and should read the identifier from `form_data` rather than trusting an edited display name as the lookup key.

## Server-Side Guide

The server participates in two directions: it passes initial values into the form through the selected entry's `extensions.form_data`, and it receives submitted values from the `postForm` CloudEvent under `data.form_data`.

### Passing Data Into the Form

When editing an existing object, include a `form_data` object in the selected feed entry. Keys must match the form field IDs.

```json
{
  "extensions": {
    "form_data": {
      "profileId": "a3JVE000005wcej2AA",
      "profileName": "Tester Test",
      "email": "profile.user@example.com",
      "displayName": "Big Test",
      "gender": "Male",
      "dob": "2000-05-31",
      "profileImage": "char:1741",
      "kidsProfile": false,
      "parentalControls": [
        "allow_comment",
        "allow_change_picture",
        "allow_change_name"
      ]
    }
  }
}
```

| Field type | Server prefill shape | Example |
| --- | --- | --- |
| `textInput` | String, or another primitive that can be stringified | `"Tester Test"` |
| `datePicker` | ISO 8601 string | `"2000-05-31"` |
| `singleSelect` | Selected option `const` string | `"char:1741"` |
| `multiSelect` | Array of selected option `const` strings | `["allow_comment", "allow_change_picture"]` |
| `checkBox` | Boolean preferred; tolerate `"true"` / `"false"` strings when receiving submissions | `false` |

For new objects, omit `form_data` or pass `{}`. Null and undefined values are not prefilled into visible fields.

### Multiselect Server Contract

- Send selected values as `string[]` in `extensions.form_data`.
- Receive selected values as `string[]` in the submitted `data.form_data`.
- Receive no selection as an empty array `[]`, including untouched multiselect fields.
- Do not send option objects as selected values. Option objects belong in the form configuration `items` or item feed, not in `form_data`.
- Do not rely on comma-separated strings at the server boundary. Comma-separated storage is an internal screen-state detail only.

Correct:

```json
{
  "parentalControls": ["allow_comment", "allow_change_picture"]
}
```

Incorrect:

```json
{
  "parentalControls": [{ "const": "allow_comment", "title": "Allow comments" }]
}
```

### Receiving Form Submissions

With `inflateData: true`, the app resolves `@{screen/}` — the whole screen state — before sending. The backend receives a CloudEvent envelope whose `data.form_data` contains the submitted values.

```json
{
  "specversion": "1.0",
  "type": "com.applicaster.post.data.v1",
  "datacontenttype": "application/json",
  "subject": "profile_update",
  "source": "aio://com.example.profileapp/versions/0.2.0-dev.1",
  "time": "2026-09-02T20:44:11.103Z",
  "data": {
    "form_data": {
      "profileName": "Tester Test",
      "kidsProfile": "false",
      "profileId": "a3JVE000005wcej2AA",
      "profileImage": "char:1741",
      "email": "profile.user@example.com",
      "displayName": "Big Test",
      "gender": "Male",
      "dob": "2000-05-31",
      "parentalControls": [
        "allow_comment",
        "allow_change_picture",
        "allow_change_name"
      ]
    }
  },
  "id": "26681f0c-796c-429b-bdab-176acba65f7c"
}
```

Read the form values from `data.form_data`. Treat the other CloudEvent fields as event metadata: `type` identifies the event kind, `subject` identifies the configured business action, `source` identifies the app and version, `time` records when the event was created, and `id` is the event identifier.

Normalize and validate the payload by field ID on the server. Important submitted shapes are:

| Field type | Submitted shape |
| --- | --- |
| `textInput` | String |
| `datePicker` | ISO 8601 string |
| `singleSelect` | Selected option `const` string |
| `multiSelect` | Array of selected option `const` strings; empty selection is `[]` |
| `checkBox` | Boolean when normalized from a declared checkbox field, but backends should also tolerate `"true"` and `"false"` strings from legacy flows or hidden values |

:::caution
Client-side `required`, `minLength`, and `minItems` are validated by `validateForm` on device as a user-experience affordance, not as enforcement. Re-check every constraint on the server.
:::

### Returning Server Validation Errors

If server validation fails, return a failed HTTP response whose body contains `formError`, `fieldErrors`, or both. `fieldErrors` must be keyed by form field IDs, not by UI component IDs.

```json
{
  "status": 400,
  "formError": "Profile could not be saved.",
  "fieldErrors": {
    "profileName": ["This profile name is already used."],
    "dob": ["Date of birth must be in the past."],
    "parentalControls": ["Select at least one parental control."]
  }
}
```

The form presents `formError` as a form-level message and presents `fieldErrors` inline on the matching fields. When the server returns field errors, the form submission action is cancelled so later actions in the button chain do not continue.

:::caution
The failure has to arrive as a **failed HTTP status**. A `200` carrying an error body is read as success, and the rest of the button's action chain — including navigation away from the form — runs anyway.
:::

### Backend Checklist

- Keep field IDs stable across form configuration, prefilled `form_data`, submitted payloads, and `fieldErrors`.
- Send multiselect selected values as arrays of option IDs.
- Treat missing optional values and empty arrays intentionally; `[]` means the user has no selected multiselect options.
- Store date values as ISO 8601 strings.
- Tolerate checkbox booleans and `"true"` / `"false"` strings when reading submitted payloads.
- Decide explicitly what to do with `form_data` keys you do not recognize. An allowlist of writable field IDs is the safe default: hidden identifiers still round-trip because you read them without writing them blindly.
- Return validation errors with `fieldErrors[fieldId] = string[]` and a failed HTTP status.
- Preserve hidden identifiers by including them in the initial `form_data` when they must come back on submit.
- Give each business action its own `subject` so one endpoint can serve save, delete, and any other write.

## Related

- [Profiles Feed](./profiles-feed.md) — the feed that lists profiles for the picker screen.
- [Cloud Events](./cloud-events.md) — envelope handling for the endpoint that receives submissions.
