lib/openagents_web/live/project_workspace_live.ex

436d37facc96 · 9 KB

defmodule OpenAgentsWeb.ProjectWorkspaceLive do
  @moduledoc """
  Every project you can read, across every repository.

  The sidebar's **Projects** row points here, for the same reason
  `OpenAgentsWeb.IssueWorkspaceLive` exists: the row used to address whichever
  repository the current page named, so it led somewhere different depending
  on where it was clicked from. `/:owner/:repo/projects` is unchanged and
  still owns creating, closing, and deleting a project, because each of those
  needs a writable membership in one named repository.

  ## What it opens on

  Open projects in every repository you can read, newest first, with the
  repository each belongs to beside it. A project's own board is one click
  away.

  ## Authorization

  `OpenAgents.Projects.list_visible_projects_page/2` joins the project table
  to `OpenAgents.Repositories.readable_by/2`, the same predicate every
  repository surface composes, so a project in a repository you have no
  membership in cannot appear. This view reads and does not write.

  ## Bound

  One page is `OpenAgents.Projects.per_page/0` rows — 25 — with the page
  number clamped the same way the issue lists clamp theirs.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Projects
  alias OpenAgents.Repositories
  alias OpenAgentsWeb.UI.Circle

  @involvements [{"Everyone", "all"}, {"Created by you", "created"}]

  def mount(_params, _session, socket) do
    if connected?(socket), do: Repositories.subscribe_all_projects()
    user = socket.assigns.current_user

    {:ok,
     socket
     |> assign(:current_scope, socket.assigns[:current_scope])
     |> assign(:involvements, involvement_options(user))
     |> assign(:refresh_timer_ref, nil)
     |> assign(
       :any_repository?,
       Repositories.any_visible_repository?(socket.assigns.current_user)
     )}
  end

  # Live updates across every repository. Any committed project write anywhere
  # re-reads the current page through this viewer's own authorization — the
  # same `readable_by` predicate the initial load used — so a viewer who keeps
  # the page open converges instead of drifting. Bursts coalesce: each change
  # (re)arms one timer and only the last fires a re-read.
  def handle_info({:projects_changed, _repository_id}, socket) do
    {:noreply, schedule_refresh(socket)}
  end

  def handle_info(:refresh_projects_now, socket) do
    {:noreply, socket |> assign(:refresh_timer_ref, nil) |> load()}
  end

  def handle_info(_other, socket), do: {:noreply, socket}

  @refresh_debounce_ms if Application.compile_env(:openagents, :runtime_environment) == :test,
                         do: 0,
                         else: 250

  # Zero debounce means tests: refresh synchronously on the change message so
  # assertions need no waiting.
  defp schedule_refresh(socket) do
    if @refresh_debounce_ms == 0,
      do: load(socket),
      else: schedule_debounced_refresh(socket)
  end

  defp schedule_debounced_refresh(socket) do
    case socket.assigns.refresh_timer_ref do
      nil ->
        assign(
          socket,
          :refresh_timer_ref,
          Process.send_after(self(), :refresh_projects_now, @refresh_debounce_ms)
        )

      ref when is_reference(ref) ->
        Process.cancel_timer(ref)

        assign(
          socket,
          :refresh_timer_ref,
          Process.send_after(self(), :refresh_projects_now, @refresh_debounce_ms)
        )
    end
  end

  def handle_params(params, _url, socket) do
    filters = %{
      "involvement" => normalize_involvement(params["involvement"], socket.assigns.current_user)
    }

    {:noreply,
     socket
     |> assign(:state, normalize_state(params["state"]))
     |> assign(:page, Projects.parse_page(params["page"]))
     |> assign(:filters, filters)
     |> assign(:filter_form, to_form(filters, as: :filter))
     |> load()}
  end

  def handle_event("filter", params, socket) do
    filters = %{
      "involvement" => normalize_involvement(params["involvement"], socket.assigns.current_user)
    }

    {:noreply, push_patch(socket, to: projects_path(filters, %{"state" => socket.assigns.state}))}
  end

  def handle_event(_unsupported_event, _params, socket) do
    {:noreply, put_flash(socket, :error, "That action is not available here.")}
  end

  defp normalize_involvement(_involvement, nil), do: "all"
  defp normalize_involvement("created", _user), do: "created"
  defp normalize_involvement(_involvement, _user), do: "all"

  defp normalize_state("closed"), do: "closed"
  defp normalize_state("all"), do: "all"
  defp normalize_state(_state), do: "open"

  defp projects_path(filters, extra) do
    query =
      filters
      |> Map.merge(extra)
      |> Enum.reject(fn {_key, value} -> value in [nil, "", "all"] end)
      |> Map.new()

    ~p"/projects?#{query}"
  end

  defp load(socket) do
    user = socket.assigns.current_user
    %{filters: filters, state: state, page: page} = socket.assigns

    opts = involvement_opts(user, filters["involvement"])

    {projects, total} =
      Projects.list_visible_projects_page(user, opts ++ [state: state, page: page])

    socket
    |> assign(:open_count, Projects.count_visible_projects(user, opts ++ [state: "open"]))
    |> assign(:closed_count, Projects.count_visible_projects(user, opts ++ [state: "closed"]))
    |> assign(:total_count, total)
    |> assign(:projects_count, length(projects))
    |> assign(:projects, projects)
  end

  defp involvement_opts(nil, _involvement), do: []
  defp involvement_opts(user, "created"), do: [owner: user]
  defp involvement_opts(_user, _all), do: []

  defp involvement_options(nil), do: [{"Everyone", "all"}]
  defp involvement_options(_user), do: @involvements

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Projects"
      subtitle="Across every repository you can read"
      wide
    >
      <Circle.issue_toolbar>
        <:leading>
          <Circle.view_tabs>
            <:tab
              label={"#{@open_count} Open"}
              patch={projects_path(@filters, %{"state" => "open"})}
              selected={@state == "open"}
            />
            <:tab
              label={"#{@closed_count} Closed"}
              patch={projects_path(@filters, %{"state" => "closed"})}
              selected={@state == "closed"}
            />
          </Circle.view_tabs>
        </:leading>

        <:actions>
          <.link navigate={~p"/repositories"} class="btn" data-variant="ghost" data-size="sm">
            <.icon name="branch" /> Repositories
          </.link>
        </:actions>
      </Circle.issue_toolbar>

      <div class="issue-filters">
        <.form for={@filter_form} phx-change="filter" id="workspace-project-filter-form">
          <.input
            type="select"
            name="involvement"
            value={@filters["involvement"]}
            options={@involvements}
            aria-label="Filter by your involvement"
          />
        </.form>
      </div>

      <.empty
        :if={@projects_count == 0 and not @any_repository?}
        id="workspace-projects-no-repositories"
        title="No repositories yet"
      >
        <%= if @current_user do %>
          Projects belong to a repository.
          <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
            Create one
          </.link>
          or
          <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
            import one from GitHub
          </.link>
          to start a board.
        <% else %>
          Sign in with GitHub to see projects from repositories you can read.
          <.github_login id="workspace-projects-signin" size={:sm} />
        <% end %>
      </.empty>

      <.empty
        :if={@projects_count == 0 and @any_repository?}
        id="workspace-projects-empty"
        title={empty_title(@state, @filters)}
      >
        {empty_body(@filters)}
      </.empty>

      <div :if={@projects_count > 0} id="workspace-projects" class="project-index">
        <div :for={project <- @projects} class="project-index__row">
          <Circle.project_row
            name={project.title}
            navigate={
              ~p"/#{project.repository.owner}/#{project.repository.name}/projects/#{project.number}"
            }
            status_category={if project.state == "closed", do: :completed, else: :unstarted}
            status_label={String.capitalize(project.state)}
            labels={[%{name: repository_path(project), tone: :neutral}]}
          />
        </div>
      </div>

      <nav :if={@total_count > Projects.per_page()} class="issue-pagination" aria-label="Pages">
        <span class="issue-pagination__status">
          Showing {@projects_count} of {@total_count}
        </span>
        <span class="issue-pagination__controls">
          <.link
            :if={@page > 1}
            patch={projects_path(@filters, %{"state" => @state, "page" => @page - 1})}
            class="btn"
            data-variant="ghost"
            data-size="sm"
          >
            Previous
          </.link>
          <.link
            :if={@page * Projects.per_page() < @total_count}
            patch={projects_path(@filters, %{"state" => @state, "page" => @page + 1})}
            class="btn"
            data-variant="ghost"
            data-size="sm"
          >
            Next
          </.link>
        </span>
      </nav>
    </Layouts.app>
    """
  end

  defp repository_path(%{repository: %{owner: owner, name: name}}), do: "#{owner}/#{name}"

  defp empty_title(state, %{"involvement" => "created"}), do: "No #{state} projects you created"
  defp empty_title(state, _filters), do: "No #{state} projects"

  defp empty_body(%{"involvement" => "created"}),
    do: "Choose Everyone to see the rest of the projects you can read."

  defp empty_body(_filters),
    do: "A project you can read in any repository appears here once it is created."
end