StatifierBlocks.Editor.ConfigForm (StatifierBlocks v0.20.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:

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.

Attributes

  • rows (:list) (required)
  • sources (:list) (required)
  • target (:any) (required)
  • block_id (:string) (required)

config_form(assigns)

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

Attributes

  • node (StatifierBlocks.ViewModel.Node) (required)

  • target (:any) (required)

  • 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 %{}.

  • 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.

    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 [].

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.

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.