StatifierBlocks.Editor.Field (StatifierBlocks v0.22.0)

Copy Markdown View Source

One config field, dispatching on the closed field-type set (ADR-0005 decision 9, ADR-0002 decision 7).

The set is closed precisely so this renderer can be total, and the mapping is the record's rather than this module's:

Field typeRendering
:stringsingle-line text input; a <select> over a host's closed candidate list, or a <datalist> over an open one, when it supplied either
:integernumber input, step 1
:booleancheckbox
{:select, choices}select, choices in declared order
:expressionstatifier-ui's expression editor when that package is present, else a single-line source input
:durationone text control; duration strings the expression language reads, with on-screen examples
{:list, t}repeatable rows of t's renderer, with add and remove
{:path, opts}single-line text input bound to a <datalist> of the declared datamodel paths; the plain input when there are none

:duration's row is decision 9 as amended 2026-08-29 and again 2026-09-05 (clause 9a, one grammar). One text control, not a value/unit pair and not a pair with an escape hatch beside it: the compound control could not spell a compound duration at all, and a control plus an escape hatch is two ways to say one thing with a rule about which wins. The field reads one grammar, the examples are on screen, and an empty field omits the key.

What the typed text means is StatifierBlocks.DurationInput's, not this module's - it is a function of the text alone, so it is asserted with LiveView absent. Where an omitted key is omitted is StatifierBlocks.Editor.ConfigForm's, which owns where a decoded value is written. This module renders the control and shows the refusal.

The refusal shown beneath a :duration is the inline check, and it is earlier than decision 9's gate rather than a second one: the gate still decides what reaches the document, and the inline sentence tells the author the text is not a duration while they are still typing it. The sentence names what is accepted and nothing else, which is clause 9d. Nothing here is stored - the stored form is the author's string verbatim, byte for byte.

A required field is marked with the word, not with an asterisk (parity item 1.9). An asterisk is a convention that has to be learned from a legend the editor does not have, it is read aloud as "star", and it is one character wide next to a label it is easy to miss. Required says the same thing to a reader and to a screen reader, and it is the field record's own required? that decides - never a key name and never a type.

Two control types carry a placeholder, and neither is chosen by key or by type name: an :expression says what kind of thing belongs in it, and a :duration shows the spelling it stores. A bare :string says nothing, because there is nothing a type as wide as "string" can suggest - which is the rule, and it is what makes the two that do say something the editor's own presentation rather than a special case.

Whether a block type may declare a placeholder on its own fields is a question this module deliberately does not answer: ADR-0002 decision 7 closes the field TYPE set, not the keys of a field record, so admitting one is a widening of that record and belongs to whoever amends it.

:expression renders through the expression_component seam. Predicator source is statifier-ui's subject (sui-bob, sui-ADR-0006), and decision 9 records a richer affordance as a deferral, so this component accepts an override for exactly that seam - and, since sb-m6e0, fills the seam itself when the package the deferral names is on the load path.

Which control an :expression gets

Three answers, in this order, and the order is the whole rule:

  1. an expression_component the host passed - the host asked for its own control and gets it, whatever else is available;
  2. StatifierUI.Live.ExpressionInput, when statifier_ui resolves - value picklists over the subset predicator can round-trip, a text input over everything else, and the author's own source string either way;
  3. the plain source input this package has always rendered, with the <datalist> of declared paths described below.

statifier_ui is an optional dependency, resolved the way phoenix_live_view is: absent, clause 3 is what an :expression renders, nothing raises, and nothing warns at compile time. The resolution is a runtime Code.ensure_loaded?/1 against a module read from :statifier_blocks, :expression_component_module, which is the same indirection statifier-ui itself uses for Predicator.Simple - it is what makes clause 3 assertable on a machine where clause 2 resolves.

Two properties of clause 2 are load-bearing and neither is this module's to weaken. The component never refuses a source string and never rewrites one: source it cannot draw as rows is drawn as text. And every control it draws writes a complete expression source string into the same named input the text mode edits, so the document still stores the author's text and this package still holds no structured expression model of its own.

