lib/kino/table.ex

defmodule Kino.Table do
  @moduledoc """
  A behaviour module for implementing tabular kinos.

  This module implements table visualization and delegates data
  fetching and traversal to the behaviour implementation.
  """

  @type info :: %{
          name: String.t(),
          features: list(:refetch | :pagination | :sorting)
        }

  @type rows_spec :: %{
          offset: non_neg_integer(),
          limit: pos_integer(),
          order: nil | %{direction: :asc | :desc, key: term()}
        }

  @type column :: %{
          :key => term(),
          :label => String.t(),
          optional(:type) => String.t(),
          optional(:summary) => %{String.t() => String.t()}
        }

  @type state :: term()

  @doc """
  Invoked once to initialize server state.
  """
  @callback init(init_arg :: term()) :: {:ok, info(), state()}

  @doc """
  Loads data matching the given specification.
  """
  @callback get_data(rows_spec(), state()) ::
              {:ok,
               %{
                 columns: list(column()),
                 data: {:columns | :rows, list(list(String.t()))},
                 total_rows: non_neg_integer() | nil
               }, state()}

  use Kino.JS, assets_path: "lib/assets/data_table/build"
  use Kino.JS.Live

  @type t :: Kino.JS.Live.t()

  @limit 10

  @doc """
  Creates a new tabular kino using the given module as data
  specification.
  """
  @spec new(module(), term()) :: t()
  def new(module, init_arg) do
    Kino.JS.Live.new(__MODULE__, {module, init_arg})
  end

  @impl true
  def init({module, init_arg}, ctx) do
    {:ok, info, state} = module.init(init_arg)

    {:ok,
     assign(ctx,
       module: module,
       info: info,
       state: state,
       key_to_string: %{},
       content: nil,
       # Data specification
       page: 1,
       limit: @limit,
       order: nil
     )}
  end

  @impl true
  def handle_connect(ctx) do
    ctx =
      if ctx.assigns.content do
        ctx
      else
        {content, ctx} = get_content(ctx)
        assign(ctx, content: content)
      end

    payload = %{
      name: ctx.assigns.info.name,
      features: ctx.assigns.info.features,
      content: ctx.assigns.content
    }

    {:ok, payload, ctx}
  end

  @impl true
  def handle_event("show_page", %{"page" => page}, ctx) do
    {:noreply, ctx |> assign(page: page) |> broadcast_update()}
  end

  def handle_event("refetch", _payload, ctx) do
    {:noreply, broadcast_update(ctx)}
  end

  def handle_event("limit", %{"limit" => limit}, ctx) do
    max_page = ceil(ctx.assigns.content.total_rows / limit)
    ctx = if ctx.assigns.content.page > max_page, do: assign(ctx, page: max_page), else: ctx
    {:noreply, ctx |> assign(limit: limit) |> broadcast_update()}
  end

  def handle_event("order_by", %{"key" => nil}, ctx) do
    {:noreply, ctx |> assign(order: nil) |> broadcast_update()}
  end

  def handle_event("order_by", %{"key" => key_string, "direction" => direction}, ctx) do
    direction = String.to_existing_atom(direction)
    key = lookup_key(ctx, key_string)
    ctx = if key, do: assign(ctx, order: %{key: key, direction: direction}), else: ctx
    {:noreply, broadcast_update(ctx)}
  end

  defp broadcast_update(ctx) do
    {content, ctx} = get_content(ctx)
    broadcast_event(ctx, "update_content", content)
    assign(ctx, content: content)
  end

  defp get_content(ctx) do
    rows_spec = %{
      offset: (ctx.assigns.page - 1) * ctx.assigns.limit,
      limit: ctx.assigns.limit,
      order: ctx.assigns.order
    }

    {:ok, %{columns: columns, data: {orientation, data}, total_rows: total_rows}, state} =
      ctx.assigns.module.get_data(rows_spec, ctx.assigns.state)

    {columns, key_to_string} = stringify_keys(columns, ctx.assigns.key_to_string)

    ctx = assign(ctx, state: state, key_to_string: key_to_string)

    {page_length, sample_data} =
      case orientation do
        :rows -> {length(data), List.first(data)}
        :columns -> {hd(data) |> length(), Enum.map(data, &List.first(&1))}
      end

    has_sample_data = if is_list(sample_data), do: Enum.any?(sample_data)

    columns =
      if has_sample_data do
        sample_data
        |> infer_types()
        |> Enum.zip_with(columns, fn type, column ->
          Map.put_new(column, :type, type)
        end)
      else
        columns
      end

    order =
      if ctx.assigns.order,
        do: %{ctx.assigns.order | key: key_to_string[ctx.assigns.order.key]}

    content = %{
      data: data,
      data_orientation: orientation,
      columns: columns,
      page: ctx.assigns.page,
      page_length: page_length,
      max_page: total_rows && ceil(total_rows / ctx.assigns.limit),
      total_rows: total_rows,
      order: order,
      limit: ctx.assigns.limit
    }

    {content, ctx}
  end

  defp infer_types(sample_data) do
    Enum.map(sample_data, &type_of/1)
  end

  defp type_of("http" <> _rest), do: "uri"

  defp type_of(data) do
    cond do
      number?(data) -> "number"
      date?(data) or date_time?(data) -> "date"
      true -> "text"
    end
  end

  defp number?(value), do: match?({_, ""}, Float.parse(value))
  defp date?(value), do: match?({:ok, _}, Date.from_iso8601(value))
  defp date_time?(value), do: match?({:ok, _, _}, DateTime.from_iso8601(value))

  # Map keys to string representation for the client side
  defp stringify_keys(columns, key_to_string) do
    {columns, key_to_string} =
      Enum.map_reduce(columns, key_to_string, fn column, key_to_string ->
        key_to_string =
          Map.put_new_lazy(key_to_string, column.key, fn ->
            key_to_string |> map_size() |> Integer.to_string()
          end)

        column = %{column | key: key_to_string[column.key]}

        {column, key_to_string}
      end)

    {columns, key_to_string}
  end

  defp lookup_key(ctx, key_string) do
    ctx.assigns.key_to_string
    |> Enum.find(&match?({_key, ^key_string}, &1))
    |> case do
      {key, _key_string} -> key
      _ -> nil
    end
  end
end