Skip to main content

lib/jido/chat/card/component.ex

defmodule Jido.Chat.Card.Component do
  @moduledoc """
  Canonical card component used by `Jido.Chat.Card`.
  """

  alias Jido.Chat.Markdown
  alias Jido.Chat.Wire

  @kinds [
    :text,
    :section,
    :fields,
    :field,
    :button,
    :link_button,
    :link,
    :actions,
    :select,
    :select_option,
    :radio_select,
    :table,
    :image,
    :divider
  ]

  @schema Zoi.struct(
            __MODULE__,
            %{
              kind: Zoi.enum(@kinds),
              id: Zoi.string() |> Zoi.nullish(),
              title: Zoi.string() |> Zoi.nullish(),
              label: Zoi.string() |> Zoi.nullish(),
              text: Zoi.string() |> Zoi.nullish(),
              url: Zoi.string() |> Zoi.nullish(),
              value: Zoi.string() |> Zoi.nullish(),
              image_url: Zoi.string() |> Zoi.nullish(),
              alt_text: Zoi.string() |> Zoi.nullish(),
              style: Zoi.string() |> Zoi.nullish(),
              disabled: Zoi.boolean() |> Zoi.default(false),
              markdown: Zoi.any() |> Zoi.nullish(),
              items: Zoi.list() |> Zoi.default([]),
              options: Zoi.list() |> Zoi.default([]),
              columns: Zoi.list() |> Zoi.default([]),
              rows: Zoi.list() |> Zoi.default([]),
              metadata: Zoi.map() |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))
  @type input :: t() | map() | String.t()

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the schema for a card component."
  def schema, do: @schema

  @doc "Creates a canonical card component."
  @spec new(t() | map()) :: t()
  def new(%__MODULE__{} = component), do: component

  def new(attrs) when is_map(attrs) do
    attrs
    |> normalize_markdown()
    |> normalize_items()
    |> normalize_options()
    |> then(&Jido.Chat.Schema.parse!(__MODULE__, @schema, &1))
  end

  @doc "Normalizes component input."
  @spec normalize(input()) :: t()
  def normalize(%__MODULE__{} = component), do: component
  def normalize(value) when is_binary(value), do: new(%{kind: :text, text: value})
  def normalize(value) when is_map(value), do: new(value)

  @doc "Serializes the component into a plain map with a type marker."
  @spec to_map(t()) :: map()
  def to_map(%__MODULE__{} = component) do
    component
    |> Map.from_struct()
    |> Map.update!(:items, &Enum.map(&1, fn item -> item |> normalize() |> to_map() end))
    |> Map.update!(:options, &Enum.map(&1, fn option -> option |> normalize() |> to_map() end))
    |> Map.update!(:markdown, fn
      nil -> nil
      %Markdown{} = markdown -> Markdown.to_map(markdown)
      other -> other
    end)
    |> Wire.to_plain()
    |> Map.put("__type__", "card_component")
  end

  @doc "Builds a component from serialized data."
  @spec from_map(map()) :: t()
  def from_map(map) when is_map(map), do: map |> Map.drop(["__type__", :__type__]) |> new()

  defp normalize_markdown(attrs) do
    case attrs[:markdown] || attrs["markdown"] do
      nil ->
        attrs

      %Markdown{} = markdown ->
        Map.put(attrs, :markdown, markdown)

      %{} = markdown ->
        Map.put(attrs, :markdown, Markdown.new(markdown))

      value when is_binary(value) ->
        Map.put(attrs, :markdown, Markdown.parse(value))
    end
  end

  defp normalize_items(attrs) do
    items = attrs[:items] || attrs["items"] || []
    attrs |> Map.delete("items") |> Map.put(:items, Enum.map(items, &normalize/1))
  end

  defp normalize_options(attrs) do
    options = attrs[:options] || attrs["options"] || []
    attrs |> Map.delete("options") |> Map.put(:options, Enum.map(options, &normalize/1))
  end
end