What a compiled page does when a visitor types in, or submits, a form its author drew.
Using it
A page carries one spec per form in __page_forms__/0, and the compiler generates one pair of
handlers per form, both of which come straight back here:
def handle_event("contact-k7m2q-validate", params, socket) do
{:noreply, PageForm.validate(socket, __page_forms__(), "contact-k7m2q-validate", params)}
end
def handle_event("contact-k7m2q-submit", params, socket) do
PageForm.submit(socket, __page_forms__(), "contact-k7m2q-submit", params)
endThe form is named by an event name ("contact-k7m2q-submit") or by its page assign
(:contact_form, what pages compiled before tokens pass). An event name finds the form carrying it,
and failing that the form carrying its token, so a handler written before its form was renamed
still finds it.
Nothing seeds the assign, and there is no mount/3: Phoenix.Component.form/1 coerces a missing
for= to %{}, so the first render of a page nobody has typed in draws an empty form on its own.
A reader debugging first-paint behaviour used to be sent here to a mount/3 that
MishkaCmsCore.Runtime.Compilers.LiveViewCompiler does not generate and a seed/1 call that does
not exist. seed/1 is real, but it is what a SUCCESSFUL submission resets the form to.
An author who holds admin:dashboard:code may write the submit handler by hand and pass a function as
the fifth argument. It receives the cast values, keyed by the fields the form declares, and its
return decides what the visitor sees: {:error, _} re-renders the form with everything still
typed in it, anything else is a success, which resets the form and flashes.
PageForm.submit(socket, __page_forms__(), "contact-k7m2q-submit", params, fn values ->
MishkaBlog.Comment
|> Ash.Changeset.for_create(:create, values)
|> Ash.create()
end)A refusal may say why. {:error, "We already have that address."} is text somebody wrote for a
visitor and is shown as it stands; {:error, %Ash.Error.Invalid{}} or {:error, :db_down} is a
programmer's reason, which would put the shape of the system on a public page, so it is written to
the log and the visitor reads the plain refusal.
What the page says back
Both outcomes are announced with put_flash/3, and announcements/1 is how they are read back
out. A compiled page carrying a form renders its own region from it (see
MishkaCmsCore.Runtime.Compilers.LiveViewCompiler) because a layout's flash region is content
somebody wrote and, measured across a stock install, no layout had one. Both writers and the one
reader live here, so a region cannot go on knowing about the success and not the refusal — which
is what left a refused submission looking like a button press that did nothing.
The rules are Ecto's, the declaration is the author's
A form's rules arrive as the constraint attributes on its field nodes, lifted at compile time into
__page_forms__/0 and run through a schemaless Ecto.Changeset by
MishkaCmsCore.Runtime.PageForm.Rules. That buys three things a hand-rolled checker does not:
errors already shaped {message, opts}, which is the only shape a translate_error/1 matches, in
this project's MishkaCmsWeb.CoreComponents and in a kit's own field alike; opts[:count] and
opts[:number] where Gettext's plural rules can read them; and coercion, so a combobox that posts
a list or a date field that posts a nested map produces an error rather than an exception in the
middle of a keystroke.
The form itself is built over the changeset's params and its errors rather than over the changeset,
because Phoenix.HTML.FormData gains a clause for Ecto.Changeset from phoenix_ecto, which this
library does not depend on and must not start requiring of a host. The two forms are the same form:
the params are the ones Ecto converted, so the _unused_<field> markers are all still there for
Phoenix.Component.used_input?/1, the values are the ones the visitor typed, and the errors are
Ecto's own tuples. action decides whether they are visible at all — nil on a form nobody has
typed in, so a page that has just loaded carries no errors to show.
Why the params are scoped first
The form's tag carries as={:contact}, so its inputs are named contact[email] and the event
arrives as %{"contact" => %{...}, "_target" => [...]}. Everything here reads params["contact"],
which is what makes two forms on one page independent for free: the _unused_<field> markers the
client sends are nested too, so Phoenix.Component.used_input?/1 reads one form's own map and can
never see a sibling's keys.
Handing to_form/2 the OUTER map instead is silent and total: value reads nil on every field
so the visitor cannot type, used_input? is false forever so no error is ever shown, and
required fails on a filled field so submit never succeeds.
Why nothing here renders an error
The field does. A kit's field opens with
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] and puts the rest
through its own translate_error/1, so a form nobody has touched shows nothing and a submitted one
shows everything at once. form_errors/1 exists only for markup that carries errors= explicitly
because its kit takes no Phoenix.HTML.FormField.
Where a submission goes
Nowhere in core. A valid submission is handed to MishkaCmsCore.Builder.Events.form_submitted/1,
the page:form:submitted seam. A plugin that stores or forwards submissions registers on that
event; none ships with the CMS. A developer can also pass the fifth argument from the form's
On submit handler. With neither, the visitor still sees the success message, the log gets the
form's name and field names but never the values typed, and the editor shows a
form-no-destination note, which is what destination?/0 answers for plugins.
Summary
Functions
What a page has to say about the forms on it: [{kind, message}], in :info, :error order.
One form, built from one params map.
Whether anything is registered to receive submissions — read by the audit rail.
A field's errors, or none while the visitor has not touched it.
A pristine form for one spec — what an accepted submission leaves behind. Carries no errors, because nothing has been typed into it.
Handles one form's submit: validates, then either re-renders it or accepts it.
Re-assigns one form from what the visitor has typed so far.
Functions
What a page has to say about the forms on it: [{kind, message}], in :info, :error order.
[] while nothing has been submitted, one entry after a submission, and two where a page carries
several forms and one was accepted while another was refused. Read by the region a compiled page
renders for itself, which is the only reader that has to agree with both put_flash/3 calls here.
@spec build(map(), map(), atom() | nil) :: Phoenix.HTML.Form.t()
One form, built from one params map.
action is what decides whether errors are visible at all: nil is a form nobody has typed in,
:validate a keystroke, :submit a press of the button.
@spec destination?() :: boolean()
Whether anything is registered to receive submissions — read by the audit rail.
@spec form_errors(Phoenix.HTML.FormField.t() | term()) :: list()
A field's errors, or none while the visitor has not touched it.
For markup that passes errors= rather than a form field — a kit that binds fields some other
way, or a hand-written <input>. Imported into every compiled page.
@spec seed(map()) :: Phoenix.HTML.Form.t()
A pristine form for one spec — what an accepted submission leaves behind. Carries no errors, because nothing has been typed into it.
@spec submit( Phoenix.LiveView.Socket.t(), [map()], atom() | String.t(), map(), (map() -> term()) | nil ) :: {:noreply, Phoenix.LiveView.Socket.t()}
Handles one form's submit: validates, then either re-renders it or accepts it.
Rejected values stay in the form so the visitor does not retype them. Accepted values reset the
form and flash the spec's success message, except on a form that sends to another address (its
spec names a trigger assign): there the values stay and the trigger is set to true, so the
form's phx-trigger-action posts them to its action.
@spec validate(Phoenix.LiveView.Socket.t(), [map()], atom() | String.t(), map()) :: Phoenix.LiveView.Socket.t()
Re-assigns one form from what the visitor has typed so far.
Only the named form's assign is written, so a page with several forms keeps the others exactly as they were.