|
1
|
+ |
defmodule OpenAgentsWeb.IssueWorkspaceLive do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Every issue you can read, across every repository.
|
|
4
|
+ |
|
|
5
|
+ |
The sidebar's **Issues** row points here. It used to point at a repository —
|
|
6
|
+ |
whichever one the page you were on named, or, on a page that named none, the
|
|
7
|
+ |
first repository in your workspace alphabetically — so the same row led
|
|
8
|
+ |
somewhere different depending on where you clicked it from, and disappeared
|
|
9
|
+ |
for an account with no membership. A row in the app's own navigation has to
|
|
10
|
+ |
mean one thing everywhere. `/:owner/:repo/issues` still exists and is
|
|
11
|
+ |
unchanged; it is reached from the repository's own tabs, which is where a
|
|
12
|
+ |
repository-scoped list belongs.
|
|
13
|
+ |
|
|
14
|
+ |
## What it opens on
|
|
15
|
+ |
|
|
16
|
+ |
Open issues in every repository you can read, newest first.
|
|
17
|
+ |
|
|
18
|
+ |
GitHub's global issue list opens on **assigned to you**, and that is the
|
|
19
|
+ |
right default there because on GitHub an assignee is how work is handed
|
|
20
|
+ |
over. It is the wrong default here. Issues on this forge arrive mostly by
|
|
21
|
+ |
import and through the API, and most of them carry no assignee at all, so
|
|
22
|
+ |
opening on "assigned to you" would show almost every account an empty page
|
|
23
|
+ |
and hide the thing they came to see. A default that hides the answer is
|
|
24
|
+ |
worse than one that shows more than you asked for. Assigned and opened-by
|
|
25
|
+ |
are one control away, and both are honest once the data supports them.
|
|
26
|
+ |
|
|
27
|
+ |
## Authorization
|
|
28
|
+ |
|
|
29
|
+ |
Every read goes through `OpenAgents.Issues.list_visible_issues_page/2`,
|
|
30
|
+ |
which joins the issue table to `OpenAgents.Repositories.readable_by/2` — the
|
|
31
|
+ |
one predicate every repository surface composes. There is no second rule
|
|
32
|
+ |
here to fall out of step with the first, and no filter this view offers can
|
|
33
|
+ |
widen the set: filters narrow an already-authorized query.
|
|
34
|
+ |
|
|
35
|
+ |
This view is read-only. Changing an issue's state or its assignees needs a
|
|
36
|
+ |
writable membership in that issue's repository, which is a different
|
|
37
|
+ |
question for every row on the page, so triage stays on the repository's own
|
|
38
|
+ |
list where the answer is settled once.
|
|
39
|
+ |
|
|
40
|
+ |
## Bound
|
|
41
|
+ |
|
|
42
|
+ |
One page is `OpenAgents.Issues.per_page/0` rows — 25 — and the page number
|
|
43
|
+ |
is clamped to 10,000 by `OpenAgents.Issues.parse_page/1`, so the deepest
|
|
44
|
+ |
reachable offset is fixed no matter what the query string says. Nothing here
|
|
45
|
+ |
loads an unbounded list.
|
|
46
|
+ |
"""
|
|
47
|
+ |
use OpenAgentsWeb, :live_view
|
|
48
|
+ |
|
|
49
|
+ |
alias OpenAgents.Issues
|
|
50
|
+ |
alias OpenAgents.Repositories
|
|
51
|
+ |
alias OpenAgentsWeb.Components.IssuePresentation
|
|
52
|
+ |
alias OpenAgentsWeb.UI.Circle
|
|
53
|
+ |
|
|
54
|
+ |
@involvements [
|
|
55
|
+ |
{"Everyone", "all"},
|
|
56
|
+ |
{"Assigned to you", "assigned"},
|
|
57
|
+ |
{"Opened by you", "created"}
|
|
58
|
+ |
]
|
|
59
|
+ |
|
|
60
|
+ |
def mount(_params, _session, socket) do
|
|
61
|
+ |
{:ok,
|
|
62
|
+ |
socket
|
|
63
|
+ |
|> assign(:current_scope, socket.assigns[:current_scope])
|
|
64
|
+ |
|> assign(:involvements, @involvements)
|
|
65
|
+ |
|> assign(
|
|
66
|
+ |
:any_repository?,
|
|
67
|
+ |
Repositories.any_visible_repository?(socket.assigns.current_user)
|
|
68
|
+ |
)}
|
|
69
|
+ |
end
|
|
70
|
+ |
|
|
71
|
+ |
def handle_params(params, _url, socket) do
|
|
72
|
+ |
filters = read_filters(params)
|
|
73
|
+ |
|
|
74
|
+ |
{:noreply,
|
|
75
|
+ |
socket
|
|
76
|
+ |
|> assign(:state, normalize_state(params["state"]))
|
|
77
|
+ |
|> assign(:page, Issues.parse_page(params["page"]))
|
|
78
|
+ |
|> assign(:filters, filters)
|
|
79
|
+ |
|> assign(:filter_form, to_form(filters, as: :filter))
|
|
80
|
+ |
|> load()}
|
|
81
|
+ |
end
|
|
82
|
+ |
|
|
83
|
+ |
# One form drives every filter, so one change event carries the complete
|
|
84
|
+ |
# desired set and patching replaces it wholesale.
|
|
85
|
+ |
def handle_event("filter", params, socket) do
|
|
86
|
+ |
filters = %{
|
|
87
|
+ |
"involvement" => normalize_involvement(params["involvement"]),
|
|
88
|
+ |
"q" => blank_to_nil(params["q"])
|
|
89
|
+ |
}
|
|
90
|
+ |
|
|
91
|
+ |
{:noreply, push_patch(socket, to: issues_path(filters, %{"state" => socket.assigns.state}))}
|
|
92
|
+ |
end
|
|
93
|
+ |
|
|
94
|
+ |
def handle_event(_unsupported_event, _params, socket) do
|
|
95
|
+ |
{:noreply, put_flash(socket, :error, "That action is not available here.")}
|
|
96
|
+ |
end
|
|
97
|
+ |
|
|
98
|
+ |
defp read_filters(params) do
|
|
99
|
+ |
%{
|
|
100
|
+ |
"involvement" => normalize_involvement(params["involvement"]),
|
|
101
|
+ |
"q" => blank_to_nil(params["q"])
|
|
102
|
+ |
}
|
|
103
|
+ |
end
|
|
104
|
+ |
|
|
105
|
+ |
# A hand-edited query string cannot smuggle an option into the context call:
|
|
106
|
+ |
# anything unrecognized becomes the default.
|
|
107
|
+ |
defp normalize_involvement(involvement) when involvement in ~w(assigned created),
|
|
108
|
+ |
do: involvement
|
|
109
|
+ |
|
|
110
|
+ |
defp normalize_involvement(_involvement), do: "all"
|
|
111
|
+ |
|
|
112
|
+ |
defp normalize_state("closed"), do: "closed"
|
|
113
|
+ |
defp normalize_state("all"), do: "all"
|
|
114
|
+ |
defp normalize_state(_state), do: "open"
|
|
115
|
+ |
|
|
116
|
+ |
defp blank_to_nil(""), do: nil
|
|
117
|
+ |
defp blank_to_nil(value), do: value
|
|
118
|
+ |
|
|
119
|
+ |
defp issues_path(filters, extra) do
|
|
120
|
+ |
query =
|
|
121
|
+ |
filters
|
|
122
|
+ |
|> Map.merge(extra)
|
|
123
|
+ |
|> Enum.reject(fn {_key, value} -> value in [nil, "", "all"] end)
|
|
124
|
+ |
|> Map.new()
|
|
125
|
+ |
|
|
126
|
+ |
~p"/issues?#{query}"
|
|
127
|
+ |
end
|
|
128
|
+ |
|
|
129
|
+ |
defp load(socket) do
|
|
130
|
+ |
user = socket.assigns.current_user
|
|
131
|
+ |
%{filters: filters, state: state, page: page} = socket.assigns
|
|
132
|
+ |
|
|
133
|
+ |
opts = involvement_opts(user, filters["involvement"]) ++ [q: filters["q"]]
|
|
134
|
+ |
count_opts = Keyword.put(opts, :page, 1)
|
|
135
|
+ |
|
|
136
|
+ |
{issues, total} =
|
|
137
|
+ |
Issues.list_visible_issues_page(user, opts ++ [state: state, page: page])
|
|
138
|
+ |
|
|
139
|
+ |
socket
|
|
140
|
+ |
|> assign(:open_count, Issues.count_visible_issues(user, count_opts ++ [state: "open"]))
|
|
141
|
+ |
|> assign(:closed_count, Issues.count_visible_issues(user, count_opts ++ [state: "closed"]))
|
|
142
|
+ |
|> assign(:total_count, total)
|
|
143
|
+ |
|> assign(:issues_count, length(issues))
|
|
144
|
+ |
|> stream(:issues, issues, reset: true)
|
|
145
|
+ |
end
|
|
146
|
+ |
|
|
147
|
+ |
# The view's involvement words become the context's own options. "Assigned"
|
|
148
|
+ |
# reads the assignee snapshot by login; "opened by" matches either the
|
|
149
|
+ |
# durable author link or an imported login.
|
|
150
|
+ |
defp involvement_opts(user, "assigned"), do: [assignee: user.github_login]
|
|
151
|
+ |
defp involvement_opts(user, "created"), do: [author: user]
|
|
152
|
+ |
defp involvement_opts(_user, _all), do: []
|
|
153
|
+ |
|
|
154
|
+ |
def render(assigns) do
|
|
155
|
+ |
~H"""
|
|
156
|
+ |
<Layouts.app
|
|
157
|
+ |
flash={@flash}
|
|
158
|
+ |
sidebar_sections={assigns[:sidebar_sections]}
|
|
159
|
+ |
current_scope={@current_scope}
|
|
160
|
+ |
title="Issues"
|
|
161
|
+ |
subtitle="Across every repository you can read"
|
|
162
|
+ |
wide
|
|
163
|
+ |
>
|
|
164
|
+ |
<Circle.issue_toolbar>
|
|
165
|
+ |
<:leading>
|
|
166
|
+ |
<Circle.view_tabs>
|
|
167
|
+ |
<:tab
|
|
168
|
+ |
label={"#{@open_count} Open"}
|
|
169
|
+ |
patch={issues_path(@filters, %{"state" => "open"})}
|
|
170
|
+ |
selected={@state == "open"}
|
|
171
|
+ |
/>
|
|
172
|
+ |
<:tab
|
|
173
|
+ |
label={"#{@closed_count} Closed"}
|
|
174
|
+ |
patch={issues_path(@filters, %{"state" => "closed"})}
|
|
175
|
+ |
selected={@state == "closed"}
|
|
176
|
+ |
/>
|
|
177
|
+ |
</Circle.view_tabs>
|
|
178
|
+ |
</:leading>
|
|
179
|
+ |
|
|
180
|
+ |
<:actions>
|
|
181
|
+ |
<.link navigate={~p"/repositories"} class="btn" data-variant="ghost" data-size="sm">
|
|
182
|
+ |
<.icon name="branch" /> Repositories
|
|
183
|
+ |
</.link>
|
|
184
|
+ |
</:actions>
|
|
185
|
+ |
</Circle.issue_toolbar>
|
|
186
|
+ |
|
|
187
|
+ |
<div class="issue-filters">
|
|
188
|
+ |
<.form for={@filter_form} phx-change="filter" id="workspace-issue-filter-form">
|
|
189
|
+ |
<.input
|
|
190
|
+ |
type="search"
|
|
191
|
+ |
name="q"
|
|
192
|
+ |
value={@filters["q"]}
|
|
193
|
+ |
placeholder="Search issues"
|
|
194
|
+ |
aria-label="Search issues"
|
|
195
|
+ |
class="!w-56"
|
|
196
|
+ |
/>
|
|
197
|
+ |
<.input
|
|
198
|
+ |
type="select"
|
|
199
|
+ |
name="involvement"
|
|
200
|
+ |
value={@filters["involvement"]}
|
|
201
|
+ |
options={@involvements}
|
|
202
|
+ |
aria-label="Filter by your involvement"
|
|
203
|
+ |
/>
|
|
204
|
+ |
</.form>
|
|
205
|
+ |
</div>
|
|
206
|
+ |
|
|
207
|
+ |
<%!-- Two different emptinesses, two different next steps. An account
|
|
208
|
+ |
with nowhere to read is not looking at a filter that matched nothing. --%>
|
|
209
|
+ |
<.empty
|
|
210
|
+ |
:if={@issues_count == 0 and not @any_repository?}
|
|
211
|
+ |
id="workspace-issues-no-repositories"
|
|
212
|
+ |
title="No repositories yet"
|
|
213
|
+ |
>
|
|
214
|
+ |
Issues appear here once you can read a repository.
|
|
215
|
+ |
<.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
|
|
216
|
+ |
Create one
|
|
217
|
+ |
</.link>
|
|
218
|
+ |
or
|
|
219
|
+ |
<.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
|
|
220
|
+ |
import one from GitHub
|
|
221
|
+ |
</.link>
|
|
222
|
+ |
, and its issues arrive with it.
|
|
223
|
+ |
</.empty>
|
|
224
|
+ |
|
|
225
|
+ |
<.empty
|
|
226
|
+ |
:if={@issues_count == 0 and @any_repository?}
|
|
227
|
+ |
id="workspace-issues-empty"
|
|
228
|
+ |
title={empty_title(@state, @filters)}
|
|
229
|
+ |
>
|
|
230
|
+ |
{empty_body(@filters)}
|
|
231
|
+ |
</.empty>
|
|
232
|
+ |
|
|
233
|
+ |
<div :if={@issues_count > 0} id="workspace-issues" phx-update="stream" class="issue-list">
|
|
234
|
+ |
<IssuePresentation.issue_row
|
|
235
|
+ |
:for={{id, issue} <- @streams.issues}
|
|
236
|
+ |
id={id}
|
|
237
|
+ |
issue={issue}
|
|
238
|
+ |
repository={IssuePresentation.repository_path(issue)}
|
|
239
|
+ |
navigate={~p"/#{issue.repository.owner}/#{issue.repository.name}/issues/#{issue.number}"}
|
|
240
|
+ |
/>
|
|
241
|
+ |
</div>
|
|
242
|
+ |
|
|
243
|
+ |
<nav :if={@total_count > Issues.per_page()} class="issue-pagination" aria-label="Pages">
|
|
244
|
+ |
<span class="issue-pagination__status">
|
|
245
|
+ |
Showing {@issues_count} of {@total_count}
|
|
246
|
+ |
</span>
|
|
247
|
+ |
<span class="issue-pagination__controls">
|
|
248
|
+ |
<.link
|
|
249
|
+ |
:if={@page > 1}
|
|
250
|
+ |
patch={issues_path(@filters, %{"state" => @state, "page" => @page - 1})}
|
|
251
|
+ |
class="btn"
|
|
252
|
+ |
data-variant="ghost"
|
|
253
|
+ |
data-size="sm"
|
|
254
|
+ |
>
|
|
255
|
+ |
Previous
|
|
256
|
+ |
</.link>
|
|
257
|
+ |
<.link
|
|
258
|
+ |
:if={@page * Issues.per_page() < @total_count}
|
|
259
|
+ |
patch={issues_path(@filters, %{"state" => @state, "page" => @page + 1})}
|
|
260
|
+ |
class="btn"
|
|
261
|
+ |
data-variant="ghost"
|
|
262
|
+ |
data-size="sm"
|
|
263
|
+ |
>
|
|
264
|
+ |
Next
|
|
265
|
+ |
</.link>
|
|
266
|
+ |
</span>
|
|
267
|
+ |
</nav>
|
|
268
|
+ |
</Layouts.app>
|
|
269
|
+ |
"""
|
|
270
|
+ |
end
|
|
271
|
+ |
|
|
272
|
+ |
defp empty_title(state, %{"involvement" => "assigned"}),
|
|
273
|
+ |
do: "No #{state} issues assigned to you"
|
|
274
|
+ |
|
|
275
|
+ |
defp empty_title(state, %{"involvement" => "created"}), do: "No #{state} issues you opened"
|
|
276
|
+ |
defp empty_title(state, _filters), do: "No #{state} issues"
|
|
277
|
+ |
|
|
278
|
+ |
defp empty_body(%{"q" => q}) when is_binary(q),
|
|
279
|
+ |
do: "Nothing matches that search. Clear it to see the rest."
|
|
280
|
+ |
|
|
281
|
+ |
defp empty_body(%{"involvement" => involvement}) when involvement != "all",
|
|
282
|
+ |
do: "Choose Everyone to see the rest of the issues you can read."
|
|
283
|
+ |
|
|
284
|
+ |
defp empty_body(_filters),
|
|
285
|
+ |
do: "Issues from every repository you can read arrive here as they are opened."
|
|
286
|
+ |
end
|