value_candidates

The values a host offers per datamodel path, %{path => [candidate]}, where a candidate is %{label: , value: } or a bare string. It reaches the expression_component beside candidates and is read by whatever is behind the seam; nothing in this package interprets it, because only a host knows which of its own paths have a value set at all. A path with no entry gets a free-text value control, which is the same "suggests, never constrains" posture the path <datalist> takes.

path_types

The value kind the host's datamodel document declares per path, %{path => kind | {:list, kind} | {:one_of, values}}, from StatifierBlocks.Datamodel.path_types/1. It reaches the expression_component beside candidates and value_candidates, and like both of those, nothing in this package interprets it.

What is behind the seam reads a declared kind as which operators the row offers and which control it draws - so a path the document declares integer offers the numeric operators rather than the ones its current source happens to imply. It is not a claim about the author's source: the operator the source carries is still offered, the value in it is still kept, and a disagreement renders as an advisory beside the clause. %{}, which is what an editor with no datamodel supplies, is the behaviour every :expression field had before the map existed.

candidates

The values a host says belong in THIS field, supplied per {type_name, field_key} through the editor's field_candidates assign and handed down one field at a time. Two spellings, and the difference between them is the whole feature:

  • [{value, label}] - a closed list. The control is a <select>, because a host that named the values is saying these are the values.
  • {:open, [{value, label}]} - an open list. The control is the text input with a <datalist>, on the terms every other suggestion list here is on: it suggests, it does not constrain, and free text stays valid.

[] is no list supplied and renders whatever the field type already rendered, so a host that supplies nothing loses nothing.

Three properties, and none of them is this component's choice to make:

  • It never decides validity. validate_config/1 is the authority (ADR-0002 decision 7), so a closed list is a control and not a rule. A stored value the list does not offer is drawn as its own selected option rather than silently rewritten to the first one, which is what a <select> would otherwise do to a document the moment its form was opened.
  • It is a :string field's affordance. A typed control - a {:select, choices}, a :duration, a {:path, opts} - already knows what to draw, and a second source of options on top of one would be two answers to one question.
  • It is host state, not authoring state. Which values exist is a property of the deployment the document runs in, so it arrives as an assign and is never stored in a block.

It is read ahead of the three key-chosen lists below, because a list keyed on this field is the narrower claim.

event_candidates (sb-82mu)

The completion events the blocks in a core.on_event's enclosing body raise, each %{label: , value: }, where the value is the generated done.outcome.<state id>.<outcome> name and the label is the sibling block's own label and that outcome's name. They are drawn as a <datalist> on the event field, on exactly the terms the invoke_type list is drawn on: the field stays a :string, a free-typed name is validated as it always was, and an empty list draws the plain input rather than an empty picker.

The list is derived by StatifierBlocks.Editor for a selected core.on_event and is empty for every other selection, so the three other core types that declare an event key are unaffected. Nothing in this module tests for a block type to decide it.

outcome_candidates (sb-r4w7)

The outcomes a host says the chart a core.subchart names actually finishes with, as plain names. They are drawn as a <datalist> on the outcomes field on exactly the invoke_type list's terms: the field stays a :string, a free-typed name is validated as it always was, and an empty list draws the plain input rather than an empty picker.

The list is looked up by StatifierBlocks.Editor from its chart_outcomes assign for a selected core.subchart, and is empty for every other selection. Nothing in this module tests for a block type.

The fixture hint (sb-e30x)

fixture_hint is StatifierBlocks.Shell.fixture_hint/3's answer for this field, and it is drawn as a sibling element after the control: the exemplar the selected block's first fixture row in declaration order binds to the path the source names, with every distinct value that path takes across the block's rows on the element's title. ADR-0005's 2026-09-05 note records it, and three of its properties are the reason it is here rather than anywhere else.

  • It is a hint, not a placeholder. The rule above - exactly two control types carry a placeholder, and neither is chosen by key or by type name - is untouched. The hint is a third element with its own text, not a third placeholder source.
  • It is never an option. Nothing about it reaches a picker, it is not merged with one_of or with a host's value_candidates, and it cannot be selected. A fixture value is an example, and an example promoted into a dropdown becomes a declaration the author never made.
  • It adds no assign to the rendering package. The hint is not passed through the expression_component seam and that component gains no key; this package draws it out of the fixtures the editor already holds. Widening another package's API to draw this package's own decoration is what the seam's shape exists to prevent.

