9.9 KiB · text History b868907
defmodule GitGudWeb.OrgLive.Dashboard do
  @moduledoc """
  `/orgs/:handle/dashboard` — the signed-in dashboard, narrowed to one
  organization.

  Same panels as `GitGudWeb.PageLive.Home`, same components, one
  difference in framing: the landing dashboard is about *you*, so it
  only ever shows work you opened. An org dashboard is about the
  organization, so it shows everyone's open work alongside yours —
  otherwise it would be the landing page with fewer rows on it, which
  is not worth a route.

  Scoped by `Repositories.visible_repo_ids_for_org/2`, so every panel
  inherits the org's visibility rules and a member of a private org
  sees what they may see and nothing else. Non-members can reach a
  public org's dashboard; everything on it is already public.
  """

  use GitGudWeb, :live_view

  import GitGudWeb.DashboardComponents

  alias GitGud.Issues
  alias GitGud.Organizations
  alias GitGud.Organizations.Organization
  alias GitGud.Profiles
  alias GitGud.PullRequests
  alias GitGud.Repositories
  alias GitGud.Workflows

  @panel_limit 6

  @impl true
  def mount(%{"handle" => handle}, _session, socket) do
    org = Organizations.get_organization_by_handle!(handle)
    user = socket.assigns.current_scope.user

    if can_view?(org, user) do
      if connected?(socket),
        do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())

      {:ok,
       socket
       |> assign(:org, org)
       |> assign(:user, user)
       |> assign(:is_admin?, Organizations.org_admin?(org, user))
       |> assign(:repo_ids, Repositories.visible_repo_ids_for_org(org, user))
       |> assign(:pins, Profiles.list_pins(org))
       |> assign(:page_title, "Dashboard — @#{org.handle}")
       |> load_work()
       |> load_repos()
       |> load_ci()}
    else
      {:ok,
       socket
       |> put_flash(:error, "You're not authorized to view this organization.")
       |> push_navigate(to: ~p"/")}
    end
  end

  # Mirrors `GitGudWeb.OrgLive.Actions` — the two org-wide views should
  # never disagree about who may look.
  defp can_view?(%{visibility: "public"}, _user), do: true
  defp can_view?(%{visibility: "internal"}, %{}), do: true
  defp can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user)
  defp can_view?(_, _), do: false

  defp load_work(socket) do
    %{user: user, repo_ids: ids} = socket.assigns

    # `maintained ∩ org` rather than all maintained: the point of this
    # page is that everything on it is about this organization.
    maintained =
      user
      |> Repositories.maintained_repo_ids_for_user()
      |> intersect(ids)

    socket
    |> assign(
      :requested_reviews,
      PullRequests.list_open_review_requests(user.id, repo_ids: ids, limit: @panel_limit)
    )
    |> assign(
      :review_queue,
      PullRequests.list_open_awaiting_review(user.id, maintained, limit: @panel_limit)
    )
    |> assign(
      :my_prs,
      PullRequests.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit)
    )
    |> assign(:my_issues, Issues.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit))
    |> assign(:open_prs, PullRequests.list_open_in_repos(ids, limit: @panel_limit))
    |> assign(:open_issues, Issues.list_open_in_repos(ids, limit: @panel_limit))
  end

  defp intersect(a, b) do
    set = MapSet.new(b)
    Enum.filter(a, &MapSet.member?(set, &1))
  end

  defp load_repos(socket) do
    assign(
      socket,
      :recent_repos,
      Repositories.list_recently_pushed(socket.assigns.repo_ids, limit: @panel_limit)
    )
  end

  defp load_ci(socket) do
    ids = socket.assigns.repo_ids

    socket
    |> assign(
      :active_runs,
      Workflows.list_runs_for_repos(ids, statuses: Workflows.active_statuses())
    )
    |> assign(:recent_runs, Workflows.list_runs_for_repos(ids, limit: @panel_limit))
  end

  @impl true
  def handle_info({:ci_run_changed, _run_id}, socket), do: {:noreply, load_ci(socket)}
  def handle_info(_msg, socket), do: {:noreply, socket}

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div class="space-y-6 pt-4">
        <header class="flex items-baseline justify-between gap-4 flex-wrap">
          <div class="flex items-center gap-3 min-w-0">
            <.avatar name={@org.handle} size="md" alt={@org.handle} />
            <div class="min-w-0">
              <h1 class="text-2xl font-semibold truncate">
                {Organization.display(@org)}
              </h1>
              <nav class="text-xs flex gap-3 opacity-80 mt-1">
                <.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover">Overview</.link>
                <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover">CI</.link>
                <.link
                  :if={@is_admin?}
                  navigate={~p"/orgs/#{@org.handle}/settings/members"}
                  class="link link-hover"
                >
                  Members
                </.link>
              </nav>
            </div>
          </div>
          <div class="flex items-center gap-2 shrink-0">
            <.link
              :if={@is_admin?}
              id="org-new-repo"
              navigate={~p"/repositories/new/#{@org.handle}"}
              class="btn btn-sm btn-primary"
            >
              <.icon name="hero-plus-micro" class="size-4" /> New repository
            </.link>
            <.link
              navigate={~p"/orgs/#{@org.handle}"}
              class="btn btn-sm btn-ghost border border-base-300"
            >
              Overview
            </.link>
          </div>
        </header>

        <div class="grid lg:grid-cols-2 gap-4">
          <.panel
            title="Needs your review"
            hint={review_hint(@requested_reviews)}
            empty="Nothing waiting on you here."
            count={length(@requested_reviews) + length(@review_queue)}
          >
            <.work_row
              :for={pr <- @requested_reviews}
              path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
              repo={repo_path(pr.repository)}
              number={pr.number}
              title={pr.title}
              meta={"you were asked to review · #{ago(pr.inserted_at)}"}
            />
            <.work_row
              :for={pr <- @review_queue}
              path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
              repo={repo_path(pr.repository)}
              number={pr.number}
              title={pr.title}
              meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"}
            />
          </.panel>

          <.panel
            title="Your work here"
            hint="Open PRs and issues you opened in this organization"
            empty="Nothing open from you here."
            count={length(@my_prs) + length(@my_issues)}
          >
            <.work_row
              :for={pr <- @my_prs}
              path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
              repo={repo_path(pr.repository)}
              number={pr.number}
              title={pr.title}
              meta={"pull request · #{ago(pr.inserted_at)}"}
            />
            <.work_row
              :for={issue <- @my_issues}
              path={
                ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}"
              }
              repo={repo_path(issue.repository)}
              number={issue.number}
              title={issue.title}
              meta={"issue · #{ago(issue.inserted_at)}"}
            />
          </.panel>

          <.panel
            title="Open pull requests"
            hint="Everyone's, across this organization"
            empty="No open pull requests."
            count={length(@open_prs)}
          >
            <.work_row
              :for={pr <- @open_prs}
              path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
              repo={repo_path(pr.repository)}
              number={pr.number}
              title={pr.title}
              meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"}
            />
          </.panel>

          <.panel
            title="Open issues"
            hint="Everyone's, across this organization"
            empty="No open issues."
            count={length(@open_issues)}
          >
            <.work_row
              :for={issue <- @open_issues}
              path={
                ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}"
              }
              repo={repo_path(issue.repository)}
              number={issue.number}
              title={issue.title}
              meta={"by #{author_name(issue)} · #{ago(issue.inserted_at)}"}
            />
          </.panel>

          <.panel
            title="CI"
            hint={
              if @active_runs == [],
                do: "Latest runs across this organization",
                else: "#{length(@active_runs)} running now"
            }
            empty="No runs yet."
            count={length(@recent_runs)}
            more_label="CI dashboard"
            more_path={~p"/orgs/#{@org.handle}/actions"}
          >
            <.ci_row :for={run <- @recent_runs} run={run} />
          </.panel>
        </div>

        <section :if={@pins != []}>
          <h2 class="text-lg font-semibold mb-2">Pinned</h2>
          <GitGudWeb.PinnedReposGrid.grid pins={@pins} />
        </section>

        <section :if={@recent_repos != []}>
          <header class="flex items-baseline justify-between mb-2">
            <h2 class="text-lg font-semibold">Recently pushed</h2>
            <.link navigate={~p"/orgs/#{@org.handle}"} class="text-xs link link-hover opacity-60">
              All repositories →
            </.link>
          </header>
          <ul class="divide-y divide-base-300">
            <.repo_row :for={repo <- @recent_repos} repo={repo} />
          </ul>
        </section>
      </div>
    </Layouts.app>
    """
  end
end