Dynamic form builder for entity data forms.
This module generates Phoenix.Component forms based on entity field definitions, enabling dynamic data entry forms that adapt to the entity's schema.
Usage
# Generate form fields for an entity
fields_html = PhoenixKitEntities.FormBuilder.build_fields(entity, changeset)
# Generate a single field
field_html = PhoenixKitEntities.FormBuilder.build_field(field_definition, changeset)
# Validate entity data against field definitions
{:ok, validated_data} = PhoenixKitEntities.FormBuilder.validate_data(entity, data_params)Field Type Support
The FormBuilder supports all field types defined in PhoenixKitEntities.FieldTypes:
- Basic Types: text, textarea, email, url, rich_text
- Numeric Types: number
- Boolean Types: boolean (toggle/checkbox)
- Date Types: date
- Choice Types: select, radio, checkbox (with options)
- Media Types: image, file (upload)
- Relational Types: relation (entity references)
Form Generation
Forms are generated as Phoenix.Component HTML with proper validation, error handling, and styling consistent with the PhoenixKit design system.
Field-Level Translations (display-only)
A field definition may carry an optional "translations" key, resolved
by translated_label/2 and translated_option_label/3:
%{
"key" => "color",
"label" => "Värv",
"type" => "radio",
"options" => ["Must", "Valge"],
"translations" => %{
"ru" => %{"label" => "Цвет", "options" => %{"Must" => "Чёрный", "Valge" => "Белый"}},
"en" => %{"label" => "Colour", "options" => %{"Must" => "Black", "Valge" => "White"}}
}
}This is display-only: the canonical, stored/submitted value is
always the original "label" / option string ("Must", never
"Чёрный") — "translations" never affects validation,
merge_other_params/2, or what lands in EntityData.data. "heading"
fields are translated the same way (their "translations" map only
ever has a "label" entry, since headings have no options).
Resolution falls back to the original text whenever a lookup can't be
satisfied: lang_code is nil, the field has no "translations" key
at all, the language isn't present, or (for options) that specific
option has no translated entry. Language-code lookups tolerate
base/dialect mismatches the same way entity-level settings["translations"]
does (see PhoenixKitEntities.get_entity_by_name/2's dialect-tolerant
lookup) — a field translated under "ru-RU" still resolves for a
caller passing the bare "ru", and vice versa.
Which surfaces resolve this
Resolved wherever build_fields/3 / build_field/3 receive a non-nil
opts[:lang_code] (every rendering clause: labels; select/radio/
checkbox also translate their option text), and by
PhoenixKitEntities.Components.LiveDataForm in both modes — :edit
via build_fields/3 (it passes its lang attr straight through as
lang_code), :readonly directly via translated_label/2 /
translated_option_label/3.
Not resolved — these surfaces render the canonical field text regardless of locale, by design; this is a scope boundary, not a bug:
PhoenixKitEntities.Web.DataForm(the admin record editor) — the non-multilang branch callsbuild_fields/3withlang_code: nilhardcoded; the multilang branch only passes a real locale when the multilang module itself is enabled, otherwise alsonil.PhoenixKitEntities.Components.EntityForm(the public entity submission form) — doesn't passlang_codetobuild_fields/3at all.- Validation error messages (
EntityData.changeset/2andvalidate_data/3here) — always interpolate the canonicalfield_def["label"]/field["label"], never a translation.
Summary
Functions
Builds a single form field based on field definition.
Builds form fields HTML for an entire entity.
Gets the current value of a field from a changeset.
Resolves allow_other sentinel values in submitted params back into free text.
Resolves a field's (or a "heading" field's) display label for
lang_code, honoring an optional "translations" key on the field
definition (see the moduledoc for the full contract).
Resolves the display label for a single radio/select/checkbox option
value, honoring the field's optional "translations" => %{lang => %{"options" => %{option_value => translated}}} map.
Validates entity data against field definitions.
Functions
Builds a single form field based on field definition.
Parameters
field- Field definition mapchangeset- The changeset for validation and valuesopts- Optional configuration
Examples
iex> field = %{"type" => "text", "key" => "title", "label" => "Title"}
iex> changeset = Ecto.Changeset.cast(%{}, %{}, [])
iex> PhoenixKitEntities.FormBuilder.build_field(field, changeset)
# Returns Phoenix.Component field HTML
Builds form fields HTML for an entire entity.
Takes an entity with its field definitions and generates the complete form HTML for data entry.
Parameters
entity- The entity struct with fields_definitionchangeset- The changeset for the entity dataopts- Optional configuration (default: [])
Options
:wrapper_class- CSS class for field wrapper divs:input_class- CSS class for input elements:label_class- CSS class for label elements:id_prefix- extra segment folded into each field wrapper's DOM id ("entity-field-#{id_prefix}-#{key}-#{lang}"). Every page that has historically called this function renders exactly one form per entity (adminDataForm, the public entity form), so the default (nil, omitted from the id) is unchanged. Pages embedding more than one instance of the same entity's fields at once — e.g.PhoenixKitEntities.Components.LiveDataFormused once per record in a list — MUST pass something unique per instance (the record's uuid) or LiveView raises "Duplicate id found while testing LiveView" (in tests) / silently misdirects DOM patches between instances (at runtime).
Examples
iex> entity = %Entities{fields_definition: [
...> %{"type" => "text", "key" => "title", "label" => "Title", "required" => true}
...> ]}
iex> changeset = Ecto.Changeset.cast(%{}, %{}, [])
iex> PhoenixKitEntities.FormBuilder.build_fields(entity, changeset)
# Returns Phoenix.Component form HTML
Gets the current value of a field from a changeset.
Helper function to extract field values from changesets or forms for form rendering.
Resolves allow_other sentinel values in submitted params back into free text.
Radio/select/checkbox fields with "allow_other" => true render an extra
"Other" option whose value is the sentinel "__other__", paired with a
companion <key>__other text input. This function replaces the sentinel
with the companion field's text (defaulting to "" if absent) and drops
every <key>__other companion key from the result. A no-op for fields
without allow_other, or for values that aren't the sentinel.
Examples
iex> field = %{"type" => "radio", "key" => "color", "allow_other" => true}
iex> PhoenixKitEntities.FormBuilder.merge_other_params(
...> [field],
...> %{"color" => "__other__", "color__other" => "Crimson"}
...> )
%{"color" => "Crimson"}
Resolves a field's (or a "heading" field's) display label for
lang_code, honoring an optional "translations" key on the field
definition (see the moduledoc for the full contract).
Falls back to field["label"] whenever lang_code is nil, the
field has no "translations" map, the language isn't present in it,
or its "label" entry is missing/blank. Display-only — never affects
the canonical field["label"] itself.
Examples
iex> field = %{"label" => "Värv", "translations" => %{"ru" => %{"label" => "Цвет"}}}
iex> PhoenixKitEntities.FormBuilder.translated_label(field, "ru")
"Цвет"
iex> PhoenixKitEntities.FormBuilder.translated_label(field, "et")
"Värv"
iex> PhoenixKitEntities.FormBuilder.translated_label(field, nil)
"Värv"
Resolves the display label for a single radio/select/checkbox option
value, honoring the field's optional "translations" => %{lang => %{"options" => %{option_value => translated}}} map.
option_value is always the canonical, stored/submitted string —
this only affects what text renders next to it (e.g. <option value={option_value}>{translated_option_label(...)}</option>). Falls
back to option_value itself whenever lang_code is nil, the field
has no "translations" map, the language isn't present, or this
specific option has no translated entry (this is also what makes a
free-text allow_other value display as-is: it was never one of the
fixed options, so it never has a translation entry to find).
Examples
iex> field = %{"translations" => %{"ru" => %{"options" => %{"Must" => "Чёрный"}}}}
iex> PhoenixKitEntities.FormBuilder.translated_option_label(field, "Must", "ru")
"Чёрный"
iex> PhoenixKitEntities.FormBuilder.translated_option_label(field, "Valge", "ru")
"Valge"
Validates entity data against field definitions.
Takes entity field definitions and validates submitted data parameters according to the field types, requirements, and constraints.
Parameters
entity- The entity with field definitionsdata_params- Map of submitted data parameters
Returns
{:ok, validated_data}- Successfully validated data{:error, errors}- Validation errors
Examples
iex> entity = %Entities{fields_definition: [
...> %{"type" => "text", "key" => "title", "required" => true}
...> ]}
iex> PhoenixKitEntities.FormBuilder.validate_data(entity, %{"title" => "Test"})
{:ok, %{"title" => "Test"}}
iex> PhoenixKitEntities.FormBuilder.validate_data(entity, %{})
{:error, %{"title" => ["is required"]}}