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.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>
A valid submission delivers {:dynamic_form, payload} to the parent
LiveView by default; invalid submissions render their errors inline and
never message the parent. Define on_success — a 1-arity function
receiving the payload — to replace the default 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, on_submit, on_success, 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.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 default {:dynamic_form, payload} message. 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.