defmodule OpenAgentsWeb.ProjectIndexLive do
@moduledoc """
Renders a list of Projects V2 for a repository.
"""
use OpenAgentsWeb, :live_view
alias OpenAgents.Projects
alias OpenAgentsWeb.UI.Circle
alias OpenAgents.Projects.Project
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)
if connected?(socket), do: Repositories.subscribe_projects(repository.id)
{: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(Projects.change_project(repository, %Project{}, %{})))
|> refresh_panel(:projects)}
end
# A project opened from the API, the CLI, or another board belongs on this
# list too. The message carries a repository id and nothing else, so the
# list re-reads through the same authorization it mounted under.
def handle_info({:projects_changed, repository_id}, socket) do
if repository_id == socket.assigns.repository.id,
do: {:noreply, LiveRefresh.mark_stale(socket, :projects, &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, :projects) do
socket
|> refresh_authority()
|> assign(:projects, Projects.list_projects(socket.assigns.repository))
end
def handle_event("save", %{"project" => project_params}, socket) do
socket = refresh_authority(socket)
if socket.assigns.can_write do
case Projects.create_project(
socket.assigns.repository,
project_params,
socket.assigns.current_user
) do
{:ok, _project} ->
{:noreply,
socket
|> refresh_panel(:projects)
|> assign(
:form,
to_form(Projects.change_project(socket.assigns.repository, %Project{}, %{}))
)
|> put_flash(:info, "Project created")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
else
{:noreply, put_flash(socket, :error, "Only repository members can create projects.")}
end
end
# Projects V2 has `state`, so closing one is a GitHub field rather than an
# invented concept -- and it is the only property of a project this schema
# carries that is worth changing without opening the board.
def handle_event("set_state", %{"id" => id, "state" => state}, socket) do
socket = refresh_authority(socket)
if socket.assigns.can_write do
project = Projects.get_project!(socket.assigns.repository, id)
{:ok, _updated} = Projects.update_project(project, %{"state" => state})
{:noreply, assign(socket, :projects, Projects.list_projects(socket.assigns.repository))}
else
{:noreply, put_flash(socket, :error, "Only repository members can change project state.")}
end
end
def handle_event("delete", %{"id" => id}, socket) do
socket = refresh_authority(socket)
if socket.assigns.can_write do
project = Projects.get_project!(socket.assigns.repository, String.to_integer(id))
{:ok, _} = Projects.delete_project(project)
{:noreply,
socket
|> assign(:projects, Projects.list_projects(socket.assigns.repository))
|> put_flash(:info, "Project deleted")}
else
{:noreply, put_flash(socket, :error, "Only repository members can delete projects.")}
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
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
sidebar_sections={assigns[:sidebar_sections]}
current_scope={@current_scope}
title="Projects"
wide
>
<.form
:if={@can_write}
for={@form}
id="new-project-form"
phx-submit="save"
class="card !mx-0 !mt-0 mb-6"
>
<.input field={@form[:title]} label="Title" required />
<footer class="flex justify-end mt-2">
<.button type="submit" variant={:primary}>Add project</.button>
</footer>
</.form>
<div :if={@projects == []} id="projects-empty" class="alert" data-variant="info" role="status">
<.icon name="info-circle" class="size-5" />
<section>No projects yet.</section>
</div>
<div :if={@projects != []} id="projects" class="project-index">
<div :for={project <- @projects} class="project-index__row">
<Circle.project_row
name={project.title}
navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}
status_category={if project.state == "closed", do: :completed, else: :unstarted}
status_label={String.capitalize(project.state)}
>
<:state :if={@can_write}>
<Circle.field_menu
id={"project-state-#{project.id}"}
label={"Change the state of #{project.title}"}
align={:end}
>
<:trigger><Circle.issue_state state={project.state} /></:trigger>
<Circle.field_menu_item
:for={state <- ~w(open closed)}
label={String.capitalize(state)}
mode={:choice}
selected={project.state == state}
closes={"project-state-#{project.id}"}
on_select={JS.push("set_state", value: %{id: project.id, state: state})}
>
<:glyph><Circle.issue_state state={state} /></:glyph>
</Circle.field_menu_item>
</Circle.field_menu>
</:state>
</Circle.project_row>
<%!-- Deletion stays a control of its own rather than something the
row grew: the row is a link to a board, and a destructive action
inside a link target is how people delete things by accident. --%>
<button
:if={@can_write}
class="btn project-index__delete"
data-variant="ghost"
data-size="sm"
data-tone="danger"
phx-click="delete"
phx-value-id={project.id}
data-confirm="Delete this project?"
>
Delete
</button>
</div>
</div>
</Layouts.app>
"""
end
end