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
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}}.
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.
@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.
@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.
@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.
Whether this component is a form field.
@spec field_label(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), [map()]) :: String.t()
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.
- The kit's OWN name attribute —
pair_attrs/1answers what the kit called it, so a component spelling itinput_namecontributes what was typed into it. Reading only the literalnamemeant a kit with its own spelling contributed nothing and the key fell through to the component's name: a droppedacme-textcame outf[:acme_text]while itsinput_namecarried the word the author had just written. - HTML's own
name, which is what a raw<input>from the Elements library carries and what a component takingname=directly still means. - The attribute the kit declares as its caption in
extra.form, or one the component itself declares calledlabel. The literallabelis 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.
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.
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.
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.
components is the palette, read when the :let sits on another component.
@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.
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.
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.
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.
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.
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.
@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.
@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.
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.
@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.
@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.
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.
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.