Board cards show labels and assignees, and the board filters by them (refs #293) 6a306cd03fc1
#293's fourth slice: a card shows its issue's or proposal's labels, each a link to the board filtered by it, and an issue's assignees; the filter takes label:, assignee: and state: with words, as the issue list does. The board reads what its cards show once per repository instead of one query per card. The API's ProjectCard gains labels and assignees (additive), and oh project view prints them.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bbce979e61ca
- When
- · 2026-10-25 08:04 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
6a306cd03fc12d18a52869bd400e9541b4c7392afdc914963dc81492f0c00fdd- Depends on
- 17a3a84b00d1 18e381ea88c2 5c6e92fd8d71 657e7d5c9da9 69e4f27c5633 a7e6e8e6094c b5668108487f b64e2537d702 c79a1111dd56 d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
- Depended on by
- f2d2776b2a14 0bc95135954d 046e3352cdfd 439ecd8708a1
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 25 atoms, 0 tree operations, 9 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Board cards show their labels and assignees, and the board filters by them (#293, filters):** an issue's or proposal's card shows its labels (each a link to the board filtered by it) and an issue's assignees; the filter takes `label:NAME`, `assignee:NAME` and `state:STATE` with words, as the issue list does, every term holding. The board reads what its cards show once per repository rather than once per card (a board of 300 cards made 300 queries). The API's cards and `oh project view` carry the labels and assignees too. |
|
| 8 | + | ||
| 7 | 9 | ## 0.8.0 (2026-10-25) |
|
| 8 | 10 | ||
| 9 | 11 | - **Built on oxy 0.6.0 and OxygenFlow 0.3.1:** tag withdrawals, keyless grants that survive a key rotation, working depth-0 clones of big repositories (oxygen#134, #135, #137; oxygenhub#301). |
|
| … | 295 unchanged lines |
modified crates/hub-api/src/lib.rs +6 −0
| … | 770 unchanged lines |
||
| 771 | 771 | pub title: String, |
|
| 772 | 772 | /// The issue's or proposal's state; empty for a note. |
|
| 773 | 773 | pub state: String, |
|
| 774 | + | /// The issue's or proposal's label names (oxygenhub#293). |
|
| 775 | + | #[serde(default)] |
|
| 776 | + | pub labels: Vec<String>, |
|
| 777 | + | /// An issue's assignees' usernames. |
|
| 778 | + | #[serde(default)] |
|
| 779 | + | pub assignees: Vec<String>, |
|
| 774 | 780 | } |
|
| 775 | 781 | ||
| 776 | 782 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 682 unchanged lines |
modified crates/hub/src/api/projects.rs +9 −1
| … | 23 unchanged lines |
||
| 24 | 24 | } |
|
| 25 | 25 | ||
| 26 | 26 | fn card_of(v: &wp::CardView) -> ProjectCard { |
|
| 27 | - | ProjectCard { id: v.card.id, kind: v.card.kind.clone(), reference: v.reference.clone(), title: v.title.clone(), state: v.state.clone() } |
|
| 27 | + | ProjectCard { |
|
| 28 | + | id: v.card.id, |
|
| 29 | + | kind: v.card.kind.clone(), |
|
| 30 | + | reference: v.reference.clone(), |
|
| 31 | + | title: v.title.clone(), |
|
| 32 | + | state: v.state.clone(), |
|
| 33 | + | labels: v.labels.iter().map(|l| l.name.clone()).collect(), |
|
| 34 | + | assignees: v.assignees.clone(), |
|
| 35 | + | } |
|
| 28 | 36 | } |
|
| 29 | 37 | ||
| 30 | 38 | async fn rights(app: &Shared, c: &Caller, owner: &str) -> Result<wp::Rights, ApiError> { |
|
| … | 235 unchanged lines |
modified crates/hub/src/web/projects.rs +104 −21
| … | 3 unchanged lines |
||
| 4 | 4 | use super::settings::csrf; |
|
| 5 | 5 | use super::{filters, Error, Page, Shell}; |
|
| 6 | 6 | use crate::auth::{roles, Access, Viewer}; |
|
| 7 | - | use crate::db::{self, projects as pdb}; |
|
| 7 | + | use crate::db::{self, issues as idb, projects as pdb}; |
|
| 8 | 8 | use crate::Shared; |
|
| 9 | 9 | use askama::Template; |
|
| 10 | 10 | use axum::extract::{Form, Path, Query, State}; |
|
| … | 172 unchanged lines |
||
| 183 | 183 | pub title: String, |
|
| 184 | 184 | /// `open`, `closed`, `landed`, `draft`… for issues and proposals. |
|
| 185 | 185 | pub state: String, |
|
| 186 | + | /// The issue's or proposal's labels, and an issue's assignees (#293). |
|
| 187 | + | pub labels: Vec<idb::Label>, |
|
| 188 | + | pub assignees: Vec<String>, |
|
| 186 | 189 | } |
|
| 187 | 190 | ||
| 188 | 191 | pub struct ColumnView { |
|
| … | 15 unchanged lines |
||
| 204 | 207 | /// The column shown whole. |
|
| 205 | 208 | #[serde(default)] |
|
| 206 | 209 | all: Option<i64>, |
|
| 207 | - | /// Only cards whose title or reference holds this. |
|
| 210 | + | /// Only cards whose title or reference holds these words, and with |
|
| 211 | + | /// `label:NAME`, `assignee:NAME` or `state:STATE` as the issue list takes. |
|
| 208 | 212 | #[serde(default)] |
|
| 209 | 213 | q: Option<String>, |
|
| 210 | 214 | /// Only cards in this state (`open`, `closed`, `landed`…). |
|
| … | 10 unchanged lines |
||
| 221 | 225 | } |
|
| 222 | 226 | } |
|
| 223 | 227 | ||
| 228 | + | /// The board filter's `label:`, `assignee:` and `state:` terms, and its words. |
|
| 229 | + | #[derive(Default)] |
|
| 230 | + | struct CardFilter { |
|
| 231 | + | labels: Vec<String>, |
|
| 232 | + | assignees: Vec<String>, |
|
| 233 | + | state: String, |
|
| 234 | + | words: Vec<String>, |
|
| 235 | + | } |
|
| 236 | + | ||
| 237 | + | impl CardFilter { |
|
| 238 | + | fn of(view: &BoardQuery) -> CardFilter { |
|
| 239 | + | let mut f = CardFilter { state: view.state(), ..Default::default() }; |
|
| 240 | + | for tok in view.text().split_whitespace() { |
|
| 241 | + | match tok.split_once(':') { |
|
| 242 | + | Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()), |
|
| 243 | + | Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()), |
|
| 244 | + | Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(), |
|
| 245 | + | _ => f.words.push(tok.to_lowercase()), |
|
| 246 | + | } |
|
| 247 | + | } |
|
| 248 | + | f |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | fn is_empty(&self) -> bool { |
|
| 252 | + | self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty() |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | /// Every term holds: each word in the title or reference, each label |
|
| 256 | + | /// and assignee among the card's, and its state. |
|
| 257 | + | fn keeps(&self, c: &CardView) -> bool { |
|
| 258 | + | let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase()); |
|
| 259 | + | self.words.iter().all(|w| title.contains(w) || reference.contains(w)) |
|
| 260 | + | && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l))) |
|
| 261 | + | && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a))) |
|
| 262 | + | && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state)) |
|
| 263 | + | } |
|
| 264 | + | } |
|
| 265 | + | ||
| 224 | 266 | /// Filter and cut the columns as `view` says. |
|
| 225 | 267 | fn shape(columns: &mut [ColumnView], view: &BoardQuery) { |
|
| 226 | - | let (q, state) = (view.text().to_lowercase(), view.state()); |
|
| 268 | + | let filter = CardFilter::of(view); |
|
| 227 | 269 | for col in columns.iter_mut() { |
|
| 228 | 270 | col.total = col.cards.len(); |
|
| 229 | - | if !q.is_empty() || !state.is_empty() { |
|
| 230 | - | col.cards.retain(|c| { |
|
| 231 | - | (q.is_empty() || c.title.to_lowercase().contains(&q) || c.reference.to_lowercase().contains(&q)) |
|
| 232 | - | && (state.is_empty() || c.state.eq_ignore_ascii_case(&state)) |
|
| 233 | - | }); |
|
| 271 | + | if !filter.is_empty() { |
|
| 272 | + | col.cards.retain(|c| filter.keeps(c)); |
|
| 234 | 273 | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) { |
|
| 235 | 274 | // Cards join a column at its end: the newest are the last. |
|
| 236 | 275 | let cut = col.cards.len() - COLUMN_SHOWN; |
|
| … | 75 unchanged lines |
||
| 312 | 351 | } |
|
| 313 | 352 | } |
|
| 314 | 353 | let cards = pdb::cards(&app.db, project.id).await?; |
|
| 354 | + | // What the cards show, read once per repository, not per card: a |
|
| 355 | + | // board of 300 cards made 300 queries. |
|
| 356 | + | let mut shown: std::collections::HashMap<(i64, bool, i64), Shown> = Default::default(); |
|
| 357 | + | let mut repo_ids: Vec<(i64, bool)> = |
|
| 358 | + | cards.iter().filter_map(|c| Some((c.repo_id?, c.kind == "issue"))).filter(|(rid, _)| readable.contains_key(rid)).collect(); |
|
| 359 | + | repo_ids.sort(); |
|
| 360 | + | repo_ids.dedup(); |
|
| 361 | + | for (rid, issues) in repo_ids { |
|
| 362 | + | shown.extend(shown_in(app, rid, issues).await?.into_iter().map(|(n, s)| ((rid, issues, n), s))); |
|
| 363 | + | } |
|
| 315 | 364 | let mut columns = Vec::new(); |
|
| 316 | 365 | for column in pdb::columns(&app.db, project.id).await? { |
|
| 317 | 366 | let mut views = Vec::new(); |
|
| … | 5 unchanged lines |
||
| 323 | 372 | url: String::new(), |
|
| 324 | 373 | title: card.note.clone(), |
|
| 325 | 374 | state: String::new(), |
|
| 375 | + | labels: Vec::new(), |
|
| 376 | + | assignees: Vec::new(), |
|
| 326 | 377 | }), |
|
| 327 | 378 | (kind, Some(rid), Some(n)) => { |
|
| 328 | 379 | // Cards of repositories the viewer cannot read are not shown at all. |
|
| 329 | 380 | let Some(row) = readable.get(&rid) else { continue }; |
|
| 330 | - | let (title, state) = if kind == "issue" { |
|
| 331 | - | match db::issues::get(&app.db, rid, n).await? { |
|
| 332 | - | Some(i) => (i.title, i.state), |
|
| 333 | - | None => continue, |
|
| 334 | - | } |
|
| 335 | - | } else { |
|
| 336 | - | match db::proposals::get(&app.db, rid, n).await? { |
|
| 337 | - | Some(p) => (p.title, p.state), |
|
| 338 | - | None => continue, |
|
| 339 | - | } |
|
| 340 | - | }; |
|
| 381 | + | let Some(s) = shown.get(&(rid, kind == "issue", n)) else { continue }; |
|
| 341 | 382 | let slug = format!("{}/{}", row.owner, row.name); |
|
| 342 | 383 | views.push(CardView { |
|
| 343 | 384 | card: card.clone(), |
|
| 344 | 385 | reference: format!("{slug}#{n}"), |
|
| 345 | 386 | url: format!("/{slug}/{kind}/{n}"), |
|
| 346 | - | title, |
|
| 347 | - | state, |
|
| 387 | + | title: s.title.clone(), |
|
| 388 | + | state: s.state.clone(), |
|
| 389 | + | labels: s.labels.clone(), |
|
| 390 | + | assignees: s.assignees.clone(), |
|
| 348 | 391 | }); |
|
| 349 | 392 | } |
|
| 350 | 393 | _ => {} |
|
| … | 5 unchanged lines |
||
| 356 | 399 | let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
|
| 357 | 400 | repos.sort(); |
|
| 358 | 401 | Ok((columns, repos)) |
|
| 402 | + | } |
|
| 403 | + | ||
| 404 | + | /// What a card shows of an issue or a proposal. |
|
| 405 | + | struct Shown { |
|
| 406 | + | title: String, |
|
| 407 | + | state: String, |
|
| 408 | + | labels: Vec<idb::Label>, |
|
| 409 | + | assignees: Vec<String>, |
|
| 410 | + | } |
|
| 411 | + | ||
| 412 | + | /// A repository's issues (or proposals) as cards show them, by number. |
|
| 413 | + | async fn shown_in(app: &Shared, repo_id: i64, issues: bool) -> sqlx::Result<Vec<(i64, Shown)>> { |
|
| 414 | + | use std::collections::HashMap; |
|
| 415 | + | let mut out = Vec::new(); |
|
| 416 | + | if issues { |
|
| 417 | + | let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new(); |
|
| 418 | + | for (id, l) in idb::labels_by_issue(&app.db, repo_id).await? { |
|
| 419 | + | labels.entry(id).or_default().push(l); |
|
| 420 | + | } |
|
| 421 | + | let mut assignees: HashMap<i64, Vec<String>> = HashMap::new(); |
|
| 422 | + | for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? { |
|
| 423 | + | assignees.entry(id).or_default().push(u); |
|
| 424 | + | } |
|
| 425 | + | for i in idb::list(&app.db, repo_id).await? { |
|
| 426 | + | let mut a = assignees.remove(&i.id).unwrap_or_default(); |
|
| 427 | + | a.sort(); |
|
| 428 | + | let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state }; |
|
| 429 | + | out.push((i.number, s)); |
|
| 430 | + | } |
|
| 431 | + | } else { |
|
| 432 | + | let mut labels: HashMap<i64, Vec<idb::Label>> = HashMap::new(); |
|
| 433 | + | for (id, l) in idb::labels_by_proposal(&app.db, repo_id).await? { |
|
| 434 | + | labels.entry(id).or_default().push(l); |
|
| 435 | + | } |
|
| 436 | + | for p in db::proposals::list(&app.db, repo_id, &[]).await? { |
|
| 437 | + | let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state }; |
|
| 438 | + | out.push((p.number, s)); |
|
| 439 | + | } |
|
| 440 | + | } |
|
| 441 | + | Ok(out) |
|
| 359 | 442 | } |
|
| 360 | 443 | ||
| 361 | 444 | pub async fn show( |
|
| … | 374 unchanged lines |
modified crates/hub/tests/api.rs +1 −0
| … | 923 unchanged lines |
||
| 924 | 924 | (card["kind"].as_str(), card["reference"].as_str(), card["title"].as_str(), card["state"].as_str()), |
|
| 925 | 925 | (Some("issue"), Some("andy/demo#1"), Some("Crash"), Some("open")) |
|
| 926 | 926 | ); |
|
| 927 | + | assert_eq!((card["labels"].as_array().map(Vec::len), card["assignees"].as_array().map(Vec::len)), (Some(0), Some(0)), "{card}"); |
|
| 927 | 928 | let (st, v) = api(app, "POST", "/api/v1/projects/andy/1/cards", Some(&a), Some(r#"{"reference":"demo#1"}"#)).await; |
|
| 928 | 929 | assert_eq!(st, StatusCode::BAD_REQUEST); |
|
| 929 | 930 | assert!(v["detail"].as_str().unwrap().contains("already on this project")); |
|
| … | 812 unchanged lines |
modified crates/hub/tests/issues.rs +61 −0
| … | 1021 unchanged lines |
||
| 1022 | 1022 | assert!(!open.contains("card-0"), "notes have no state"); |
|
| 1023 | 1023 | } |
|
| 1024 | 1024 | ||
| 1025 | + | /// #293: a card shows its issue's labels and assignees, each a link to |
|
| 1026 | + | /// the board filtered by it, and the filter takes `label:`, `assignee:` |
|
| 1027 | + | /// and `state:` with words, every term holding. |
|
| 1025 | 1028 | #[tokio::test(flavor = "multi_thread")] |
|
| 1029 | + | async fn a_board_filters_by_label_and_assignee() { |
|
| 1030 | + | let h = hub().await; |
|
| 1031 | + | let app = &h.app; |
|
| 1032 | + | let andy = register(app, "andy").await; |
|
| 1033 | + | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 1034 | + | post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap")).await; |
|
| 1035 | + | app.repos.create(&RepoName::parse("andy/game").unwrap(), None).unwrap(); |
|
| 1036 | + | let row = db::repos::create(&app.db, "andy", "game", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 1037 | + | let mut ids = Vec::new(); |
|
| 1038 | + | for title in ["Sound crackles", "Jump is floaty", "Title screen"] { |
|
| 1039 | + | let i = db::issues::create(&app.db, db::issues::NewIssue { repo_id: row.id, title, body: "", author_id: None, author: "andy" }) |
|
| 1040 | + | .await |
|
| 1041 | + | .unwrap(); |
|
| 1042 | + | ids.push(i.id); |
|
| 1043 | + | } |
|
| 1044 | + | let bug = db::issues::upsert_label(&app.db, row.id, "bug", "#d73a4a", "Something is wrong").await.unwrap(); |
|
| 1045 | + | let feel = db::issues::upsert_label(&app.db, row.id, "feel", "#0e8a16", "").await.unwrap(); |
|
| 1046 | + | db::issues::set_issue_label(&app.db, ids[0], bug.id, true).await.unwrap(); |
|
| 1047 | + | db::issues::set_issue_label(&app.db, ids[1], bug.id, true).await.unwrap(); |
|
| 1048 | + | db::issues::set_issue_label(&app.db, ids[1], feel.id, true).await.unwrap(); |
|
| 1049 | + | let (andy_id,): (i64,) = sqlx::query_as("SELECT id FROM accounts WHERE username = 'andy'").fetch_one(&app.db).await.unwrap(); |
|
| 1050 | + | db::issues::set_assignee(&app.db, ids[1], andy_id, true).await.unwrap(); |
|
| 1051 | + | let (project, todo): (i64, i64) = |
|
| 1052 | + | sqlx::query_as("SELECT p.id, c.id FROM projects p JOIN project_columns c ON c.project_id = p.id ORDER BY c.position LIMIT 1") |
|
| 1053 | + | .fetch_one(&app.db) |
|
| 1054 | + | .await |
|
| 1055 | + | .unwrap(); |
|
| 1056 | + | for n in 1..=3i64 { |
|
| 1057 | + | sqlx::query( |
|
| 1058 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, created_ms) VALUES ($1, $2, $3, 'issue', $4, $5, '', 0)", |
|
| 1059 | + | ) |
|
| 1060 | + | .bind(project) |
|
| 1061 | + | .bind(todo) |
|
| 1062 | + | .bind(n) |
|
| 1063 | + | .bind(row.id) |
|
| 1064 | + | .bind(n) |
|
| 1065 | + | .execute(&app.db) |
|
| 1066 | + | .await |
|
| 1067 | + | .unwrap(); |
|
| 1068 | + | } |
|
| 1069 | + | let board = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 1070 | + | assert!(board.contains("href=\"/projects/andy/1?q=label:bug\""), "{board}"); |
|
| 1071 | + | assert!(board.contains("title=\"Something is wrong\">bug</a>"), "a label's description is its title"); |
|
| 1072 | + | assert!(board.contains(">@andy</a>"), "{board}"); |
|
| 1073 | + | let bugs = get(app, "/projects/andy/1?q=label:bug", Some(&andy)).await.body; |
|
| 1074 | + | assert!(bugs.contains("Sound crackles") && bugs.contains("Jump is floaty") && !bugs.contains("Title screen"), "{bugs}"); |
|
| 1075 | + | assert!(bugs.contains("2 of 3")); |
|
| 1076 | + | let both = get(app, "/projects/andy/1?q=label:BUG+label:feel", Some(&andy)).await.body; |
|
| 1077 | + | assert!(!both.contains("Sound crackles") && both.contains("Jump is floaty"), "every label must hold"); |
|
| 1078 | + | let mine = get(app, "/projects/andy/1?q=assignee:%40andy", Some(&andy)).await.body; |
|
| 1079 | + | assert!(mine.contains("Jump is floaty") && !mine.contains("Sound crackles"), "{mine}"); |
|
| 1080 | + | let words = get(app, "/projects/andy/1?q=label:bug+sound", Some(&andy)).await.body; |
|
| 1081 | + | assert!(words.contains("Sound crackles") && !words.contains("Jump is floaty")); |
|
| 1082 | + | let closed = get(app, "/projects/andy/1?q=state:closed", Some(&andy)).await.body; |
|
| 1083 | + | assert!(closed.contains("0 of 3"), "{closed}"); |
|
| 1084 | + | } |
|
| 1085 | + | ||
| 1086 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1026 | 1087 | async fn issue_markdown_follows_the_default_bookmark() { |
|
| 1027 | 1088 | let h = hub().await; |
|
| 1028 | 1089 | let app = &h.app; |
|
| … | 242 unchanged lines |
modified crates/oh/src/main.rs +4 −1
| … | 1748 unchanged lines |
||
| 1749 | 1749 | for card in &col.cards { |
|
| 1750 | 1750 | let what = if card.reference.is_empty() { ui::dim("note") } else { card.reference.clone() }; |
|
| 1751 | 1751 | let st = if card.state.is_empty() { String::new() } else { format!(" {}", ui::dim(&card.state)) }; |
|
| 1752 | - | println!(" {} {what}{st} {}", ui::dim(&format!("{:>4}", card.id)), card.title); |
|
| 1752 | + | let mut tags: Vec<String> = card.labels.iter().map(|l| format!("[{l}]")).collect(); |
|
| 1753 | + | tags.extend(card.assignees.iter().map(|a| format!("@{a}"))); |
|
| 1754 | + | let tags = if tags.is_empty() { String::new() } else { format!(" {}", ui::dim(&tags.join(" "))) }; |
|
| 1755 | + | println!(" {} {what}{st} {}{tags}", ui::dim(&format!("{:>4}", card.id)), card.title); |
|
| 1753 | 1756 | } |
|
| 1754 | 1757 | } |
|
| 1755 | 1758 | println!("{}", ui::dim(&format!("\n{}", b.project.url))); |
|
| … | 307 unchanged lines |
modified templates/project.html +3 −2
| … | 6 unchanged lines |
||
| 7 | 7 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | 8 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | 9 | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search"> |
|
| 10 | - | <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter cards"> |
|
| 10 | + | <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, label:bug, assignee:andy" title="Words in the title or reference; label:NAME, assignee:NAME and state:STATE as on the issue list"> |
|
| 11 | 11 | <label class="visually-hidden" for="board-state">State</label><select id="board-state" name="state"><option value="">any state</option>{% for s in ["open", "closed", "landed", "draft"] %}<option{% if state == *s %} selected{% endif %}>{{ s }}</option>{% endfor %}</select> |
|
| 12 | 12 | <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}">clear</a>{% endif %} |
|
| 13 | 13 | </form> |
|
| … | 8 unchanged lines |
||
| 22 | 22 | <li class="card-item{% if c.card.kind == "note" %} note{% endif %}"> |
|
| 23 | 23 | {% if c.card.kind == "note" %}<p>{{ c.title }}</p> |
|
| 24 | 24 | {% else %}<a href="{{ c.url }}">{{ c.title }}</a> |
|
| 25 | - | <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span></p>{% endif %} |
|
| 25 | + | <p class="small mute"><span class="glyph" aria-hidden="true">{% if c.card.kind == "issue" %}◇{% else %}⇄{% endif %}</span> {{ c.reference }} · <span class="state-{{ c.state }}">{{ c.state }}</span>{% for a in c.assignees %} · <a class="card-who" href="/projects/{{ project.owner }}/{{ project.number }}?q=assignee:{{ a|urlencode }}" title="Cards assigned to {{ a }}">@{{ a }}</a>{% endfor %}</p> |
|
| 26 | + | {% if !c.labels.is_empty() %}<p class="card-labels">{% for l in c.labels %}<a class="label" style="--c: {{ l.colour }}" href="/projects/{{ project.owner }}/{{ project.number }}?q=label:{{ l.name|urlencode }}" title="{% if l.description.is_empty() %}Cards labelled {{ l.name }}{% else %}{{ l.description }}{% endif %}">{{ l.name }}</a>{% endfor %}</p>{% endif %}{% endif %} |
|
| 26 | 27 | {% if can_edit %} |
|
| 27 | 28 | <form class="tiny card-actions" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ c.card.id }}"> |
|
| 28 | 29 | <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label> |
|
| … | 47 unchanged lines |
modified templates/style.css +4 −0
| … | 621 unchanged lines |
||
| 622 | 622 | .card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; } |
|
| 623 | 623 | .card-item.note { border-left: 3px solid var(--warn); } |
|
| 624 | 624 | .card-item p { margin: 4px 0 0; } |
|
| 625 | + | .card-item .card-labels { display: flex; flex-wrap: wrap; gap: 4px; } |
|
| 626 | + | .card-item .card-labels .label { margin-right: 0; text-decoration: none; } |
|
| 627 | + | .card-item .card-labels .label:hover, .card-item .card-labels .label:focus-visible { background: color-mix(in srgb, var(--c, var(--acc)) 14%, transparent); } |
|
| 628 | + | .card-item .card-who { color: inherit; } |
|
| 625 | 629 | .card-item .card-actions { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; align-items: center; } |
|
| 626 | 630 | .card-item .card-actions select { width: auto; margin: 0; padding: 1px 4px; font-size: 12px; } |
|
| 627 | 631 | .board .add-card { margin-top: 8px; } |
|
| … | 816 unchanged lines |