nil - a block with no fixture rows, or no fixtures source at all - draws no element, so such a block renders exactly as it did before. That is silence rather than an empty affordance, which is the same thing the empty <datalist> cases below do.

The :expression path suggestions (sb-0vt)

That plain input gains a <datalist> of the declared datamodel paths when path_candidates is non-empty, on exactly the invoke_type terms below: it suggests and does not constrain, free text stays valid, an undeclared path stays the :info advisory StatifierBlocks.Datamodel already produced rather than becoming a refusal, and an empty list renders the input the package has always rendered. The same list reaches the expression_component override as :candidates, so a host that fills the seam is handed the paths rather than re-deriving them.

This is the data, not the feature. Decision 9's deferral of rich expression editing to statifier-ui is untouched, and one property of a <datalist> is why that matters rather than being a formality: the browser matches options against the input's whole value, so the list is live while the author is typing the leading path and goes quiet the moment the expression grows an operator. That is genuinely useful for the bare-path condition and for the first token of any other, and it is not completion. Mid-expression completion needs to know where the caret is inside the source, which needs either a hook this package may not add (decision 7's two-hook limit) or the richer component decision 9 defers - and it needs predicator's operator vocabulary, which px-15q tracks.

Ordering follows the same reasoning as the clause order below: the override wins over the datalist, because a host that supplied a component asked for its own control and getting the package's suggestion markup stapled beside it would be the package overriding the override.

This module is a renderer, not a gate. Nothing here decides whether a value is acceptable: validate_config/1 does, through StatifierBlocks.Edit.check_config/3, which is why an unparseable integer reaches the draft config as the string the author typed rather than being silently coerced or dropped.

The {:path, opts} control

Decision 7's eighth field type holds a path into the host's datamodel, and it reaches its control by type, which is the whole reason the type exists beside the datamodel_path?: true key: a host that never learned the key wrote type: :string and got neither a candidate list nor an advisory, and nothing in its declaration said anything was missing.

The control is the same <datalist> of path_candidates the :expression input carries, on the same terms - it suggests and does not constrain, free text stays valid, and an empty list renders the plain text input a :string rendered, which is what "no datamodel supplied" looks like on screen. It is not a {:select, choices}, because the declared paths are what a host happens to have declared and not the set of values a field may hold: validate_config/1 is still the only authority on that, and an undeclared path is still ADR-0005 clause 11e's :info advisory anchored on this field's key, never a refusal.

opts is read by nothing here, and its two keys are why that is worth saying rather than obvious. expects and writes (ADR-0011 decision

  1. are claims about the document's data flow at the path, read by StatifierBlocks.Environment and reported by StatifierBlocks.Assignability as a finding on this field's own key. They are not claims about the bytes this control edits, so the control is the same one either way and an author sees the difference in the findings beneath it. A {:path, opts} inside a {:list, t} renders as the row fallback text input, since a list row draws no suggestion markup for any type.

The invoke_type suggestion list

invoke_types is the one control this module chooses by key, and the exception is deliberate rather than an oversight of the rule above. A host that knows which invoke types it has registered can pass them as an editor assign, and an invoke_type field then renders as a text input bound to a <datalist> of those strings. With the assign absent or empty the same field renders as the plain text input it has always been, so the suggestion list is additive and a host that supplies nothing loses nothing.

Three properties make this a suggestion rather than a vocabulary, and each of them is ADR-0004 decision 8 rather than a choice made here:

  • Free text stays valid. A <datalist> suggests; it does not constrain, which is exactly why it is the control used and a {:select, choices} is not. An author can type a type that is not on the list and the editor stores it verbatim.
  • An unknown type stays a lint. The two-registry check is the compiler's opt-in :known_invoke_types lint, and it reports; it never refuses. Nothing here changes what compiles.
  • The handler set is deployment state, not authoring state. Which types a host can actually run is a property of the deployment the document is run in, not of the document, so it arrives as an assign the host fills in and never as anything stored in the block.

The assign shares its name with the compiler's invoke_types surface - StatifierBlocks.Compiled's field and the compiler's :known_invoke_types option - and the two are separate surfaces that happen to describe the same vocabulary from opposite ends. The compiler's is derived from a document: the sorted set of types that document actually emits. This one is supplied by a host: the types it is prepared to answer. Neither reads the other.

Keying a control on a field's key is a narrower thing than the placeholder question above, which is why it does not reopen it: a block type declaring an invoke_type field of some other type keeps that type's control, because this clause is reached only after every typed clause has had its turn.

Summary

Functions

Decodes one field's slice of a form's params back into a config value, dispatching on the same closed type set the renderer does.

One field: its label, its control, and its own findings (decision 11).

The DOM id for a field's control. Part of decision 7's DOM contract.

The form param name a field's control posts under.

Functions

decode(arg1, raw)

Decodes one field's slice of a form's params back into a config value, dispatching on the same closed type set the renderer does.

Total, and deliberately non-coercing at the edges: an integer field whose input does not parse yields the string the author typed, so validate_config/1 reports it and decision 9's gate keeps it out of the document. Coercing to zero here would silently discard the author's intent and commit a value they never asked for.

field(assigns)

One field: its label, its control, and its own findings (decision 11).

Attributes

  • field (StatifierBlocks.ViewModel.Field) (required)

  • target (:any) (required)

  • class (:string) - Defaults to nil.

  • expression_component (:any) - Override for :expression, per ADR-0005 decision 9's seam. Receives the same assigns. Defaults to nil.

  • invoke_types (:list) - The invoke types the host is prepared to answer. Suggestions for an invoke_type field, never a constraint on it; empty is no list supplied and renders the plain input.

    Defaults to [].

  • path_candidates (:list) - The declared datamodel paths, from StatifierBlocks.Datamodel.candidates/3. Suggestions on an :expression field and on a {:path, opts} one, and passed to expression_component as :candidates; empty renders the plain input.

    Defaults to [].

  • value_candidates (:map) - The values a host offers per datamodel path, %{path => [candidate]}. Passed to expression_component as :value_candidates and read only there; %{} offers none and a path with no entry gets free text.

    Defaults to %{}.

  • path_types (:map) - The kinds the host's datamodel declares per path, from StatifierBlocks.Datamodel.path_types/1. Passed to expression_component as :path_types and read only there; %{} declares none and a path with no entry renders as it always did.

    Defaults to %{}.

  • event_candidates (:list) - The completion events the enclosing body's blocks raise, each %{label: , value: }. Suggestions for a core.on_event event field, never a constraint on it; empty is no list supplied and renders the plain input.

    Defaults to [].

  • outcome_candidates (:list) - The outcomes the host says the referenced chart finishes with, as plain names. Suggestions for a core.subchart outcomes field, never a constraint on it; empty is no list supplied and renders the plain input.

    Defaults to [].

  • candidates (:any) - The values a host offers for this field, [{value, label}] for a closed list or {:open, [{value, label}]} for an open one. [] is no list supplied and renders the control the field type already rendered.

    Defaults to [].

  • fixture_hint (:any) - StatifierBlocks.Shell.fixture_hint/3's answer for this field, or nil. Drawn as an element beside the control, never passed through the expression_component seam and never an option.

    Defaults to nil.

input_id(arg)

The DOM id for a field's control. Part of decision 7's DOM contract.

input_name(arg)

@spec input_name(StatifierBlocks.ViewModel.Field.t()) :: String.t()

The form param name a field's control posts under.