# `StatifierBlocks.Declarations`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/declarations.ex#L1)

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.

## The accepted events

The panel's second row is the document's `accepts` list (ADR-0014
decision 6): the names of the external events the document accepts,
written through `{:set_accepts, names}` on the terms the roots are
written through `{:set_datamodel, entries}`. `remove/2` and `move/3` serve
both lists unchanged, because neither looks inside an entry.
`add_accepted/1` and `put_accepted/3` are the two gestures that do, and
they exist beside `add/1` and `put/4` rather than inside them because a
name is a bare string with no fields to address. `add_accepted/1` mints
for `add/1`'s reason: an empty name is refused, so a blank row would be a
press that produces a refusal.

# `field`

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

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

# `add`

```elixir
@spec add([StatifierBlocks.Document.DatamodelEntry.t()]) :: [
  StatifierBlocks.Document.DatamodelEntry.t()
]
```

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"

# `add_accepted`

```elixir
@spec add_accepted([String.t()]) :: [String.t()]
```

Appends a freshly named accepted event: `event_1`, or the first `event_N`
the list does not already hold.

The name is a placeholder the author is expected to overwrite with the
event their host sends, and it is one `{:set_accepts, names}` accepts, so
the press lands as a row rather than as a refusal.

    iex> StatifierBlocks.Declarations.add_accepted([])
    ["event_1"]

    iex> StatifierBlocks.Declarations.add_accepted(["event_1", "email.verified"])
    ["event_1", "email.verified", "event_2"]

# `change`

```elixir
@spec change([StatifierBlocks.Document.DatamodelEntry.t()], term(), map()) :: [
  StatifierBlocks.Document.DatamodelEntry.t()
]
```

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`

```elixir
@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`

```elixir
@spec move([entry], term(), term()) :: [entry]
when entry: StatifierBlocks.Document.DatamodelEntry.t() | String.t()
```

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`

```elixir
@spec put([StatifierBlocks.Document.DatamodelEntry.t()], term(), field(), term()) :: [
  StatifierBlocks.Document.DatamodelEntry.t()
]
```

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"

# `put_accepted`

```elixir
@spec put_accepted([String.t()], term(), term()) :: [String.t() | term()]
```

Writes the accepted event name at `index`, or returns `names` unchanged
when no name sits there.

The value is written through verbatim, blank included, as `put/4` writes
an `id`: a name is required, so there is nothing for a blank to mean, and
the refusal `{:set_accepts, names}` answers with is the panel's message to
the author.

    iex> StatifierBlocks.Declarations.put_accepted(["event_1"], 0, "email.verified")
    ["email.verified"]
    iex> StatifierBlocks.Declarations.put_accepted(["event_1"], 3, "email.verified")
    ["event_1"]

# `refusal`

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

A refusal from `{:set_datamodel, entries}` or `{:set_accepts, names}`, 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(
    ...>   {:malformed_envelope, {:accepts, {:entry, 0, :empty}}}
    ...> )
    "Accepted event 1 needs a name, such as email.verified."

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

# `remove`

```elixir
@spec remove([entry], term()) :: [entry]
when entry: StatifierBlocks.Document.DatamodelEntry.t() | String.t()
```

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

---

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