MishkaCmsCore.Runtime.PageBuilder.Fields (Mishka CMS Core v0.0.1)

Copy Markdown View Source

What part of a form a palette component plays, what part a node in the document plays, and how a field is staged for preview.

One answer, four callers

form_node?/2 and field_node?/2 are the whole editor's answer to "is this node a form" and "is this node a field". MishkaCmsCore.Runtime.PageBuilder.Containment refuses a form dropped inside one, MishkaCmsCore.Runtime.PageBuilder.PageForms.FormPlacement binds a field to the form it lands in, MishkaCmsCore.Runtime.PageBuilder.PageForms.FormChecks reports what is wrong with each, and the compiled page reads the same markup back through MishkaCmsCore.Runtime.PageBuilder.PageForms. They had four implementations and the narrowest one — Containment's — never asked the kit at all, so a kit's own form wrapper was allowed to nest inside another form and the audit then reported the nesting the drop was supposed to refuse.

Deciding a component's role parses that component's markup, and a page holds hundreds of copies of one card, so both questions take the memo roles/2 builds: one answer per distinct component name, not one per node.

The catalog is not the whole palette

A name with no catalog row is asked of the CMS's own function components — local_modules/0, the same two modules the canvas resolves a dotted tag from — and their __components__/0 metadata is translated into the shape a catalog row carries, so everything downstream reads one dialect.

Without that, <.input> answered :none. It is MishkaCmsCore.CoreComponents.input/1 rather than a database row, and it is what every shipped Forms block and every Forms element is built out of — so the drop never bound one to the form it landed in, and no field-level check ever fired on one. Dragging a field out of its form left a page that renders on the canvas, raises nothing in the audit rail, and then fails to compile on publish, because field={f[:email]} with no :let in scope is an undefined variable.

Declaration first, inference second

role/1 asks MishkaCmsCore.Builder.FormRole what the component's own kit declared in its extra.form. A kit that declares nothing — which is every kit that shipped before the key existed — falls to inference over the component's attributes and its own markup.

An inferred role then passes through the builder:form:role transform (MishkaCmsCore.Builder.Events.form_role/2); a declared one never does.

:error is never inferred

An error component renders ordinary markup and declares the same errors list a field does, so nothing in its structure says what it is. A kit that wants the role declares it.

Detecting a Send button

