# Settings Fields (Declarative UI)

Most of Flowcrafter's settings UI is just data. A PHP method returns an array of
field definitions, and the builder renders the whole panel for you — inputs,
dropdowns, toggles, repeaters, the flow tag picker, the works. No React, no build
step, no JavaScript at all.

![A node settings panel using text, select, flow tag, and parameter controls](/documentation/flowcrafter/screenshots/send-email-settings.png)

This is the declarative API. If you can describe a field with a plain PHP
array, you never have to touch a component. When arrays aren't enough — a bespoke
picker, live data, custom interaction — reach for the [React API](/documentation/flowcrafter/extending/react-api/).

Any node handler or extension provider returns these arrays from a
`get_settings_fields()` method. New to writing nodes? Start with
[Writing a Node](/documentation/flowcrafter/extending/writing-a-node/); this page is the field vocabulary
both nodes and extensions share.

The sample classes used below are reference source in `docs/examples/extension-samples/`, not nodes or providers loaded by core. Copy and adapt them in your own integration plugin; see [Real Integrations](/documentation/flowcrafter/extending/examples/) for maintained production patterns.

![Declarative and React sample extension fields in HTTP Request settings](/documentation/flowcrafter/screenshots/editor-node-extensions.png)

## The field types

There are **17 field types**. Every field is a plain PHP associative array with a
few required keys — `name` (the settings key), `label`, and `type` — plus
whatever config the type accepts.

A few things worth knowing up front:

- An unknown `type` renders a visible `Unsupported field type: <type>` placeholder
  rather than silently vanishing, which makes typos easy to spot.
- `section` is the odd one out — it groups other fields instead of rendering a
  control of its own, so it's handled a level up from the rest.
