DynamicForm (DynamicForm v0.18.1)

Copy Markdown View Source

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

When using DynamicForm.RendererLive (LiveComponent):

  1. Set hide_submit={true} on your LiveComponent
  2. Use DynamicForm.submit_button/1 with 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:

  1. Set hide_submit={true} and provide a custom form_id
  2. Use DynamicForm.submit_button/1 with that form_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

form(assigns)

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
end

The 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 to nil.
  • json (:string) - Data mode: a SurveyJS-compatible JSON string, decoded with Instance.decode!/1. Mutually exclusive with instance and <:field> slots. Defaults to nil.
  • title (:string) - Instance title (declarative mode). Defaults to nil.
  • description (:string) - Instance description (declarative mode). Defaults to nil.
  • 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 to nil.
  • on_submit (:any) - 1-arity function (DynamicForm.Payload) -> DynamicForm.Payload, run on every submit — valid or not. Defaults to nil.
  • 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 to nil.
  • hide_submit (:boolean) - Hide the submit button. Defaults to false.
  • gettext (:atom) - Gettext backend for translations. Defaults to DynamicForm.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 to nil.
  • 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 to nil.
  • validation_summary (:string) - Display validation errors at the top of the form: nil, "simple", or "detailed". Defaults to nil.
  • 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 to false.
  • form (:any) - Render-only mode: the parent-owned Phoenix.HTML.Form to render against. Defaults to nil.
  • phx_change (:string) - Render-only mode: change event name (default "validate"). Defaults to nil.
  • phx_submit (:string) - Render-only mode: submit event name (default "submit"). Defaults to nil.

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.

submit_button(assigns)

See DynamicForm.RendererLive.submit_button/1.