DynamicForm (DynamicForm v1.0.0)

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.

Dynamic Forms

DynamicForm.form/1 is the unified entry point for rendering forms. It accepts a declarative definition using <:field> slots, a SurveyJS-compatible JSON string, or an instance using Elixir data structures:

<%!-- FromComponent --%>
<DynamicForm.form id="contact-form">
  <:field type="text" input_type="email" name="email" label="Email" required />
</DynamicForm.form>

<%!-- Data: SurveyJS-compatible JSON string --%>
<DynamicForm.form id="contact-form" json={@json} />

<%!-- Data: Instance struct or map --%>
<DynamicForm.form id="contact-form" instance={@form_instance} />

Summary

Functions

Renders a dynamic form from an instance, a SurveyJS-compatible JSON string, or <:field> slots (declarative mode).

The whole form's current values, for reading inside a <:field> slot body.

Functions

form(assigns)

Renders a dynamic form from an instance, a SurveyJS-compatible JSON string, or <:field> slots (declarative mode).

Wraps DynamicForm.Renderer.LiveComponent, which manages form state, validation, and submission. Exactly one of the instance attribute, the json attribute, or <:field> slots must be provided.

Messages

When a user changes a value or submits the form, the library validates the changes with it's own internal state and then sends messages to the parent LiveView or LiveComponent.

The messages are in the format of {:dynamic_form, event, payload}.

By default, the form will only send the :success event. Which only occurs when the user submits a form with no validation errors.

<DynamicForm.form id="signup">

def handle_info({:dynamic_form, :success, payload}, socket) do
  {:ok, record} = Context.create(payload.data)

  {:noreply, push_navigate(socket, to: ~p"/success")}
end

It is also possible to receive :change and :submit events too. The send_messages_on attribute can be used to define a list of events to receive:

<DynamicForm.form id="signup" send_message_on={[:change, :success]}>

def handle_info({:dynamic_form, :change, payload}, socket) do
  # ...
end

def handle_info({:dynamic_form, :success, payload}, socket) do
  # ...
end

When using :change and :submit the form data may be invalid. The DynamicForm.Payload.valid?/1 helper is available to check valid state.

Note: The :change event can also be paired with the change_debounce_in_ms to add a debounce filter to the change events sent. Without it, every change will send a message.

See DynamicForm.Renderer.LiveComponent and DynamicForm.Payload for the full details.

Lifecycle callbacks

Receiving messages is the standard way to work with data coming from DynamicForm. Most form flows do not require any additional changes to the form's lifecycle - the library defaults are sufficient.

For some more complex form flows, it's useful to be able to hook into the internal lifecycle events that happen within the library.

In those cases, there are 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, unless change_debounce_in_ms is 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>

FromComponent mode

<:field> entries parse to a DynamicForm.Instance in template order (see DynamicForm.Parser.FromComponent). 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>

A body reads its own scope through the value it receives, and the whole form — including other nested forms' entries — through DynamicForm.form_data/1.

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

The DynamicForm library can also be used as a pure renderer. In this setup, the form is rendered as a functional component on the page. The standard phx-change and phx-submit event handlers get sent directly to the parent LiveView as-is. That means no LiveComponent or managed state. In other words, no validation or type casting, no changeset or error management.

To enable this mode, add the render_only attribute.

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.

Note: Lifecycle attributes (on_change, change_debounce_in_ms, on_submit, on_success, send_message_on, data, form_name, validation_summary) have no meaning in the render_only mode and will raise an exception. File upload questions require the stateful component and also 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, parsed with Parser.FromData.parse!/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.
  • 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 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 {:dynamic_form, :success, payload} message. Defaults to nil.
  • send_message_on (:list) - Lifecycle events that message the parent LiveView: any of [:success, :change, :submit] (default: [:success]). 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.ComponentResolver. 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 (:any) - Question title / image alt text. Blank (nil, false, or "") renders no label, and no required marker with it; omitting it falls back to the name.
    • 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", ...].
    • choices_from (:string) - Carry forward: build this field's choices from another question's values, typically a <:nested> form. Mutually exclusive with options.
    • choice_value (:string) - Carry forward: the source field supplying each choice's value (default: the entry's dynamic_form_id).
    • choice_text (:string) - Carry forward: the source field supplying each choice's label, or a template interpolating them — "{min} - {max}", "{panelIndex}".
    • choices_mode (:string) - Carry forward from another choice field: "all" (default), "selected", or "unselected".
    • no_choices_text (:string) - Shown in place of the control when the field has no choices yet — typically a carried-forward source with no entries.
    • required (:boolean)
    • required_label (:any) - Mark shown beside a required field's label (default "*"). Blank (nil, false, or "") shows none while the field stays required.
    • 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 (:any) - Panel heading; blank (nil, false, or "") renders none.
    • type (:string) - Layout: "horizontal" (default, members share a row and wrap) or "vertical", or a type your components module defines.
    • visible_if (:string)
    • enable_if (:string)
    • group (:string) - Place this group inside another <:group> panel. It renders at the position of its own first member field, and must declare the same nested scope as its parent.
    • 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 (:any) - Section heading; blank (nil, false, or "") renders none, while omitting it falls back to the capitalized name.
    • description (:string) - Help text shown below the title.
    • entry_title (:any) - Per-entry heading; "{panelIndex}" interpolates the 1-based entry number. Blank renders none.
    • 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"). The control is an icon, so this becomes its tooltip and screen-reader name.
    • 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.
    • generate_ids (:boolean) - Seed each entry with a stable dynamic_form_id, copied from the entry's id when the data came from a stored record (default true).
    • 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.
    • required_label (:any) - Mark beside the section heading when required (default "*"); blank shows none.
    • 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.

form_data(arg)

@spec form_data(Phoenix.HTML.FormField.t() | Phoenix.HTML.Form.t()) :: map()

The whole form's current values, for reading inside a <:field> slot body.

Takes the Phoenix.HTML.FormField a custom control receives, or the Phoenix.HTML.Form a type="custom" element receives, and returns the applied changeset data — the same map a :change message delivers as payload.data, with nested entries as lists of maps:

%{name: "Ada", addresses: [%{street: "110 Main St", city: "Portland"}]}

The map is always form-level, even inside a nested entry, so a control in one nested form can read another's entries:

<:field :let={field} nested="rooms" type="checkbox" name="teachers">
  <%= for teacher <- DynamicForm.form_data(field)[:staff] || [] do %>
    <label>
      <input type="checkbox" name={"#{field.name}[]"} value={teacher[:id]} />
      {teacher[:name]}
    </label>
  <% end %>
</:field>

Values the user hasn't entered are absent rather than nil, as with any Ecto.Changeset.apply_changes/1 result — default to [] or %{} when reading. In render-only mode the parent owns the changeset, so the shape is whatever that changeset applies to.

Raises when given a form DynamicForm didn't render.

submit_button(assigns)

See DynamicForm.Renderer.LiveComponent.submit_button/1.