Skip to main content

lib/skua/field.ex

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