DynamicForm - A Phoenix LiveView library for creating dynamic forms with full server-side validation using changesets. Also supports building forms through a WYSIWYG interface.
This library enables users to build forms dynamically through a visual interface, then render those forms using standard Phoenix LiveView patterns with robust validation and submission handling.
External Submit Buttons
DynamicForm supports placing submit buttons outside of the form element using
the HTML form attribute. This is useful for:
- Placing submit buttons in modal footers
- Creating sticky footers with submit buttons
- Multi-step forms with navigation controls
- Complex layouts where the submit button needs to be separate
Usage with RendererLive (Recommended)
When using DynamicForm.RendererLive (LiveComponent):
- Set
hide_submit={true}on your LiveComponent - Use
DynamicForm.submit_button/1with the form ID"#{component_id}-form"
Example:
# External submit button
<DynamicForm.submit_button form="contact-form-form">
Submit
</DynamicForm.submit_button>
# LiveComponent (id "contact-form" generates form ID "contact-form-form")
<.live_component
module={DynamicForm.RendererLive}
id="contact-form"
instance={@form_instance}
hide_submit={true}
/>Usage with Renderer (Functional Component)
When using DynamicForm.Renderer.render/1:
- Set
hide_submit={true}and provide a customform_id - Use
DynamicForm.submit_button/1with thatform_id
Example:
# External submit button
<DynamicForm.submit_button form="my-form">
Save
</DynamicForm.submit_button>
# Renderer with custom form_id
<DynamicForm.Renderer.render
instance={@form_instance}
form={@form}
form_id="my-form"
hide_submit={true}
phx_submit="submit"
phx_change="validate"
/>See DynamicForm.RendererLive.submit_button/1 for more details.
Declarative Forms
DynamicForm.form/1 is the unified entry point for rendering forms. It
accepts a prebuilt instance, a SurveyJS-compatible JSON string, or
<:field> slots (declarative mode) — exactly one of the three:
<%!-- Data mode: Instance struct or map --%>
<DynamicForm.form id="contact-form" instance={@form_instance} />
<%!-- Data mode: SurveyJS-compatible JSON string --%>
<DynamicForm.form id="contact-form" json={@json} />
<%!-- Declarative mode --%>
<DynamicForm.form id="contact-form" title="Contact Form">
<:field type="text" input_type="email" name="email" label="Email Address" required />
<:field type="dropdown" name="subject" label="Subject"
options={[{"Support", "support"}, {"Sales", "sales"}]} />
<:field type="comment" name="details" label="Details"
visible_if="{subject} = 'support'" />
</DynamicForm.form>See form/1 for the full attribute and slot reference.
Summary
Functions
Renders a dynamic form from an instance, a SurveyJS-compatible JSON string,
or <:field> slots (declarative mode).
Functions
Renders a dynamic form from an instance, a SurveyJS-compatible JSON string,
or <:field> slots (declarative mode).
Wraps DynamicForm.RendererLive, which manages form state, validation, and
submission. Exactly one of the instance attribute, the json attribute,
or <:field> slots must be provided.
Lifecycle callbacks
Two optional validation hooks mirror the form's phx-change/phx-submit
events. Each is a 1-arity function receiving a DynamicForm.Payload and
returning it, transformed or untouched. Reject a submission with
DynamicForm.Payload.add_error/4; side effects belong in the parent's
handle_info/2:
on_change— runs after the built-in validations on every change (and during the submit validation pass). Keep it cheap — it runs per keystroke, unlesschange_debounce_in_msis set.on_submit— runs on every submit, valid or not, so it can batch expensive checks with the built-in errors into one complete error list.<DynamicForm.form id="contact-form" on_submit={&Contacts.verify/1}> <:field type="text" name="email" label="Email" required format="email" /> </DynamicForm.form>
change_debounce_in_ms waits for that many milliseconds of quiet before
running the change work, so a callback too expensive for a keystroke runs
once the user pauses. The built-in validations still render on every
change, and submitting always runs the callback inline:
<DynamicForm.form id="signup" on_change={&Accounts.check_availability/1}
change_debounce_in_ms={300}>
<:field type="text" name="username" label="Username" required />
</DynamicForm.form>Messages
The parent LiveView is messaged as {:dynamic_form, event, payload}.
send_message_on picks the events — any of [:success, :change, :submit], defaulting to [:success]:
<DynamicForm.form id="signup" send_message_on={[:success, :change]}>
def handle_info({:dynamic_form, :change, payload}, socket) do
{:noreply, assign(socket, :preview, payload.data)}
end:change and :submit payloads are routinely invalid — check
DynamicForm.Payload.valid?/1 before acting on them. Pair :change with
change_debounce_in_ms to keep a message (and a parent re-render) off
every keystroke. Define on_success — a 1-arity function receiving the
payload — to replace the :success message with custom behavior. See
DynamicForm.RendererLive and DynamicForm.Payload for the full
contracts.
Declarative mode
<:field> entries convert to a DynamicForm.Instance in template order
(see DynamicForm.Instance.FromSlots). Question types collect input;
html, image, and custom render static or custom content:
<DynamicForm.form id="signup">
<:field type="html" name="intro" html="<h2>Sign up</h2>" />
<:field type="text" name="email" label="Email" format="email" required />
<:field type="rating" name="score" label="Score" rate_min={1} rate_max={10} />
</DynamicForm.form>Groups (panels)
Fields sharing a group attribute are collected into a panel declared by a
<:group> entry. The panel renders at the position of its first member:
<:group name="address" title="Shipping Address" visible_if="{ship} = true" />
<:field group="address" type="text" name="street" label="Street" />
<:field group="address" type="text" name="city" label="City" />Nested forms (repeating entries)
A <:nested> entry declares a repeating child form; fields join it with
nested="name" and the submitted value becomes a list of maps, each entry
validated with its own changeset. nested declares a field's data scope
and group its visual grouping — they combine. See the Nested Forms guide:
<:nested name="addresses" title="Addresses" min_entries={1}
add_text="Add another address" />
<:field nested="addresses" type="text" name="street" label="Street" required />
<:field nested="addresses" type="text" name="city" label="City" required />Custom markup (slot bodies)
A <:field> body customizes rendering. Three tiers:
<%!-- Content block: body instead of the html attribute --%>
<:field type="html" name="intro">
<h2>Welcome, {@current_user.name}</h2>
</:field>
<%!-- Custom control: body receives the Phoenix.HTML.FormField; the
library still renders the label and errors, and the changeset
still validates the field --%>
<:field type="text" input_type="number" name="amount" label="Amount" :let={field}>
<input type="range" min="0" max="100" name={field.name} id={field.id}
value={field.value || 0} />
</:field>
<%!-- Fully custom element: body receives the Phoenix form --%>
<:field type="custom" name="summary" :let={form}>
<p>Total: {form[:amount].value}</p>
</:field>Slot bodies are in-memory only: instances containing them JSON-encode without the bodies, and such forms cannot round-trip through the WYSIWYG builder.
Render-only mode
For full control over the form lifecycle, render_only renders the markup
only — no LiveComponent, no managed state. Events are emitted without a
phx-target, so they land in the parent LiveView's handle_event/3
exactly like an idiomatic <form phx-change="validate" phx-submit="submit">,
and the parent owns the form state, passing its Phoenix.HTML.Form in:
<DynamicForm.form id="signup" render_only form={@form}>
<:field type="text" name="name" label="Name" required />
<:field type="text" name="email" input_type="email" label="Email" required />
</DynamicForm.form>
def handle_event("validate", %{"signup" => params}, socket) do
changeset = Accounts.change_user(%User{}, params) |> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset, as: "signup"))}
end
def handle_event("submit", %{"signup" => params}, socket) do
# entirely yours
endThe definition drives presentation — markup, labels, errors, conditional
visibility — while the parent's changeset drives the data. Override the
event names with phx_change and phx_submit.
Lifecycle attributes (on_change, change_debounce_in_ms, on_submit,
on_success, send_message_on, data, form_name,
validation_summary) have no meaning without the managed lifecycle and
raise. File upload questions require the stateful component and raise.
Attributes
id(:string) (required) - Component ID; also the instance id in declarative mode.instance(:any) - Data mode: an Instance struct, JSON string, or map. Mutually exclusive with json and <:field> slots. Defaults tonil.json(:string) - Data mode: a SurveyJS-compatible JSON string, decoded with Instance.decode!/1. Mutually exclusive with instance and <:field> slots. Defaults tonil.title(:string) - Instance title (declarative mode). Defaults tonil.description(:string) - Instance description (declarative mode). Defaults tonil.on_change(:any) - 1-arity function (DynamicForm.Payload) -> DynamicForm.Payload, run after built-in validations on every change and during the submit validation pass. Defaults tonil.change_debounce_in_ms(:integer) - Milliseconds of quiet before a change runs on_change and sends its :change message; without it both happen on every change. Defaults tonil.on_submit(:any) - 1-arity function (DynamicForm.Payload) -> DynamicForm.Payload, run on every submit — valid or not. Defaults tonil.data(:map) - Initial form data for edit mode — existing record values (a payload's data round-trips directly). Defaults to%{}.form_name(:string) - Form namespace for submitted params. Defaults to"dynamic_form".submit_text(:string) - Submit button text. Defaults to"Submit".on_success(:any) - 1-arity function (DynamicForm.Payload), run on every valid submission instead of sending the {:dynamic_form, :success, payload} message. Defaults tonil.send_message_on(:list) - Lifecycle events that message the parent LiveView: any of [:success, :change, :submit] (default: [:success]). Defaults tonil.hide_submit(:boolean) - Hide the submit button. Defaults tofalse.gettext(:atom) - Gettext backend for translations. Defaults toDynamicForm.Gettext.components(:atom) - Custom components module (e.g. the app's Phoenix-generated CoreComponents); functions it exports override the built-ins per function. Falls back to the :dynamic_form, :components config — see DynamicForm.Components. Defaults tonil.custom_field_types(:map) - Custom field types map (type name => Ecto type), merged over the :dynamic_form, :custom_field_types config; rendering dispatches to the components module's input/1 — see DynamicForm.FieldTypes. Defaults tonil.validation_summary(:string) - Display validation errors at the top of the form: nil, "simple", or "detailed". Defaults tonil.render_only(:boolean) - Render the form markup only: events go to the parent LiveView's handle_event/3 and the parent owns the form state. Requires form. Defaults tofalse.form(:any) - Render-only mode: the parent-owned Phoenix.HTML.Form to render against. Defaults tonil.phx_change(:string) - Render-only mode: change event name (default "validate"). Defaults tonil.phx_submit(:string) - Render-only mode: submit event name (default "submit"). Defaults tonil.
Slots
field- Form elements in render order (declarative mode). Accepts attributes:type(:string) (required) - Question or element type: text, comment, dropdown, radiogroup, checkbox, boolean, rating, tagbox, file, html, image, custom, or a registered custom field type (validated at runtime).name(:string) - Field name (required for question types; auto-generated for html/image/custom).label(:string) - Question title / image alt text.placeholder(:string)description(:string) - Help text shown below the input.input_type(:string) - HTML input type for type="text" (email, number, ...).default(:any) - Default value seeded into the form params.options(:list) - Choices for dropdown/radiogroup/checkbox/tagbox: [{"Label", "value"}, ...] or ["value", ...].required(:boolean)required_if(:string) - SurveyJS expression, e.g. "{other} notempty".visible_if(:string) - SurveyJS expression, e.g. "{subject} = 'support'".enable_if(:string) - SurveyJS expression; disabled when false.read_only(:boolean)group(:string) - Collect this field into the <:group> panel with this name.nested(:string) - Data scope: collect this field into the <:nested> form with this name. Combines with group — see the Nested Forms guide.rate_min(:integer) - type="rating" only.rate_max(:integer) - type="rating" only.rate_step(:integer) - type="rating" only.min_length(:integer) - Text length validation.max_length(:integer) - Text length validation.min(:any) - Numeric range validation.max(:any) - Numeric range validation.pattern(:string) - Regex validation.format(:string) - Format validation; supported: "email".validators(:list) - Escape hatch: Instance.Validator structs or atom-keyed maps.html(:string) - Raw HTML content for type="html" (alternative to a slot body).src(:string) - type="image" only: image URL.width(:string) - type="image" only, e.g. "300px".height(:string) - type="image" only.fit(:string) - type="image" only: CSS object-fit value.metadata(:map) - Metadata map (file upload config, radiogroup style, ...).
group- Panel declarations referenced by <:field group="..."> entries. Accepts attributes:name(:string) (required)title(:string)visible_if(:string)enable_if(:string)nested(:string) - Data scope this group lives in. A group inside a nested form declares it here, and every member field must declare the identical nested scope.
nested- Nested (repeating) form declarations referenced by <:field nested="..."> entries — the declarative counterpart to the SurveyJS paneldynamic question. Accepts attributes:name(:string) (required) - Data key: the value is a list of entry maps.title(:string)description(:string) - Help text shown below the title.entry_title(:string) - Per-entry heading; "{panelIndex}" interpolates the 1-based entry number.entries(:integer) - Entries seeded on a fresh form (default 0, raised to min_entries).min_entries(:integer) - Entries cannot be removed below this; validated on submit.max_entries(:integer) - The add button hides at this count; validated on submit.add_text(:string) - Add button label (default "Add new").remove_text(:string) - Remove button label (default "Remove").no_entries_text(:string) - Shown when the form has zero entries.confirm_delete(:boolean) - Ask for confirmation before removing an entry.confirm_text(:string) - Confirmation dialog text.key(:string) - Member field whose value must be unique across entries.key_error(:string) - Error message for key duplicates.default(:list) - Initial value: a list of entry maps (edit-mode style seeding).default_entry(:map) - Values seeded into each newly added entry.required(:boolean) - At least one entry is required.visible_if(:string)enable_if(:string)nested(:string) - Place this nested form inside another <:nested> form's template.group(:string) - Place this nested form inside a <:group> panel.