A component is :submit when its template's one OUTER element is a <button> whose type can be submit, or an <input> whose type can be submit or image — through its literal, the default of the attribute it is bound to, or that attribute's values. Buttons inside the markup never count. A row with no markup (the CMS's own <.button>) is asked of its declared type attribute.

Whether a placed node sends its form is decided per node by submit_node?/3: the node's own literal type, else the component's default, else HTML's own default. A component that renders a link in place of its button (one with no markup that declares href, navigate or patch) never sends when given one; an outer <button> is rendered whatever the node carries, so it still sends. A type the node computes (type={@kind}) cannot be read: type_unread?/3.

Passing its form inside

lends_form?/2 is true when the markup has render_slot(@inner_block, v) under a :let={v} on something that hands out a form (hands_form?/2): Phoenix's <.form>, or a component that itself lends one, followed through the called component's markup. A raw <form> hands out nothing, because HEEx refuses :let on an HTML tag.

Detecting a field

The load-bearing signal is an attribute typed as a Phoenix.HTML.FormField struct — 23 of the installed kit's 136 components declare one, and it is exactly the set that renders a value-carrying control. The attribute may be called anything at all: binding_attr/1 answers with its name rather than assuming the name field.

Only Phoenix's own struct is inferred, because only Phoenix's own struct is what the editor can write. What goes into that attribute is f[:email], and Phoenix.Component.to_form/2 produces nothing but a %Phoenix.HTML.FormField{}. Matching on the last dot-segment recognised a vendor's Acme.FormField as well — and then wrote a Phoenix struct into it, which the kit's own %Acme.FormField{} = field clause head cannot match, so the call fell through to the rendering clause and raised KeyError :name on the published page. A kit with its own struct declares extra.form instead, which is the kit saying what to write and where.

A kit that binds fields some other way falls to structural?/1: the component's markup is PARSED, and a control it renders whose name and value (or checked) are bound to assigns the component declares is a field. Reading that as text called a kit's submit button a field — the same markup appears in <input type="submit" name={@name} value={@value} /> — and called a component that merely quotes an input inside a comment one too. The parse answers both: a control whose type submits carries no value, and a comment is not a control.

Neither branch reads helpers. Helper clauses are bundled per source MODULE, so chelekom-select-option-group carries native_select's %Phoenix.HTML.FormField{} clause head while rendering nothing but <option> — a detector that trusted them would call it a field.

Detecting a wrapper

A wrapper is a component that renders a form of its own. Its template is PARSED and walked for a form or .form node, so a wrapper emitting a raw <form> element is found and a template that merely names one in a comment or a code sample is not.

chelekom-simple-form renders neither: it forwards everything to chelekom-form-wrapper through an {assigns} splat, so its own markup never mentions a form. A for attribute the component declares REQUIRED counts as well, because required is the component saying the form's data is its own to hand on. So does a template whose one outer element is a component that is itself a wrapper, read from that component's own markup.

Which attributes a binding supersedes

pair_attrs/1 names the two attributes a kit derives from the form field, so the drop can clear the blanks a palette card is seeded with — leave name="" beside the binding that replaces it and the blank wins, because a kit fills it with assign_new/3, which only fills an ABSENT key.

The kit's declaration wins; the kit's own markup answers next, read by the same parse that decides structural?/1; and nil is the honest answer when neither says. It used to fall back to the literal names "name" and "value", which are Phoenix's spelling rather than the kit's — so a kit calling them input_name and input_value had two attributes it does not have dropped and its two real blanks left in place, which is exactly the failure the drop exists to prevent.

bindings/1 answers the binding attribute and the pair together, for the caller that needs both — which is every caller that writes one, because writing the binding and clearing what it supersedes is one decision made once.

Where the author's own word comes from

field_label/2 is the read side of the same doctrine, and it was the half still written in Phoenix's spelling: a two-word vocabulary of name then label, so a kit that calls them something else contributed nothing an author had typed. It asks pair_attrs/1 for the kit's name attribute instead. See the function for what is left hardcoded and why.

Why the previews need a field

A text field previewed on its own is a box with a label. It is not wrong — the kit's own docs render all 589 field examples unwrapped, and no field template depends on a form ancestor — but it is not what the reader is being asked about either. The modal exists to answer "what does this look like", and a field's answer includes the form it lives in.

So stage/2 wraps the preview source, and only the preview source.

Why a dropped field IS wrapped, elsewhere

HTML forbids a nested <form>, and the failure is silent rather than loud: the parser's form-element pointer discards the inner start tag — data-node-id and all — and lets the inner </form> close the OUTER one, ejecting every later sibling from submission.

So the wrapping a dropped field needs is conditional, and it does not belong here. The insert funnel wraps a field that lands with no form ancestor, in one undoable step, and Containment refuses a form dropped inside a form.

Summary

Functions

The attribute name to write a %Phoenix.HTML.FormField{} binding on, or nil when this component cannot take one — in which case the editor writes the kit's name and value pair instead.

Both of the above at once: {binding attribute, {name attribute, value attribute}}.

Whether this placed node is a button of any kind: a <button>, an <input> typed as one, or a :submit component placed as one. A component whose outer element is an <input> is a button only when this node's type (else its default) is a button's; a computed type counts.

The declaration behind a node: its palette row, or the CMS-local function component's own metadata translated into the same shape, or nil when neither has heard of the name.

Whether this node says in writing that it submits: type="submit" or "image" (or a literal such as {:submit}) on the node, or on its component's outer element. A button left to a default does not.

Whether this component is a form field.

The author's own word for this control, which is what a field key is minted from.

Whether this node is a field: a component the kit calls one, or one of HTML's own value-carrying controls.

Whether this node is a form: HTML's own <form>, Phoenix's <.form>, a component the kit calls a wrapper, or any node that names a page assign with for=.

The literal type a component's markup writes on the <input> its catch-all :global attribute is spread onto, lowercased, or nil when it spreads it onto no <input> or the type is not written as text.

Whether a :let on this node receives a form: Phoenix's <.form>, or a component whose markup lends its form. A raw <form> does not.

Whether this component's markup passes a form to what is written inside it: render_slot(@inner_block, v) under a :let={v} on something hands_form?/2 answers yes for.

The modules a dotted tag with no catalog row is asked of, in the order a compiled page imports them — MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive resolves a component the same way, and MishkaCmsCore.Runtime.PageBuilder.Containment asks them what declares a slot.

The name and value attribute names a binding supersedes, either of which is nil when this component has no such attribute.

This component's part in a form: :wrapper, :field, :error, :fieldset, :submit or :none.

The part each component named in nodes plays, keyed by component name — the memo both node questions take.

The preview source for a component, wrapped in a form when it is a field.

The fallback signal, for a kit that carries no form-field struct: a control the component renders whose name and value are both bound to assigns it declares.

Whether this placed node sends the form it is in: its own literal type, else its component's default, else HTML's default. A component that renders a link in its place never sends.

How each :submit component in roles sends, keyed by name — the memo submit_node?/3, explicit_submit?/3, button_node?/3 and type_unread?/3 read. Built once per component, not per node.

Whether an attribute name written on this node reaches what it renders: any attribute on an HTML tag; on a component, one it declares, or one its catch-all :global attribute takes (a phx-, data- or aria- name, one of HTML's global names, or one its include list names) when its markup spreads that attribute. A component with no declaration takes nothing.

The attribute this placed button's type is written on: type on a <button> or an <input>, the kit's own on a :submit component, and nil when the component's markup fixes the type or the node renders a link, which reads no type.

Whether this placed button computes its type (type={@kind}), so whether it sends its form cannot be read. A link, and a component whose markup fixes its type, are read. An <input> computing its type is a field (PageForms.control?/1), not a button.

Whether this component renders a form of its own, so the author placed the form themselves. components is the palette, read when the template's outer element is another component.

Whether this node carries a value the author wrote rather than one it reads off the form: a radio option's one, a checkbox's true, the hidden false beside it.

Functions

binding_attr(entry)

@spec binding_attr(map()) :: String.t() | nil

The attribute name to write a %Phoenix.HTML.FormField{} binding on, or nil when this component cannot take one — in which case the editor writes the kit's name and value pair instead.

bindings(entry)

@spec bindings(map()) :: {String.t() | nil, {String.t() | nil, String.t() | nil}}

Both of the above at once: {binding attribute, {name attribute, value attribute}}.

For the caller that needs both, which is the insert funnel — writing a binding and clearing the pair it supersedes is one decision. Asked separately it rebuilt the kit's declaration twice and could parse the component's own markup twice with it, and chelekom-combobox's markup is 6.7 KB.

button_node?(node, roles, rules)

@spec button_node?(map(), %{optional(String.t()) => atom()}, %{
  optional(String.t()) => map()
}) ::
  boolean()

Whether this placed node is a button of any kind: a <button>, an <input> typed as one, or a :submit component placed as one. A component whose outer element is an <input> is a button only when this node's type (else its default) is a button's; a computed type counts.

roles and rules are the memos roles/2 and submit_rules/2 build.

entry(name, components)

@spec entry(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | String.t() | nil, [
  map()
]) :: map() | nil

The declaration behind a node: its palette row, or the CMS-local function component's own metadata translated into the same shape, or nil when neither has heard of the name.

What a binding is written onto is read off it, so a caller holding a node and the palette asks here rather than searching the catalog itself — searching the catalog alone is what left every <.input> on every shipped Forms block unbound.

explicit_submit?(node, roles, rules)

@spec explicit_submit?(map(), %{optional(String.t()) => atom()}, %{
  optional(String.t()) => map()
}) ::
  boolean()

Whether this node says in writing that it submits: type="submit" or "image" (or a literal such as {:submit}) on the node, or on its component's outer element. A button left to a default does not.

field?(entry)

@spec field?(map()) :: boolean()

Whether this component is a form field.

field_label(node, components)

The author's own word for this control, which is what a field key is minted from.

The attributes are tried in this order, and the first one carrying a word the author actually wrote wins; a blank is not a word, because the palette seeds a card with one so it renders standing alone.

  1. The kit's OWN name attribute — pair_attrs/1 answers what the kit called it, so a component spelling it input_name contributes what was typed into it. Reading only the literal name meant a kit with its own spelling contributed nothing and the key fell through to the component's name: a dropped acme-text came out f[:acme_text] while its input_name carried the word the author had just written.
  2. HTML's own name, which is what a raw <input> from the Elements library carries and what a component taking name= directly still means.
  3. The attribute the kit declares as its caption in extra.form, or one the component itself declares called label. The literal label is checked against the component's own declarations rather than written blind — a kit that spells it otherwise says so beside the rest of its bindings.

The node's own label last, which is the component's name and is the answer of last resort rather than a choice anybody made.

Used by the insert funnel and by the audit's Fix, which had a copy of the two-word version each.

field_node?(node, roles \\ %{})

@spec field_node?(map(), %{optional(String.t()) => atom()}) :: boolean()

Whether this node is a field: a component the kit calls one, or one of HTML's own value-carrying controls.

Those control names are the one vocabulary in this work, and they are HTML's rather than a kit's. They are written down in MishkaCmsCore.Runtime.PageBuilder.PageForms.control?/1 and nowhere else, because the compiled page's own reader has to agree with what the editor calls a field.

form_node?(node, roles \\ %{})

@spec form_node?(map(), %{optional(String.t()) => atom()}) :: boolean()

Whether this node is a form: HTML's own <form>, Phoenix's <.form>, a component the kit calls a wrapper, or any node that names a page assign with for=.

roles is the memo roles/2 builds. The last answer is PageForms.form?/1, and it is why a kit wrapper still counts when the palette is not to hand: a wrapper the author has already given a page assign declares itself in the markup.

forwarded_type(entry)

@spec forwarded_type(map() | nil) :: String.t() | nil

The literal type a component's markup writes on the <input> its catch-all :global attribute is spread onto, lowercased, or nil when it spreads it onto no <input> or the type is not written as text.

hands_form?(node, components)

@spec hands_form?(map(), [map()]) :: boolean()

Whether a :let on this node receives a form: Phoenix's <.form>, or a component whose markup lends its form. A raw <form> does not.

lends_form?(entry, components \\ [])

@spec lends_form?(map() | nil, [map()]) :: boolean()

Whether this component's markup passes a form to what is written inside it: render_slot(@inner_block, v) under a :let={v} on something hands_form?/2 answers yes for.

components is the palette, read when the :let sits on another component.

local_modules()

@spec local_modules() :: [module()]

The modules a dotted tag with no catalog row is asked of, in the order a compiled page imports them — MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive resolves a component the same way, and MishkaCmsCore.Runtime.PageBuilder.Containment asks them what declares a slot.

pair_attrs(entry)

@spec pair_attrs(map()) :: {String.t() | nil, String.t() | nil}

The name and value attribute names a binding supersedes, either of which is nil when this component has no such attribute.

Declared first, read off the component's own markup second, nil last — never guessed. See the moduledoc for what guessing cost.

role(entry, components \\ [])

@spec role(map(), [map()]) :: atom()

This component's part in a form: :wrapper, :field, :error, :fieldset, :submit or :none.

A declaration wins; otherwise it is inferred and passed through builder:form:role. :error is never inferred. components is the palette, read when the template's outer element is another component.

Accepts a palette entry or anything shaped like a component row.

roles(nodes, components)

@spec roles([map()], [map()]) :: %{required(String.t()) => atom()}

The part each component named in nodes plays, keyed by component name — the memo both node questions take.

Asked once per distinct component in the document rather than once per node: deciding a role parses the component's own template, and a page can hold hundreds of copies of one card.

stage(source, entry)

@spec stage(String.t(), map() | nil) :: String.t()

The preview source for a component, wrapped in a form when it is a field.

phx-submit is not decoration: a <form> with no handler still submits implicitly, so pressing Enter in a previewed text field navigates the preview iframe and the LiveView is gone. With the binding present LiveView intercepts the submit and the canvas's own catch-all handle_event/3 swallows it.

structural?(entry)

@spec structural?(map()) :: boolean()

The fallback signal, for a kit that carries no form-field struct: a control the component renders whose name and value are both bound to assigns it declares.

Public because it is the half a second kit is judged on, and worth being able to check directly.

submit_node?(node, roles, rules)

@spec submit_node?(map(), %{optional(String.t()) => atom()}, %{
  optional(String.t()) => map()
}) ::
  boolean()

Whether this placed node sends the form it is in: its own literal type, else its component's default, else HTML's default. A component that renders a link in its place never sends.

roles and rules are the memos roles/2 and submit_rules/2 build.

submit_rules(roles, components)

@spec submit_rules(%{required(String.t()) => atom()}, [map()]) :: %{
  required(String.t()) => map()
}

How each :submit component in roles sends, keyed by name — the memo submit_node?/3, explicit_submit?/3, button_node?/3 and type_unread?/3 read. Built once per component, not per node.

takes?(node, name, components)

@spec takes?(map(), String.t(), [map()]) :: boolean()

Whether an attribute name written on this node reaches what it renders: any attribute on an HTML tag; on a component, one it declares, or one its catch-all :global attribute takes (a phx-, data- or aria- name, one of HTML's global names, or one its include list names) when its markup spreads that attribute. A component with no declaration takes nothing.

type_attr(node, roles, rules)

@spec type_attr(map(), %{optional(String.t()) => atom()}, %{
  optional(String.t()) => map()
}) ::
  String.t() | nil

The attribute this placed button's type is written on: type on a <button> or an <input>, the kit's own on a :submit component, and nil when the component's markup fixes the type or the node renders a link, which reads no type.

roles and rules are the memos roles/2 and submit_rules/2 build.

type_unread?(node, roles, rules)

@spec type_unread?(map(), %{optional(String.t()) => atom()}, %{
  optional(String.t()) => map()
}) ::
  boolean()

Whether this placed button computes its type (type={@kind}), so whether it sends its form cannot be read. A link, and a component whose markup fixes its type, are read. An <input> computing its type is a field (PageForms.control?/1), not a button.

roles and rules are the memos roles/2 and submit_rules/2 build.

wrapper?(entry, components \\ [])

@spec wrapper?(map(), [map()]) :: boolean()

Whether this component renders a form of its own, so the author placed the form themselves. components is the palette, read when the template's outer element is another component.

written_value?(node)

@spec written_value?(map()) :: boolean()

Whether this node carries a value the author wrote rather than one it reads off the form: a radio option's one, a checkbox's true, the hidden false beside it.

It is what separates several controls that are ONE field — a group answering to one name — from several fields that would write over each other, and it is why a binding leaves such a value where it is. A palette card's seeded blank is not a value.