lib/openagents_web/live/milestone_index_live.ex

main at 436d37facc96 · 9 KB

defmodule OpenAgentsWeb.MilestoneIndexLive do
  @moduledoc """
  Renders repository milestones and a form to create them.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Issues
  alias OpenAgents.Milestones
  alias OpenAgents.Milestones.Milestone
  alias OpenAgents.Repositories
  alias OpenAgentsWeb.LiveRefresh

  def mount(%{"owner" => owner, "repo" => repo}, _session, socket) do
    repository = visible_repository!(owner, repo, socket.assigns.current_user)
    can_write = Repositories.writable?(repository, socket.assigns.current_user)

    # The three numbers beside a milestone are counts of issues, so they move
    # whenever the repository's issues do -- from the API, the CLI, or another
    # tab -- and not only when somebody edits a milestone here. The milestone
    # topic carries the other half: a milestone opened, renamed, closed, or
    # deleted elsewhere changes which rows the page has to draw at all.
    if connected?(socket) do
      Repositories.subscribe_issues(repository.id)
      Milestones.subscribe_milestones(repository.id)
    end

    {:ok,
     socket
     |> LiveRefresh.init()
     |> assign(:current_scope, socket.assigns[:current_scope])
     |> assign(:owner, owner)
     |> assign(:repo, repo)
     |> assign(:repository, repository)
     |> assign(:can_write, can_write)
     |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))
     |> refresh_panel(:milestones)}
  end

  def handle_info({message, repository_id}, socket)
      when message in [:issues_changed, :milestones_changed] do
    if repository_id == socket.assigns.repository.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :milestones, &refresh_panel/2)},
      else: {:noreply, socket}
  end

  def handle_info(:live_refresh, socket),
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}

  defp refresh_panel(socket, :milestones) do
    socket
    |> refresh_authority()
    |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
  end

  def handle_event("save", %{"milestone" => milestone_params}, socket) do
    socket = refresh_authority(socket)

    if socket.assigns.can_write do
      case Milestones.create_milestone(
             socket.assigns.repository,
             milestone_params,
             socket.assigns.current_user
           ) do
        {:ok, _milestone} ->
          {:noreply,
           socket
           |> refresh_panel(:milestones)
           |> assign(:form, to_form(Milestones.change_milestone(%Milestone{})))
           |> put_flash(:info, "Milestone created")}

        {:error, changeset} ->
          {:noreply, assign(socket, :form, to_form(changeset))}
      end
    else
      {:noreply, put_flash(socket, :error, "Only repository members can create milestones.")}
    end
  end

  def handle_event("close", %{"id" => id}, socket) do
    socket = refresh_authority(socket)

    if socket.assigns.can_write do
      milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
      {:ok, _} = Milestones.update_milestone(milestone, %{"state" => "closed"})

      {:noreply,
       socket
       |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
       |> put_flash(:info, "Milestone closed")}
    else
      {:noreply, put_flash(socket, :error, "Only repository members can close milestones.")}
    end
  end

  def handle_event("delete", %{"id" => id}, socket) do
    socket = refresh_authority(socket)

    if socket.assigns.can_write do
      milestone = Milestones.get_milestone!(socket.assigns.repository, String.to_integer(id))
      {:ok, _} = Milestones.delete_milestone(milestone)

      {:noreply,
       socket
       |> assign(:milestones, milestones_with_stats(socket.assigns.repository))
       |> put_flash(:info, "Milestone deleted")}
    else
      {:noreply, put_flash(socket, :error, "Only repository members can delete milestones.")}
    end
  end

  defp refresh_authority(socket) do
    assign(
      socket,
      :can_write,
      Repositories.writable?(socket.assigns.repository, socket.assigns.current_user)
    )
  end

  defp visible_repository!(owner, repo, user) do
    Repositories.get_visible_by_path!(owner, repo, user)
  rescue
    Ecto.NoResultsError ->
      raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
  end

  # One grouped aggregate for every milestone on the page, rather than every
  # issue in the repository loaded and counted in memory. `93c3383` removed
  # that read from the homepage for the same reason; a page that follows the
  # issues it counts would otherwise pay for the whole collection once per
  # write instead of once per visit.
  defp milestones_with_stats(repository) do
    counts = Issues.count_issues_by_milestone(repository)

    Milestones.list_milestones(repository)
    |> Enum.map(fn milestone ->
      %{open: open, closed: closed} =
        Map.get(counts, milestone.number, %{open: 0, closed: 0})

      total = open + closed
      progress = if total > 0, do: div(closed * 100, total), else: 0

      Map.merge(milestone, %{
        open_count: open,
        closed_count: closed,
        total_count: total,
        progress: progress
      })
    end)
  end

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
    >
      <div class="flex items-center justify-between mb-4">
        <h1 id="milestones-title" class="text-2xl font-bold">Milestones</h1>
      </div>

      <.form
        :if={@can_write}
        for={@form}
        id="new-milestone-form"
        phx-submit="save"
        class="card !mx-0 !mt-0 mb-6"
      >
        <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
          <.input field={@form[:title]} label="Title" required />
          <.input field={@form[:due_on]} label="Due on" />
          <.input field={@form[:description]} label="Description" />
        </div>
        <footer class="flex justify-end mt-2">
          <.button type="submit" variant={:primary}>Add milestone</.button>
        </footer>
      </.form>

      <%= if @milestones == [] do %>
        <div id="milestones-empty" class="alert" data-variant="info" role="status">
          <.icon name="info-circle" class="size-5" />
          <section>No milestones yet.</section>
        </div>
      <% else %>
        <div class="space-y-4">
          <%= for milestone <- @milestones do %>
            <div id={"milestone-#{milestone.number}"} class="card !m-0">
              <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 mb-2">
                <h3 class="text-lg font-semibold">{milestone.title}</h3>
                <div class="flex gap-2">
                  <span
                    class="badge"
                    data-variant={if milestone.state == "open", do: "success", else: "dim"}
                  >
                    {milestone.state}
                  </span>
                  <%= if milestone.due_on do %>
                    <span class="badge" data-variant="dim">Due {milestone.due_on}</span>
                  <% end %>
                </div>
              </div>

              <%= if milestone.description do %>
                <p class="text-sm text-muted-foreground mb-3">
                  {milestone.description}
                </p>
              <% end %>

              <div class="flex items-center gap-4 mb-2">
                <%!-- A meter, not task progress: the value is a measured
                ratio of closed to total, so `<progress>`'s indeterminate state
                never applies. Drawn from the surface rungs rather than a
                component variant, since the style pack has no bar. --%>
                <div
                  class="flex-1 h-2 rounded-full bg-muted overflow-hidden"
                  role="progressbar"
                  aria-valuenow={milestone.progress}
                  aria-valuemin="0"
                  aria-valuemax="100"
                  aria-label={"#{milestone.title} progress"}
                >
                  <div class="h-full bg-success" style={"width: #{milestone.progress}%;"}></div>
                </div>
                <span class="text-sm font-semibold w-12 text-right">
                  {milestone.progress}%
                </span>
              </div>

              <div class="flex items-center gap-4 text-sm text-muted-foreground mb-3">
                <span>{milestone.open_count} open</span>
                <span>{milestone.closed_count} closed</span>
                <span>{milestone.total_count} total</span>
              </div>

              <footer class="flex justify-end gap-2">
                <%= if @can_write and milestone.state == "open" do %>
                  <button
                    class="btn"
                    data-variant="ghost"
                    data-size="sm"
                    phx-click="close"
                    phx-value-id={milestone.id}
                  >
                    Close
                  </button>
                <% end %>
                <button
                  :if={@can_write}
                  class="btn"
                  data-variant="ghost"
                  data-size="sm"
                  data-tone="danger"
                  phx-click="delete"
                  phx-value-id={milestone.id}
                  data-confirm="Delete this milestone?"
                >
                  Delete
                </button>
              </footer>
            </div>
          <% end %>
        </div>
      <% end %>
    </Layouts.app>
    """
  end
end