5.5 KiB · text History b868907
defmodule GitGudWeb.DashboardComponents do
  @moduledoc """
  The panels a dashboard is made of, and the small display helpers that
  go with them.

  Two dashboards use these: `GitGudWeb.PageLive.Home` (everything the
  viewer can reach) and `GitGudWeb.OrgLive.Dashboard` (the same slice,
  narrowed to one organization). They differ only in what they query;
  they should never differ in how a row of work looks, which is why
  this lives here rather than being written twice.
  """
  use GitGudWeb, :html

  alias GitGud.Repositories.Storage

  @doc """
  A titled, counted list panel. Renders the empty-state copy instead of
  the list when `count` is zero, so callers don't each re-invent "or
  else say nothing's here".
  """
  attr :title, :string, required: true
  attr :hint, :string, default: nil
  attr :empty, :string, required: true
  attr :count, :integer, required: true
  attr :more_label, :string, default: nil
  attr :more_path, :string, default: nil
  slot :inner_block, required: true

  def panel(assigns) do
    ~H"""
    <section class="rounded-lg border border-base-300 p-4">
      <header class="flex items-baseline justify-between gap-2 mb-2">
        <div class="min-w-0">
          <h2 class="font-semibold">
            {@title}
            <span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span>
          </h2>
          <p :if={@hint} class="text-xs opacity-60">{@hint}</p>
        </div>
        <.link
          :if={@more_path}
          navigate={@more_path}
          class="text-xs link link-hover opacity-60 shrink-0"
        >
          {@more_label} →
        </.link>
      </header>

      <p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p>

      <ul :if={@count > 0} class="divide-y divide-base-300">
        {render_slot(@inner_block)}
      </ul>
    </section>
    """
  end

  @doc "One issue or pull request, as a row inside a `panel/1`."
  attr :path, :string, required: true
  attr :repo, :string, required: true
  attr :number, :integer, required: true
  attr :title, :string, required: true
  attr :meta, :string, required: true

  def work_row(assigns) do
    ~H"""
    <li class="py-2 min-w-0">
      <.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link>
      <p class="text-xs opacity-60 mt-0.5 truncate">
        <span class="font-mono">{@repo}#{@number}</span> · {@meta}
      </p>
    </li>
    """
  end

  @doc "One workflow run, as a row inside a `panel/1`."
  attr :run, :map, required: true

  def ci_row(assigns) do
    ~H"""
    <li class="py-2 flex items-baseline gap-2 min-w-0">
      <span class={["badge badge-xs shrink-0", ci_badge(@run.status)]}>{@run.status}</span>
      <.link
        navigate={
          ~p"/r/#{repo_handle(@run.repository)}/#{@run.repository.name}/actions/#{@run.number}"
        }
        class="text-sm link link-hover truncate"
      >
        {@run.workflow_name || "run ##{@run.number}"}
      </.link>
      <span class="text-xs opacity-60 font-mono truncate">{repo_path(@run.repository)}</span>
      <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@run.inserted_at)}</span>
    </li>
    """
  end

  @doc "One repository, as a row in a recently-pushed list."
  attr :repo, :map, required: true

  def repo_row(assigns) do
    ~H"""
    <li class="py-2 flex items-baseline gap-2 min-w-0">
      <.link
        navigate={~p"/r/#{repo_handle(@repo)}/#{@repo.name}"}
        class="font-mono text-sm link link-hover truncate"
      >
        {repo_path(@repo)}
      </.link>
      <span :if={@repo.description} class="text-xs opacity-60 truncate">
        {@repo.description}
      </span>
      <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@repo.pushed_at)}</span>
    </li>
    """
  end

  # ── display helpers ──────────────────────────────────────────────────

  @doc "The namespace handle a repository lives under."
  def repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
  def repo_handle(%{owner: user}), do: Storage.owner_handle(user)

  @doc "`owner/name`, for the monospace crumbs on every row."
  def repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}"

  @doc "daisyUI badge class for a workflow run status."
  def ci_badge("success"), do: "badge-success"
  def ci_badge("failure"), do: "badge-error"
  def ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning"
  def ci_badge(_), do: "badge-ghost"

  @doc """
  Who opened something. Federated rows carry a `source_actor` and no
  local author, so this deliberately doesn't assume one.
  """
  def author_name(%{author: %{handle: h}}) when is_binary(h), do: h
  def author_name(_), do: "someone"

  @doc "Coarse relative time. Precision past a day isn't worth a row of chrome."
  def ago(nil), do: ""

  def ago(%DateTime{} = dt) do
    case DateTime.diff(DateTime.utc_now(), dt) do
      s when s < 60 -> "just now"
      s when s < 3600 -> "#{div(s, 60)}m ago"
      s when s < 86_400 -> "#{div(s, 3600)}h ago"
      s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
      s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago"
      s -> "#{div(s, 31_536_000)}y ago"
    end
  end

  @doc """
  What to call the review panel's inference.

  Once someone has actually been asked, say so; otherwise the panel is
  still guessing from maintainer status and should admit it.
  """
  def review_hint([]), do: "Open in repos you maintain, opened by someone else"
  def review_hint(_requested), do: "Requested of you, plus repos you maintain"
end