Skip to main content

lib/athanor/field.ex

defmodule Athanor.Field do
  @moduledoc """
  Behaviour for consumer-supplied `:custom` field modules.

  A `:custom` field declared in `c:Athanor.Component.fields/0` points at a
  module implementing this behaviour:

      def fields, do: [
        {"image", :custom, label: "Image", module: MyApp.PageBuilder.Fields.MediaPicker}
      ]

  The module IS a `Phoenix.LiveComponent`. Athanor mounts it inside the
  auto-generated configure panel with these assigns:

  - `:value` — current value of the prop (`props[key]`), or `nil` if unset
  - `:on_change` — 1-arity function. Call it with the new value and
                   Athanor plumbs `:update_component_props` back to the
                   host LiveView. Wholesale replace of `props[key]`.
  - `:ctx` — the full `Athanor.Ctx`. Use for passthrough context like
             `account_id`, `user_id`, `api_token`, etc.
  - `:label` — optional, declared via `label:` in the field opts.

  The custom LC owns its own UI completely. It can mount more LCs inside,
  hit the DB, render whatever HTML it wants. Athanor stays out of the way.

  ## Example

      defmodule MyApp.PageBuilder.Fields.MediaPicker do
        use Phoenix.LiveComponent

        @behaviour Athanor.Field

        @impl true
        def update(assigns, socket) do
          {:ok, assign(socket, assigns)}
        end

        @impl true
        def render(assigns) do
          ~H\"\"\"
          <div>
            <img :if={@value} src={@value} class="..." />
            <button phx-click="pick" phx-target={@myself}>Pick image</button>
          </div>
          \"\"\"
        end

        @impl true
        def handle_event("pick", _params, socket) do
          # Open media picker, get url back, then:
          socket.assigns.on_change.(url)
          {:noreply, socket}
        end
      end

  ## Why a LiveComponent and not a function component

  Picker flows commonly need their own state (file uploads, async loads,
  multi-step modals). A `Phoenix.LiveComponent` lets the custom field
  own that state without leaking into the host LiveView.
  """

  # No callbacks declared here on purpose — `update/2` and `render/1`
  # already come from `Phoenix.LiveComponent`, which `:custom` field
  # modules use. Redeclaring them here just produced "conflicting
  # behaviours" warnings without adding any contract value.

  @doc """
  Returns `true` when `module` implements the `Athanor.Field` behaviour
  (or at minimum exports the required LiveComponent callbacks).
  """
  def implements?(module) when is_atom(module) do
    Code.ensure_loaded?(module) and
      function_exported?(module, :update, 2) and
      function_exported?(module, :render, 1)
  end
end