defmodule PhoenixKit.Modules.Entities.FormBuilder do
@moduledoc """
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 = PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset)
# Generate a single field
field_html = PhoenixKit.Modules.Entities.FormBuilder.build_field(field_definition, changeset)
# Validate entity data against field definitions
{:ok, validated_data} = PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, data_params)
## Field Type Support
The FormBuilder supports all field types defined in `PhoenixKit.Modules.Entities.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.
"""
import Phoenix.Component
import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1]
import PhoenixKitWeb.Components.Core.FormFieldLabel, only: [label: 1]
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Entities.Multilang
@doc """
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_definition
- `changeset` - The changeset for the entity data
- `opts` - 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
## Examples
iex> entity = %Entities{fields_definition: [
...> %{"type" => "text", "key" => "title", "label" => "Title", "required" => true}
...> ]}
iex> changeset = Ecto.Changeset.cast(%{}, %{}, [])
iex> PhoenixKit.Modules.Entities.FormBuilder.build_fields(entity, changeset)
# Returns Phoenix.Component form HTML
"""
def build_fields(entity, changeset, opts \\ []) do
fields_definition = entity.fields_definition || []
lang_code = opts[:lang_code]
# For secondary languages, extract primary data for placeholder text
opts = maybe_add_primary_placeholders(opts, changeset, entity, lang_code)
# When multilang: extract language-specific data into a view changeset
# so all existing build_field/get_field_value calls work unchanged.
changeset = maybe_apply_language_view(changeset, entity, lang_code)
assigns = %{
fields_definition: fields_definition,
changeset: changeset,
opts: opts
}
~H"""
<%= for field <- @fields_definition do %>
{build_field(field, @changeset, @opts)}
<% end %>
"""
end
# When a lang_code is provided, extract that language's data (merged with
# primary) and replace the :data field in the changeset so downstream
# build_field calls read the correct values via get_field_value/2.
defp maybe_apply_language_view(changeset, _entity, nil), do: changeset
defp maybe_apply_language_view(%Phoenix.HTML.Form{} = form, _entity, lang_code) do
data = Ecto.Changeset.get_field(form.source, :data)
if Multilang.multilang_data?(data) do
lang_data = Multilang.get_language_data(data, lang_code)
updated_changeset = Ecto.Changeset.put_change(form.source, :data, lang_data)
%{form | source: updated_changeset}
else
form
end
end
defp maybe_apply_language_view(changeset, _entity, lang_code) do
data = Ecto.Changeset.get_field(changeset, :data)
if Multilang.multilang_data?(data) do
lang_data = Multilang.get_language_data(data, lang_code)
Ecto.Changeset.put_change(changeset, :data, lang_data)
else
changeset
end
end
# ── Multilang placeholder helpers ──────────────────────────────
defp maybe_add_primary_placeholders(opts, _changeset, _entity, nil), do: opts
defp maybe_add_primary_placeholders(opts, changeset, _entity, lang_code) do
primary = Multilang.primary_language()
if lang_code == primary do
opts
else
data = extract_data_from_changeset(changeset)
if Multilang.multilang_data?(data) do
primary_data = Multilang.get_primary_data(data)
Keyword.put(opts, :primary_placeholders, primary_data)
else
opts
end
end
end
defp extract_data_from_changeset(%Phoenix.HTML.Form{} = form),
do: Ecto.Changeset.get_field(form.source, :data)
defp extract_data_from_changeset(changeset),
do: Ecto.Changeset.get_field(changeset, :data)
defp get_effective_placeholder(field, opts, default \\ "") do
case opts[:primary_placeholders] do
%{} = primary_data ->
primary_value = Map.get(primary_data, field["key"])
if primary_value != nil and to_string(primary_value) != "" do
to_string(primary_value)
else
field["placeholder"] || default
end
_ ->
field["placeholder"] || default
end
end
# For text-like fields on secondary languages, show empty when value
# matches primary (inherited) — the primary value appears as placeholder.
defp get_effective_text_value(changeset, field_key, opts) do
current = get_field_value(changeset, field_key)
case opts[:primary_placeholders] do
%{} = primary_data ->
primary_value = Map.get(primary_data, field_key)
if inherited_value?(current, primary_value), do: nil, else: current
_ ->
current
end
end
defp inherited_value?(nil, _), do: true
defp inherited_value?("", _), do: true
defp inherited_value?(a, b), do: to_string(a) == to_string(b)
@doc """
Builds a single form field based on field definition.
## Parameters
- `field` - Field definition map
- `changeset` - The changeset for validation and values
- `opts` - Optional configuration
## Examples
iex> field = %{"type" => "text", "key" => "title", "label" => "Title"}
iex> changeset = Ecto.Changeset.cast(%{}, %{}, [])
iex> PhoenixKit.Modules.Entities.FormBuilder.build_field(field, changeset)
# Returns Phoenix.Component field HTML
"""
def build_field(field, changeset, opts \\ [])
# Text Input
def build_field(%{"type" => "text"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts)
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
"""
end
# Textarea
def build_field(%{"type" => "textarea"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts)
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label for={@field["key"]}>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
"""
end
# Email Input
def build_field(%{"type" => "email"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts, gettext("user@example.com"))
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label for={@field["key"]}>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
"""
end
# URL Input
def build_field(%{"type" => "url"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts, gettext("https://example.com"))
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label for={@field["key"]}>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
"""
end
# Rich Text Editor
def build_field(%{"type" => "rich_text"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts, gettext("Enter rich text content..."))
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label for={@field["key"]}>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<.label class="label">
{gettext("Rich text editor (HTML supported)")}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
"""
end
# Number Input
def build_field(%{"type" => "number"} = field, changeset, opts) do
placeholder = get_effective_placeholder(field, opts)
value = get_effective_text_value(changeset, field["key"], opts)
assigns = %{
field: field,
changeset: changeset,
opts: opts,
placeholder: placeholder,
value: value
}
~H"""
<.label for={@field["key"]}>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%= if @field["description"] do %>
<.label class="label">
{@field["description"]}
<% end %>
<.label>
{@field["label"]}{if @field["required"] && !@opts[:primary_placeholders], do: " *"}
<%!-- Display current files if any --%>
<%= if @current_files != [] and is_list(@current_files) do %>
{gettext("Current files:")}
<%= for file <- @current_files do %>
<.icon name="hero-document" class="w-4 h-4 text-base-content/60" />
{file["filename"] || gettext("Unknown file")}
<%= if file["size"] do %>
{format_bytes(file["size"])}
<% end %>
<% end %>
<% end %>
<%!-- File upload placeholder for admin forms --%>
"""
end
# Helper function to format file sizes
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1_048_576 do
"#{Float.round(bytes / 1024, 1)} KB"
end
defp format_bytes(bytes) do
"#{Float.round(bytes / 1_048_576, 1)} MB"
end
@doc """
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 definitions
- `data_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> PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, %{"title" => "Test"})
{:ok, %{"title" => "Test"}}
iex> PhoenixKit.Modules.Entities.FormBuilder.validate_data(entity, %{})
{:error, %{"title" => ["is required"]}}
"""
def validate_data(entity, data_params, lang_code \\ nil)
def validate_data(entity, data_params, nil) do
fields_definition = entity.fields_definition || []
errors = %{}
validated_data = %{}
result =
Enum.reduce(fields_definition, {validated_data, errors}, fn field, {data_acc, errors_acc} ->
field_key = field["key"]
field_value = Map.get(data_params, field_key)
case validate_field_value(field, field_value) do
{:ok, validated_value} ->
{Map.put(data_acc, field_key, validated_value), errors_acc}
{:error, field_errors} ->
{data_acc, Map.put(errors_acc, field_key, field_errors)}
end
end)
case result do
{validated_data, errors} when map_size(errors) == 0 ->
{:ok, validated_data}
{_data, errors} ->
{:error, errors}
end
end
def validate_data(entity, data_params, lang_code) do
primary = Multilang.primary_language()
if lang_code == primary do
# Primary language: full validation (same as default)
validate_data(entity, data_params, nil)
else
# Secondary language: type validation only, no required checks.
# Empty values are stripped (not stored as overrides).
validate_secondary_data(entity, data_params)
end
end
defp validate_secondary_data(entity, data_params) do
fields_definition = entity.fields_definition || []
result =
Enum.reduce(fields_definition, {%{}, %{}}, fn field, {data_acc, errors_acc} ->
field_key = field["key"]
field_value = Map.get(data_params, field_key)
case field_value do
nil ->
{data_acc, errors_acc}
"" ->
{data_acc, errors_acc}
value ->
case validate_type(field, value) do
{:ok, validated_value} ->
{Map.put(data_acc, field_key, validated_value), errors_acc}
{:error, field_errors} ->
{data_acc, Map.put(errors_acc, field_key, field_errors)}
end
end
end)
case result do
{validated_data, errors} when map_size(errors) == 0 ->
{:ok, validated_data}
{_data, errors} ->
{:error, errors}
end
end
@doc """
Gets the current value of a field from a changeset.
Helper function to extract field values from changesets or forms for form rendering.
"""
def get_field_value(%Phoenix.HTML.Form{} = form, field_key) do
# When passed a form, access the underlying changeset
# Use Ecto.Changeset.get_field to get the value from changes or fallback to struct
case Ecto.Changeset.get_field(form.source, :data) do
nil -> nil
data when is_map(data) -> Map.get(data, field_key)
_ -> nil
end
end
def get_field_value(changeset, field_key) do
# When passed a changeset directly
case Ecto.Changeset.get_field(changeset, :data) do
nil -> nil
data when is_map(data) -> Map.get(data, field_key)
_ -> nil
end
end
# Private Functions
defp validate_field_value(field, value) do
with {:ok, value} <- validate_required(field, value) do
validate_type(field, value)
end
end
defp validate_required(%{"required" => true}, value) when value in [nil, ""] do
{:error, [gettext("is required")]}
end
defp validate_required(_field, value), do: {:ok, value}
defp validate_type(%{"type" => "email"}, value) when is_binary(value) and value != "" do
if String.contains?(value, "@") do
{:ok, value}
else
{:error, [gettext("must be a valid email address")]}
end
end
defp validate_type(%{"type" => "url"}, value) when is_binary(value) and value != "" do
normalized_value =
if String.starts_with?(value, ["http://", "https://"]) do
value
else
"https://#{value}"
end
{:ok, normalized_value}
end
defp validate_type(%{"type" => "number"}, value) when is_binary(value) and value != "" do
case Float.parse(value) do
{num, ""} -> {:ok, num}
_ -> {:error, [gettext("must be a valid number")]}
end
end
defp validate_type(%{"type" => "boolean"}, value) do
cond do
value in [true, "true", "1", 1] -> {:ok, true}
value in [false, "false", "0", 0, nil, ""] -> {:ok, false}
true -> {:error, [gettext("must be true or false")]}
end
end
defp validate_type(%{"type" => "select", "options" => options}, value) when is_list(options) do
cond do
value in [nil, ""] -> {:ok, nil}
value in options -> {:ok, value}
true -> {:error, [gettext("must be one of: %{options}", options: Enum.join(options, ", "))]}
end
end
defp validate_type(%{"type" => "radio", "options" => options}, value) when is_list(options) do
cond do
value in [nil, ""] -> {:ok, nil}
value in options -> {:ok, value}
true -> {:error, [gettext("must be one of: %{options}", options: Enum.join(options, ", "))]}
end
end
defp validate_type(%{"type" => "checkbox", "options" => options}, values)
when is_list(options) and is_list(values) do
invalid_values = values -- options
if Enum.empty?(invalid_values) do
{:ok, values}
else
{:error,
[gettext("contains invalid options: %{invalid}", invalid: Enum.join(invalid_values, ", "))]}
end
end
defp validate_type(_field, value), do: {:ok, value}
end