lib/luminous/panel.ex

defmodule Luminous.Panel do
  @moduledoc """
  A panel represents a single visual element (chart) in a dashboard
  that can contain many queries. A panel is "refreshed" when the live
  view first loads, as well as when a variable or the time range are
  updated. The panel's data (as returned by the queries) are stored in
  `Luminous.Dashboard`.

  The module defines a behaviour that must be implemented by concrete
  panels either inside Luminous (e.g. `Luminous.Panel.Chart`,
  `Luminous.Panel.Stat` etc.) or on the client side.

  When a Panel is refreshed (`refresh/3`), the execution flow is as follows:

  - for each query:
    - execute the query (`Luminous.Query.execute()`)
    - `transform/2` the query result
  - `reduce/3` (aggregate) the transformed query results
  """

  use Phoenix.Component

  alias Luminous.{Attributes, Dashboard, Query, TimeRange, Variable}

  defmacro __using__(_opts) do
    quote do
      use Phoenix.Component

      @behaviour Luminous.Panel
    end
  end

  @type t :: map()

  @doc """
  transform a query result to view data acc. to the panel type
  """
  @callback transform(Query.result(), t()) :: any()

  @doc """
  aggregate all transformed results to a single map
  that will be sent for visualization
  """
  @callback reduce(list(), t(), Dashboard.t()) :: map()

  @doc """
  The phoenix function component that renders the panel. The panel's
  title, description tooltip, contextual menu etc. are rendered
  elsewhere. See `Luminous.Components.panel/1` for a description of the available assigns.
  """
  @callback render(map()) :: Phoenix.LiveView.Rendered.t()

  @doc """
  A list of the available panel actions that will be transmitted as events using JS.dispatch
  using the format "panel:${panel_id}:${event}"
  The `label` will be shown in the dropdown.
  This is an optional callback -- if undefined (or if it returns []), then the dropdown is not rendered
  """
  @callback actions() :: [%{event: binary(), label: binary()}]

  @doc """
  Define custom attributes specific to the concrete panel type
  These will be used to parse, validate and populate the client's input
  """
  @callback panel_attributes() :: Attributes.Schema.t()

  @doc """
  Define the panel type's supported data attributes
  These will be used to parse, validate and populate the client's input
  """
  @callback data_attributes() :: Attributes.Schema.t()

  @optional_callbacks panel_attributes: 0, data_attributes: 0, actions: 0

  @doc """

  Initialize a panel. Verifies all supplied options both generic
  and the concrete panel's attributes.  Will raise if
  the validation fails. The supported generic attributes are:

  #{NimbleOptions.docs(Attributes.Schema.panel())}

  """
  @spec define!(Keyword.t()) :: t()
  def define!(opts \\ []) do
    mod = fetch_panel_module!(opts)
    schema = Keyword.merge(Attributes.Schema.panel(), get_attributes!(mod, :panel_attributes))

    case Attributes.parse(opts, schema) do
      {:ok, panel} ->
        Map.put(panel, :data_attributes, validate_data_attributes!(panel))

      {:error, message} ->
        raise message
    end
  end

  @doc """
  Refresh all panel queries.
  """
  @spec refresh(t(), [Variable.t()], TimeRange.t()) :: [any()]
  def refresh(panel, variables, time_range) do
    Enum.reduce(panel.queries, [], fn query, results ->
      # perform query
      result = Query.execute(query, time_range, variables)
      # transform result and add to results
      case apply(panel.type, :transform, [result, panel]) do
        data when is_list(data) -> results ++ data
        data -> [data | results]
      end
    end)
  end

  defp fetch_panel_module!(opts) do
    case Keyword.fetch(opts, :type) do
      {:ok, mod} -> mod
      :error -> raise "Please specify the :type argument with the desired panel module"
    end
  end

  defp validate_data_attributes!(panel) do
    schema =
      Keyword.merge(Attributes.Schema.data(), get_attributes!(panel.type, :data_attributes))

    panel.data_attributes
    |> Enum.map(fn {label, attrs} -> {label, Attributes.parse!(attrs, schema)} end)
    |> Map.new()
  end

  defp get_attributes!(panel_type, attribute_type) do
    # first, we need to ensure that the module `panel_type` is loaded
    # because if it isn't then function_exported?/3 will return false
    # even if the module is defined
    panel_type =
      case Code.ensure_loaded(panel_type) do
        {:module, mod} -> mod
        {:error, reason} -> raise "failed to load module #{panel_type}: #{reason}"
      end

    if function_exported?(panel_type, attribute_type, 0) do
      apply(panel_type, attribute_type, [])
    else
      []
    end
  end
end