StatifierBlocks.Declarations (StatifierBlocks v0.17.0)

Copy Markdown View Source

The declarations panel's arithmetic: every author gesture on the document's datamodel key, as a pure list-to-list function (ADR-0005's 2026-09-01 amendment, clauses 2g-2m).

Deliberately outside StatifierBlocks.Editor.*, and for decision 1's reason: the editor's job is translation, so which entry moved and what the new list is are decided here and asserted with phoenix_live_view absent from the dependency tree. Nothing in this module renders, and nothing in it writes a document - every function returns a candidate list, which the caller hands to {:set_datamodel, entries} and the command either accepts or refuses (2h).

Every function is total, and out of range is a no-op

An index arrives from a phx-value-index attribute, so a stale one is an ordinary race - the author pressed a row's remove button on markup that a concurrent change had already shortened - and a crafted one is a payload, not a bug. Both get the same answer: the list unchanged. That is the same stance StatifierBlocks.Shell.drawer_tab/2 takes for a tab name off the wire, and it is what keeps the panel free of an error state nobody can act on.

Why add/1 mints a name instead of appending a blank row

ADR-0001 11b gives id no empty spelling: it is ~r/\A[a-z][a-z0-9_]*\z/ or it is refused. A blank row would therefore be a list {:set_datamodel, entries} refuses, so pressing Add would produce a refusal rather than a row - the author would have to type a legal name before the gesture they already made took effect.

add/1 mints root_1, root_2, ... - the first that no entry already holds - so the list a press produces is always one the command accepts. This is decision 2's minting discipline in a second place and for the same reason it gives there: the value is completed at gesture time and the recorded command carries a finished list, so replaying a command log yields the same document every time.

Summary

Types

The three fields ADR-0001 11b gives an entry, as they arrive from a form.

Functions

Appends a freshly named entry: root_1, or the first root_N no entry already holds.

The entry list a phx-change payload asks for: every field the form carries, written onto the entry at index.

How many declarations the document carries - the drawer strip's count for the Declarations tab.

Swaps the entry at index with its neighbour in direction.

Writes one field of the entry at index.

A refusal from {:set_datamodel, entries}, as one sentence for the panel.

Drops the entry at index, or returns entries unchanged when no entry sits there.

Types

field()

@type field() :: :id | :expr | :description

The three fields ADR-0001 11b gives an entry, as they arrive from a form.

Functions

add(entries)

Appends a freshly named entry: root_1, or the first root_N no entry already holds.

expr and description are nil, which is the absent spelling ADR-0001 11b gives both and the one 11d omits from the canonical bytes. The new root therefore reads as undefined until something assigns it, which is what an author who has just named a root has said about it.

iex> StatifierBlocks.Declarations.add([])
[%StatifierBlocks.Document.DatamodelEntry{id: "root_1", expr: nil, description: nil}]

iex> entry = %StatifierBlocks.Document.DatamodelEntry{id: "root_1"}
iex> [_kept, minted] = StatifierBlocks.Declarations.add([entry])
iex> minted.id
"root_2"

change(entries, index, params)

The entry list a phx-change payload asks for: every field the form carries, written onto the entry at index.

Keys the form did not send are left alone, so a panel that grows a field later does not silently blank the ones beside it, and a key outside 11b's three is ignored rather than written - the payload is untrusted input and this is the only place it becomes an entry.

The id field arrives under the name "name". That is not a rename of 11b's field, which is still id on the struct and in the bytes; it is the one spelling LiveView leaves alone. An <input name="id"> inside a form overrides the form element's own DOM id, which LiveView refuses at compile time, so the wire name and the struct key differ here and this table is where they are reconciled.

iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "a"}]
iex> params = %{"name" => "signup", "description" => "the wizard", "nope" => "x"}
iex> [entry] = StatifierBlocks.Declarations.change(entries, 0, params)
iex> {entry.id, entry.description}
{"signup", "the wizard"}

count(entries)

@spec count(term()) :: non_neg_integer()

How many declarations the document carries - the drawer strip's count for the Declarations tab.

iex> StatifierBlocks.Declarations.count([%StatifierBlocks.Document.DatamodelEntry{id: "a"}])
1
iex> StatifierBlocks.Declarations.count(nil)
0

move(entries, index, direction)

Swaps the entry at index with its neighbour in direction.

Order is the emission order of the document's <data> elements (ADR-0001 11a), so this is an authoring act with a visible meaning in the compiled chart rather than a display preference - which is why it is a document edit and not editor state.

A move off either end is a no-op, so the first row's "up" and the last row's "down" are inert rather than wrapping. Wrapping would make one press of a repeated gesture do the opposite of the press before it.

iex> a = %StatifierBlocks.Document.DatamodelEntry{id: "a"}
iex> b = %StatifierBlocks.Document.DatamodelEntry{id: "b"}
iex> StatifierBlocks.Declarations.move([a, b], 1, :up) |> Enum.map(& &1.id)
["b", "a"]
iex> StatifierBlocks.Declarations.move([a, b], 0, :up) |> Enum.map(& &1.id)
["a", "b"]

put(entries, index, field, value)

Writes one field of the entry at index.

expr and description are optional in ADR-0001 11b and absent is spelled nil on the struct, so a cleared text input - the empty string a browser sends for a field the author emptied - becomes nil here rather than "". The alternative is a value 11b refuses and 11d would never encode, reached by the ordinary act of clearing a box.

id is written through verbatim, blank included. It is required, so there is nothing for a blank to mean, and the refusal the command answers with is the panel's message to the author (2l) rather than something to paper over here.

iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "a", expr: "1"}]
iex> StatifierBlocks.Declarations.put(entries, 0, :expr, "") |> hd() |> Map.get(:expr)
nil
iex> StatifierBlocks.Declarations.put(entries, 0, :id, "signup") |> hd() |> Map.get(:id)
"signup"

refusal(arg1)

@spec refusal(term()) :: String.t()

A refusal from {:set_datamodel, entries}, as one sentence for the panel.

ADR-0005 decision 11's anchors name a block, a slot or a config key, and none of them can name a declaration entry, so a refusal here is not a %StatifierBlocks.Finding{} and does not enter the findings pipeline (2l). It is rendered in the panel that produced it, which is the only place it is about.

Anything this function does not recognize gets the generic sentence rather than an inspected tuple: the panel is read by an author, and a term it cannot phrase is a term the author cannot act on either.

iex> StatifierBlocks.Declarations.refusal(
...>   {:malformed_envelope, {:datamodel, {:duplicate_id, "signup"}}}
...> )
~s(Two declarations are named "signup". Every id must be unique.)

iex> StatifierBlocks.Declarations.refusal(:something_else)
"That change was refused."

remove(entries, index)

Drops the entry at index, or returns entries unchanged when no entry sits there.

iex> entries = [%StatifierBlocks.Document.DatamodelEntry{id: "signup"}]
iex> StatifierBlocks.Declarations.remove(entries, 0)
[]
iex> StatifierBlocks.Declarations.remove(entries, 4) == entries
true