steid

@jamesgill /

Rust32.1 KBCode·Blame·History·Raw
1//! Labels — `/{handle}/repos/{name}/labels`.
2//!
3//! One page, two doors: the issues toolbar and the repository settings section list.
4//! It sits inside the repository frame under the Issues tab rather than under Settings,
5//! because a label is a thing you reach for while looking at issues — the settings entry
6//! is the second way in, not the page's home.
7//!
8//! **Readable by anyone who can read the repository, writable only by its owner.** The
9//! pills are already on the issues list, so a legend nobody may read would be a legend
10//! that does not work; the use cases decide both, and the guards here only keep a form
11//! from rendering for someone whose submission would be refused anyway.
12//!
13//! The colour control is a row of eight radio inputs drawn as swatches — see
14//! `styles.css`. There is no hex field and there will not be one: ui.md's rule is theme
15//! tokens, never raw colours.
16
17use std::time::SystemTime;
18
19use serde::Deserialize;
20use topcoat::{
21 Result,
22 context::Cx,
23 icon::{icon, iconify::iconify_icon},
24 router::{
25 StatusCode,
26 content::Form,
27 error::{bad_request, not_found},
28 page, path_param, query_params,
29 },
30 view::{attributes, component, view},
31};
32
33use crate::{
34 application::{
35 Error, LabelEdit, LabelPage, LabelRow, LabelView, NewLabel, RepoView, Stores, create_label,
36 delete_label, list_labels, set_issue_labels, set_pull_labels, update_label,
37 },
38 components::{
39 button::{ButtonSize, ButtonVariant, button},
40 flash::{FlashKind, flash},
41 input::input,
42 label::label,
43 },
44 domain::{DomainError, IssueNumber, Label, LabelColour, LabelId},
45};
46
47use super::{
48 context::{
49 current_actor, issues, labels, location, memberships, orgs, owned_repo, pulls, repos,
50 server_error, write_failure,
51 },
52 csrf::{CSRF_FIELD, CsrfGuard, csrf_field, csrf_verified},
53 layout::wide,
54 repo::{Tab, repo_for, repo_header},
55};
56
57/// `{id}` from the path — a label's id.
58///
59/// Named for the parameter, not for what it identifies: a `#[path_param]` struct's name
60/// snake-cased **is** the URL parameter, so a struct called `Label` would have to be
61/// `{label}` in the route and would then shadow the domain's `Label` in this module.
62#[path_param]
63struct Id(str);
64
65/// `{number}` from the path, on the assignment route. Its own struct rather than
66/// `issues`' because a `#[path_param]` type is module-scoped.
67#[path_param]
68struct Number(str);
69
70/// Set after a delete so the confirmation survives the redirect.
71#[query_params(error = bad_request)]
72struct Removed {
73 removed: Option<String>,
74}
75
76#[derive(Debug, Deserialize)]
77struct LabelForm {
78 /// The hidden field every form carries; see [`csrf`](super::csrf).
79 ///
80 /// `default` so that a submission with no token at all is refused by
81 /// `csrf_verified` — a 403 saying what happened — rather than failing
82 /// deserialization into a bare 400 that says nothing.
83 #[serde(default)]
84 csrf_token: String,
85 name: String,
86 colour: String,
87 description: String,
88}
89
90// --- URLs ---------------------------------------------------------------------------
91
92pub(super) fn labels_url(handle: &str, name: &str) -> String {
93 format!("/{handle}/repos/{name}/labels")
94}
95
96/// The issues list narrowed to one label.
97///
98/// The name goes in the query string rather than the path: it is a filter on a list, not
99/// a place of its own, and a label may be renamed without breaking a URL that anybody
100/// bookmarked as "this repository's issues".
101pub(super) fn filtered_issues_url(handle: &str, name: &str, pill: &str) -> String {
102 format!("/{handle}/repos/{name}/issues?label={}", encode(pill))
103}
104
105/// The same, for the pull request list.
106pub(super) fn filtered_pulls_url(handle: &str, name: &str, pill: &str) -> String {
107 format!("/{handle}/repos/{name}/pulls?label={}", encode(pill))
108}
109
110/// Which list a pill leads to.
111///
112/// A label is on issues and on pull requests alike, so a pill has two possible
113/// destinations and only its surroundings know which: the one you clicked on a pull
114/// request row means "the other requests like this". Issues is the default because that
115/// is where most pills are drawn, and it keeps the existing call sites unchanged.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub(super) enum Filtering {
118 #[default]
119 Issues,
120 Pulls,
121}
122
123impl Filtering {
124 fn url(self, handle: &str, name: &str, pill: &str) -> String {
125 match self {
126 Self::Issues => filtered_issues_url(handle, name, pill),
127 Self::Pulls => filtered_pulls_url(handle, name, pill),
128 }
129 }
130}
131
132/// Percent-encodes a label name for a query string.
133///
134/// Hand-rolled because the one thing that needs escaping here is small and known: a name
135/// is a single line of at most fifty characters, and everything outside the unreserved
136/// set is encoded rather than guessed at.
137pub(super) fn encode(value: &str) -> String {
138 let mut encoded = String::with_capacity(value.len());
139
140 for byte in value.as_bytes() {
141 match byte {
142 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
143 encoded.push(*byte as char)
144 }
145 other => encoded.push_str(&format!("%{other:02X}")),
146 }
147 }
148
149 encoded
150}
151
152// --- Resolution -----------------------------------------------------------------------
153
154async fn listing(cx: &Cx, repo: &RepoView) -> Result<LabelPage> {
155 list_labels(
156 &current_actor(cx).await?,
157 &repo.handle,
158 &repo.name,
159 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
160 &labels(cx),
161 )
162 .await
163 .map_err(server_error)?
164 // `repo_for` already resolved the repository as visible, so `None` here means it
165 // vanished between the two reads.
166 .ok_or_else(|| not_found().into())
167}
168
169/// The colour a form posted, or a bad request.
170///
171/// A value the palette cannot name is a tampered form, not something to default:
172/// defaulting would silently save a label in a colour nobody chose.
173fn colour_of(value: &str) -> Result<LabelColour> {
174 value
175 .parse::<LabelColour>()
176 .map_err(|_| bad_request("unknown label colour").into())
177}
178
179/// A description field, absent rather than empty when nothing was typed.
180fn described(value: &str) -> Option<String> {
181 let trimmed = value.trim();
182
183 (!trimmed.is_empty()).then(|| trimmed.to_owned())
184}
185
186// --- The page -------------------------------------------------------------------------
187
188#[page("/{handle}/repos/{name}/labels")]
189async fn labels_page(cx: &Cx) -> Result {
190 let repo = repo_for(cx).await?;
191 let listed = listing(cx, &repo).await?;
192 let removed = query_params::<Removed>(cx)?.removed.is_some();
193
194 view! {
195 wide(
196 repo_header(repo: &repo, rev: "", active: Tab::Issues)
197 labels_view(
198 handle: repo.handle.as_str(),
199 name: repo.name.as_str(),
200 listed: &listed,
201 notice: Notice { removed, ..Notice::default() },
202 )
203 )
204 }
205}
206
207/// Adds a label, then redirects so a reload cannot repeat it.
208#[page(POST "/{handle}/repos/{name}/labels")]
209async fn add(cx: &Cx, Form(submitted): Form<LabelForm>) -> Result {
210 csrf_verified(cx, &submitted.csrf_token).await?;
211
212 let repo = owned_repo(cx).await?;
213 let colour = colour_of(&submitted.colour)?;
214
215 let outcome = create_label(
216 &current_actor(cx).await?,
217 &repo.handle,
218 &repo.name,
219 &NewLabel {
220 name: submitted.name.clone(),
221 colour,
222 description: described(&submitted.description),
223 },
224 SystemTime::now(),
225 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
226 &labels(cx),
227 )
228 .await;
229
230 let message = match outcome {
231 Ok(()) => {
232 return view! {
233 (StatusCode::SEE_OTHER)
234 (location(&labels_url(repo.handle.as_str(), repo.name.as_str()))?)
235 };
236 }
237 Err(error) => write_failure(error)?,
238 };
239
240 // Re-rendered with what was typed. Bouncing back to an empty form would throw away
241 // the name and the colour to say the name was taken.
242 let listed = listing(cx, &repo).await?;
243
244 view! {
245 wide(
246 repo_header(repo: &repo, rev: "", active: Tab::Issues)
247 labels_view(
248 handle: repo.handle.as_str(),
249 name: repo.name.as_str(),
250 listed: &listed,
251 notice: Notice {
252 error: message.as_str(),
253 typed: submitted.name.as_str(),
254 described: submitted.description.as_str(),
255 colour,
256 ..Notice::default()
257 },
258 )
259 )
260 }
261}
262
263/// Renames, recolours or redescribes one label.
264#[page(POST "/{handle}/repos/{name}/labels/{id}/edit")]
265async fn edit(cx: &Cx, Form(submitted): Form<LabelForm>) -> Result {
266 csrf_verified(cx, &submitted.csrf_token).await?;
267
268 let repo = owned_repo(cx).await?;
269 let id = LabelId::from_trusted(path_param::<Id>(cx));
270 let colour = colour_of(&submitted.colour)?;
271
272 let outcome = update_label(
273 &current_actor(cx).await?,
274 &repo.handle,
275 &repo.name,
276 &id,
277 &LabelEdit {
278 name: submitted.name.clone(),
279 colour,
280 description: described(&submitted.description),
281 },
282 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
283 &labels(cx),
284 )
285 .await;
286
287 let message = match outcome {
288 Ok(()) => {
289 return view! {
290 (StatusCode::SEE_OTHER)
291 (location(&labels_url(repo.handle.as_str(), repo.name.as_str()))?)
292 };
293 }
294 Err(error) => write_failure(error)?,
295 };
296
297 // The row's panel is opened on the label that was refused, so the reason is visible
298 // and so is what caused it.
299 let listed = listing(cx, &repo).await?;
300
301 view! {
302 wide(
303 repo_header(repo: &repo, rev: "", active: Tab::Issues)
304 labels_view(
305 handle: repo.handle.as_str(),
306 name: repo.name.as_str(),
307 listed: &listed,
308 notice: Notice {
309 error: message.as_str(),
310 editing: Some(&id),
311 ..Notice::default()
312 },
313 )
314 )
315 }
316}
317
318/// Removes a label, then redirects so a reload cannot repeat it.
319#[page(POST "/{handle}/repos/{name}/labels/{id}/delete")]
320async fn remove(cx: &Cx, Form(guard): Form<CsrfGuard>) -> Result {
321 csrf_verified(cx, &guard.csrf_token).await?;
322
323 let repo = owned_repo(cx).await?;
324 let id = LabelId::from_trusted(path_param::<Id>(cx));
325
326 match delete_label(
327 &current_actor(cx).await?,
328 &repo.handle,
329 &repo.name,
330 &id,
331 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
332 &labels(cx),
333 )
334 .await
335 {
336 Ok(()) => {}
337 // Already gone, or never this repository's. Both leave the list looking the way
338 // the visitor wanted it.
339 Err(Error::Domain(DomainError::NotFound { .. })) => {}
340 Err(other) => {
341 write_failure(other)?;
342 }
343 }
344
345 view! {
346 (StatusCode::SEE_OTHER)
347 (location(&format!(
348 "{}?removed",
349 labels_url(repo.handle.as_str(), repo.name.as_str())
350 ))?)
351 }
352}
353
354/// Replaces an issue's labels with what the checkboxes say.
355///
356/// `Form<Vec<(String, String)>>` rather than a struct: a checkbox list posts the same key
357/// once per tick, which no struct field can hold. Reading the raw pairs is also what
358/// makes "none ticked" arrive as an empty set rather than as a missing field.
359/// The CSRF token out of a form submitted as raw pairs.
360///
361/// The two label-assignment forms deserialize into `Vec<(String, String)>` rather than
362/// a struct — a checkbox list has one repeated field name and no fixed shape — so the
363/// hidden input arrives as another pair rather than as a field. Absent, it is the empty
364/// string, which `csrf_verified` refuses like any other wrong token.
365fn presented_token(submitted: &[(String, String)]) -> &str {
366 submitted
367 .iter()
368 .find(|(field, _)| field == CSRF_FIELD)
369 .map_or("", |(_, value)| value.as_str())
370}
371
372#[page(POST "/{handle}/repos/{name}/issues/{number}/labels")]
373async fn set_labels(cx: &Cx, Form(submitted): Form<Vec<(String, String)>>) -> Result {
374 csrf_verified(cx, presented_token(&submitted)).await?;
375
376 let repo = owned_repo(cx).await?;
377
378 let number = path_param::<Number>(cx)
379 .parse::<IssueNumber>()
380 .map_err(|_| not_found())?;
381
382 let chosen: Vec<LabelId> = submitted
383 .into_iter()
384 .filter(|(field, _)| field == "label")
385 .map(|(_, id)| LabelId::from_trusted(id))
386 .collect();
387
388 match set_issue_labels(
389 &current_actor(cx).await?,
390 &repo.handle,
391 &repo.name,
392 number,
393 &chosen,
394 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
395 &issues(cx),
396 &labels(cx),
397 )
398 .await
399 {
400 Ok(()) => {}
401 // Nothing typed to preserve and nothing to explain — an id that is not this
402 // repository's is a tampered form, not a mistake somebody made.
403 Err(error) => {
404 write_failure(error)?;
405 }
406 }
407
408 view! {
409 (StatusCode::SEE_OTHER)
410 (location(&format!(
411 "/{}/repos/{}/issues/{number}",
412 repo.handle, repo.name
413 ))?)
414 }
415}
416
417/// Replaces a pull request's labels with what the checkboxes say.
418///
419/// The issue route's twin, and it lives here beside it rather than in
420/// [`pulls`](super::pulls): the form, the `Vec<(String, String)>` reading and the
421/// tampered-id rule are the same three things, and two copies would be two places for
422/// them to drift.
423#[page(POST "/{handle}/repos/{name}/pulls/{number}/labels")]
424async fn set_pull_request_labels(cx: &Cx, Form(submitted): Form<Vec<(String, String)>>) -> Result {
425 csrf_verified(cx, presented_token(&submitted)).await?;
426
427 let repo = owned_repo(cx).await?;
428
429 let number = path_param::<Number>(cx)
430 .parse::<IssueNumber>()
431 .map_err(|_| not_found())?;
432
433 let chosen: Vec<LabelId> = submitted
434 .into_iter()
435 .filter(|(field, _)| field == "label")
436 .map(|(_, id)| LabelId::from_trusted(id))
437 .collect();
438
439 match set_pull_labels(
440 &current_actor(cx).await?,
441 &repo.handle,
442 &repo.name,
443 number,
444 &chosen,
445 &Stores::new(&orgs(cx), &memberships(cx), &repos(cx)),
446 &pulls(cx),
447 &labels(cx),
448 )
449 .await
450 {
451 Ok(()) => {}
452 Err(error) => {
453 write_failure(error)?;
454 }
455 }
456
457 view! {
458 (StatusCode::SEE_OTHER)
459 (location(&format!(
460 "/{}/repos/{}/pulls/{number}",
461 repo.handle, repo.name
462 ))?)
463 }
464}
465
466// --- Shared components ------------------------------------------------------------------
467
468/// One label, drawn as a pill, linking to the issues it is on.
469///
470/// A link everywhere it appears — on a list row, on an issue, on this page — because
471/// "what else is like this" is the only question a label prompts. The colour class is
472/// built here rather than being a Tailwind utility: see the note above `.label-pill` in
473/// `styles.css`.
474#[component]
475pub(super) async fn label_pill(
476 handle: &str,
477 name: &str,
478 pill: &LabelView,
479 /// Which list to lead to. Issues unless the pill is drawn on a pull request.
480 #[default]
481 to: Filtering,
482) -> Result {
483 view! {
484 <a
485 href=(to.url(handle, name, &pill.name))
486 class=(format!("label-pill label-{}", pill.colour))
487 title=(pill.description.as_deref().unwrap_or(""))
488 >(pill.name.as_str())</a>
489 }
490}
491
492/// The eight swatches, as radio inputs.
493#[component]
494async fn swatches(field: &str, chosen: LabelColour) -> Result {
495 view! {
496 <div class="flex flex-wrap items-center gap-2">
497 for colour in LabelColour::ALL {
498 <label class=(format!("label-swatch label-{colour}")) title=(colour.as_str())>
499 <input
500 type="radio"
501 name=(field)
502 value=(colour.as_str())
503 checked=(colour == chosen)
504 />
505 <span></span>
506 </label>
507 }
508 </div>
509 }
510}
511
512// --- The view ---------------------------------------------------------------------------
513
514/// What the page has to say about the last thing that happened, grouped so the view takes
515/// one argument for it rather than five that must stay in the same order at three call
516/// sites — the shape `webhook.rs`'s `Notice` already uses.
517#[derive(Debug, Clone, Copy, Default)]
518struct Notice<'a> {
519 removed: bool,
520 error: &'a str,
521 /// A name that was rejected, put back in the add form.
522 typed: &'a str,
523 /// A description that was rejected, put back with it.
524 described: &'a str,
525 /// The colour that was chosen when it was rejected.
526 colour: LabelColour,
527 /// The row whose panel starts open — the one whose edit was refused.
528 editing: Option<&'a LabelId>,
529}
530
531#[component]
532async fn labels_view(handle: &str, name: &str, listed: &LabelPage, notice: Notice<'_>) -> Result {
533 let Notice {
534 removed,
535 error,
536 typed,
537 described,
538 colour,
539 editing,
540 } = notice;
541
542 view! {
543 <div class="max-w-3xl">
544 <div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
545 <h1 class="text-lg font-semibold tracking-tight">"Labels"</h1>
546 <span class="font-mono text-xs text-muted-foreground">(listed.labels.len())</span>
547 <a
548 href=(format!("/{handle}/repos/{name}/issues"))
549 class="ml-auto text-sm text-muted-foreground hover:text-foreground"
550 >"Back to issues"</a>
551 </div>
552 <p class="mt-1 text-sm text-muted-foreground">
553 "A label is this repository's own — the same word on another repository is "
554 "a different label. Issues and pull requests share the set; click a pill "
555 "for the issues, or the count for the pull requests."
556 </p>
557
558 if removed {
559 <div class="mt-4">
560 flash(kind: FlashKind::Success, "Label deleted. The issues that had it kept everything else.")
561 </div>
562 }
563
564 if !error.is_empty() {
565 <div class="mt-4">
566 flash(kind: FlashKind::Error, (error))
567 </div>
568 }
569
570 if listed.viewer_is_owner {
571 add_form(
572 handle: handle,
573 name: name,
574 typed: typed,
575 described: described,
576 colour: colour,
577 )
578 }
579
580 if listed.labels.is_empty() {
581 <div class="mt-4 rounded-lg border border-border px-4 py-10 text-center">
582 <p class="text-sm text-muted-foreground">"No labels yet."</p>
583 if !listed.viewer_is_owner {
584 <p class="mt-1 text-xs text-muted-foreground">
585 "Only the owner can make one."
586 </p>
587 }
588 </div>
589 } else {
590 <ul class="mt-4 divide-y divide-border rounded-lg border border-border">
591 for row in &listed.labels {
592 label_row(
593 handle: handle,
594 name: name,
595 row: row,
596 editable: listed.viewer_is_owner,
597 open: editing == Some(&row.label.id),
598 )
599 }
600 </ul>
601 }
602 </div>
603 }
604}
605
606/// The add form, in a panel above the list.
607#[component]
608async fn add_form(
609 handle: &str,
610 name: &str,
611 typed: &str,
612 described: &str,
613 colour: LabelColour,
614) -> Result {
615 view! {
616 <form
617 method="post"
618 action=(labels_url(handle, name))
619 class="mt-4 space-y-3 rounded-lg border border-border p-4"
620 >
621 csrf_field()
622 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
623 "New label"
624 </h2>
625
626 // Name and colour on one row: they are the two halves of one decision, and a
627 // row each spent a row on each.
628 <div class="flex flex-wrap items-end gap-x-4 gap-y-3">
629 <div class="min-w-48 flex-1 space-y-2">
630 label(attrs: attributes! { for="label-name" }, "Name")
631 input(attrs: attributes! {
632 id="label-name"
633 name="name"
634 type="text"
635 value=(typed)
636 placeholder="bug"
637 required=(true)
638 autocomplete="off"
639 maxlength=(Label::MAX_NAME_LEN.to_string())
640 })
641 </div>
642 <div class="space-y-2">
643 label(attrs: attributes! { for="" }, "Colour")
644 swatches(field: "colour", chosen: colour)
645 </div>
646 </div>
647
648 <div class="space-y-2">
649 label(attrs: attributes! { for="label-description" }, "Description")
650 input(attrs: attributes! {
651 id="label-description"
652 name="description"
653 type="text"
654 value=(described)
655 placeholder="Something is broken (optional)"
656 autocomplete="off"
657 maxlength=(Label::MAX_DESCRIPTION_LEN.to_string())
658 })
659 </div>
660
661 button(size: ButtonSize::Sm, attrs: attributes! { type="submit" }, "Add label")
662 </form>
663 }
664}
665
666/// One label: the pill, what it means, how much use it gets, and the panel that changes
667/// it.
668///
669/// For the owner the whole row is a `<summary>`, so the panel opens from anywhere on it
670/// rather than from a four-pixel word at the end. The pill and the count are still links
671/// inside it, which a browser resolves in the link's favour: clicking one navigates
672/// rather than toggling. The row **must not** be a `display: contents` disclosure —
673/// tried, and the panel becomes a flex sibling of the row's own items and lands beside
674/// them instead of under them.
675#[component]
676async fn label_row(handle: &str, name: &str, row: &LabelRow, editable: bool, open: bool) -> Result {
677 let id = row.label.id.as_str();
678 let action = |what: &str| format!("{}/{id}/{what}", labels_url(handle, name));
679
680 view! {
681 <li class="px-4 py-2.5 text-sm">
682 if editable {
683 <details open=(open)>
684 <summary class="flex cursor-pointer list-none flex-wrap items-center gap-x-3 gap-y-1.5">
685 label_summary(handle: handle, name: name, row: row)
686 <span class="shrink-0 text-xs text-muted-foreground">"Edit"</span>
687 </summary>
688
689 <div class="mt-3 space-y-4">
690 <form method="post" action=(action("edit")) class="space-y-3">
691 csrf_field()
692 <div class="flex flex-wrap items-end gap-x-4 gap-y-3">
693 <div class="min-w-48 flex-1 space-y-2">
694 label(attrs: attributes! { for=(format!("name-{id}")) }, "Name")
695 input(attrs: attributes! {
696 id=(format!("name-{id}"))
697 name="name"
698 type="text"
699 value=(row.label.name.as_str())
700 required=(true)
701 autocomplete="off"
702 maxlength=(Label::MAX_NAME_LEN.to_string())
703 })
704 </div>
705 <div class="space-y-2">
706 label(attrs: attributes! { for="" }, "Colour")
707 swatches(field: "colour", chosen: row.label.colour)
708 </div>
709 </div>
710
711 <div class="space-y-2">
712 label(
713 attrs: attributes! { for=(format!("description-{id}")) },
714 "Description"
715 )
716 input(attrs: attributes! {
717 id=(format!("description-{id}"))
718 name="description"
719 type="text"
720 value=(row.label.description.as_deref().unwrap_or(""))
721 autocomplete="off"
722 maxlength=(Label::MAX_DESCRIPTION_LEN.to_string())
723 })
724 </div>
725
726 button(
727 size: ButtonSize::Sm,
728 attrs: attributes! { type="submit" },
729 "Save label"
730 )
731 </form>
732
733 // The confirm, without JavaScript: the checkbox is `required`, so
734 // the button cannot delete anything until it is ticked.
735 // Deliberately behind the same disclosure as the edit form, which
736 // is what keeps a stray click on a list row harmless.
737 <form
738 method="post"
739 action=(action("delete"))
740 class="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border pt-3"
741 >
742 csrf_field()
743 <label class="flex items-center gap-2 text-xs text-muted-foreground">
744 <input type="checkbox" name="confirm" value="yes" required=(true) />
745 // What it comes off, counted honestly: a label on nothing
746 // but pull requests must not say "0 issues" and read as
747 // free to delete.
748 if row.issues > 0 || row.pulls > 0 {
749 "Delete this label — it comes off "
750 if row.issues > 0 {
751 (row.issues)
752 if row.issues == 1 { " issue" } else { " issues" }
753 }
754 if row.issues > 0 && row.pulls > 0 { " and " }
755 if row.pulls > 0 {
756 (row.pulls)
757 if row.pulls == 1 {
758 " pull request"
759 } else {
760 " pull requests"
761 }
762 }
763 "."
764 } else {
765 "Delete this label."
766 }
767 </label>
768 button(
769 variant: ButtonVariant::Ghost,
770 size: ButtonSize::Sm,
771 attrs: attributes! {
772 type="submit"
773 class="text-destructive hover:bg-destructive/10"
774 },
775 icon(
776 data: iconify_icon!("feather:trash-2"),
777 attrs: attributes! { class="size-3.5" }
778 )
779 "Delete"
780 )
781 </form>
782 </div>
783 </details>
784 } else {
785 <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5">
786 label_summary(handle: handle, name: name, row: row)
787 </div>
788 }
789 </li>
790 }
791}
792
793/// What a row says whoever is looking at it: the pill, the description, the count.
794#[component]
795async fn label_summary(handle: &str, name: &str, row: &LabelRow) -> Result {
796 view! {
797 label_pill(handle: handle, name: name, pill: &row.label)
798
799 if let Some(description) = row.label.description.as_deref() {
800 <span class="min-w-0 flex-1 truncate text-xs text-muted-foreground">(description)</span>
801 } else {
802 <span class="min-w-0 flex-1"></span>
803 }
804
805 // Both counts, because a label is on two kinds of thing and each links to its
806 // own list. The pull request count is drawn only when there is one: a repository
807 // that labels issues alone should not grow a column of zeroes.
808 <a
809 href=(filtered_issues_url(handle, name, &row.label.name))
810 class="shrink-0 text-xs text-muted-foreground hover:text-foreground"
811 >
812 <span class="font-mono">(row.issues)</span>
813 if row.issues == 1 { " issue" } else { " issues" }
814 </a>
815
816 if row.pulls > 0 {
817 <a
818 href=(filtered_pulls_url(handle, name, &row.label.name))
819 class="shrink-0 text-xs text-muted-foreground hover:text-foreground"
820 >
821 <span class="font-mono">(row.pulls)</span>
822 if row.pulls == 1 { " pull request" } else { " pull requests" }
823 </a>
824 }
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use crate::domain::LabelColour;
831
832 /// The stylesheet, read at compile time. The palette lives in two places that must
833 /// change together — [`LabelColour::ALL`] and the `--label-*` tokens — and nothing
834 /// but this test connects them: a ninth variant with no tokens renders a pill with
835 /// no tint at all, silently, exactly like every other class Tailwind never sees.
836 const STYLES: &str = include_str!("../../../styles.css");
837
838 /// Tokens under the `--label-` prefix that are not colours: the mix ratios and the
839 /// per-pill tint each rule sets. Listed so the orphan check below can tell "a colour
840 /// nothing declares" from "a knob".
841 const NOT_COLOURS: [&str; 4] = ["fill", "edge", "ink", "tint"];
842
843 #[test]
844 fn every_label_colour_has_its_tokens_in_both_schemes() {
845 for colour in LabelColour::ALL {
846 let name = colour.as_str();
847
848 // Both schemes, because a colour defined in one and missing from the other
849 // reads correctly to whoever wrote it and is invisible to everyone else.
850 assert_eq!(
851 STYLES.matches(&format!("--label-{name}: ")).count(),
852 2,
853 "--label-{name} should be defined for the light and dark schemes"
854 );
855 assert!(
856 STYLES.contains(&format!(".label-{name} {{")),
857 "no .label-{name} rule to give a pill its tint"
858 );
859 }
860 }
861
862 #[test]
863 fn no_token_names_a_colour_that_no_longer_exists() {
864 // The other direction: a variant removed from the enum leaves dead tokens, and
865 // a typo in a token name looks exactly the same as a missing one.
866 for line in STYLES.lines() {
867 let Some(rest) = line.trim().strip_prefix("--label-") else {
868 continue;
869 };
870 let Some(name) = rest.split(':').next() else {
871 continue;
872 };
873
874 if NOT_COLOURS.contains(&name) {
875 continue;
876 }
877
878 assert!(
879 LabelColour::ALL
880 .into_iter()
881 .any(|colour| colour.as_str() == name),
882 "--label-{name} names no LabelColour variant"
883 );
884 }
885 }
886}