defmodule OpenAgentsWeb.IssueIndexLive do
@moduledoc """
Lists issues for a repository.
Reading is public on a public repository, the way code browsing already is.
Interacting — opening an issue, commenting on the detail page — needs a
signed-in person, and triage (state, assignees) needs a writable membership,
so every control that writes checks its authority at the server and not only
in what the template renders.
"""
use OpenAgentsWeb, :live_view
alias OpenAgents.Issues
alias OpenAgents.Labels
alias OpenAgents.Milestones
alias OpenAgents.Repositories
alias OpenAgentsWeb.Components.IssuePresentation
alias OpenAgentsWeb.OG
alias OpenAgentsWeb.UI.Circle
@filter_keys ~w(label assignee milestone q)
def mount(_params, _session, socket) do
{:ok, assign(socket, :current_scope, socket.assigns[:current_scope])}
end
def handle_params(%{"owner" => owner, "repo" => repo} = params, _url, socket) do
with {:ok, repository} <- visible_repository(owner, repo, socket.assigns.current_user) do
if connected?(socket), do: Repositories.subscribe_issues(repository.id)
user = socket.assigns.current_user
can_write = Repositories.writable?(repository, user)
filters = read_filters(params)
socket =
socket
|> assign(:owner, owner)
|> assign(:repo, repo)
|> assign(:repository, repository)
|> assign(:current_user, user)
|> assign(:can_write, can_write)
|> assign(:can_participate, Repositories.issue_participant?(repository, user))
|> assign(:state, normalize_state(params["state"]))
|> assign(:page, Issues.parse_page(params["page"]))
|> assign(:filters, filters)
|> assign(:filter_form, to_form(filters, as: :filter))
|> assign(:label_options, Labels.list_labels(repository))
|> assign(:assignable, Repositories.list_assignable_users(repository))
|> assign(:milestone_options, Milestones.list_milestones(repository))
|> load()
|> assign(:og, OG.meta(OG.repo_card_for(repository)))
{:noreply, socket}
else
:error -> raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
end
end
# Filters arrive as query params; anything unrecognized is dropped so a
# hand-edited URL cannot smuggle an option into the context call.
defp read_filters(params) do
Map.new(@filter_keys, fn key -> {key, blank_to_nil(params[key])} end)
end
# One form drives every filter, so one change event carries the complete
# desired set and patching replaces it wholesale.
def handle_event("filter", params, socket) do
filters = Map.new(@filter_keys, fn key -> {key, blank_to_nil(params[key])} end)
apply_filters(socket, filters)
end
def handle_event("set_state", %{"id" => id, "state" => state} = params, socket)
when state in ~w(open closed) do
socket = refresh_authority(socket)
if socket.assigns.can_write do
attrs =
case state do
"open" -> %{"state" => "open", "state_reason" => nil}
"closed" -> %{"state" => "closed", "state_reason" => params["reason"]}
end
Issues.update_issue(issue!(socket, id), attrs, socket.assigns.current_user)
{:noreply, load(socket)}
else
{:noreply, put_flash(socket, :error, "Only repository members can change issue state.")}
end
end
def handle_event("toggle_assignee", %{"id" => id, "login" => login}, socket) do
socket = refresh_authority(socket)
if socket.assigns.can_write do
issue = issue!(socket, id)
if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
Issues.remove_assignees(issue, [login], socket.assigns.current_user)
else
Issues.add_assignees(issue, [login], socket.assigns.current_user)
end
{:noreply, load(socket)}
else
{:noreply, put_flash(socket, :error, "Only repository members can change assignees.")}
end
end
def handle_event(_unsupported_event, _params, socket) do
{:noreply, put_flash(socket, :error, "That issue action is not available.")}
end
# Live updates: any committed issue write in this repository re-reads the
# current page through this viewer's own authorization, so two people
# triaging together converge instead of drifting.
def handle_info({:issues_changed, repository_id}, socket) do
if repository_id == socket.assigns.repository.id,
do: {:noreply, socket |> refresh_authority() |> load()},
else: {:noreply, socket}
end
defp refresh_authority(socket) do
repository = socket.assigns.repository
user = socket.assigns.current_user
socket
|> assign(:can_write, Repositories.writable?(repository, user))
|> assign(:can_participate, Repositories.issue_participant?(repository, user))
end
defp apply_filters(socket, filters) do
{:noreply,
push_patch(socket, to: issues_path(socket.assigns.owner, socket.assigns.repo, filters))}
end
defp issues_path(owner, repo, filters, extra \\ %{}) do
query =
filters
|> Map.merge(extra)
|> Enum.reject(fn {_key, value} -> value in [nil, ""] end)
|> Map.new()
~p"/#{owner}/#{repo}/issues?#{query}"
end
defp blank_to_nil(""), do: nil
defp blank_to_nil(value), do: value
defp normalize_state("closed"), do: "closed"
defp normalize_state(_state), do: "open"
defp visible_repository(owner, repo, user) do
try do
{:ok, Repositories.get_visible_by_path!(owner, repo, user)}
rescue
Ecto.NoResultsError -> :error
end
end
# Reloading rather than patching one row: closing an issue while the Open tab
# is showing has to remove it from the list and change both tab counts, and a
# row that stays visible after being closed is worse than a reload.
defp load(socket) do
repository = socket.assigns.repository
%{filters: filters, state: state, page: page} = socket.assigns
opts =
filters
|> Keyword.new(fn {key, value} -> {String.to_existing_atom(key), value} end)
|> Keyword.put(:state, state)
|> Keyword.put(:page, page)
count_opts = Keyword.drop(opts, [:page])
{issues, total} = Issues.list_issues_page(repository, opts)
socket
|> assign(:progress, Issues.progress_map(issues, socket.assigns.current_user))
|> assign(
:open_count,
Issues.count_issues(repository, Keyword.put(count_opts, :state, "open"))
)
|> assign(
:closed_count,
Issues.count_issues(repository, Keyword.put(count_opts, :state, "closed"))
)
|> assign(:total_count, total)
|> assign(:issues_count, length(issues))
|> stream(:issues, issues, reset: true)
end
# `JS.push` sends the id as a number; a `phx-value-` attribute would send a
# string. The handler takes whichever arrives.
defp issue!(socket, id) when is_integer(id),
do: Issues.get_issue!(socket.assigns.repository, id)
defp issue!(socket, id) when is_binary(id),
do: Issues.get_issue!(socket.assigns.repository, String.to_integer(id))
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
sidebar_sections={assigns[:sidebar_sections]}
current_scope={@current_scope}
title="Issues"
wide
>
<Circle.issue_toolbar>
<:leading>
<Circle.view_tabs>
<:tab
label={"#{@open_count} Open"}
patch={issues_path(@owner, @repo, @filters, %{"state" => "open"})}
selected={@state == "open"}
/>
<:tab
label={"#{@closed_count} Closed"}
patch={issues_path(@owner, @repo, @filters, %{"state" => "closed"})}
selected={@state == "closed"}
/>
</Circle.view_tabs>
</:leading>
<:actions>
<.link
navigate={~p"/#{@owner}/#{@repo}/labels"}
class="btn"
data-variant="ghost"
data-size="sm"
>
<.icon name="tag" /> Labels
</.link>
<.link
navigate={~p"/#{@owner}/#{@repo}/milestones"}
class="btn"
data-variant="ghost"
data-size="sm"
>
<.icon name="flag" /> Milestones
</.link>
<.link
:if={@can_participate}
navigate={~p"/#{@owner}/#{@repo}/issues/new"}
class="btn"
data-variant="primary"
data-size="sm"
>
New issue
</.link>
</:actions>
</Circle.issue_toolbar>
<div class="issue-filters">
<.form for={@filter_form} phx-change="filter" id="issue-filter-form">
<.input
type="search"
name="q"
value={@filters["q"]}
placeholder="Search issues"
aria-label="Search issues"
class="!w-56"
/>
<.input
:if={@milestone_options != []}
type="select"
name="milestone"
value={@filters["milestone"]}
options={Enum.map(@milestone_options, &{&1.title, Integer.to_string(&1.number)})}
prompt="All milestones"
aria-label="Filter by milestone"
/>
<.input
:if={@label_options != []}
type="select"
name="label"
value={@filters["label"]}
options={Enum.map(@label_options, &{&1.name, &1.name})}
prompt="All labels"
aria-label="Filter by label"
/>
<.input
:if={@assignable != []}
type="select"
name="assignee"
value={@filters["assignee"]}
options={Enum.map(@assignable, &{&1.github_login, &1.github_login})}
prompt="Everyone"
aria-label="Filter by assignee"
/>
</.form>
</div>
<.empty
:if={@issues_count == 0}
id="issues-empty"
title={"No #{@state} issues"}
>
<%= if @can_participate do %>
Issues will show up here once they are created.
<% else %>
Nothing matches here yet.
<% end %>
</.empty>
<div :if={@issues_count > 0} id="issues" phx-update="stream" class="issue-list">
<IssuePresentation.issue_row
:for={{id, issue} <- @streams.issues}
id={id}
issue={issue}
progress={@progress[issue.id]}
navigate={~p"/#{@owner}/#{@repo}/issues/#{issue.number}"}
>
<:state :if={@can_write}>
<Circle.field_menu
id={"row-state-#{issue.id}"}
label={"Change the state of issue ##{issue.number}"}
>
<%!-- The trigger draws what the static row would draw, derived value
included, so opening the menu is the only difference a
member sees. --%>
<:trigger>
<Circle.issue_status
category={IssuePresentation.category(issue, @progress[issue.id])}
label={IssuePresentation.status_label(issue, @progress[issue.id])}
/>
</:trigger>
<Circle.field_menu_item
:for={{label, state, reason} <- IssuePresentation.state_options()}
label={label}
mode={:choice}
selected={issue.state == state and IssuePresentation.close_reason(issue) == reason}
closes={"row-state-#{issue.id}"}
on_select={JS.push("set_state", value: %{id: issue.id, state: state, reason: reason})}
>
<:glyph><Circle.issue_state state={state} reason={reason} /></:glyph>
</Circle.field_menu_item>
</Circle.field_menu>
</:state>
<:people :if={@can_write}>
<Circle.field_menu
id={"row-assignee-#{issue.id}"}
label={"Assign issue ##{issue.number}"}
align={:end}
>
<:trigger>
<Circle.assignee
name={IssuePresentation.assignee(issue)[:name]}
src={IssuePresentation.assignee(issue)[:src]}
/>
</:trigger>
<Circle.field_menu_item
:for={user <- @assignable}
label={user.github_login}
selected={IssuePresentation.assigned?(issue, user.github_login)}
on_select={
JS.push("toggle_assignee", value: %{id: issue.id, login: user.github_login})
}
>
<:glyph><Circle.assignee name={user.github_login} size={:sm} /></:glyph>
</Circle.field_menu_item>
</Circle.field_menu>
</:people>
</IssuePresentation.issue_row>
</div>
<nav :if={@total_count > Issues.per_page()} class="issue-pagination" aria-label="Pages">
<span class="issue-pagination__status">
Showing {@issues_count} of {@total_count}
</span>
<span class="issue-pagination__controls">
<.link
:if={@page > 1}
patch={issues_path(@owner, @repo, @filters, %{"state" => @state, "page" => @page - 1})}
class="btn"
data-variant="ghost"
data-size="sm"
>
Previous
</.link>
<.link
:if={@page * Issues.per_page() < @total_count}
patch={issues_path(@owner, @repo, @filters, %{"state" => @state, "page" => @page + 1})}
class="btn"
data-variant="ghost"
data-size="sm"
>
Next
</.link>
</span>
</nav>
</Layouts.app>
"""
end
end