- "Flow tags?" means the field can opt into the flow tag picker via
  `supports_flow_tags: true` (see [flow tag support](#flow-tag-support) below). Setting the
  flag on a type without flow tag support does nothing.

| # | `type` | Renders as | Key config props | flow tags? |
|---|--------|-----------|------------------|---------|
| 1 | `text` | Single-line text input | `placeholder` | Yes (opt-in) |
| 2 | `textarea` | Multi-line auto-growing text | `placeholder`, `rows` | Yes (opt-in) |
| 3 | `php_code` | Text field styled for PHP (CodeMirror) | `placeholder` | Yes (opt-in) |
| 4 | `number` | Numeric input | `placeholder`, `required`, `suffix` | Yes (opt-in) |
| 5 | `select` | Dropdown | `choices` (`[{label,value}]`), `placeholder`, `required` | Yes (opt-in) |
| 6 | `field_select` | Live GF form-field dropdown | `choices` (static fallback), `placeholder`, `required` | Yes (opt-in) |
| 7 | `checkbox` | Group of checkboxes | `choices` (`[{label,value}]`) | No |
| 8 | `toggle` | On/off switch, or a two-option segmented control | `segmented`, `group` (boolean `default`) | No |
| 9 | `key_value` | Repeater of key/value rows | `media_picker` (bool), `mime_types` | Yes (opt-in, value input) |
| 10 | `parameter_adder` | Preset-driven multi-row repeater | `presets`, `add_button_label`, `show_keys`, `custom_option_hint` | Yes (opt-in) |
| 11 | `entry_source` | Entry picker (`$input` / upstream node / literal) | `default` | Yes (opt-in) |
| 12 | `form_source` | Form picker with auto-detect badge | `default` | Yes (opt-in) |
| 13 | `conditions` | Flowcrafter's own rule builder | `subject` (names the sibling form/entry setting) | Yes (opt-in, both sides) |
| 14 | `media` | WP media-library picker | `mime_types` (`string\|string[]`), `multiple` (bool) | No |
| 15 | `notice` | Static callout banner | `variant` (`info\|success\|warning\|error`), `message` | No |
| 16 | `hidden` | Nothing — carries a stored value with no UI | — | No |
| 17 | `section` | Collapsible group of named child fields | `fields` (`string[]` of child names), `collapsed` (bool), `flat` (bool) | n/a |

A few type-specific notes:

- **`textarea`** — `rows` is the starting height in lines (defaults to 4, minimum
  of 2). It auto-grows a few lines before it starts scrolling.
- **`select`** — a non-required select shows a leading empty option, muted
  until something is picked: `placeholder`, or "Choose a {label}" built from
  the field's label ("Choose a body format"). Write placeholders the same way:
  sentence case, saying what to choose, no em dashes.
- **`number`** — `suffix` puts a muted unit inside the box after the value
  ("24 hours", "25 entries") and narrows it to 180px. Use it instead of a unit
  in the label ("Expiry", not "Expiry (hours)"). There are no spinner arrows.
- **`toggle`** — renders as a switch row: switch, label, then `tooltip` as a
  one-line description, in a bordered box. Set `segmented` when naming each
  state explains it better than on/off: `[ 'on' => 'Full entries', 'off' =>
  'Entry IDs only', 'on_help' => '…', 'off_help' => '…' ]` draws a two-option
  segmented control (on first) over the same boolean, and the matching help
  replaces `tooltip`.
- **`field_select`** — populates from the connected form's fields at render time;
  `choices` is the fallback when there's no live form to read.
- **`conditions`** — a rule builder: match `all` or `any` of a list of
  `source · operator · value` rows. `subject` decides what a rule's left side
  means, and it is the only prop that matters:

  | `subject` | Left side | Evaluated against |
  |---|---|---|
  | omitted | a flow tag or literal | an upstream node's output |
  | `'form_source'` or `'entry_source'` | a dropdown of that form's real fields | a Gravity Forms entry |

  Name the sibling setting that already picks the form — the one Flowcrafter
  injects for you when your handler implements `Requires_Form` or
  `Requires_Entry`. Getting this wrong is not cosmetic: it also picks which PHP
  evaluator runs, so a rule authored as a field ID but evaluated as a token
  compares the literal string `"3"` and never matches. Operators come from
  Gravity Forms, so add-on operators appear automatically.
- **`key_value`** — `media_picker: true` adds a "Choose from media library"
  button that appends one row per file (value = URL, key = filename).
- **`parameter_adder`** — each preset is
  `{ key, label, description, input_type, placeholder, group }`, where
  `input_type` is `number`, `text`, or `json`. There's always a "Custom
  Key/Value" row alongside the presets. `add_button_label` defaults to "Add
  Parameter".
- **`notice`** — read-only. `label` is the heading, `message` is the body.
- **`section`** — child fields are declared normally at the top level and listed
  by `name` in `fields`; they render *inside* the section instead of at the top.
  With `flat` set they render inline instead, with no accordion: the section
  only groups them for code, such as `autoInjectSettingsSections`.
  `collapsed` (default true) sets the initial state.

### Common optional keys

Every field type accepts these:

| Key | Type | Meaning |
|-----|------|---------|
| `default` | mixed | Initial value. |
| `allow_empty` | bool | For `number` fields, preserve an explicitly cleared value instead of restoring `default`. Omit it to keep the default-restoring behavior. |
| `tooltip` | string | Help text (rendered as the GF description / info tip). |
| `required` | bool | Marks the field required (honored by text-family, `select`, `field_select`, `number`). |
| `placeholder` | string | Input placeholder (text-family, `select`, `number`, `field_select`). |
| `group` | string | `toggle` only. Consecutive top-level toggles with the same value share one bordered fieldset under this heading, with each tooltip as its description. Current limitation: toggles moved into a `section` or claimed by composition render separately. |
| `conditional` | `{ field, operator: 'is'\|'is_not', value }` | Show this field only when a sibling field's value matches. Hidden fields render nothing. |
| `supports_flow_tags` | bool | Opt into the flow tag picker. See [below](#flow-tag-support). |

:::note[`media` is fully typed]
`media` is in the public `SettingsField` union, and its `mime_types`, `multiple`,
and `media_picker` props are declared — so it type-checks against the shipped
`.d.ts` with no casts or `// @ts-expect-error` needed.
:::

The reference sample logging node turns PHP field definitions into a flow tag-aware message field and a level selector.

![Log Message and Log Level controls rendered from PHP field definitions](/documentation/flowcrafter/screenshots/guide-sample-node-settings.png)

## Declarative nodes vs. declarative extensions

The same field arrays power two different jobs, through two different base
classes.

- **A whole new node** — your handler *owns* its settings panel.
- **Extending a built-in node** — your provider *appends* fields to a node
  someone else built, without touching it.

Here's the split side by side:

| | Declarative **node** | Declarative **extension** |
|---|---|---|
| Base class | `Abstract_Node_Handler` | `Abstract_Node_Extension_Provider` |
| Fields method | `get_settings_fields( int $form_id )` | `get_settings_fields( array $base_node, array $extension_settings )` |
| Registers via | `flowcrafter_register_nodes` | `flowcrafter_register_node_extensions` **and** `flowcrafter_register_node_extension_providers` |
| Targets | itself (a new node type key) | an existing node type (`get_base_node_type()`) |
| Values stored at | `data.settings.<name>` | `data.settings.extensions.<slug>.settings.<name>` |
| flow tag contract | implement `Resolves_Flow_Tags` if any field opts in | provider owns resolution; validate via `validate()` |
| Active / inactive | always active once registered | active only when a live provider is registered (metadata-only = upsell) |

### A whole new node

A node handler extends `Abstract_Node_Handler` and returns its entire settings
panel from `get_settings_fields( int $form_id )`. The `$form_id` is the node's
primary form (or `0` when there is no form context). Values save to `data.settings.<name>`.

```php
class Sample_Log_Node extends Abstract_Node_Handler implements Contracts\Resolves_Flow_Tags {
    use Traits\Resolves_Flow_Tags;

    public function get_settings_fields( int $form_id ): array {
        return [
            [ 'name' => 'log_message', 'label' => 'Log Message', 'type' => 'text',
              'default' => '', 'placeholder' => 'Enter a message to log',
              'supports_flow_tags' => true, 'required' => true ],
            [ 'name' => 'log_level', 'label' => 'Log Level', 'type' => 'select',
              'default' => 'info',
              'choices' => [
                  [ 'label' => 'Info',    'value' => 'info' ],
                  [ 'label' => 'Warning', 'value' => 'warning' ],
                  [ 'label' => 'Error',   'value' => 'error' ],
              ] ],
        ];
    }
    // + get_label / get_category / get_description / execute / get_output_* …
}
```

Register the type against the `flowcrafter_register_nodes` hook:

```php
add_action( 'flowcrafter_register_nodes', function ( string $registry_class ): void {
    $registry_class::register( 'SampleLogNode', Sample_Log_Node::class );
} );
```

Because `log_message` declares `supports_flow_tags: true`, the handler **must**
implement the `Resolves_Flow_Tags` contract — Flowcrafter throws an
`InvalidArgumentException` naming the offending field if you declare a flow tag
field without it. Declaring a flow tag field is a promise to resolve it at runtime.

### Extending a built-in node

An extension *provider* implements `Node_Extension_Provider` (usually via
`Abstract_Node_Extension_Provider`) and targets an existing node type. Its fields
render in that node's modal as an extra section — the built-in node is untouched,
and its values save under a namespaced key so they never mix with the node's own
settings.

Note the different method signature — a provider gets the base node and the
current extension settings, **not** a form id:

```php
public function get_settings_fields( array $base_node, array $extension_settings ): array
```

Registration is two steps, and both matter:

```php
// 1. Metadata — what the builder lists, gating, upsell copy.
add_action( 'flowcrafter_register_node_extensions', function ( string $registry_class ): void {
    $registry_class::register( 'HTTPRequestNode', 'sample-declarative', [
        'label'        => 'Sample Declarative Extension',
        'plugin_slug'  => 'flowcrafter',
        'product_line' => 'gravity_connect',
        'value_prop'   => 'Sample extension showing declarative settings fields.',
    ] );
} );

// 2. The live provider instance — this is what makes the extension "active".
add_action( 'flowcrafter_register_node_extension_providers', function ( string $registry_class ): void {
    $registry_class::register( new Sample_Declarative_Provider() );
} );
```

Why two registries? They answer two different questions:

- **Metadata** feeds the builder UI so it can *show* an extension (and its upsell)
  even when the plugin providing it isn't active. That's how a locked node still
  advertises what it could do.
- **Provider** holds the actual instance. Present = the extension is available and
  active; absent = the metadata still lists it, but greyed out with an upsell. The
  builder filters on `available` before rendering, so an inactive extension never
  paints its fields.

The registry is keyed by `(base_node_type, slug)`, so one plugin slug can enhance
several node types with distinct providers.

You also get **settings validation for free**: a declarative provider derives a
JSON schema from its own `get_settings_fields()`, so saved values are validated
without any extra code.

## Where extension fields land

By default, an active declarative extension gets one appended **collapsible
section** — titled with your `label`, holding your fields, open by default. No
slot registration, nothing to wire. Field ids are prefixed `ext-<slug>-<name>` so
they can't collide with the core node's fields.

For tighter integration — putting your UI *next to* or *over* a specific core
field instead of in a section at the bottom — Flowcrafter has a **composition**
system. An extension files a *claim* against a named section of the node with a
**role** that says how its content should sit relative to the core fields:

| Role | What it does |
|------|--------------|
| `replace` | Your content **replaces** the covered core fields and paints the whole section. Exclusive — highest `priority` wins (default 10). |
| `prepend` | Your content is injected **before** the section's core fields. Every prepend claim is hosted. |
| `append` | Your content is injected **after** the section's core fields. Every append claim is hosted. |
| `inline` | Your content renders **inside** a core field's own render slot; Flowcrafter just hides your standalone section and leaves the core field in place. (This is how GP Advanced Conditional Logic takes over a `conditions` field.) |

Composition is registered from JavaScript, so it's technically a React seam — but
the content those slots host can still be plain declarative fields. It's worth
knowing the roles exist even if you start with the simple appended section. The
full mechanics live in the [React API](/documentation/flowcrafter/extending/react-api/).

:::note
Composition only applies to nodes using the declarative renderer. A node with a
custom React `settingsComponent` doesn't expose these slots — extensions fall
back to the default appended section there.
:::

## Flow tag support

Making a field flow tag-aware is **one flag**: `supports_flow_tags: true`. That's the
entire declarative flow tag API.

When it is on, the field receives upstream flow tag groups and shows **Insert flow tag** on its label row. Number fields use the same label action and switch to a flow tag input after a tag is selected. Select and source fields use a **List / Custom** switch: List keeps the normal choices, while Custom accepts flow tags and text. When support is off or absent, the flow tag controls are not shown. You don't assemble the flow tags — the builder supplies them from the node's
upstream data and any test-run outputs. Opting in is all you do.

```php
[ 'name' => 'custom_header', 'label' => 'Custom Header', 'type' => 'text',
  'default' => '',
  'placeholder' => 'X-Custom-Header: value',
  'supports_flow_tags' => true ],   // ← adds the flow tag picker
```

Two things to know:

- **The flag is strict.** Flowcrafter normalizes it to a real boolean before
  anything renders — only a literal `true` counts. `"true"`, `1`, or a malformed
  value all become `false`. So the frontend can trust it.
- **Only these types honor it:** `text`, `textarea`, `php_code`, `number`,
  `select`, `field_select`, `key_value`, `parameter_adder`, `entry_source`,
  `form_source`, `conditions`. Setting it on `checkbox`, `toggle`, `notice`,
  `section`, `hidden`, or `media` does nothing.

There's no per-field flow tag allow-list or custom flow tag source in the declarative
API — it's a boolean. If you need a bespoke flow tag experience (say, an
array-path picker), that's a job for a custom React `settingsComponent` in the
[React API](/documentation/flowcrafter/extending/react-api/).

## A minimal declarative extension

Here's the whole thing — a PHP-only extension on the built-in [HTTP Request](/documentation/flowcrafter/nodes/actions/http-request/) node,
no JavaScript bundle. Every interface method is implemented, but the ones that
don't apply stay trivial.

```php
namespace Flowcrafter;

class Sample_Declarative_Provider extends Abstract_Node_Extension_Provider {

    // Stable machine slug — the storage namespace is settings.extensions['sample-declarative'].
    public function get_slug(): string { return 'sample-declarative'; }

    // Which built-in node this attaches to. (base_node_type, slug) is the registry key.
    public function get_base_node_type(): string { return 'HTTPRequestNode'; }

    // Section heading shown in the modal.
    public function get_label(): string { return 'Sample Declarative Extension'; }

    // Listing / gating / upsell metadata surfaced via REST.
    public function get_metadata(): array {
        return [
            'label'        => $this->get_label(),
            'plugin_slug'  => 'flowcrafter',
            'product_line' => 'gravity_connect',
            'value_prop'   => 'Sample extension showing declarative settings fields.',
        ];
    }

    // THE declarative UI. Plain field arrays — rendered automatically.
    // Signature is ($base_node, $extension_settings) — NOT a form id.
    public function get_settings_fields( array $base_node, array $extension_settings ): array {
        return [
            [ 'name' => 'endpoint_preset', 'label' => 'Endpoint Preset', 'type' => 'select',
              'default' => 'none',
              'choices' => [
                  [ 'label' => 'None',       'value' => 'none' ],
                  [ 'label' => 'Production', 'value' => 'prod' ],
                  [ 'label' => 'Staging',    'value' => 'staging' ],
              ],
              'tooltip' => 'Select a preset endpoint environment.' ],

            [ 'name' => 'log_level', 'label' => 'Verbose Logging', 'type' => 'toggle',
              'default' => false,
              'tooltip' => 'Enable verbose logging for this extension.' ],

            [ 'name' => 'custom_header', 'label' => 'Custom Header', 'type' => 'text',
              'default' => '',
              'placeholder' => 'X-Custom-Header: value',
              'supports_flow_tags' => true ],   // ← the one flag that turns on the flow tag picker
        ];
    }

    // Extra output flow tags this extension contributes downstream. Return $base_schema unchanged if none.
    public function get_output_schema( array $base_schema ): array {
        return [ 'preset_used' => [ 'label' => 'Preset Used', 'type' => 'string' ] ];
    }

    // Save-time validation of this extension's own settings. Return WP_Errors to block; [] = OK.
    public function validate( array $base_node, array $extension_settings, array $context ): array {
        return [];
    }

    // 'declarative' = render settings_fields automatically, no JS bundle.
    // 'react' would require window.flowcrafter.registerNodeExtension() + a component.
    public function get_render_mode(): string { return 'declarative'; }
}
```

After adapting and registering this sample in an integration plugin, open an **HTTP Request** node to find a collapsible **Sample Declarative Extension** section with a preset dropdown, a verbose-logging toggle, and a flow tag-aware Custom Header field — zero React. Values save to
`data.settings.extensions['sample-declarative'].settings.{…}`, and you read them
back at runtime with
`Node_Extension_Provider_Registry::get_settings_for_node( $node, 'sample-declarative' )`.

This example is illustrative — a one-field provider is perfectly valid. You must
implement every interface method (`get_slug`, `get_base_node_type`, `get_label`,
`get_metadata`, `get_settings_fields`, `get_output_schema`, `validate`,
`get_render_mode`), but `get_output_schema` can return `$base_schema` untouched
and `validate` can return `[]`.

## When declarative isn't enough

Reach for the [React API](/documentation/flowcrafter/extending/react-api/) when you need live data, a custom
picker, a bespoke flow tag experience, or want your fields woven into specific core
fields via composition. Everything else — inputs, dropdowns, repeaters, toggles,
notices, the standard flow tag picker — is just an array.
