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 @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 || [] assigns = %{ fields_definition: fields_definition, changeset: changeset, opts: opts } ~H"""
<%= for field <- @fields_definition do %>
{build_field(field, @changeset, @opts)}
<% end %>
""" end @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 assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Textarea def build_field(%{"type" => "textarea"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Email Input def build_field(%{"type" => "email"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # URL Input def build_field(%{"type" => "url"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], 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 assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], 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 assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Boolean Toggle def build_field(%{"type" => "boolean"} = field, changeset, opts) do field_value = get_field_value(changeset, field["key"]) is_checked = field_value in [true, "true", "1", 1] assigns = %{field: field, changeset: changeset, opts: opts, is_checked: is_checked} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"}
<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Date Input def build_field(%{"type" => "date"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Select Dropdown def build_field(%{"type" => "select"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label for={@field["key"]}>{@field["label"]}{if @field["required"], do: " *"} <%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Radio Buttons def build_field(%{"type" => "radio"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"}
<%= for {option, index} <- Enum.with_index(@field["options"] || []) do %> <% end %>
<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Checkbox Group def build_field(%{"type" => "checkbox"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"}
<%= for {option, index} <- Enum.with_index(@field["options"] || []) do %> <% end %>
<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Image Upload (placeholder - not yet implemented) def build_field(%{"type" => "image"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"}
<.icon name="hero-photo" class="w-12 h-12 mx-auto text-base-content/40 mb-3" />

{gettext("Image upload coming soon")}

{gettext("This feature is not yet available")}

<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # File Upload (admin entity forms - requires LiveView upload configuration) def build_field(%{"type" => "file"} = field, changeset, opts) do # Get current value from changeset (array of file metadata) current_files = get_field_value(changeset, field["key"]) || [] # Extract upload configuration max_entries = field["max_entries"] || 5 max_file_size_mb = Float.round((field["max_file_size"] || 15_728_640) / 1_048_576, 1) accept_list = field["accept"] || [".pdf", ".jpg", ".jpeg", ".png"] accept_display = Enum.map_join(accept_list, ", ", fn ext -> ext |> String.replace_prefix(".", "") |> String.upcase() end) assigns = %{ field: field, changeset: changeset, opts: opts, current_files: current_files, max_entries: max_entries, max_file_size_mb: max_file_size_mb, accept_display: accept_display } ~H"""
<.label>{@field["label"]}{if @field["required"], 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 --%>
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40 mb-3" />

{gettext("File upload in admin forms requires LiveView upload configuration")}

{gettext("File uploads work in public forms (contact forms, etc.)")}

<%!-- Show field configuration --%>

{gettext("Field Configuration:")}

• {gettext("Accepted types:")} {@accept_display}

• {gettext("Max files:")} {@max_entries}

• {gettext("Max size:")} {@max_file_size_mb} MB {gettext("per file")}

<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Relation Field (placeholder - not yet implemented) def build_field(%{"type" => "relation"} = field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.label>{@field["label"]}{if @field["required"], do: " *"}
<.icon name="hero-link" class="w-12 h-12 mx-auto text-base-content/40 mb-3" />

{gettext("Entity relations coming soon")}

{gettext("This feature is not yet available")}

<%= if @field["description"] do %> <.label class="label"> {@field["description"]} <% end %>
""" end # Fallback for unknown field types def build_field(field, changeset, opts) do assigns = %{field: field, changeset: changeset, opts: opts} ~H"""
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> {gettext("Unknown field type: %{type}", type: @field["type"])}
""" 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) 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 @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