# `StatifierBlocks.Editor.ConfigForm`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/editor/config_form.ex#L2)

The selected block's config form (ADR-0005 decisions 9 and 13).

The form is a **projection of current config, never a stateful mirror of
it**: `StatifierBlocks.ViewModel` re-derives `config_schema/1` against the
block's current config on every build, so a branch that gains an arm gains
a field on the very next render with nothing here to invalidate.

Two things this component does not do, both deliberate:

  * It does not decide what reaches the document. Decision 9's gate -
    an `:update_config` is applied only when `validate_config/1` returns
    `:ok` - lives in `StatifierBlocks.Edit.check_config/3`, which
    `StatifierBlocks.Edit.History.commit/4` calls. This component posts
    params; `StatifierBlocks.Editor` decodes them and offers the command.
  * It does not render an unresolvable block's config. There is no
    `config_schema/1` for one, and inventing a form would be guessing
    (decision 12), so `StatifierBlocks.Editor.BlockNode` shows canonical
    JSON read-only instead.

What it draws is `StatifierBlocks.ViewModel.shown_fields/1`'s list and
nothing else. The view model lists every declared field, `hidden?: true`
ones included, because it is a projection of the schema; the surface
filters (ADR-0002 decision 7, amended 2026-09-07, section F7). That
filter is written once, in the view model, and a host drawing its own
form calls the same function - so a change to what counts as shown
reaches both surfaces at once, with no second copy of the predicate here
to fall behind it. `Field.field/1` renders nothing for a hidden field
either; the two agree, and neither is load-bearing alone. A node
carrying no form at all is the same shape rather than a special case:
`shown_fields/1` answers `[]` for one, so a host that calls this
component directly with an unresolvable block's node draws an empty
form instead of raising.

**A host composes this call rather than re-writing it.** Two attrs are
what make that possible: `event` names what the form posts under, and
`target` may be omitted for a host whose form posts to the LiveView
itself. Neither changes what is drawn, and both default to what
`StatifierBlocks.Editor` already passes, so a caller that names neither
renders exactly what it rendered before they existed. The block the
params are about arrives without being asked for: the form posts it as
a hidden `block-id` input, which is where a host reads the id out of
its params. The field controls, their labels and their layout stay this
package's; the chrome around the form stays the host's.

`form.unrouted` renders at the head. That bucket exists because
`Core.Branch.config_schema/1` keys one field per arm by the arm's own
slot name while `validate_config/1` also emits findings keyed `"arms"` -
a key matching no field, because adding or removing an arm is a document
edit, not a form value. Rendering it here is what stops such a finding
from silently having nowhere to go.

# `candidates_for`

```elixir
@spec candidates_for(
  map(),
  StatifierBlocks.Block.type_name(),
  StatifierBlocks.ViewModel.Field.t()
) ::
  term()
```

The candidate list a host offered for one field, or `[]`.

Keyed `{type_name, field_key}`: the values belong to a field of a
block TYPE rather than to a block, because which values exist is a
property of the deployment and the same field on two blocks of one
type offers the same ones.

# `capture_rows`

The capture pairs, one two-control row each (ADR-0011 decision 10).

A row is a datamodel path written and a path inside the firing event's
`_event.data` read, and it is a repeated row rather than a field
because ADR-0002 decision 7's closed field-type set has no member that
describes a map and this record declined to add one. So the pairs have
no `config_schema/1` declaration to render from, and they are drawn
here from the config the editor already holds.

There is always **one blank row at the end**, and it is what adds a
pair: filling it writes a pair and the next blank row appears beneath
it. Clearing both controls of a row is what removes one. That is two
gestures rather than an add button and a remove button, and it is the
shape the pairs already have: a map with a blank key is not a pair, so
a row that says nothing is a row that is not there.

A stored map has no order of its own, so the rows before the blank one
are in their targets' sorted order - the order the emission already
fixes, for the same reason.

