StatifierBlocks.ViewModel (StatifierBlocks v0.1.0)

Copy Markdown View Source

Everything the editor renders, derived from {document, palette, findings} (ADR-0005 decisions 9, 10, 11, 12, 13).

This is the load-bearing module decision 13 names: it is where resolution, migration, validation, and palette_entry/0 lookup happen, and it produces a structure in which every block already carries its declared slots, its form fields, its presentation metadata, and its findings routed to the position that renders them. It lives outside StatifierBlocks.Editor.* despite being an editor concern, and it names no LiveView module - the components downstream of it are close to mechanical, reading a view model and emitting markup with no palette lookups and no callback invocations of their own.

Two derived finding sources, and no compiler adapter

build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()]. This module derives exactly two sources of its own, because decision 13 puts resolution, migration and validation inside ViewModel rather than upstream of it:

  • :resolution - Palette.resolve/2 failing on a block (:unknown_block_type, :block_type_too_new, :migration_failed), anchored {:block, id}, severity :error.
  • :config - validate_config/1 on a resolved block, one per {key, message} pair, anchored {:config, id, key}, severity :error.

:arity, :assignability and :lint findings are never produced here; their producers live elsewhere - sb-da9 (palette-aware arity and undeclared-slot checks, not yet built), Assignability.validate/3, and the compiler's invoke-type lint respectively - and this module does not adapt StatifierBlocks.Compiler.Finding into StatifierBlocks.Finding to manufacture them. That adapter is a real, mechanical possibility (Compiler.Finding carries block_id and config_key, which map onto {:config, id, key} / {:block, id} cleanly) and it is deliberately not built in this bead - see the plan's Open Questions for where it belongs instead. Derived and caller-supplied findings are concatenated - derived first - into one list, which is both t().findings and the document-level panel's source.

Routing, and the case that must not vanish

Each finding in the concatenated list is placed by its anchor:

AnchorPosition in the view model
any anchor naming a block id not in the documentt().orphan_findings
{:block, id}that node's findings
{:slot, id, name}that slot's findings (a slot name the node does not carry falls back to the node's findings)
{:config, id, key}, key matches a config_schema/1 fieldthat field's findings
{:config, id, key}, key matches no fieldthat node's form.unrouted

The fourth row exists because Core.Branch.config_schema/1 keys one field per arm by the arm's own slot name, but validate_config/1 also emits findings keyed "arms" - a key that matches no field, because adding or removing an arm is a document edit, not a form value (core/branch.ex). form.unrouted is rendered at the head of the config form for exactly this case, and an unresolvable node - which has no form at all - folds the same case into its own findings instead of discarding it.

No route drops a finding: every arm of the table above lands somewhere, and t().findings_count accumulates the same placements. A node's findings_count covers its whole subtree - its own findings, its slots' findings, its form's field and unrouted findings, plus every child's own findings_count - so a collapsed subtree can carry a count badge (decision 11's last sentence) without walking back down into it.

d10's defaults

palette_entry/0 is optional, and every one of its eight keys has a default (decision 10) so a block type that implements none of it still renders: label defaults to the type name, group to "Other", description to "", icon to nil, keywords to [], order to 0, layout to :stack, slot_style to %{}.

d12: unresolvable nodes

A block whose type does not resolve renders with its type name, a status: {:unresolvable, reason} carrying Palette.resolve/2's own error term, form: nil, its config as canonical-JSON text in raw_config_json (there is no config_schema/1 to drive a form and inventing one would be guessing), a :resolution finding, and its existing children rendered normally, recursively with raw slot names - the document's slots map preserved every one of them, decoding never having consulted a registry.

d9: the form is a projection, never cached

A resolved node's form.fields come from module.config_schema/1 called against the block's current config, every time build/3 runs. Nothing here memoizes a schema across an edit: a branch that gains an arm gains a field the very next time build/3 is called, because the schema is a function of config (ADR-0002 decision 7), not a cache of one.

Summary

Types

Threaded through the recursive walk instead of two positional arguments.

t()

Functions

Builds the view model. Derives :resolution and :config findings from {document, palette}, concatenates findings after them, routes every one of the combined list per the moduledoc's table, and groups palette's types into palette_groups.

Types

ctx()

Threaded through the recursive walk instead of two positional arguments.

t()

@type t() :: %StatifierBlocks.ViewModel{
  document_id: StatifierBlocks.Document.id(),
  findings: [StatifierBlocks.Finding.t()],
  orphan_findings: [StatifierBlocks.Finding.t()],
  palette_groups: [StatifierBlocks.ViewModel.PaletteGroup.t()],
  revision: non_neg_integer(),
  root: StatifierBlocks.ViewModel.Node.t()
}

Functions

build(document, palette, findings)

Builds the view model. Derives :resolution and :config findings from {document, palette}, concatenates findings after them, routes every one of the combined list per the moduledoc's table, and groups palette's types into palette_groups.