StatifierBlocks.Editor.ConfigForm (StatifierBlocks v0.29.0)

Copy Markdown View Source

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:

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.

Summary

Functions

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

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

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

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

Functions

candidates_for(field_candidates, type_name, field)

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(assigns)

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(assigns)

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 (sb-rd29) 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(fields, params, base \\ %{})

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, and campaign-SF036 ruling RQ-SF036-15): 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.