lib/ex_teal/resource/fields.ex

defmodule ExTeal.Resource.Fields do
  @moduledoc """
  Provides the `fields/0` callback used by the following ExTeal actions:

    * ExTeal.Resource.Index
  """

  alias ExTeal.{Field, Panel}
  alias ExTeal.Fields.ManyToMany

  @doc """
  Used to get the fields available to the current action.

  All resources will override this:

      def fields(), do: [
        Id.make() |> sortable(),
        Text.make('Name') |> sortable(),
        DateTime.make(:updated_at, 'Last Updated At') |> hide_from_index()
      ]
  """
  @callback fields() :: list(Field.t())

  defmacro __using__(_) do
    quote do
      @behaviour ExTeal.Resource.Fields

      alias ExTeal.Resource.Fields

      def identifier(model), do: model.id

      @inferred_fields ExTeal.Resource.Fields.fields_from_model(__MODULE__)
      def fields, do: @inferred_fields

      def meta_for(method, data, all, total, resource, conn),
        do: Fields.meta_for(method, data, all, total, resource, conn)

      def serialize_response(method, resource, data, conn),
        do: Fields.serialize_response(method, resource, data, conn)

      defoverridable(fields: 0, serialize_response: 4, identifier: 1)
    end
  end

  def fields_from_model(_model) do
    []
  end

  def meta_for(:index, _data, all, total, resource, conn) do
    fields = index_fields(resource, conn)

    %{
      label: resource.title(),
      fields: fields,
      total: total,
      all: all,
      sortable_by: resource.sortable_by() || false
    }
  end

  def serialize_response(:index, resource, data, conn) do
    fields = index_fields(resource, conn)
    policy = resource.policy()

    data
    |> Enum.map(fn model ->
      fields = apply_values(fields, model, resource, conn, :index, nil)

      %{
        fields: fields,
        id: id_for(model),
        meta: %{
          can_delete?: policy.delete?(conn, model),
          can_update?: policy.update?(conn, model),
          can_view?: policy.view?(conn, model),
          detail_link: resource.navigate_to(:detail, conn, model),
          edit_link: resource.navigate_to(:edit, conn, model)
        }
      }
    end)
  end

  def serialize_response(method, resource, model, conn) do
    panels = Panel.gather_panels(resource)
    [default | _others] = panels

    fields =
      :show
      |> fields_for(resource)
      |> apply_values(model, resource, conn, method, default)
      |> Enum.filter(&viewable/1)

    policy = resource.policy()

    %{
      id: resource.identifier(model),
      fields: fields,
      panels: panels,
      meta: %{
        can_delete?: policy.delete?(conn, model),
        can_update?: policy.update?(conn, model),
        can_view?: policy.view?(conn, model)
      }
    }
  end

  def index_fields(resource, conn) do
    is_many_to_many = Map.get(conn.params, "relationship_type") == "ManyToMany"
    is_has_many = Map.get(conn.params, "relationship_type") == "hasMany"

    fields =
      cond do
        is_many_to_many ->
          fields_for_many_to_many(resource, conn)

        is_has_many ->
          fields_for_has_many(resource, conn)

        true ->
          fields_for(:index, resource)
      end

    fields
    |> Enum.filter(&viewable/1)
    |> Enum.map(&apply_can_see(&1, conn))
    |> Enum.reject(&is_nil/1)
  end

  defp viewable(%{options: %{can_view_any: false}}), do: false
  defp viewable(%{}), do: true

  defp id_for(%{pivot: true, _row: %{id: id}}), do: id
  defp id_for(%{id: id}), do: id

  @doc """
  Instead of returning the fields for an index table of a resource,
  this function is called to render a simple belongs to field via a
  `ManyToManyBelongsTo` field.

  Given a many to many relationship between posts and tags, an index query
  for the tags associated with a post should return a single many to many belongs to
  field that represents a tag associated with the post.  The field will then have options
  built up to make it behave like a belongs_to on the client side.
  """
  def fields_for_many_to_many(_resource_queried, conn) do
    with {:ok, queried_through_resource_key} <- Map.fetch(conn.params, "via_resource"),
         {:ok, relationship} <- Map.fetch(conn.params, "via_relationship"),
         {:ok, queried_resource} <- ExTeal.resource_for(queried_through_resource_key),
         {:ok, related_resource} <-
           ExTeal.resource_for_relationship(queried_resource, relationship) do
      primary = ManyToMany.index_fields(queried_resource, relationship, related_resource)

      pivot =
        pivot_fields_for(
          queried_resource,
          String.to_existing_atom(relationship),
          related_resource
        )

      primary ++ pivot
    else
      _ -> {:error, :not_found}
    end
  end

  def fields_for_has_many(resource, conn) do
    with {:ok, queried_through_resource_key} <- Map.fetch(conn.params, "via_resource"),
         {:ok, relationship} <- Map.fetch(conn.params, "via_relationship"),
         {:ok, queried_resource} <- ExTeal.resource_for(queried_through_resource_key) do
      relationship_field =
        relationship_field(queried_resource, String.to_existing_atom(relationship))

      index_fields_for_many(relationship_field, resource)
    else
      _ -> {:error, :not_found}
    end
  end

  def pivot_fields_for(related, rel, _queried) do
    relationship_field = relationship_field(related, rel)

    with {:ok, options} <- Map.fetch(relationship_field, :private_options),
         {:ok, pivot_fields} <- Map.fetch(options, :pivot_fields) do
      pivot_fields
    else
      _ ->
        []
    end
  end

  def index_fields_for_many(relationship_field, queried_resource) do
    with {:ok, options} <- Map.fetch(relationship_field, :private_options),
         {:ok, index_fields} <- Map.fetch(options, :index_fields) do
      index_fields
    else
      _ ->
        fields_for(:index, queried_resource)
    end
  end

  @doc """
  Find the field on the resource that includes context for the relationship that
  is being queried.
  """
  def relationship_field(related_resource, rel) do
    related_resource
    |> all_fields()
    |> Enum.find(&(&1.field == rel))
  end

  def fields_for(:index, resource) do
    resource
    |> all_fields()
    |> Enum.filter(& &1.show_on_index)
  end

  def fields_for(:show, resource) do
    resource
    |> all_fields()
    |> Enum.filter(& &1.show_on_detail)
  end

  def fields_for(:new, resource) do
    resource
    |> all_fields()
    |> Enum.filter(& &1.show_on_new)
  end

  def fields_for(:edit, resource) do
    resource
    |> all_fields()
    |> Enum.filter(& &1.show_on_edit)
  end

  def all_fields(resource) do
    resource.fields()
    |> Enum.map(&subfields/1)
    |> Enum.concat()
  end

  def field_for(resource, name) do
    field_name = String.to_existing_atom(name)

    result =
      resource
      |> all_fields()
      |> Enum.find(fn f -> f.field == field_name end)

    case result do
      nil -> {:error, :not_found}
      result = %Field{} -> {:ok, result}
    end
  end

  defp subfields(%Field{options: %{fields: fields}}), do: fields
  defp subfields(%Field{} = field), do: [field]
  defp subfields(%Panel{fields: fields}), do: fields

  def apply_values(fields, model, resource, conn, type, panel \\ nil)

  def apply_values(fields, %{pivot: true} = model, resource, conn, type, panel) do
    model = Map.merge(model._row, model._pivot)
    apply_values(fields, model, resource, conn, type, panel)
  end

  def apply_values(fields, model, _resource, conn, type, panel) do
    fields
    |> Enum.map(fn field ->
      value = field.type.value_for(field, model, type)

      field
      |> Map.put(:value, value)
      |> add_panel_key(panel)
      |> field.type.apply_options_for(model, conn, type)
      |> apply_can_see(conn)
    end)
    |> Enum.reject(&is_nil/1)
  end

  @doc """
  Apply the can_see function attached to a field to filter
  out the field from serialized responses.
  """
  @spec apply_can_see(Field.t(), Plug.Conn.t()) :: Field.t() | nil
  def apply_can_see(%Field{can_see: nil} = field, _), do: field

  def apply_can_see(%Field{can_see: see} = field, conn) when is_function(see, 1) do
    if see.(conn) do
      field
    else
      nil
    end
  end

  def add_panel_key(%Field{panel: panel} = field, _) when not is_nil(panel), do: field
  def add_panel_key(%Field{} = field, nil), do: field
  def add_panel_key(%Field{} = field, %Panel{key: key}), do: Map.put(field, :panel, key)
end