|
1
|
+ |
defmodule OpenAgentsWeb.UI.Circle do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Issue, project, and team surfaces: the shapes a tracker is built from.
|
|
4
|
+ |
|
|
5
|
+ |
Adapted from Circle (MIT, © 2025 lndev-ui), a Linear-shaped issue tracker
|
|
6
|
+ |
built with Next.js, Tailwind, shadcn/ui, Zustand, `motion/react`, and
|
|
7
|
+ |
`react-dnd`. Nothing is copied. Every source component is a client component
|
|
8
|
+ |
reading a Zustand store, and the interesting ones are wrapped in Radix
|
|
9
|
+ |
primitives; none of that survives the move to HEEx. What carried over is the
|
|
10
|
+ |
**information design**: what an issue row holds and in what order, that the
|
|
11
|
+ |
status glyph is a filled arc rather than a coloured dot, that a group header
|
|
12
|
+ |
is tinted by its own status at a fraction of its strength, that a filter
|
|
13
|
+ |
reads as subject / operator / value with the value removable on its own. See
|
|
14
|
+ |
`docs/2026-08-20-circle-ui-port.md`.
|
|
15
|
+ |
|
|
16
|
+ |
Three departures from the source are deliberate:
|
|
17
|
+ |
|
|
18
|
+ |
* **Tokens, not a second palette.** Circle assigns a hand-picked hex value
|
|
19
|
+ |
to each of thirteen statuses and eleven labels. Those colours belong to
|
|
20
|
+ |
Linear, not to this product, and adopting them would put a second colour
|
|
21
|
+ |
system beside the one every other surface uses. Colour here is assigned
|
|
22
|
+ |
per status *category* — six of them — off the same token ladder as
|
|
23
|
+ |
`OpenAgentsWeb.UI.status_indicator/1`, so activity is `--info`,
|
|
24
|
+ |
completion is `--success`, and anything asking for attention is
|
|
25
|
+ |
`--warning`. Six colours say less than thirteen; they also stay true in
|
|
26
|
+ |
both themes and never disagree with the rest of the interface.
|
|
27
|
+ |
|
|
28
|
+ |
* **No JavaScript except where the keyboard needs it.** Rows, groups,
|
|
29
|
+ |
boards, filters, and headers are server-rendered and static. The command
|
|
30
|
+ |
palette is the exception: a `⌘K` binding and incremental filtering
|
|
31
|
+ |
cannot be expressed in markup, so it carries one colocated hook.
|
|
32
|
+ |
|
|
33
|
+ |
* **State is the caller's.** The source keeps grouping, filters, search,
|
|
34
|
+ |
and drag results in client stores. These components take what to draw and
|
|
35
|
+ |
emit `Phoenix.LiveView.JS` commands the caller supplies; none of them own
|
|
36
|
+ |
state. That is what makes the same row usable in a list, in a board, and
|
|
37
|
+ |
in a search result.
|
|
38
|
+ |
|
|
39
|
+ |
Every component takes plain maps and atoms rather than structs, so a surface
|
|
40
|
+ |
can render from an Ecto schema, a map from an API, or a literal in a test
|
|
41
|
+ |
without a conversion layer.
|
|
42
|
+ |
"""
|
|
43
|
+ |
|
|
44
|
+ |
use Phoenix.Component
|
|
45
|
+ |
|
|
46
|
+ |
alias OpenAgentsWeb.UI
|
|
47
|
+ |
alias Phoenix.LiveView.JS
|
|
48
|
+ |
|
|
49
|
+ |
@categories [:triage, :backlog, :unstarted, :started, :completed, :canceled]
|
|
50
|
+ |
@priorities [:none, :low, :medium, :high, :urgent]
|
|
51
|
+ |
@tones [:neutral, :primary, :info, :success, :warning, :danger]
|
|
52
|
+ |
@presences [:none, :online, :away, :offline]
|
|
53
|
+ |
|
|
54
|
+ |
@doc """
|
|
55
|
+ |
The state of one issue, as a glyph and optionally a word.
|
|
56
|
+ |
|
|
57
|
+ |
The source draws six shapes: a triage disc, a dashed gear for backlog, an
|
|
58
|
+ |
empty ring, a ring with a filled arc, a filled tick, and a filled cross. Five
|
|
59
|
+ |
of those already exist in the vendored icon set. The sixth — the arc — is the
|
|
60
|
+ |
only one that reads a number, so it is drawn in CSS from `progress` rather
|
|
61
|
+ |
than picked from a fixed set of fractions. A ring that is a quarter full is
|
|
62
|
+ |
the one thing in a Linear list that says how far along the work is, and an
|
|
63
|
+ |
icon set cannot carry it.
|
|
64
|
+ |
|
|
65
|
+ |
Colour comes from the category, never from the individual status: `:started`
|
|
66
|
+ |
is `--info` because it is activity, `:completed` is `--success`, `:triage` is
|
|
67
|
+ |
`--warning` because it is asking for a decision, and the two resting states
|
|
68
|
+ |
are grey. This is the same vocabulary `status_indicator/1` uses.
|
|
69
|
+ |
|
|
70
|
+ |
The glyph announces itself unless `show_label` puts the word beside it, in
|
|
71
|
+ |
which case announcing both says the state twice.
|
|
72
|
+ |
"""
|
|
73
|
+ |
attr :category, :atom, values: @categories, required: true
|
|
74
|
+ |
attr :label, :string, required: true, doc: "the status's own name, such as `In review`"
|
|
75
|
+ |
|
|
76
|
+ |
attr :progress, :integer,
|
|
77
|
+ |
default: nil,
|
|
78
|
+ |
doc: "0-100, drawn as a filled arc; only meaningful for `:started`"
|
|
79
|
+ |
|
|
80
|
+ |
attr :show_label, :boolean, default: false
|
|
81
|
+ |
attr :class, :any, default: nil
|
|
82
|
+ |
attr :rest, :global
|
|
83
|
+ |
|
|
84
|
+ |
def issue_status(assigns) do
|
|
85
|
+ |
assigns = assign(assigns, :arc, clamp(assigns.progress))
|
|
86
|
+ |
|
|
87
|
+ |
~H"""
|
|
88
|
+ |
<span class={["issue-status", @class]} data-category={@category} {@rest}>
|
|
89
|
+ |
<span
|
|
90
|
+ |
:if={@category == :started}
|
|
91
|
+ |
class="issue-status__arc"
|
|
92
|
+ |
style={"--issue-arc: #{@arc}"}
|
|
93
|
+ |
role={if(!@show_label, do: "img")}
|
|
94
|
+ |
aria-label={if(!@show_label, do: @label)}
|
|
95
|
+ |
aria-hidden={if(@show_label, do: "true")}
|
|
96
|
+ |
/>
|
|
97
|
+ |
<UI.icon
|
|
98
|
+ |
:if={@category != :started}
|
|
99
|
+ |
name={category_icon(@category)}
|
|
100
|
+ |
label={if(!@show_label, do: @label)}
|
|
101
|
+ |
class="issue-status__glyph"
|
|
102
|
+ |
/>
|
|
103
|
+ |
<span :if={@show_label} class="issue-status__label">{@label}</span>
|
|
104
|
+ |
</span>
|
|
105
|
+ |
"""
|
|
106
|
+ |
end
|
|
107
|
+ |
|
|
108
|
+ |
@doc """
|
|
109
|
+ |
How urgent one issue is, as four ascending bars or an alarm.
|
|
110
|
+ |
|
|
111
|
+ |
The bar chart is the source's own idea and it is a good one: the level reads
|
|
112
|
+ |
from how much of the shape is lit, so the ordering survives greyscale and a
|
|
113
|
+ |
reader who cannot separate the tints. Urgent breaks the pattern on purpose —
|
|
114
|
+ |
it is not one more step up the same ramp, and drawing it as one invites the
|
|
115
|
+ |
eye to skip it.
|
|
116
|
+ |
|
|
117
|
+ |
Drawn in CSS rather than vendored as five glyphs, because the bars are one
|
|
118
|
+ |
shape read at five levels rather than five different pictures.
|
|
119
|
+ |
"""
|
|
120
|
+ |
attr :level, :atom, values: @priorities, required: true
|
|
121
|
+ |
attr :label, :string, default: nil, doc: "overrides the level's own name"
|
|
122
|
+ |
attr :show_label, :boolean, default: false
|
|
123
|
+ |
attr :class, :any, default: nil
|
|
124
|
+ |
attr :rest, :global
|
|
125
|
+ |
|
|
126
|
+ |
def issue_priority(assigns) do
|
|
127
|
+ |
assigns = assign_new(assigns, :name, fn -> assigns.label || priority_name(assigns.level) end)
|
|
128
|
+ |
|
|
129
|
+ |
~H"""
|
|
130
|
+ |
<span class={["issue-priority", @class]} data-level={@level} {@rest}>
|
|
131
|
+ |
<UI.icon
|
|
132
|
+ |
:if={@level == :urgent}
|
|
133
|
+ |
name="triangle-exclamation-filled-error-warning"
|
|
134
|
+ |
label={if(!@show_label, do: @name)}
|
|
135
|
+ |
class="issue-priority__alarm"
|
|
136
|
+ |
/>
|
|
137
|
+ |
<span
|
|
138
|
+ |
:if={@level != :urgent}
|
|
139
|
+ |
class="issue-priority__bars"
|
|
140
|
+ |
role={if(!@show_label, do: "img")}
|
|
141
|
+ |
aria-label={if(!@show_label, do: @name)}
|
|
142
|
+ |
aria-hidden={if(@show_label, do: "true")}
|
|
143
|
+ |
>
|
|
144
|
+ |
<span class="issue-priority__bar" /><span class="issue-priority__bar" /><span class="issue-priority__bar" />
|
|
145
|
+ |
</span>
|
|
146
|
+ |
<span :if={@show_label} class="issue-priority__label">{@name}</span>
|
|
147
|
+ |
</span>
|
|
148
|
+ |
"""
|
|
149
|
+ |
end
|
|
150
|
+ |
|
|
151
|
+ |
@doc """
|
|
152
|
+ |
One label on an issue: a dot and a word in a pill.
|
|
153
|
+ |
|
|
154
|
+ |
The source colours the dot from a per-label hex value chosen when the label
|
|
155
|
+ |
was created. That model does not survive the tokens rule, so `tone` picks one
|
|
156
|
+ |
of six values off the ladder instead. Six tones cannot distinguish eleven
|
|
157
|
+ |
labels by colour alone, which is why the word is never optional here — the
|
|
158
|
+ |
dot is a grouping hint, not the identity.
|
|
159
|
+ |
"""
|
|
160
|
+ |
attr :name, :string, required: true
|
|
161
|
+ |
attr :tone, :atom, values: @tones, default: :neutral
|
|
162
|
+ |
attr :class, :any, default: nil
|
|
163
|
+ |
attr :rest, :global
|
|
164
|
+ |
|
|
165
|
+ |
def issue_label(assigns) do
|
|
166
|
+ |
~H"""
|
|
167
|
+ |
<span class={["issue-label", @class]} data-tone={@tone} {@rest}>
|
|
168
|
+ |
<span class="issue-label__dot" aria-hidden="true" />{@name}
|
|
169
|
+ |
</span>
|
|
170
|
+ |
"""
|
|
171
|
+ |
end
|
|
172
|
+ |
|
|
173
|
+ |
@doc """
|
|
174
|
+ |
Who an issue belongs to, or that it belongs to nobody.
|
|
175
|
+ |
|
|
176
|
+ |
Unassigned is drawn rather than left blank. A blank cell in a list of faces
|
|
177
|
+ |
reads as a rendering failure, and "nobody has picked this up" is one of the
|
|
178
|
+ |
more actionable facts a triage view carries.
|
|
179
|
+ |
|
|
180
|
+ |
`presence` adds the small corner dot. It is decorative here: the row already
|
|
181
|
+ |
names the person, and a second announcement of "online" on every row of a
|
|
182
|
+ |
list is noise.
|
|
183
|
+ |
"""
|
|
184
|
+ |
attr :name, :string, default: nil, doc: "`nil` renders the unassigned state"
|
|
185
|
+ |
attr :src, :string, default: nil
|
|
186
|
+ |
attr :presence, :atom, values: @presences, default: :none
|
|
187
|
+ |
attr :size, :atom, values: [:sm, :default, :lg], default: :default
|
|
188
|
+ |
attr :show_name, :boolean, default: false
|
|
189
|
+ |
attr :class, :any, default: nil
|
|
190
|
+ |
attr :rest, :global
|
|
191
|
+ |
|
|
192
|
+ |
def assignee(assigns) do
|
|
193
|
+ |
~H"""
|
|
194
|
+ |
<span class={["assignee", @class]} data-size={@size} {@rest}>
|
|
195
|
+ |
<span class="assignee__figure">
|
|
196
|
+ |
<UI.avatar
|
|
197
|
+ |
:if={@name}
|
|
198
|
+ |
src={@src}
|
|
199
|
+ |
fallback={String.first(@name)}
|
|
200
|
+ |
size={@size}
|
|
201
|
+ |
label={if(!@show_name, do: @name)}
|
|
202
|
+ |
/>
|
|
203
|
+ |
<span :if={!@name} class="assignee__empty" role="img" aria-label="Unassigned">
|
|
204
|
+ |
<UI.icon name="user" />
|
|
205
|
+ |
</span>
|
|
206
|
+ |
<span :if={@name && @presence != :none} class="assignee__presence" data-presence={@presence} />
|
|
207
|
+ |
</span>
|
|
208
|
+ |
<span :if={@show_name} class="assignee__name">{@name || "Unassigned"}</span>
|
|
209
|
+ |
</span>
|
|
210
|
+ |
"""
|
|
211
|
+ |
end
|
|
212
|
+ |
|
|
213
|
+ |
@doc """
|
|
214
|
+ |
Several people as overlapping faces, with a count for the ones that do not fit.
|
|
215
|
+ |
|
|
216
|
+ |
The count is the point. Six faces and a `+14` says the size of a team; six
|
|
217
|
+ |
faces alone says the team has six people, which would be wrong.
|
|
218
|
+ |
"""
|
|
219
|
+ |
attr :people, :list, required: true, doc: "`[%{name: String.t(), src: String.t() | nil}]`"
|
|
220
|
+ |
attr :limit, :integer, default: 5
|
|
221
|
+ |
attr :class, :any, default: nil
|
|
222
|
+ |
attr :rest, :global
|
|
223
|
+ |
|
|
224
|
+ |
def assignee_stack(assigns) do
|
|
225
|
+ |
assigns =
|
|
226
|
+ |
assigns
|
|
227
|
+ |
|> assign(:shown, Enum.take(assigns.people, assigns.limit))
|
|
228
|
+ |
|> assign(:overflow, max(length(assigns.people) - assigns.limit, 0))
|
|
229
|
+ |
|
|
230
|
+ |
~H"""
|
|
231
|
+ |
<span class={["assignee-stack", @class]} {@rest}>
|
|
232
|
+ |
<span class="assignee-stack__faces">
|
|
233
|
+ |
<UI.avatar
|
|
234
|
+ |
:for={person <- @shown}
|
|
235
|
+ |
src={person[:src]}
|
|
236
|
+ |
fallback={String.first(person[:name])}
|
|
237
|
+ |
size={:sm}
|
|
238
|
+ |
label={person[:name]}
|
|
239
|
+ |
/>
|
|
240
|
+ |
</span>
|
|
241
|
+ |
<span :if={@overflow > 0} class="assignee-stack__count">+{@overflow}</span>
|
|
242
|
+ |
</span>
|
|
243
|
+ |
"""
|
|
244
|
+ |
end
|
|
245
|
+ |
|
|
246
|
+ |
@doc """
|
|
247
|
+ |
One issue as a row: the shape a tracker is mostly made of.
|
|
248
|
+ |
|
|
249
|
+ |
Order is load-bearing and inherited from the source. Priority, identifier,
|
|
250
|
+ |
and status lead because they are the three things a person scans a list for;
|
|
251
|
+ |
the title takes the remaining width and truncates; everything discretionary —
|
|
252
|
+ |
labels, project, dates, assignee — collects at the trailing edge where it can
|
|
253
|
+ |
be dropped by width without disturbing the scan column.
|
|
254
|
+ |
|
|
255
|
+ |
Only the title is a link. The source makes the row a drag handle and the
|
|
256
|
+ |
title a link inside it, which means a click lands on one of two different
|
|
257
|
+ |
things depending on where in a 44-pixel row it falls. One target is easier to
|
|
258
|
+ |
hit and easier to explain.
|
|
259
|
+ |
"""
|
|
260
|
+ |
attr :identifier, :string, required: true, doc: "the short key, such as `OA-142`"
|
|
261
|
+ |
attr :title, :string, required: true
|
|
262
|
+ |
attr :navigate, :any, default: nil, doc: "where the title goes; a plain title without it"
|
|
263
|
+ |
attr :status_category, :atom, values: @categories, required: true
|
|
264
|
+ |
attr :status_label, :string, required: true
|
|
265
|
+ |
attr :progress, :integer, default: nil
|
|
266
|
+ |
attr :priority, :atom, values: @priorities, default: :none
|
|
267
|
+ |
attr :labels, :list, default: [], doc: "`[%{name: String.t(), tone: atom()}]`"
|
|
268
|
+ |
attr :project, :string, default: nil
|
|
269
|
+ |
attr :due, :string, default: nil, doc: "already formatted; overdue is the caller's judgement"
|
|
270
|
+ |
attr :created, :string, default: nil
|
|
271
|
+ |
attr :assignee, :map, default: nil, doc: "`%{name:, src:, presence:}`; `nil` is unassigned"
|
|
272
|
+ |
attr :selected, :boolean, default: false
|
|
273
|
+ |
attr :class, :any, default: nil
|
|
274
|
+ |
attr :rest, :global
|
|
275
|
+ |
|
|
276
|
+ |
def issue_row(assigns) do
|
|
277
|
+ |
~H"""
|
|
278
|
+ |
<div class={["issue-row", @class]} data-selected={@selected} {@rest}>
|
|
279
|
+ |
<span class="issue-row__scan">
|
|
280
|
+ |
<.issue_priority level={@priority} />
|
|
281
|
+ |
<span class="issue-row__identifier">{@identifier}</span>
|
|
282
|
+ |
<.issue_status
|
|
283
|
+ |
category={@status_category}
|
|
284
|
+ |
label={@status_label}
|
|
285
|
+ |
progress={@progress}
|
|
286
|
+ |
/>
|
|
287
|
+ |
</span>
|
|
288
|
+ |
|
|
289
|
+ |
<.link :if={@navigate} navigate={@navigate} class="issue-row__title">{@title}</.link>
|
|
290
|
+ |
<span :if={!@navigate} class="issue-row__title">{@title}</span>
|
|
291
|
+ |
|
|
292
|
+ |
<span class="issue-row__trailing">
|
|
293
|
+ |
<span :if={@labels != [] or @project} class="issue-row__chips">
|
|
294
|
+ |
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
|
|
295
|
+ |
<span :if={@project} class="issue-label" data-tone="neutral">
|
|
296
|
+ |
<UI.icon name="cube" class="issue-label__glyph" />{@project}
|
|
297
|
+ |
</span>
|
|
298
|
+ |
</span>
|
|
299
|
+ |
<span :if={@due} class="issue-row__due">Due {@due}</span>
|
|
300
|
+ |
<span :if={@created} class="issue-row__date">{@created}</span>
|
|
301
|
+ |
<.assignee
|
|
302
|
+ |
name={@assignee && @assignee[:name]}
|
|
303
|
+ |
src={@assignee && @assignee[:src]}
|
|
304
|
+ |
presence={(@assignee && @assignee[:presence]) || :none}
|
|
305
|
+ |
/>
|
|
306
|
+ |
</span>
|
|
307
|
+ |
</div>
|
|
308
|
+ |
"""
|
|
309
|
+ |
end
|
|
310
|
+ |
|
|
311
|
+ |
@doc """
|
|
312
|
+ |
The same issue as a card, for a board column.
|
|
313
|
+ |
|
|
314
|
+ |
A card is not a row turned sideways: it has width and no neighbours, so the
|
|
315
|
+ |
title gets two lines instead of one and the labels get their own band instead
|
|
316
|
+ |
of competing with the trailing edge. The scan column becomes a header line,
|
|
317
|
+ |
and the assignee drops to the foot where it reads as ownership of the whole
|
|
318
|
+ |
card rather than one more attribute.
|
|
319
|
+ |
"""
|
|
320
|
+ |
attr :identifier, :string, required: true
|
|
321
|
+ |
attr :title, :string, required: true
|
|
322
|
+ |
attr :navigate, :any, default: nil
|
|
323
|
+ |
attr :status_category, :atom, values: @categories, required: true
|
|
324
|
+ |
attr :status_label, :string, required: true
|
|
325
|
+ |
attr :progress, :integer, default: nil
|
|
326
|
+ |
attr :priority, :atom, values: @priorities, default: :none
|
|
327
|
+ |
attr :labels, :list, default: []
|
|
328
|
+ |
attr :project, :string, default: nil
|
|
329
|
+ |
attr :created, :string, default: nil
|
|
330
|
+ |
attr :assignee, :map, default: nil
|
|
331
|
+ |
attr :class, :any, default: nil
|
|
332
|
+ |
attr :rest, :global
|
|
333
|
+ |
|
|
334
|
+ |
def issue_card(assigns) do
|
|
335
|
+ |
~H"""
|
|
336
|
+ |
<article class={["issue-card", @class]} {@rest}>
|
|
337
|
+ |
<header class="issue-card__head">
|
|
338
|
+ |
<span class="issue-card__scan">
|
|
339
|
+ |
<.issue_priority level={@priority} />
|
|
340
|
+ |
<span class="issue-row__identifier">{@identifier}</span>
|
|
341
|
+ |
</span>
|
|
342
|
+ |
<.issue_status
|
|
343
|
+ |
category={@status_category}
|
|
344
|
+ |
label={@status_label}
|
|
345
|
+ |
progress={@progress}
|
|
346
|
+ |
/>
|
|
347
|
+ |
</header>
|
|
348
|
+ |
|
|
349
|
+ |
<.link :if={@navigate} navigate={@navigate} class="issue-card__title">{@title}</.link>
|
|
350
|
+ |
<p :if={!@navigate} class="issue-card__title">{@title}</p>
|
|
351
|
+ |
|
|
352
|
+ |
<div :if={@labels != [] or @project} class="issue-card__chips">
|
|
353
|
+ |
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
|
|
354
|
+ |
<span :if={@project} class="issue-label" data-tone="neutral">
|
|
355
|
+ |
<UI.icon name="cube" class="issue-label__glyph" />{@project}
|
|
356
|
+ |
</span>
|
|
357
|
+ |
</div>
|
|
358
|
+ |
|
|
359
|
+ |
<footer class="issue-card__foot">
|
|
360
|
+ |
<span class="issue-row__date">{@created}</span>
|
|
361
|
+ |
<.assignee
|
|
362
|
+ |
name={@assignee && @assignee[:name]}
|
|
363
|
+ |
src={@assignee && @assignee[:src]}
|
|
364
|
+ |
presence={(@assignee && @assignee[:presence]) || :none}
|
|
365
|
+ |
/>
|
|
366
|
+ |
</footer>
|
|
367
|
+ |
</article>
|
|
368
|
+ |
"""
|
|
369
|
+ |
end
|
|
370
|
+ |
|
|
371
|
+ |
@doc """
|
|
372
|
+ |
A named run of issues under a sticky, tinted header.
|
|
373
|
+ |
|
|
374
|
+ |
The tint is the source's idea and it earns its place: a list grouped by
|
|
375
|
+ |
status has no other way to say where one group ends and the next begins once
|
|
376
|
+ |
the header has scrolled past its own rows. It is mixed from the category
|
|
377
|
+ |
colour at a low percentage, so it is a wash rather than a fill, and the
|
|
378
|
+ |
header is still legible over it.
|
|
379
|
+ |
|
|
380
|
+ |
`layout` picks the two arrangements the same group takes: a full-width band
|
|
381
|
+ |
in a list, or a fixed-width column in a board. The header, count, and actions
|
|
382
|
+ |
are identical in both, which is why they are one component.
|
|
383
|
+ |
"""
|
|
384
|
+ |
attr :label, :string, required: true
|
|
385
|
+ |
attr :count, :integer, required: true
|
|
386
|
+ |
attr :category, :atom, values: @categories ++ [:none], default: :none, doc: "drives the tint"
|
|
387
|
+ |
attr :layout, :atom, values: [:list, :board], default: :list
|
|
388
|
+ |
attr :class, :any, default: nil
|
|
389
|
+ |
attr :rest, :global
|
|
390
|
+ |
slot :glyph, doc: "the marker beside the name; a status, a priority, or a face"
|
|
391
|
+ |
slot :actions, doc: "controls at the trailing edge of the header"
|
|
392
|
+ |
slot :inner_block, required: true
|
|
393
|
+ |
|
|
394
|
+ |
def issue_group(assigns) do
|
|
395
|
+ |
~H"""
|
|
396
|
+ |
<section class={["issue-group", @class]} data-layout={@layout} data-category={@category} {@rest}>
|
|
397
|
+ |
<header class="issue-group__head">
|
|
398
|
+ |
<span class="issue-group__name">
|
|
399
|
+ |
{render_slot(@glyph)}
|
|
400
|
+ |
<span class="issue-group__label">{@label}</span>
|
|
401
|
+ |
<span class="issue-group__count">{@count}</span>
|
|
402
|
+ |
</span>
|
|
403
|
+ |
<span :if={@actions != []} class="issue-group__actions">{render_slot(@actions)}</span>
|
|
404
|
+ |
</header>
|
|
405
|
+ |
<div class="issue-group__body">{render_slot(@inner_block)}</div>
|
|
406
|
+ |
</section>
|
|
407
|
+ |
"""
|
|
408
|
+ |
end
|
|
409
|
+ |
|
|
410
|
+ |
@doc """
|
|
411
|
+ |
Board columns side by side, scrolling horizontally.
|
|
412
|
+ |
|
|
413
|
+ |
Each column scrolls on its own so a long backlog does not push the other
|
|
414
|
+ |
columns' headers off the top. The source achieves this with a drag-and-drop
|
|
415
|
+ |
provider wrapped around the same layout; the layout is the part worth having.
|
|
416
|
+ |
"""
|
|
417
|
+ |
attr :class, :any, default: nil
|
|
418
|
+ |
attr :rest, :global
|
|
419
|
+ |
slot :inner_block, required: true
|
|
420
|
+ |
|
|
421
|
+ |
def issue_board(assigns) do
|
|
422
|
+ |
~H"""
|
|
423
|
+ |
<div class={["issue-board", @class]} {@rest}>{render_slot(@inner_block)}</div>
|
|
424
|
+ |
"""
|
|
425
|
+ |
end
|
|
426
|
+ |
|
|
427
|
+ |
@doc """
|
|
428
|
+ |
One applied filter, read as subject, operator, value.
|
|
429
|
+ |
|
|
430
|
+ |
Splitting the chip into three segments is what makes a filter editable
|
|
431
|
+ |
without a modal: each segment is its own control, so changing `is` to
|
|
432
|
+ |
`is not` does not mean removing the filter and building it again. The
|
|
433
|
+ |
segments here are static text unless the caller supplies commands; the
|
|
434
|
+ |
division is the part that matters, and it is what the source's
|
|
435
|
+ |
`data-table-filter` spends most of its code on.
|
|
436
|
+ |
"""
|
|
437
|
+ |
attr :subject, :string, required: true
|
|
438
|
+ |
attr :operator, :string, required: true
|
|
439
|
+ |
attr :value, :string, required: true
|
|
440
|
+ |
attr :icon, :string, default: nil, doc: "a glyph for the subject"
|
|
441
|
+ |
attr :on_remove, JS, default: nil, doc: "dropped from the applied set when clicked"
|
|
442
|
+ |
attr :class, :any, default: nil
|
|
443
|
+ |
attr :rest, :global
|
|
444
|
+ |
|
|
445
|
+ |
def filter_chip(assigns) do
|
|
446
|
+ |
~H"""
|
|
447
|
+ |
<span class={["filter-chip", @class]} {@rest}>
|
|
448
|
+ |
<span class="filter-chip__subject">
|
|
449
|
+ |
<UI.icon :if={@icon} name={@icon} />{@subject}
|
|
450
|
+ |
</span>
|
|
451
|
+ |
<span class="filter-chip__operator">{@operator}</span>
|
|
452
|
+ |
<span class="filter-chip__value">{@value}</span>
|
|
453
|
+ |
<button
|
|
454
|
+ |
:if={@on_remove}
|
|
455
|
+ |
type="button"
|
|
456
|
+ |
class="filter-chip__remove"
|
|
457
|
+ |
phx-click={@on_remove}
|
|
458
|
+ |
aria-label={"Remove the #{@subject} filter"}
|
|
459
|
+ |
>
|
|
460
|
+ |
<UI.icon name="x" />
|
|
461
|
+ |
</button>
|
|
462
|
+ |
</span>
|
|
463
|
+ |
"""
|
|
464
|
+ |
end
|
|
465
|
+ |
|
|
466
|
+ |
@doc """
|
|
467
|
+ |
The row of applied filters, with somewhere to add one and a way to drop them all.
|
|
468
|
+ |
|
|
469
|
+ |
It appears only when a filter is applied — the source hides it otherwise and
|
|
470
|
+ |
keeps the entry point in the toolbar, which is right: an empty filter bar is
|
|
471
|
+ |
a permanent reminder of a feature nobody is using. Rendering nothing when
|
|
472
|
+ |
there are no chips is the caller's decision, so this component does not
|
|
473
|
+ |
guess.
|
|
474
|
+ |
"""
|
|
475
|
+ |
attr :on_clear, JS, default: nil
|
|
476
|
+ |
attr :class, :any, default: nil
|
|
477
|
+ |
attr :rest, :global
|
|
478
|
+ |
slot :add, doc: "the control that opens the subject picker"
|
|
479
|
+ |
slot :inner_block, required: true, doc: "the applied chips"
|
|
480
|
+ |
|
|
481
|
+ |
def filter_bar(assigns) do
|
|
482
|
+ |
~H"""
|
|
483
|
+ |
<div class={["filter-bar", @class]} {@rest}>
|
|
484
|
+ |
<div class="filter-bar__chips">
|
|
485
|
+ |
{render_slot(@add)}
|
|
486
|
+ |
{render_slot(@inner_block)}
|
|
487
|
+ |
</div>
|
|
488
|
+ |
<button :if={@on_clear} type="button" class="filter-bar__clear" phx-click={@on_clear}>
|
|
489
|
+ |
Clear
|
|
490
|
+ |
</button>
|
|
491
|
+ |
</div>
|
|
492
|
+ |
"""
|
|
493
|
+ |
end
|
|
494
|
+ |
|
|
495
|
+ |
@doc """
|
|
496
|
+ |
The saved views of one collection, as pills.
|
|
497
|
+ |
|
|
498
|
+ |
Pills rather than underlined tabs because these switch a filter rather than a
|
|
499
|
+ |
page: the content below keeps its shape, and an underline promises a bigger
|
|
500
|
+ |
change than actually happens. The selected pill carries `aria-current`, so
|
|
501
|
+ |
the state is not colour alone.
|
|
502
|
+ |
"""
|
|
503
|
+ |
attr :label, :string, default: "Views", doc: "names the group for assistive technology"
|
|
504
|
+ |
attr :class, :any, default: nil
|
|
505
|
+ |
attr :rest, :global
|
|
506
|
+ |
|
|
507
|
+ |
slot :tab, required: true do
|
|
508
|
+ |
attr :label, :string, required: true
|
|
509
|
+ |
attr :navigate, :any, required: true
|
|
510
|
+ |
attr :selected, :boolean
|
|
511
|
+ |
end
|
|
512
|
+ |
|
|
513
|
+ |
def view_tabs(assigns) do
|
|
514
|
+ |
~H"""
|
|
515
|
+ |
<nav class={["view-tabs", @class]} aria-label={@label} {@rest}>
|
|
516
|
+ |
<.link
|
|
517
|
+ |
:for={tab <- @tab}
|
|
518
|
+ |
navigate={tab.navigate}
|
|
519
|
+ |
class="view-tabs__tab"
|
|
520
|
+ |
aria-current={tab[:selected] && "page"}
|
|
521
|
+ |
>
|
|
522
|
+ |
{tab.label}
|
|
523
|
+ |
</.link>
|
|
524
|
+ |
</nav>
|
|
525
|
+ |
"""
|
|
526
|
+ |
end
|
|
527
|
+ |
|
|
528
|
+ |
@doc """
|
|
529
|
+ |
The bar above a collection: what you are looking at, and what you can do to it.
|
|
530
|
+ |
|
|
531
|
+ |
The source splits this into two stacked rows — navigation above, options
|
|
532
|
+ |
below — and the split is worth keeping when both are full. This renders one
|
|
533
|
+ |
row with a leading and a trailing slot; stack two of them for the source's
|
|
534
|
+ |
arrangement. Making it one component rather than two means a surface with
|
|
535
|
+ |
only options does not inherit an empty navigation strip.
|
|
536
|
+ |
"""
|
|
537
|
+ |
attr :class, :any, default: nil
|
|
538
|
+ |
attr :rest, :global
|
|
539
|
+ |
slot :leading, doc: "tabs, a count, or a title"
|
|
540
|
+ |
slot :actions, doc: "filter, display, and view controls"
|
|
541
|
+ |
|
|
542
|
+ |
def issue_toolbar(assigns) do
|
|
543
|
+ |
~H"""
|
|
544
|
+ |
<div class={["issue-toolbar", @class]} {@rest}>
|
|
545
|
+ |
<div class="issue-toolbar__leading">{render_slot(@leading)}</div>
|
|
546
|
+ |
<div class="issue-toolbar__actions">{render_slot(@actions)}</div>
|
|
547
|
+ |
</div>
|
|
548
|
+ |
"""
|
|
549
|
+ |
end
|
|
550
|
+ |
|
|
551
|
+ |
@doc """
|
|
552
|
+ |
The `⌘K` surface: a search field over grouped commands.
|
|
553
|
+ |
|
|
554
|
+ |
This is the one component here that needs script. `⌘K` is a document-level
|
|
555
|
+ |
binding, incremental filtering means hiding rows as characters arrive, and
|
|
556
|
+ |
arrow-key selection has to survive both — none of which markup can express.
|
|
557
|
+ |
The hook does exactly those four things and nothing else; every command is a
|
|
558
|
+ |
real `<button>` that works without it.
|
|
559
|
+ |
|
|
560
|
+ |
Built on `<dialog>` rather than a positioned panel, so the browser supplies
|
|
561
|
+ |
the modal semantics, the focus trap, the backdrop, and `Escape`. Anything
|
|
562
|
+ |
with `data-command-target` matching this palette's id opens it, which is how
|
|
563
|
+ |
a surface offers a visible way in beside the shortcut.
|
|
564
|
+ |
|
|
565
|
+ |
`context` is the source's best idea in this surface: when the palette is
|
|
566
|
+ |
opened from an issue, it says which issue, so `Change status…` is unambiguous
|
|
567
|
+ |
before you pick anything.
|
|
568
|
+ |
"""
|
|
569
|
+ |
attr :id, :string, required: true
|
|
570
|
+ |
attr :placeholder, :string, default: "Type a command or search"
|
|
571
|
+ |
attr :context, :string, default: nil, doc: "what the commands act on, if anything"
|
|
572
|
+ |
attr :empty, :string, default: "No results found."
|
|
573
|
+ |
attr :class, :any, default: nil
|
|
574
|
+ |
attr :rest, :global
|
|
575
|
+ |
slot :inner_block, required: true, doc: "`command_group/1` elements"
|
|
576
|
+ |
|
|
577
|
+ |
def command_palette(assigns) do
|
|
578
|
+ |
~H"""
|
|
579
|
+ |
<dialog id={@id} class={["command-palette", @class]} phx-hook=".CommandPalette" {@rest}>
|
|
580
|
+ |
<div class="command-palette__panel">
|
|
581
|
+ |
<p :if={@context} class="command-palette__context">{@context}</p>
|
|
582
|
+ |
<div class="command-palette__search">
|
|
583
|
+ |
<UI.icon name="search" class="command-palette__glyph" />
|
|
584
|
+ |
<input
|
|
585
|
+ |
type="text"
|
|
586
|
+ |
class="command-palette__input"
|
|
587
|
+ |
placeholder={@placeholder}
|
|
588
|
+ |
aria-label={@placeholder}
|
|
589
|
+ |
autocomplete="off"
|
|
590
|
+ |
data-command-input
|
|
591
|
+ |
/>
|
|
592
|
+ |
</div>
|
|
593
|
+ |
<div class="command-palette__list">
|
|
594
|
+ |
{render_slot(@inner_block)}
|
|
595
|
+ |
<p class="command-palette__empty" data-command-empty hidden>{@empty}</p>
|
|
596
|
+ |
</div>
|
|
597
|
+ |
</div>
|
|
598
|
+ |
</dialog>
|
|
599
|
+ |
<script :type={Phoenix.LiveView.ColocatedHook} name=".CommandPalette">
|
|
600
|
+ |
export default {
|
|
601
|
+ |
mounted() {
|
|
602
|
+ |
const input = this.el.querySelector("[data-command-input]")
|
|
603
|
+ |
const empty = this.el.querySelector("[data-command-empty]")
|
|
604
|
+ |
const items = () => Array.from(this.el.querySelectorAll("[data-command-item]"))
|
|
605
|
+ |
const visible = () => items().filter((item) => !item.hidden)
|
|
606
|
+ |
|
|
607
|
+ |
const select = (item) => {
|
|
608
|
+ |
items().forEach((other) => other.removeAttribute("data-active"))
|
|
609
|
+ |
if (!item) return
|
|
610
|
+ |
item.setAttribute("data-active", "")
|
|
611
|
+ |
item.scrollIntoView({block: "nearest"})
|
|
612
|
+ |
}
|
|
613
|
+ |
|
|
614
|
+ |
const filter = () => {
|
|
615
|
+ |
const query = input.value.trim().toLowerCase()
|
|
616
|
+ |
items().forEach((item) => {
|
|
617
|
+ |
item.hidden = query !== "" && !item.dataset.commandLabel.includes(query)
|
|
618
|
+ |
})
|
|
619
|
+ |
this.el.querySelectorAll("[data-command-group]").forEach((group) => {
|
|
620
|
+ |
group.hidden = group.querySelectorAll("[data-command-item]:not([hidden])").length === 0
|
|
621
|
+ |
})
|
|
622
|
+ |
const shown = visible()
|
|
623
|
+ |
if (empty) empty.hidden = shown.length !== 0
|
|
624
|
+ |
select(shown[0])
|
|
625
|
+ |
}
|
|
626
|
+ |
|
|
627
|
+ |
const open = () => {
|
|
628
|
+ |
if (this.el.open) return
|
|
629
|
+ |
input.value = ""
|
|
630
|
+ |
filter()
|
|
631
|
+ |
this.el.showModal()
|
|
632
|
+ |
input.focus()
|
|
633
|
+ |
}
|
|
634
|
+ |
|
|
635
|
+ |
this.onKeyDown = (event) => {
|
|
636
|
+ |
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
|
637
|
+ |
event.preventDefault()
|
|
638
|
+ |
this.el.open ? this.el.close() : open()
|
|
639
|
+ |
}
|
|
640
|
+ |
}
|
|
641
|
+ |
|
|
642
|
+ |
this.onClick = (event) => {
|
|
643
|
+ |
const trigger = event.target.closest(`[data-command-target="${this.el.id}"]`)
|
|
644
|
+ |
if (trigger) open()
|
|
645
|
+ |
}
|
|
646
|
+ |
|
|
647
|
+ |
// Arrow keys move a selection the browser has no concept of, so the
|
|
648
|
+ |
// active row is tracked here and Enter forwards to its own click
|
|
649
|
+ |
// handler rather than duplicating what the row does.
|
|
650
|
+ |
this.onPaletteKey = (event) => {
|
|
651
|
+ |
const shown = visible()
|
|
652
|
+ |
if (shown.length === 0) return
|
|
653
|
+ |
const at = shown.findIndex((item) => item.hasAttribute("data-active"))
|
|
654
|
+ |
if (event.key === "ArrowDown") {
|
|
655
|
+ |
event.preventDefault()
|
|
656
|
+ |
select(shown[(at + 1) % shown.length])
|
|
657
|
+ |
} else if (event.key === "ArrowUp") {
|
|
658
|
+ |
event.preventDefault()
|
|
659
|
+ |
select(shown[(at - 1 + shown.length) % shown.length])
|
|
660
|
+ |
} else if (event.key === "Enter" && at >= 0) {
|
|
661
|
+ |
event.preventDefault()
|
|
662
|
+ |
shown[at].click()
|
|
663
|
+ |
}
|
|
664
|
+ |
}
|
|
665
|
+ |
|
|
666
|
+ |
input.addEventListener("input", filter)
|
|
667
|
+ |
this.el.addEventListener("keydown", this.onPaletteKey)
|
|
668
|
+ |
window.addEventListener("keydown", this.onKeyDown)
|
|
669
|
+ |
document.addEventListener("click", this.onClick)
|
|
670
|
+ |
filter()
|
|
671
|
+ |
},
|
|
672
|
+ |
destroyed() {
|
|
673
|
+ |
window.removeEventListener("keydown", this.onKeyDown)
|
|
674
|
+ |
document.removeEventListener("click", this.onClick)
|
|
675
|
+ |
}
|
|
676
|
+ |
}
|
|
677
|
+ |
</script>
|
|
678
|
+ |
"""
|
|
679
|
+ |
end
|
|
680
|
+ |
|
|
681
|
+ |
@doc """
|
|
682
|
+ |
A titled run of commands inside the palette.
|
|
683
|
+ |
|
|
684
|
+ |
Headings are what keep a palette of forty commands readable, and they are
|
|
685
|
+ |
also what makes filtering legible: a group with nothing left in it hides
|
|
686
|
+ |
itself rather than leaving a heading over a gap.
|
|
687
|
+ |
"""
|
|
688
|
+ |
attr :heading, :string, required: true
|
|
689
|
+ |
attr :class, :any, default: nil
|
|
690
|
+ |
attr :rest, :global
|
|
691
|
+ |
slot :inner_block, required: true
|
|
692
|
+ |
|
|
693
|
+ |
def command_group(assigns) do
|
|
694
|
+ |
~H"""
|
|
695
|
+ |
<div class={["command-group", @class]} data-command-group {@rest}>
|
|
696
|
+ |
<p class="command-group__heading">{@heading}</p>
|
|
697
|
+ |
{render_slot(@inner_block)}
|
|
698
|
+ |
</div>
|
|
699
|
+ |
"""
|
|
700
|
+ |
end
|
|
701
|
+ |
|
|
702
|
+ |
@doc """
|
|
703
|
+ |
One command: a glyph, a name, and the keys that reach it directly.
|
|
704
|
+ |
|
|
705
|
+ |
The shortcut chips are documentation, not bindings — the palette does not
|
|
706
|
+ |
install them. Showing them anyway is how a person stops needing the palette,
|
|
707
|
+ |
which is the point of having one.
|
|
708
|
+ |
|
|
709
|
+ |
`label` doubles as the filter key, so a command matches on the words a person
|
|
710
|
+ |
would actually type.
|
|
711
|
+ |
"""
|
|
712
|
+ |
attr :label, :string, required: true
|
|
713
|
+ |
attr :icon, :string, default: nil
|
|
714
|
+ |
attr :keys, :list, default: [], doc: "shortcut keys shown at the trailing edge"
|
|
715
|
+ |
attr :on_select, JS, default: nil
|
|
716
|
+ |
attr :class, :any, default: nil
|
|
717
|
+ |
attr :rest, :global
|
|
718
|
+ |
|
|
719
|
+ |
def command_item(assigns) do
|
|
720
|
+ |
~H"""
|
|
721
|
+ |
<button
|
|
722
|
+ |
type="button"
|
|
723
|
+ |
class={["command-item", @class]}
|
|
724
|
+ |
data-command-item
|
|
725
|
+ |
data-command-label={String.downcase(@label)}
|
|
726
|
+ |
phx-click={@on_select}
|
|
727
|
+ |
{@rest}
|
|
728
|
+ |
>
|
|
729
|
+ |
<UI.icon :if={@icon} name={@icon} class="command-item__glyph" />
|
|
730
|
+ |
<span class="command-item__label">{@label}</span>
|
|
731
|
+ |
<span :if={@keys != []} class="command-item__keys">
|
|
732
|
+ |
<UI.kbd :for={key <- @keys}>{key}</UI.kbd>
|
|
733
|
+ |
</span>
|
|
734
|
+ |
</button>
|
|
735
|
+ |
"""
|
|
736
|
+ |
end
|
|
737
|
+ |
|
|
738
|
+ |
@doc """
|
|
739
|
+ |
One project as a row: name on the left, everything measurable on the right.
|
|
740
|
+ |
|
|
741
|
+ |
Projects are read across rather than down — the question is which project is
|
|
742
|
+ |
behind, not what any one of them is called — so the trailing fields sit in
|
|
743
|
+ |
fixed columns that line up between rows. They drop by width from the least
|
|
744
|
+ |
load-bearing inwards, which is why progress is the last to go.
|
|
745
|
+ |
|
|
746
|
+ |
Health is a word, not a colour: `at risk` and `off track` are different
|
|
747
|
+ |
claims, and a reader should not have to learn which shade of amber means
|
|
748
|
+ |
which.
|
|
749
|
+ |
"""
|
|
750
|
+ |
attr :name, :string, required: true
|
|
751
|
+ |
attr :navigate, :any, default: nil
|
|
752
|
+ |
attr :icon, :string, default: "cube"
|
|
753
|
+ |
attr :health, :atom, values: [:on_track, :at_risk, :off_track, :unknown], default: :unknown
|
|
754
|
+ |
attr :priority, :atom, values: @priorities, default: :none
|
|
755
|
+ |
attr :lead, :map, default: nil, doc: "`%{name:, src:}`; `nil` renders unassigned"
|
|
756
|
+ |
attr :target, :string, default: nil, doc: "already formatted target date"
|
|
757
|
+ |
attr :issues, :integer, default: nil
|
|
758
|
+ |
attr :status_category, :atom, values: @categories, required: true
|
|
759
|
+ |
attr :status_label, :string, required: true
|
|
760
|
+ |
attr :percent, :integer, default: nil
|
|
761
|
+ |
attr :labels, :list, default: []
|
|
762
|
+ |
attr :class, :any, default: nil
|
|
763
|
+ |
attr :rest, :global
|
|
764
|
+ |
|
|
765
|
+ |
def project_row(assigns) do
|
|
766
|
+ |
~H"""
|
|
767
|
+ |
<div class={["project-row", @class]} {@rest}>
|
|
768
|
+ |
<span class="project-row__name">
|
|
769
|
+ |
<span class="project-row__icon"><UI.icon name={@icon} /></span>
|
|
770
|
+ |
<.link :if={@navigate} navigate={@navigate} class="project-row__link">{@name}</.link>
|
|
771
|
+ |
<span :if={!@navigate} class="project-row__link">{@name}</span>
|
|
772
|
+ |
<.issue_label :for={label <- @labels} name={label[:name]} tone={label[:tone] || :neutral} />
|
|
773
|
+ |
</span>
|
|
774
|
+ |
|
|
775
|
+ |
<span :if={@health != :unknown} class="project-row__health" data-health={@health}>
|
|
776
|
+ |
{health_name(@health)}
|
|
777
|
+ |
</span>
|
|
778
|
+ |
<span class="project-row__priority"><.issue_priority level={@priority} /></span>
|
|
779
|
+ |
<span class="project-row__lead">
|
|
780
|
+ |
<.assignee name={@lead && @lead[:name]} src={@lead && @lead[:src]} size={:sm} />
|
|
781
|
+ |
</span>
|
|
782
|
+ |
<span :if={@target} class="project-row__target">{@target}</span>
|
|
783
|
+ |
<span :if={@issues} class="project-row__issues">{@issues}</span>
|
|
784
|
+ |
<span class="project-row__status">
|
|
785
|
+ |
<.issue_status
|
|
786
|
+ |
category={@status_category}
|
|
787
|
+ |
label={@status_label}
|
|
788
|
+ |
progress={@percent}
|
|
789
|
+ |
/>
|
|
790
|
+ |
<span :if={@percent} class="project-row__percent">{@percent}%</span>
|
|
791
|
+ |
</span>
|
|
792
|
+ |
</div>
|
|
793
|
+ |
"""
|
|
794
|
+ |
end
|
|
795
|
+ |
|
|
796
|
+ |
@doc """
|
|
797
|
+ |
One team as a row: identity, membership, and what it owns.
|
|
798
|
+ |
|
|
799
|
+ |
The identifier is shown beside the name rather than instead of it because it
|
|
800
|
+ |
is the prefix on every issue key the team produces — `OA-142` is only
|
|
801
|
+ |
findable if somebody can connect `OA` to a team.
|
|
802
|
+ |
"""
|
|
803
|
+ |
attr :name, :string, required: true
|
|
804
|
+ |
attr :identifier, :string, required: true
|
|
805
|
+ |
attr :glyph, :string, default: nil, doc: "a short mark, typically one character"
|
|
806
|
+ |
attr :navigate, :any, default: nil
|
|
807
|
+ |
attr :joined, :boolean, default: false
|
|
808
|
+ |
attr :members, :list, default: []
|
|
809
|
+ |
attr :projects, :integer, default: nil
|
|
810
|
+ |
attr :cycles, :integer, default: nil
|
|
811
|
+ |
attr :class, :any, default: nil
|
|
812
|
+ |
attr :rest, :global
|
|
813
|
+ |
|
|
814
|
+ |
def team_row(assigns) do
|
|
815
|
+ |
~H"""
|
|
816
|
+ |
<div class={["team-row", @class]} {@rest}>
|
|
817
|
+ |
<span class="team-row__name">
|
|
818
|
+ |
<span class="team-row__glyph" aria-hidden="true">{@glyph || String.first(@identifier)}</span>
|
|
819
|
+ |
<.link :if={@navigate} navigate={@navigate} class="team-row__link">{@name}</.link>
|
|
820
|
+ |
<span :if={!@navigate} class="team-row__link">{@name}</span>
|
|
821
|
+ |
<span class="team-row__identifier">{@identifier}</span>
|
|
822
|
+ |
</span>
|
|
823
|
+ |
|
|
824
|
+ |
<span class="team-row__membership">
|
|
825
|
+ |
<span :if={@joined} class="team-row__joined"><UI.icon name="check" />Joined</span>
|
|
826
|
+ |
</span>
|
|
827
|
+ |
<span class="team-row__members">
|
|
828
|
+ |
<.assignee_stack :if={@members != []} people={@members} limit={6} />
|
|
829
|
+ |
</span>
|
|
830
|
+ |
<span :if={@cycles} class="team-row__metric"><UI.icon name="loop" />{@cycles}</span>
|
|
831
|
+ |
<span :if={@projects} class="team-row__metric"><UI.icon name="cube" />{@projects}</span>
|
|
832
|
+ |
</div>
|
|
833
|
+ |
"""
|
|
834
|
+ |
end
|
|
835
|
+ |
|
|
836
|
+ |
@doc """
|
|
837
|
+ |
One person as a row: who they are, what they may do, and where they belong.
|
|
838
|
+ |
|
|
839
|
+ |
Two lines of identity rather than one. A display name is what a colleague
|
|
840
|
+ |
recognises and a handle is what appears in a mention, and a directory that
|
|
841
|
+ |
shows only one of them fails whichever question is being asked.
|
|
842
|
+ |
"""
|
|
843
|
+ |
attr :name, :string, required: true
|
|
844
|
+ |
attr :handle, :string, required: true
|
|
845
|
+ |
attr :src, :string, default: nil
|
|
846
|
+ |
attr :role, :string, default: nil
|
|
847
|
+ |
attr :role_tone, :atom, values: [:neutral, :accent], default: :neutral
|
|
848
|
+ |
attr :joined, :string, default: nil, doc: "already formatted joining date"
|
|
849
|
+ |
attr :teams, :list, default: [], doc: "team identifiers"
|
|
850
|
+ |
attr :presence, :atom, values: @presences, default: :none
|
|
851
|
+ |
attr :navigate, :any, default: nil
|
|
852
|
+ |
attr :class, :any, default: nil
|
|
853
|
+ |
attr :rest, :global
|
|
854
|
+ |
|
|
855
|
+ |
def member_row(assigns) do
|
|
856
|
+ |
assigns =
|
|
857
|
+ |
assigns
|
|
858
|
+ |
|> assign(:shown_teams, Enum.take(assigns.teams, 2))
|
|
859
|
+ |
|> assign(:extra_teams, max(length(assigns.teams) - 2, 0))
|
|
860
|
+ |
|
|
861
|
+ |
~H"""
|
|
862
|
+ |
<div class={["member-row", @class]} {@rest}>
|
|
863
|
+ |
<span class="member-row__identity">
|
|
864
|
+ |
<.assignee name={@name} src={@src} presence={@presence} size={:lg} />
|
|
865
|
+ |
<span class="member-row__names">
|
|
866
|
+ |
<.link :if={@navigate} navigate={@navigate} class="member-row__name">{@name}</.link>
|
|
867
|
+ |
<span :if={!@navigate} class="member-row__name">{@name}</span>
|
|
868
|
+ |
<span class="member-row__handle">{@handle}</span>
|
|
869
|
+ |
</span>
|
|
870
|
+ |
</span>
|
|
871
|
+ |
|
|
872
|
+ |
<span :if={@role} class="member-row__role" data-tone={@role_tone}>{@role}</span>
|
|
873
|
+ |
<span :if={@joined} class="member-row__joined">{@joined}</span>
|
|
874
|
+ |
<span :if={@teams != []} class="member-row__teams">
|
|
875
|
+ |
<UI.icon name="group" />{Enum.join(@shown_teams, ", ")}
|
|
876
|
+ |
<span :if={@extra_teams > 0}>
|
|
877
|
+ |
+{@extra_teams}
|
|
878
|
+ |
</span>
|
|
879
|
+ |
</span>
|
|
880
|
+ |
</div>
|
|
881
|
+ |
"""
|
|
882
|
+ |
end
|
|
883
|
+ |
|
|
884
|
+ |
# ── the fixed vocabularies ─────────────────────────────────────────────────
|
|
885
|
+ |
|
|
886
|
+ |
# Five of the source's six status shapes exist in the vendored set. Triage is
|
|
887
|
+ |
# opposing arrows in a disc, which `compare-arrows` says exactly; the dashed
|
|
888
|
+ |
# gear it uses for backlog has no equivalent and `circle-dashed` carries the
|
|
889
|
+ |
# same "not yet real" reading without vendoring a glyph for one state.
|
|
890
|
+ |
defp category_icon(:triage), do: "compare-arrows"
|
|
891
|
+ |
defp category_icon(:backlog), do: "circle-dashed"
|
|
892
|
+ |
defp category_icon(:unstarted), do: "empty-circle"
|
|
893
|
+ |
defp category_icon(:completed), do: "check-circle-filled"
|
|
894
|
+ |
defp category_icon(:canceled), do: "x-circle-filled"
|
|
895
|
+ |
|
|
896
|
+ |
defp priority_name(:none), do: "No priority"
|
|
897
|
+ |
defp priority_name(:low), do: "Low priority"
|
|
898
|
+ |
defp priority_name(:medium), do: "Medium priority"
|
|
899
|
+ |
defp priority_name(:high), do: "High priority"
|
|
900
|
+ |
defp priority_name(:urgent), do: "Urgent"
|
|
901
|
+ |
|
|
902
|
+ |
# `:unknown` has no word because the row renders no health cell for it. A
|
|
903
|
+ |
# project nobody has reported on should leave a gap in the column, not claim
|
|
904
|
+ |
# "no update" as though that were a fourth health state.
|
|
905
|
+ |
defp health_name(:on_track), do: "On track"
|
|
906
|
+ |
defp health_name(:at_risk), do: "At risk"
|
|
907
|
+ |
defp health_name(:off_track), do: "Off track"
|
|
908
|
+ |
|
|
909
|
+ |
# A progress value arrives from a count of finished issues over a count of
|
|
910
|
+ |
# issues, so it can be anything; the arc has to be drawable regardless.
|
|
911
|
+ |
defp clamp(nil), do: 0
|
|
912
|
+ |
defp clamp(value) when is_integer(value), do: value |> max(0) |> min(100)
|
|
913
|
+ |
defp clamp(_), do: 0
|
|
914
|
+ |
end
|