lib/openagents_web/components/issue_presentation.ex

main at 58e6347eeb72 · 6 KB

defmodule OpenAgentsWeb.Components.IssuePresentation do
  @moduledoc """
  How an `%OpenAgents.Issues.Issue{}` is drawn as a row, in one place.

  `OpenAgentsWeb.UI.Circle.issue_row/1` takes plain maps and atoms on purpose,
  so it can be rendered from a schema, an API payload, or a literal in a test.
  That leaves a translation layer — which state category a close reason maps
  to, which assignee of several the face shows, how an author with no account
  is named — and there is exactly one right answer to each. This module owns
  those answers so the repository list and the workspace-wide list cannot
  drift apart, and so a third list would inherit them rather than restate
  them.

  It renders the row and nothing else. Who may change an issue's state or its
  assignees is the caller's question: a caller with the authority passes
  controls through the `:state` and `:people` slots, and a caller without it
  passes none and gets the static glyphs.
  """
  use Phoenix.Component

  alias OpenAgents.Issues.Issue
  alias OpenAgentsWeb.RelativeTime
  alias OpenAgentsWeb.UI.Circle

  attr :id, :string, required: true
  attr :issue, Issue, required: true
  attr :navigate, :any, required: true, doc: "where the title goes"

  attr :repository, :string,
    default: nil,
    doc: "`owner/name`; set it on a cross-repository list, leave it off within one"

  attr :progress, :string,
    default: nil,
    doc: "the derived `issue.openagents.progress` value, when the caller has read it"

  slot :state, doc: "a control that changes the issue's state, for a caller who may"
  slot :people, doc: "a control that changes the issue's assignees, for a caller who may"

  def issue_row(assigns) do
    ~H"""
    <Circle.issue_row
      id={@id}
      identifier={"##{@issue.number}"}
      repository={@repository}
      title={@issue.title}
      navigate={@navigate}
      status_category={category(@issue, @progress)}
      status_label={status_label(@issue, @progress)}
      labels={labels(@issue)}
      assignee={assignee(@issue)}
      created={"opened #{relative(@issue.inserted_at)} ago"}
      author={author(@issue)}
      comments={@issue.comments}
    >
      <:state :if={@state != []}>{render_slot(@state)}</:state>
      <:people :if={@people != []}>{render_slot(@people)}</:people>
    </Circle.issue_row>
    """
  end

  @doc """
  GitHub's state iconography, and one thing on top of it that the API already
  publishes.

  An open issue takes the green circle-dot. `not_planned` is the one close
  reason with a distinct reading, so it keeps the cancelled glyph; every other
  close is the purple check-circle.

  The exception is `in_progress`. An open issue somebody has started takes
  Circle's `:started` arc — an attempt holding the issue, a session bound to
  it, or a board column saying so, whichever the reader may see
  (`OpenAgents.Issues.progress_map/2`). The value is the same derived
  `issue.openagents.progress` the API serves, read through the same reader's
  visibility, so the list and the API cannot show different work as underway.
  A caller that has not read progress passes none and gets GitHub's two states.
  """
  def category(issue, progress \\ nil)
  def category(%{state: "closed", state_reason: "not_planned"}, _progress), do: :canceled
  def category(%{state: "closed"}, _progress), do: :completed
  def category(%{state: "open"}, "in_progress"), do: :started
  def category(_issue, _progress), do: :open

  @doc "The word beside the glyph, for assistive technology and for a label."
  def status_label(issue, progress \\ nil)

  def status_label(%{state: "closed", state_reason: "not_planned"}, _progress),
    do: "Closed as not planned"

  def status_label(%{state: "closed"}, _progress), do: "Closed"
  def status_label(%{state: "open"}, "in_progress"), do: "In progress"
  def status_label(_issue, _progress), do: "Open"

  @doc """
  The states a triaging member may choose between.

  `duplicate` is rendered when it arrives from the API but is not offered: the
  menu has nowhere to record which issue it duplicates, and a duplicate that
  does not say of what is worse than a plain close.
  """
  def state_options do
    [
      {"Open", "open", nil},
      {"Closed as completed", "closed", "completed"},
      {"Closed as not planned", "closed", "not_planned"}
    ]
  end

  @doc "The close reason a menu should show as selected, defaulted like GitHub."
  def close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
  def close_reason(_issue), do: nil

  @doc "Whether `login` is among the issue's assignees."
  def assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))

  @doc """
  The issue's labels, as the row wants them.

  A label carries a colour on GitHub; the row takes a tone from our ladder
  rather than that hex, so the list stays in one palette.
  """
  def labels(%{labels: labels}) when is_list(labels) do
    Enum.map(labels, fn label ->
      %{name: label["name"] || label[:name] || "label", tone: :neutral}
    end)
  end

  def labels(_issue), do: []

  @doc """
  The assignee the row's face shows.

  GitHub issues carry many assignees; the row shows the first, which is the one
  GitHub itself treats as `assignee`.
  """
  def assignee(%{assignees: [first | _rest]}) when is_map(first) do
    %{
      name: first["login"] || first[:login],
      src: first["avatar_url"] || first[:avatar_url],
      presence: :none
    }
  end

  def assignee(_issue), do: nil

  @doc """
  Who opened the issue.

  GitHub prints this beside when. An issue whose author is gone still has a
  history, so it says so rather than showing a blank.
  """
  def author(%{user: %{} = user}), do: user["login"] || user[:login] || "anonymous"
  def author(_issue), do: "anonymous"

  @doc """
  How long ago, coarsely: minutes, then hours, then days.

  The implementation lives in `OpenAgentsWeb.RelativeTime`, which every recency
  surface shares. This name stays because the row and the issue page already
  read it, and one relative-time answer serving both is the point.
  """
  defdelegate relative(at), to: RelativeTime, as: :since

  @doc "The `owner/name` a cross-repository row shows, from a preloaded issue."
  def repository_path(%{repository: %{owner: owner, name: name}}), do: "#{owner}/#{name}"
  def repository_path(_issue), do: nil
end