MishkaCmsCore.Runtime.PageForm (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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)
end

The 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

announcements(flash)

@spec announcements(map()) :: [{:info | :error, String.t()}]

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.

build(spec, values, action)

@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.

destination?()

@spec destination?() :: boolean()

Whether anything is registered to receive submissions — read by the audit rail.

form_errors(field)

@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.

seed(spec)

@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.

submit(socket, specs, form, params, run \\ nil)

@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.

validate(socket, specs, form, params)

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.