A **literal** pair - `{:const, value}` in the source position,
`["const", value]` in the stored document - draws a read-only row
instead of the two controls (ADR-0005's Note of 2026-09-13). Neither
control can author it: one takes a datamodel path and the other a path
inside the payload, and a literal is read from the document rather than
from either. Drawing it read-only is what makes the pair visible at
all, and `decode_capture/2` carries it over the wholesale replace so a
form that cannot draw its controls no longer deletes it on the next
post. The value is spelled with `inspect/1`, which is a rendering of
what the document holds and not the predicator literal `emit/2` writes.

## Attributes

* `rows` (`:list`) (required)
* `sources` (`:list`) (required)
* `target` (`:any`) (required)
* `block_id` (`:string`) (required)
* `debounce` (`:any`) - Defaults to `nil`.

# `config_form`

One block's form: unrouted findings, then a control per schema field.
## Attributes

* `node` (`StatifierBlocks.ViewModel.Node`) (required)
* `target` (`:any`) - What `phx-target` this form and its controls carry, or `nil` for none.

  `nil` is a host whose form posts to the LiveView it is mounted in
  rather than to a component inside it, and it renders no `phx-target`
  attribute anywhere - not on the form, not on a control, not on the
  Discard button. The editor passes its own component target and is
  unchanged by the default.

  Defaults to `nil`.
* `event` (`:string`) - The event name this form posts under, written to both `phx-change`
  and `phx-submit`.

  The default is the name `StatifierBlocks.Editor` handles, so every
  caller that names nothing is byte for byte what this component
  rendered before the attr existed. A host that draws one block's
  fields under its **own** `handle_event/3` names its own event here
  and reads the block the params are about out of the hidden
  `block-id` input the form already posts.

  Defaults to `"config-change"`.
* `class` (`:string`) - Defaults to `nil`.
* `expression_component` (`:any`) - Defaults to `nil`.
* `invoke_types` (`:list`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `[]`.
* `path_candidates` (`:list`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `[]`.
* `value_candidates` (`:map`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `%{}`.
* `path_types` (`:map`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `%{}`.
* `type_candidates` (`:list`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `[]`.
* `event_candidates` (`:list`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `[]`.
* `outcome_candidates` (`:list`) - Passed through to `StatifierBlocks.Editor.Field`; see its moduledoc. Defaults to `[]`.
* `fixtures` (`:any`) - The `fixtures` the editor holds, `%{block_id => [TruthTable.t()]}` or
  `nil`. Read here through `StatifierBlocks.Shell.tables_for/2` for the
  selected block's rows and passed to `StatifierBlocks.Editor.Field` as
  the fixture hint; nothing else is derived from it.

  Defaults to `nil`.
* `field_candidates` (`:map`) - The values a host offers, keyed `{type_name, field_key}`. Looked up
  here for the selected node's own type and handed to
  `StatifierBlocks.Editor.Field` one field at a time; see its moduledoc
  for the two spellings.

  Defaults to `%{}`.
* `capture_pairs` (`:any`) - The block's capture pairs as ordered `{target, source}` rows, or
  `nil` for a block that takes no capture map. `[]` is a block that
  takes one and has none yet, which still draws the row.

  A `source` is a binary - the path inside the firing event's payload -
  or `{:const, value}` for a literal pair (`["const", value]` in the
  stored document, ADR-0002's Note of 2026-09-12, `N1`). A literal row
  is drawn read-only: its value is not a path and neither of the two
  controls a capture row offers can author it (ADR-0005's Note of
  2026-09-13).

  Defaults to `nil`.
* `capture_sources` (`:list`) - The source keys a captured event's example payload carries, drawn as
  the source control's `<datalist>`. Empty renders a plain input.

  Defaults to `[]`.
* `pending` (`:list`) - The fields whose typed value is not in the document, in schema order.
  Empty when the block has no outstanding draft.

  Defaults to `[]`.
* `field_focus` (`:any`) - One-shot: the `config_key` of the field the source tab's click-through
  just asked to be focused, or `nil` for "leave focus alone".
  `StatifierBlocks.Editor` sets it on a click into a config-emitted span
  and clears it on every other route, the same discipline
  `StatifierBlocks.Editor.Drawer`'s `focus_tab` keeps for the tab strip.
  Resolved here against `@node.form.fields` rather than trusted verbatim,
  so a key belonging to some other block's form - stale, or never valid -
  focuses nothing.

  Defaults to `nil`.
* `read_only` (`:boolean`) - Whether this mount edits. A read-only form is not a form: it is a
  `<div>` of the same fields drawn through `Field.field/1`'s
  **readonly** branch - label and value, no control, nothing that posts
  (ADR-0005's 2026-09-07 profile amendment, `read_only?` clause 3). A
  disabled input is a control that refuses, and what is wanted here is a
  reading, which is why the same branch `readonly?: true` already renders
  one field with is what renders all of them.

  Defaults to `false`.
* `debounce` (`:any`) - What `phx-debounce` this form's controls carry, or `nil` for none.
  Handed to `StatifierBlocks.Editor.Field` and to the capture rows,
  which write it onto every control they render; that module's attr
  documents the accepted values and why every control means every
  control.

  The form posts `phx-change` on each change event and
  `StatifierBlocks.Editor` offers an `:update_config` for each one it
  decodes, so a host that persists on its own `on_change` writes once
  per keystroke unless it asks for something slower. Asking is what
  this attr is: how often a document is written is a decision about
  the host's storage, and the controls are this package's markup, so
  the host needs a way to say it here.

  `nil` renders no attribute anywhere, which is byte for byte what
  this component rendered before the attr existed. Nothing about
  decoding changes with it either: `decode/3` still reads only the
  params a post carried, and a field whose control did not post still
  keeps the value it had - a slower post is a *later* post, never a
  partial one.

  A read-only form draws no controls at all, so it has nothing to
  carry this and is not handed it.

  Defaults to `nil`.

# `decode`

```elixir
@spec decode(
  [StatifierBlocks.ViewModel.Field.t()],
  map(),
  StatifierBlocks.Block.config()
) ::
  StatifierBlocks.Block.config()
```

Decodes a `phx-change` payload into a config map, one field at a time
through `StatifierBlocks.Editor.Field.decode/2`.

Three properties, each of which is a bug if it is missing:

  * **It starts from `base`, the config the block already carries.** An
    `:update_config` command *replaces* a block's whole config, so a
    decode that built a fresh map from the schema alone would delete every
    key the schema does not name. ADR-0002 decision 7 makes
    `config_schema/1` a rendering hint rather than the authority, and
    ADR-0001 decision 9's principle - leave alone what you do not
    understand - applies to config keys as much as to block types.
  * **It is keyed off the schema, not off the params.** A param naming
    something the schema does not is ignored, so a crafted payload cannot
    inject config keys.
  * **A field whose control did not post keeps the value it had.** A
    partially rendered form does not blank out the fields it did not show.

A fourth, added 2026-09-07 with the `hidden?` / `readonly?` flags
(ADR-0002 decision 7's amendment, section F6, as the operator
ruled it): **a flagged field ignores any posted value for its key.**
The decode is keyed off the schema and a hidden field is in the schema,
so a crafted payload posting under a hidden field's key would otherwise
be decoded through a control no form ever drew. For a `hidden?: true` or
`readonly?: true` field the unposted branch is taken unconditionally,
which defends a key the form withheld and touches none of the three
properties above.

## Where a decoded value is written

A field's `key` names its control; where the value goes is the field's
`value_path` (ADR-0002 decision 7, amended 2026-08-27), which is `[key]`
unless the block type said otherwise. `Core.Branch` is the one core type
that says otherwise: its per-arm condition fields keep their slot-name
keys and declare `["arms", i, "cond"]`, so a branch's conditions are
editable here without this component ever inferring anything from the
shape of a key. Writing through the path is also what stops a top-level
`config["arm_approved"]` from accumulating beside the arm the author
actually edited.

## The one value that is not written at all

An empty `:duration` **omits its key** rather than storing `""`
(ADR-0005 decision 9, amended 2026-08-29). A cleared field and a
never-set field are the same value - there is no zero-duration
stand-in and no third
state for "the author touched this and then did not finish" - so the
two have to produce the same config, and the only config an absent key
can produce is one without the key.

This is the only place that can perform it. `Field.decode/2` returns a
value and every value it could return is a value the key would then
hold; whether a key exists is a property of the map, which is this
function's to write. The omission is still the ordinary path in every
other respect: the resulting config goes to the same whole-config gate
through `StatifierBlocks.Edit.check_config/3`, and a required duration
left empty is refused there rather than here.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
