defmodule Skua.Field do
@moduledoc """
Normalizes a `Phoenix.HTML.FormField` (or raw name/value) into the
attributes every Skua form component needs: a sanitized DOM `id`, the form
`name`, the current `value`, and the list of `errors` to display.
This is the bridge that makes `field={@form[:email]}` "just work" — derived
ids/names, changeset errors surfaced only after the user has touched the
input (`Phoenix.Component.used_input?/1`), and a clean escape hatch for
components used outside a form.
Components call `normalize/1` in their function body:
assigns = Skua.Field.normalize(assigns)
and then read `@id`, `@name`, `@value`, `@errors`, `@show_errors?`.
## Error translation
Changeset errors arrive as `{message, opts}` tuples. By default Skua does
simple `%{key}`/`%{count}` interpolation. To route through your app's
Gettext (the usual Phoenix setup), configure a translator:
config :skua, :error_translator, {MyAppWeb.CoreComponents, :translate_error}
"""
import Phoenix.Component, only: [assign: 3]
@doc """
Reads `:field` (a `Phoenix.HTML.FormField`) plus any explicit
`:id`/`:name`/`:value`/`:errors` overrides and assigns the normalized
`:id`, `:name`, `:value`, `:errors`, and `:show_errors?` keys.
Explicit overrides always win over the field-derived values, so a component
can be driven by a bare `name`/`value` with no form at all.
"""
def normalize(assigns) do
normalize(assigns, Map.get(assigns, :field))
end
defp normalize(assigns, %Phoenix.HTML.FormField{} = field) do
# Errors are only meaningful once the field has been part of a submitted /
# validated params map — otherwise a pristine form flashes every error.
show_errors? = Phoenix.Component.used_input?(field)
errors = if show_errors?, do: Enum.map(field.errors, &translate_error/1), else: []
# Field-derived values override the components' nil attr defaults but yield
# to any explicit (non-nil) override the caller passed.
assigns
|> derive(:id, sanitize_id(field.id))
|> derive(:name, field.name)
|> derive(:value, field.value)
|> assign(:errors, errors)
|> assign(:show_errors?, show_errors? and errors != [])
end
defp normalize(assigns, nil) do
name = Map.get(assigns, :name)
errors = assigns |> Map.get(:errors) |> List.wrap() |> Enum.map(&translate_error/1)
assigns
|> derive(:id, name && sanitize_id(name))
|> assign(:name, name)
|> assign(:value, Map.get(assigns, :value))
|> assign(:errors, errors)
|> assign(:show_errors?, errors != [])
end
# Set the key only when the caller left it nil (attr default), so explicit
# overrides win but the field value fills the blank.
defp derive(assigns, key, value) do
if Map.get(assigns, key) in [nil, []], do: assign(assigns, key, value), else: assigns
end
@doc """
The errors to display for a field (or explicit `:errors`), gated on
`used_input?/1` so a pristine form doesn't flash them. Lets components read
errors without going through `normalize/1` (which would clobber `:value`).
"""
def display_errors(%{field: %Phoenix.HTML.FormField{} = field}) do
if Phoenix.Component.used_input?(field),
do: Enum.map(field.errors, &translate_error/1),
else: []
end
def display_errors(%{errors: errors}) when is_list(errors),
do: Enum.map(errors, &translate_error/1)
def display_errors(_), do: []
# `teams[]` / `user[email]` are valid form names but invalid as raw DOM ids
# and CSS selectors — collapse the bracket noise to hyphens.
@doc false
def sanitize_id(nil), do: nil
def sanitize_id(id) do
id
|> to_string()
|> String.replace(~r/[\[\]]+/, "_")
|> String.replace(~r/[^A-Za-z0-9_\-:]/, "-")
|> String.trim("_")
end
@doc """
Translates a single changeset error. Accepts a `{msg, opts}` tuple or a plain
string. Delegates to the configured `:error_translator` when set.
"""
def translate_error({msg, opts}) do
case Application.get_env(:skua, :error_translator) do
{mod, fun} -> apply(mod, fun, [{msg, opts}])
_ -> default_translate(msg, opts)
end
end
def translate_error(msg) when is_binary(msg), do: msg
defp default_translate(msg, opts) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end