Board views: saved views, filter bar, lanes, WIP limits, table, keys, card history (refs #361) 0bc95135954d
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key fccab701eeea
- When
- · 2026-11-03 23:28 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
0bc95135954de9e9011169e25ec4dcdfbf6b7746800ffa79d0b580e0f08f2d88- Depends on
- 046e3352cdfd 1000b40a2629 119fd17d213f 17a3a84b00d1 18e381ea88c2 306aec1839aa 3b1bc27fe805 439ecd8708a1 456f4197ade3 50928fab5ffc 5c6e92fd8d71 5d90e0e045e2 64e5964e9f13 657e7d5c9da9 6a306cd03fc1 93be9ce4653e 946fe7a09faa a1b9869565aa a7aa76ac52d7 b5668108487f d23464c0ac60 e1ec9fbf538e ef20fdc14e8b
- Depended on by
- fb8f35d025ab 40a43921ef42 f2d2776b2a14
- In bookmarks
- main
- In tags
- v0.9.24
- Edits
- 92 atoms, 2 tree operations, 13 files
Browse the tree at this change
added crates/hub/migrations-pg/0105_board_views.sql +34 −0
| 1 | + | -- Board views and history (oxygenhub#361, #357): a column's work-in-progress |
|
| 2 | + | -- limit; a board's saved views, each a layout with its filter, lanes and |
|
| 3 | + | -- sort; and every card's moves (added, moved, archived, restored, removed), |
|
| 4 | + | -- from which cumulative flow, cycle time and forecasts are drawn. A move to |
|
| 5 | + | -- no column (NULL) takes the card off the board. Cards already on boards |
|
| 6 | + | -- start their history where they are now. |
|
| 7 | + | ALTER TABLE project_columns ADD COLUMN wip_limit BIGINT; |
|
| 8 | + | ||
| 9 | + | CREATE TABLE project_views ( |
|
| 10 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 11 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 12 | + | name TEXT NOT NULL, |
|
| 13 | + | layout TEXT NOT NULL DEFAULT 'board', |
|
| 14 | + | q TEXT NOT NULL DEFAULT '', |
|
| 15 | + | lane TEXT NOT NULL DEFAULT '', |
|
| 16 | + | sort TEXT NOT NULL DEFAULT '', |
|
| 17 | + | position BIGINT NOT NULL, |
|
| 18 | + | created_ms BIGINT NOT NULL, |
|
| 19 | + | UNIQUE (project_id, name) |
|
| 20 | + | ); |
|
| 21 | + | ||
| 22 | + | CREATE TABLE project_card_moves ( |
|
| 23 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 24 | + | project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 25 | + | card_id BIGINT NOT NULL, |
|
| 26 | + | column_id BIGINT, |
|
| 27 | + | at_ms BIGINT NOT NULL |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX project_card_moves_by_project ON project_card_moves (project_id, at_ms); |
|
| 30 | + | ||
| 31 | + | INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) |
|
| 32 | + | SELECT project_id, id, column_id, moved_ms FROM project_cards; |
|
| 33 | + | INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) |
|
| 34 | + | SELECT project_id, id, NULL, archived_ms FROM project_cards WHERE archived_ms IS NOT NULL; |
added crates/hub/migrations/0105_board_views.sql +34 −0
| 1 | + | -- Board views and history (oxygenhub#361, #357): a column's work-in-progress |
|
| 2 | + | -- limit; a board's saved views, each a layout with its filter, lanes and |
|
| 3 | + | -- sort; and every card's moves (added, moved, archived, restored, removed), |
|
| 4 | + | -- from which cumulative flow, cycle time and forecasts are drawn. A move to |
|
| 5 | + | -- no column (NULL) takes the card off the board. Cards already on boards |
|
| 6 | + | -- start their history where they are now. |
|
| 7 | + | ALTER TABLE project_columns ADD COLUMN wip_limit INTEGER; |
|
| 8 | + | ||
| 9 | + | CREATE TABLE project_views ( |
|
| 10 | + | id INTEGER PRIMARY KEY, |
|
| 11 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 12 | + | name TEXT NOT NULL, |
|
| 13 | + | layout TEXT NOT NULL DEFAULT 'board', |
|
| 14 | + | q TEXT NOT NULL DEFAULT '', |
|
| 15 | + | lane TEXT NOT NULL DEFAULT '', |
|
| 16 | + | sort TEXT NOT NULL DEFAULT '', |
|
| 17 | + | position INTEGER NOT NULL, |
|
| 18 | + | created_ms INTEGER NOT NULL, |
|
| 19 | + | UNIQUE (project_id, name) |
|
| 20 | + | ); |
|
| 21 | + | ||
| 22 | + | CREATE TABLE project_card_moves ( |
|
| 23 | + | id INTEGER PRIMARY KEY, |
|
| 24 | + | project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, |
|
| 25 | + | card_id INTEGER NOT NULL, |
|
| 26 | + | column_id INTEGER, |
|
| 27 | + | at_ms INTEGER NOT NULL |
|
| 28 | + | ); |
|
| 29 | + | CREATE INDEX project_card_moves_by_project ON project_card_moves (project_id, at_ms); |
|
| 30 | + | ||
| 31 | + | INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) |
|
| 32 | + | SELECT project_id, id, column_id, moved_ms FROM project_cards; |
|
| 33 | + | INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) |
|
| 34 | + | SELECT project_id, id, NULL, archived_ms FROM project_cards WHERE archived_ms IS NOT NULL; |
modified CHANGELOG.md +11 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **Board views (#361):** a board shows what matters now and other views of the same cards. |
|
| 8 | + | - **Saved views** as tabs across the top: a layout with its filter, lanes and order, saved under a name and shared by its address (`?view=NAME`). |
|
| 9 | + | - **One filter bar** for every view: words, `repo:`, `assignee:`, `label:`, `milestone:` (quote a title with spaces), `state:`, `is:open|closed`, `age:>14d` and `age:<7d`. |
|
| 10 | + | - **Board**: swimlanes by repository, assignee, label or milestone; a **work-in-progress limit** per column, whose header turns to the warning colour past it; **Done shows this iteration's work** while one is open, saying how many cards finished before it; and **compact cards** (labels as dots). |
|
| 11 | + | - **Table**: every card as a row (title, reference, state, column, assignees, labels, milestone, opened), sortable, grouped, a column changed in place, 50 rows a page. |
|
| 12 | + | - **Keys**: `j`/`k` between cards, `Enter` opens one, `m` moves it, `/` filters. |
|
| 13 | + | - **History**: every card's moves (added, moved, archived, restored, removed) are kept from now on, for the charts and forecasts of #357. |
|
| 14 | + | - The API answers `GET /projects/{owner}/{n}?q=` or `?view=NAME` with the filter applied, and lists the board's views and each column's limit; `oh project view N --filter TEXT` or `--view NAME`. |
|
| 15 | + | ||
| 5 | 16 | ## 0.9.23 (2026-11-03) |
|
| 6 | 17 | ||
| 7 | 18 | - **A push no longer stops main's running run (#369):** a push cancels its bookmark's older push runs, and those already running too unless Settings › OxygenFlow keeps them; on the default bookmark, where releases are cut from the run of their commit, a running push run now always finishes, and only queued ones are cancelled. A release commit's run lost to the next push is what prompted it. |
|
| … | 454 unchanged lines |
modified assets/hub.js +51 −0
| … | 1019 unchanged lines |
||
| 1020 | 1020 | setTimeout(ask, 1500); |
|
| 1021 | 1021 | })(); |
|
| 1022 | 1022 | })(); |
|
| 1023 | + | ||
| 1024 | + | // Board keys (oxygenhub#361): j/k move between cards, Enter opens one, m |
|
| 1025 | + | // moves it (focuses its column choice), / goes to the filter. Not while |
|
| 1026 | + | // typing in a field. |
|
| 1027 | + | (function () { |
|
| 1028 | + | var filter = document.querySelector("[data-board-filter]"); |
|
| 1029 | + | if (!filter) return; |
|
| 1030 | + | function cards() { |
|
| 1031 | + | return Array.prototype.slice.call(document.querySelectorAll(".card-item[data-card]")); |
|
| 1032 | + | } |
|
| 1033 | + | function typing(el) { |
|
| 1034 | + | return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable); |
|
| 1035 | + | } |
|
| 1036 | + | document.addEventListener("keydown", function (e) { |
|
| 1037 | + | if (e.ctrlKey || e.metaKey || e.altKey || typing(document.activeElement)) return; |
|
| 1038 | + | var list = cards(); |
|
| 1039 | + | var at = list.indexOf(document.activeElement && document.activeElement.closest ? document.activeElement.closest(".card-item[data-card]") : null); |
|
| 1040 | + | if (e.key === "/") { |
|
| 1041 | + | e.preventDefault(); |
|
| 1042 | + | filter.focus(); |
|
| 1043 | + | filter.select(); |
|
| 1044 | + | } else if (e.key === "j" || e.key === "k") { |
|
| 1045 | + | if (!list.length) return; |
|
| 1046 | + | e.preventDefault(); |
|
| 1047 | + | var next = at < 0 ? 0 : Math.max(0, Math.min(list.length - 1, at + (e.key === "j" ? 1 : -1))); |
|
| 1048 | + | list[next].focus(); |
|
| 1049 | + | list[next].scrollIntoView({ block: "nearest", inline: "nearest" }); |
|
| 1050 | + | } else if (e.key === "Enter" && at >= 0) { |
|
| 1051 | + | var href = list[at].getAttribute("data-href"); |
|
| 1052 | + | if (href) { |
|
| 1053 | + | e.preventDefault(); |
|
| 1054 | + | window.location.href = href; |
|
| 1055 | + | } |
|
| 1056 | + | } else if (e.key === "m" && at >= 0) { |
|
| 1057 | + | var sel = list[at].querySelector("select[name=column]"); |
|
| 1058 | + | if (sel) { |
|
| 1059 | + | e.preventDefault(); |
|
| 1060 | + | if (list[at].closest(".compact")) list[at].querySelector(".card-actions").style.display = "flex"; |
|
| 1061 | + | sel.focus(); |
|
| 1062 | + | } |
|
| 1063 | + | } |
|
| 1064 | + | }); |
|
| 1065 | + | // A table row's column choice moves the card when changed. |
|
| 1066 | + | document.querySelectorAll("select[data-autosubmit]").forEach(function (sel) { |
|
| 1067 | + | sel.addEventListener("change", function () { |
|
| 1068 | + | var form = sel.form; |
|
| 1069 | + | var btn = form && form.querySelector("button[name=action][value=move]"); |
|
| 1070 | + | if (btn) btn.click(); |
|
| 1071 | + | }); |
|
| 1072 | + | }); |
|
| 1073 | + | })(); |
modified crates/hub-api/src/lib.rs +24 −0
| … | 1078 unchanged lines |
||
| 1079 | 1079 | pub id: i64, |
|
| 1080 | 1080 | pub name: String, |
|
| 1081 | 1081 | pub cards: Vec<ProjectCard>, |
|
| 1082 | + | /// Its work-in-progress limit, when it has one (#361). |
|
| 1083 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1084 | + | pub wip_limit: Option<i64>, |
|
| 1082 | 1085 | } |
|
| 1083 | 1086 | ||
| 1087 | + | /// A board's saved view (#361): `GET /projects/{owner}/{n}?view=NAME` |
|
| 1088 | + | /// gives the board with its filter applied. |
|
| 1089 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1090 | + | pub struct ProjectView { |
|
| 1091 | + | pub name: String, |
|
| 1092 | + | /// `board` or `table`. |
|
| 1093 | + | pub layout: String, |
|
| 1094 | + | pub q: String, |
|
| 1095 | + | #[serde(default)] |
|
| 1096 | + | pub lane: String, |
|
| 1097 | + | #[serde(default)] |
|
| 1098 | + | pub sort: String, |
|
| 1099 | + | } |
|
| 1100 | + | ||
| 1084 | 1101 | /// `POST /projects/{owner}/{n}/columns` (#366): a column at the right. |
|
| 1085 | 1102 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1086 | 1103 | pub struct NewColumn { |
|
| … | 16 unchanged lines |
||
| 1103 | 1120 | pub struct Board { |
|
| 1104 | 1121 | pub project: Project, |
|
| 1105 | 1122 | pub columns: Vec<ProjectColumn>, |
|
| 1123 | + | /// Its saved views (#361). |
|
| 1124 | + | #[serde(default)] |
|
| 1125 | + | pub views: Vec<ProjectView>, |
|
| 1126 | + | /// The filter applied (`?q=`, or the `?view=`'s), when one was. |
|
| 1127 | + | #[serde(default, skip_serializing_if = "String::is_empty")] |
|
| 1128 | + | pub filter: String, |
|
| 1106 | 1129 | } |
|
| 1107 | 1130 | ||
| 1108 | 1131 | /// `POST /projects/{owner}`. |
|
| … | 521 unchanged lines |
||
| 1630 | 1653 | ("NewProject", schemars::schema_for!(NewProject)), |
|
| 1631 | 1654 | ("ProjectEdit", schemars::schema_for!(ProjectEdit)), |
|
| 1632 | 1655 | ("NewColumn", schemars::schema_for!(NewColumn)), |
|
| 1656 | + | ("ProjectView", schemars::schema_for!(ProjectView)), |
|
| 1633 | 1657 | ("ColumnEdit", schemars::schema_for!(ColumnEdit)), |
|
| 1634 | 1658 | ("NewCard", schemars::schema_for!(NewCard)), |
|
| 1635 | 1659 | ("CardMove", schemars::schema_for!(CardMove)), |
|
| … | 334 unchanged lines |
modified crates/hub/src/api/projects.rs +52 −4
| … | 108 unchanged lines |
||
| 109 | 109 | Ok((StatusCode::CREATED, Json(project_of(&app, &p)))) |
|
| 110 | 110 | } |
|
| 111 | 111 | ||
| 112 | + | /// `GET /projects/{owner}/{n}`'s filter (#361): `q` as the board's filter |
|
| 113 | + | /// bar takes it, or the `view` saved under that name. |
|
| 114 | + | #[derive(serde::Deserialize, Default)] |
|
| 115 | + | pub struct BoardFilter { |
|
| 116 | + | #[serde(default)] |
|
| 117 | + | q: Option<String>, |
|
| 118 | + | #[serde(default)] |
|
| 119 | + | view: Option<String>, |
|
| 120 | + | } |
|
| 121 | + | ||
| 112 | 122 | /// `GET /projects/{owner}/{n}`. |
|
| 113 | 123 | pub async fn board( |
|
| 114 | 124 | State(app): State<Shared>, |
|
| 115 | 125 | headers: HeaderMap, |
|
| 116 | 126 | Path((owner, number)): Path<(String, i64)>, |
|
| 127 | + | Query(f): Query<BoardFilter>, |
|
| 117 | 128 | ) -> Result<Json<Board>, ApiError> { |
|
| 118 | 129 | let c = caller(&app, &headers).await?; |
|
| 119 | - | board_of(&app, &c, &owner, number).await |
|
| 130 | + | let (_, p) = visible(&app, &c, &owner, number).await?; |
|
| 131 | + | let q = match (f.q, f.view) { |
|
| 132 | + | (Some(q), _) => q, |
|
| 133 | + | (None, Some(name)) => pdb::views(&app.db, p.id) |
|
| 134 | + | .await? |
|
| 135 | + | .into_iter() |
|
| 136 | + | .find(|v| v.name == name) |
|
| 137 | + | .map(|v| v.q) |
|
| 138 | + | .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no view {name}")))?, |
|
| 139 | + | (None, None) => String::new(), |
|
| 140 | + | }; |
|
| 141 | + | let Json(mut b) = board_of(&app, &c, &owner, number).await?; |
|
| 142 | + | if !q.trim().is_empty() { |
|
| 143 | + | let filter = wp::CardFilter::parse(&q); |
|
| 144 | + | let now = crate::db::now_ms(); |
|
| 145 | + | let (columns, _) = wp::board_columns(&app, c.account.as_ref(), &owner, &p, false).await?; |
|
| 146 | + | for (out, col) in b.columns.iter_mut().zip(columns.iter()) { |
|
| 147 | + | out.cards = col.cards.iter().filter(|card| filter.keeps(card, now)).map(card_of).collect(); |
|
| 148 | + | } |
|
| 149 | + | b.filter = q.trim().to_string(); |
|
| 150 | + | } |
|
| 151 | + | Ok(Json(b)) |
|
| 120 | 152 | } |
|
| 121 | 153 | ||
| 122 | 154 | /// `PATCH /projects/{owner}/{n}`. |
|
| … | 41 unchanged lines |
||
| 164 | 196 | project: project_of(app, &p), |
|
| 165 | 197 | columns: columns |
|
| 166 | 198 | .iter() |
|
| 167 | - | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
|
| 199 | + | .map(|col| ProjectColumn { |
|
| 200 | + | id: col.column.id, |
|
| 201 | + | name: col.column.name.clone(), |
|
| 202 | + | cards: col.cards.iter().map(card_of).collect(), |
|
| 203 | + | wip_limit: col.column.wip_limit, |
|
| 204 | + | }) |
|
| 205 | + | .collect(), |
|
| 206 | + | views: pdb::views(&app.db, p.id) |
|
| 207 | + | .await? |
|
| 208 | + | .into_iter() |
|
| 209 | + | .map(|v| hub_api::ProjectView { name: v.name, layout: v.layout, q: v.q, lane: v.lane, sort: v.sort }) |
|
| 168 | 210 | .collect(), |
|
| 211 | + | filter: String::new(), |
|
| 169 | 212 | })) |
|
| 170 | 213 | } |
|
| 171 | 214 | ||
| … | 90 unchanged lines |
||
| 262 | 305 | Query(q): Query<CardsQuery>, |
|
| 263 | 306 | ) -> Result<Json<Vec<ProjectColumn>>, ApiError> { |
|
| 264 | 307 | if !q.archived { |
|
| 265 | - | return Ok(Json(board(State(app), headers, Path((owner, number))).await?.0.columns)); |
|
| 308 | + | return Ok(Json(board(State(app), headers, Path((owner, number)), Query(BoardFilter::default())).await?.0.columns)); |
|
| 266 | 309 | } |
|
| 267 | 310 | let c = caller(&app, &headers).await?; |
|
| 268 | 311 | let (_, p) = visible(&app, &c, &owner, number).await?; |
|
| … | 1 unchanged lines |
||
| 270 | 313 | Ok(Json( |
|
| 271 | 314 | columns |
|
| 272 | 315 | .iter() |
|
| 273 | - | .map(|col| ProjectColumn { id: col.column.id, name: col.column.name.clone(), cards: col.cards.iter().map(card_of).collect() }) |
|
| 316 | + | .map(|col| ProjectColumn { |
|
| 317 | + | id: col.column.id, |
|
| 318 | + | name: col.column.name.clone(), |
|
| 319 | + | cards: col.cards.iter().map(card_of).collect(), |
|
| 320 | + | wip_limit: col.column.wip_limit, |
|
| 321 | + | }) |
|
| 274 | 322 | .collect(), |
|
| 275 | 323 | )) |
|
| 276 | 324 | } |
|
| … | 156 unchanged lines |
modified crates/hub/src/db/projects.rs +188 −18
| … | 26 unchanged lines |
||
| 27 | 27 | /// Cards whose issue closes or whose proposal lands or closes move here |
|
| 28 | 28 | /// (#325); one column of a board at most. |
|
| 29 | 29 | pub takes_finished: bool, |
|
| 30 | + | /// Work in progress past this many cards turns its header amber (#361). |
|
| 31 | + | #[sqlx(default)] |
|
| 32 | + | pub wip_limit: Option<i64>, |
|
| 30 | 33 | } |
|
| 31 | 34 | ||
| 32 | 35 | #[derive(Clone, Debug, FromRow)] |
|
| … | 205 unchanged lines |
||
| 238 | 241 | .bind(to.id) |
|
| 239 | 242 | .fetch_one(&mut *tx) |
|
| 240 | 243 | .await?; |
|
| 244 | + | log_moves(&mut *tx, Some(to.id), now_ms(), "column_id = $1 AND archived_ms IS NULL", &[column_id]).await?; |
|
| 241 | 245 | sqlx::query("UPDATE project_cards SET column_id = $1, position = position + $2 WHERE column_id = $3") |
|
| 242 | 246 | .bind(to.id) |
|
| 243 | 247 | .bind(base) |
|
| … | 6 unchanged lines |
||
| 250 | 254 | Ok(true) |
|
| 251 | 255 | } |
|
| 252 | 256 | ||
| 257 | + | /// Record, for every card `select` picks (`SELECT project_id, id FROM |
|
| 258 | + | /// project_cards WHERE …`, bound from `$1` on), that it moved to `column` |
|
| 259 | + | /// (`None`: off the board) at `at` (#357). Called before the update that |
|
| 260 | + | /// moves them, with the same conditions. |
|
| 261 | + | async fn log_moves<'e, E: sqlx::Executor<'e, Database = super::Db>>( |
|
| 262 | + | ex: E, |
|
| 263 | + | column: Option<i64>, |
|
| 264 | + | at: i64, |
|
| 265 | + | select: &str, |
|
| 266 | + | binds: &[i64], |
|
| 267 | + | ) -> sqlx::Result<()> { |
|
| 268 | + | let sql = format!( |
|
| 269 | + | "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, {}, {at} FROM project_cards WHERE {select}", |
|
| 270 | + | match column { |
|
| 271 | + | Some(c) => c.to_string(), |
|
| 272 | + | None => "NULL".to_string(), |
|
| 273 | + | } |
|
| 274 | + | ); |
|
| 275 | + | let mut q = sqlx::query(&sql); |
|
| 276 | + | for b in binds { |
|
| 277 | + | q = q.bind(*b); |
|
| 278 | + | } |
|
| 279 | + | q.execute(ex).await?; |
|
| 280 | + | Ok(()) |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | /// A board's card moves since `since`, oldest first: (card, column or |
|
| 284 | + | /// `None` off the board, when). |
|
| 285 | + | pub async fn moves(pool: &Pool, project_id: i64, since: i64) -> sqlx::Result<Vec<(i64, Option<i64>, i64)>> { |
|
| 286 | + | sqlx::query_as("SELECT card_id, column_id, at_ms FROM project_card_moves WHERE project_id = $1 AND at_ms >= $2 ORDER BY at_ms, id") |
|
| 287 | + | .bind(project_id) |
|
| 288 | + | .bind(since) |
|
| 289 | + | .fetch_all(pool) |
|
| 290 | + | .await |
|
| 291 | + | } |
|
| 292 | + | ||
| 253 | 293 | pub struct NewCard<'a> { |
|
| 254 | 294 | pub column_id: i64, |
|
| 255 | 295 | pub kind: &'a str, |
|
| … | 10 unchanged lines |
||
| 266 | 306 | .bind(c.column_id) |
|
| 267 | 307 | .fetch_one(pool) |
|
| 268 | 308 | .await?; |
|
| 269 | - | let r = sqlx::query( |
|
| 270 | - | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING", |
|
| 309 | + | let now = now_ms(); |
|
| 310 | + | let id: Option<i64> = sqlx::query_scalar( |
|
| 311 | + | "INSERT INTO project_cards (project_id, column_id, position, kind, repo_id, number, note, added_by, created_ms, moved_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) ON CONFLICT DO NOTHING RETURNING id", |
|
| 271 | 312 | ) |
|
| 272 | 313 | .bind(project_id) |
|
| 273 | 314 | .bind(c.column_id) |
|
| … | 3 unchanged lines |
||
| 277 | 318 | .bind(c.number) |
|
| 278 | 319 | .bind(c.note) |
|
| 279 | 320 | .bind(c.added_by) |
|
| 280 | - | .bind(now_ms()) |
|
| 281 | - | .execute(pool) |
|
| 321 | + | .bind(now) |
|
| 322 | + | .fetch_optional(pool) |
|
| 282 | 323 | .await?; |
|
| 324 | + | if let Some(id) = id { |
|
| 325 | + | log_moves(pool, Some(c.column_id), now, "id = $1", &[id]).await?; |
|
| 326 | + | } |
|
| 283 | 327 | touch(pool, project_id).await?; |
|
| 284 | - | Ok(r.rows_affected() == 1) |
|
| 328 | + | Ok(id.is_some()) |
|
| 285 | 329 | } |
|
| 286 | 330 | ||
| 287 | 331 | /// Move a card to the bottom of another column of the same project. |
|
| … | 10 unchanged lines |
||
| 298 | 342 | .bind(column_id) |
|
| 299 | 343 | .fetch_one(pool) |
|
| 300 | 344 | .await?; |
|
| 345 | + | let now = now_ms(); |
|
| 346 | + | log_moves( |
|
| 347 | + | pool, |
|
| 348 | + | Some(column_id), |
|
| 349 | + | now, |
|
| 350 | + | "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL", |
|
| 351 | + | &[card_id, project_id, column_id], |
|
| 352 | + | ) |
|
| 353 | + | .await?; |
|
| 301 | 354 | sqlx::query("UPDATE project_cards SET moved_ms = CASE WHEN column_id = $1 THEN moved_ms ELSE $3 END, column_id = $1, position = $2 WHERE id = $4 AND project_id = $5") |
|
| 302 | 355 | .bind(column_id) |
|
| 303 | 356 | .bind(pos) |
|
| 304 | - | .bind(now_ms()) |
|
| 357 | + | .bind(now) |
|
| 305 | 358 | .bind(card_id) |
|
| 306 | 359 | .bind(project_id) |
|
| 307 | 360 | .execute(pool) |
|
| … | 11 unchanged lines |
||
| 319 | 372 | .await?; |
|
| 320 | 373 | let now = now_ms(); |
|
| 321 | 374 | for (i, id) in card_ids.iter().enumerate() { |
|
| 375 | + | log_moves( |
|
| 376 | + | &mut *tx, |
|
| 377 | + | Some(column_id), |
|
| 378 | + | now, |
|
| 379 | + | "id = $1 AND project_id = $2 AND column_id <> $3 AND archived_ms IS NULL", |
|
| 380 | + | &[*id, project_id, column_id], |
|
| 381 | + | ) |
|
| 382 | + | .await?; |
|
| 322 | 383 | sqlx::query("UPDATE project_cards SET column_id = $1, position = $2, moved_ms = $3 WHERE id = $4 AND project_id = $5") |
|
| 323 | 384 | .bind(column_id) |
|
| 324 | 385 | .bind(pos + i as i64) |
|
| … | 13 unchanged lines |
||
| 338 | 399 | let now = now_ms(); |
|
| 339 | 400 | let mut n = 0; |
|
| 340 | 401 | for id in card_ids { |
|
| 402 | + | log_moves(&mut *tx, None, now, "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[*id, project_id]).await?; |
|
| 341 | 403 | n += sqlx::query("UPDATE project_cards SET archived_ms = $1 WHERE id = $2 AND project_id = $3 AND archived_ms IS NULL") |
|
| 342 | 404 | .bind(now) |
|
| 343 | 405 | .bind(id) |
|
| … | 19 unchanged lines |
||
| 363 | 425 | .fetch_one(&mut *tx) |
|
| 364 | 426 | .await?; |
|
| 365 | 427 | let now = now_ms(); |
|
| 428 | + | sqlx::query(&format!( |
|
| 429 | + | "INSERT INTO project_card_moves (project_id, card_id, column_id, at_ms) SELECT project_id, id, column_id, {now} FROM project_cards \ |
|
| 430 | + | WHERE id = $1 AND project_id = $2 AND archived_ms IS NOT NULL" |
|
| 431 | + | )) |
|
| 432 | + | .bind(card_id) |
|
| 433 | + | .bind(project_id) |
|
| 434 | + | .execute(&mut *tx) |
|
| 435 | + | .await?; |
|
| 366 | 436 | // Back on the board, it no longer counts as done in a past iteration. |
|
| 367 | 437 | let r = sqlx::query( |
|
| 368 | 438 | "UPDATE project_cards SET archived_ms = NULL, iteration_id = NULL, moved_ms = $1, position = $2 \ |
|
| … | 68 unchanged lines |
||
| 437 | 507 | } |
|
| 438 | 508 | let mut n = 0; |
|
| 439 | 509 | if let Some(done) = done { |
|
| 510 | + | log_moves(&mut *tx, None, now, "project_id = $1 AND column_id = $2 AND archived_ms IS NULL", &[project_id, done]).await?; |
|
| 440 | 511 | n = sqlx::query( |
|
| 441 | 512 | "UPDATE project_cards SET archived_ms = $1, iteration_id = $2 WHERE project_id = $3 AND column_id = $4 AND archived_ms IS NULL", |
|
| 442 | 513 | ) |
|
| … | 57 unchanged lines |
||
| 500 | 571 | /// Days after which a column with no setting of its own archives its |
|
| 501 | 572 | /// finished cards (#293): a board shows the last fortnight's done work. |
|
| 502 | 573 | pub const FINISHED_KEPT_DAYS: i64 = 14; |
|
| 574 | + | ||
| 575 | + | /// The finished cards due for archiving: in a column with no setting of |
|
| 576 | + | /// its own, joined before `$1`, and their issue closed or proposal closed |
|
| 577 | + | /// or landed. |
|
| 578 | + | const FINISHED_DUE: &str = "archived_ms IS NULL AND moved_ms < $1 \ |
|
| 579 | + | AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \ |
|
| 580 | + | AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \ |
|
| 581 | + | AND i.number = project_cards.number AND i.state = 'closed')) \ |
|
| 582 | + | OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \ |
|
| 583 | + | AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))"; |
|
| 503 | 584 | ||
| 504 | 585 | /// A column's `archive_after_days` that means never. |
|
| 505 | 586 | pub const NEVER: i64 = -1; |
|
| … | 4 unchanged lines |
||
| 510 | 591 | /// closed or landed proposal) after [`FINISHED_KEPT_DAYS`], so a Done column |
|
| 511 | 592 | /// stops growing while open work and notes stay. [`NEVER`]: none. |
|
| 512 | 593 | pub async fn archive_due(pool: &Pool, now: i64) -> sqlx::Result<u64> { |
|
| 594 | + | log_moves( |
|
| 595 | + | pool, |
|
| 596 | + | None, |
|
| 597 | + | now, |
|
| 598 | + | "archived_ms IS NULL AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id \ |
|
| 599 | + | AND c.archive_after_days >= 0 AND project_cards.moved_ms < $1 - c.archive_after_days * $2)", |
|
| 600 | + | &[now, DAY_MS], |
|
| 601 | + | ) |
|
| 602 | + | .await?; |
|
| 513 | 603 | let set = sqlx::query( |
|
| 514 | 604 | "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND EXISTS \ |
|
| 515 | 605 | (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days >= 0 \ |
|
| … | 3 unchanged lines |
||
| 519 | 609 | .bind(DAY_MS) |
|
| 520 | 610 | .execute(pool) |
|
| 521 | 611 | .await?; |
|
| 522 | - | let finished = sqlx::query( |
|
| 523 | - | "UPDATE project_cards SET archived_ms = $1 WHERE archived_ms IS NULL AND moved_ms < $2 \ |
|
| 524 | - | AND EXISTS (SELECT 1 FROM project_columns c WHERE c.id = project_cards.column_id AND c.archive_after_days IS NULL) \ |
|
| 525 | - | AND ((kind = 'issue' AND EXISTS (SELECT 1 FROM issues i WHERE i.repo_id = project_cards.repo_id \ |
|
| 526 | - | AND i.number = project_cards.number AND i.state = 'closed')) \ |
|
| 527 | - | OR (kind = 'proposal' AND EXISTS (SELECT 1 FROM proposals p WHERE p.repo_id = project_cards.repo_id \ |
|
| 528 | - | AND p.number = project_cards.number AND p.state IN ('closed', 'landed'))))", |
|
| 529 | - | ) |
|
| 530 | - | .bind(now) |
|
| 531 | - | .bind(now - FINISHED_KEPT_DAYS * DAY_MS) |
|
| 532 | - | .execute(pool) |
|
| 533 | - | .await?; |
|
| 612 | + | log_moves(pool, None, now, FINISHED_DUE, &[now - FINISHED_KEPT_DAYS * DAY_MS]).await?; |
|
| 613 | + | let finished = sqlx::query(&format!("UPDATE project_cards SET archived_ms = $2 WHERE {FINISHED_DUE}")) |
|
| 614 | + | .bind(now - FINISHED_KEPT_DAYS * DAY_MS) |
|
| 615 | + | .bind(now) |
|
| 616 | + | .execute(pool) |
|
| 617 | + | .await?; |
|
| 534 | 618 | Ok(set.rows_affected() + finished.rows_affected()) |
|
| 535 | 619 | } |
|
| 536 | 620 | ||
| … | 25 unchanged lines |
||
| 562 | 646 | } |
|
| 563 | 647 | ||
| 564 | 648 | pub async fn remove_card(pool: &Pool, project_id: i64, card_id: i64) -> sqlx::Result<()> { |
|
| 649 | + | log_moves(pool, None, now_ms(), "id = $1 AND project_id = $2 AND archived_ms IS NULL", &[card_id, project_id]).await?; |
|
| 565 | 650 | sqlx::query("DELETE FROM project_cards WHERE id = $1 AND project_id = $2").bind(card_id).bind(project_id).execute(pool).await?; |
|
| 566 | 651 | touch(pool, project_id).await |
|
| 567 | 652 | } |
|
| … | 55 unchanged lines |
||
| 623 | 708 | } |
|
| 624 | 709 | } |
|
| 625 | 710 | Ok(out) |
|
| 711 | + | } |
|
| 712 | + | ||
| 713 | + | /// A board's saved view (#361): a layout with its filter, lanes and sort. |
|
| 714 | + | #[derive(Clone, Debug, FromRow)] |
|
| 715 | + | pub struct View { |
|
| 716 | + | pub id: i64, |
|
| 717 | + | pub project_id: i64, |
|
| 718 | + | pub name: String, |
|
| 719 | + | /// `board` or `table`. |
|
| 720 | + | pub layout: String, |
|
| 721 | + | pub q: String, |
|
| 722 | + | /// Swimlanes (board) or groups (table): `repo`, `assignee`, `label`, |
|
| 723 | + | /// `milestone`, or empty. |
|
| 724 | + | pub lane: String, |
|
| 725 | + | /// The table's order: `title`, `reference`, `state`, `column`, `age`, |
|
| 726 | + | /// a `-` before it for the reverse; empty is the board's order. |
|
| 727 | + | pub sort: String, |
|
| 728 | + | pub position: i64, |
|
| 729 | + | pub created_ms: i64, |
|
| 730 | + | } |
|
| 731 | + | ||
| 732 | + | pub async fn views(pool: &Pool, project_id: i64) -> sqlx::Result<Vec<View>> { |
|
| 733 | + | sqlx::query_as("SELECT * FROM project_views WHERE project_id = $1 ORDER BY position, id").bind(project_id).fetch_all(pool).await |
|
| 734 | + | } |
|
| 735 | + | ||
| 736 | + | /// Save a view under `name`, replacing the one of that name. |
|
| 737 | + | pub async fn save_view(pool: &Pool, project_id: i64, name: &str, layout: &str, q: &str, lane: &str, sort: &str) -> sqlx::Result<View> { |
|
| 738 | + | let mut tx = super::begin_write(pool).await?; |
|
| 739 | + | let existing: Option<i64> = sqlx::query_scalar("SELECT id FROM project_views WHERE project_id = $1 AND name = $2") |
|
| 740 | + | .bind(project_id) |
|
| 741 | + | .bind(name) |
|
| 742 | + | .fetch_optional(&mut *tx) |
|
| 743 | + | .await?; |
|
| 744 | + | let v: View = match existing { |
|
| 745 | + | Some(id) => { |
|
| 746 | + | sqlx::query_as("UPDATE project_views SET layout = $1, q = $2, lane = $3, sort = $4 WHERE id = $5 RETURNING *") |
|
| 747 | + | .bind(layout) |
|
| 748 | + | .bind(q) |
|
| 749 | + | .bind(lane) |
|
| 750 | + | .bind(sort) |
|
| 751 | + | .bind(id) |
|
| 752 | + | .fetch_one(&mut *tx) |
|
| 753 | + | .await? |
|
| 754 | + | } |
|
| 755 | + | None => { |
|
| 756 | + | let pos: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(position), -1) + 1 FROM project_views WHERE project_id = $1") |
|
| 757 | + | .bind(project_id) |
|
| 758 | + | .fetch_one(&mut *tx) |
|
| 759 | + | .await?; |
|
| 760 | + | sqlx::query_as( |
|
| 761 | + | "INSERT INTO project_views (project_id, name, layout, q, lane, sort, position, created_ms) \ |
|
| 762 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *", |
|
| 763 | + | ) |
|
| 764 | + | .bind(project_id) |
|
| 765 | + | .bind(name) |
|
| 766 | + | .bind(layout) |
|
| 767 | + | .bind(q) |
|
| 768 | + | .bind(lane) |
|
| 769 | + | .bind(sort) |
|
| 770 | + | .bind(pos) |
|
| 771 | + | .bind(now_ms()) |
|
| 772 | + | .fetch_one(&mut *tx) |
|
| 773 | + | .await? |
|
| 774 | + | } |
|
| 775 | + | }; |
|
| 776 | + | tx.commit().await?; |
|
| 777 | + | touch(pool, project_id).await?; |
|
| 778 | + | Ok(v) |
|
| 779 | + | } |
|
| 780 | + | ||
| 781 | + | pub async fn delete_view(pool: &Pool, project_id: i64, view_id: i64) -> sqlx::Result<bool> { |
|
| 782 | + | let r = sqlx::query("DELETE FROM project_views WHERE id = $1 AND project_id = $2").bind(view_id).bind(project_id).execute(pool).await?; |
|
| 783 | + | touch(pool, project_id).await?; |
|
| 784 | + | Ok(r.rows_affected() == 1) |
|
| 785 | + | } |
|
| 786 | + | ||
| 787 | + | /// A column's work-in-progress limit (#361); `None` for none. |
|
| 788 | + | pub async fn set_wip_limit(pool: &Pool, project_id: i64, column_id: i64, limit: Option<i64>) -> sqlx::Result<()> { |
|
| 789 | + | sqlx::query("UPDATE project_columns SET wip_limit = $1 WHERE id = $2 AND project_id = $3") |
|
| 790 | + | .bind(limit) |
|
| 791 | + | .bind(column_id) |
|
| 792 | + | .bind(project_id) |
|
| 793 | + | .execute(pool) |
|
| 794 | + | .await?; |
|
| 795 | + | touch(pool, project_id).await |
|
| 626 | 796 | } |
modified crates/hub/src/web/projects.rs +529 −16
| … | 174 unchanged lines |
||
| 175 | 175 | } |
|
| 176 | 176 | ||
| 177 | 177 | /// A card as the board shows it. |
|
| 178 | + | #[derive(Clone)] |
|
| 178 | 179 | pub struct CardView { |
|
| 179 | 180 | pub card: pdb::Card, |
|
| 180 | 181 | /// `owner/name#N`, for issue and proposal cards. |
|
| … | 5 unchanged lines |
||
| 186 | 187 | /// The issue's or proposal's labels, and an issue's assignees (#293). |
|
| 187 | 188 | pub labels: Vec<idb::Label>, |
|
| 188 | 189 | pub assignees: Vec<String>, |
|
| 190 | + | /// Its repository's name, its milestone's title, and when the issue or |
|
| 191 | + | /// proposal was opened (a note: when it was added), for the filter's |
|
| 192 | + | /// `repo:`, `milestone:` and `age:` and for lanes (#361). |
|
| 193 | + | pub repo: String, |
|
| 194 | + | pub milestone: String, |
|
| 195 | + | pub opened_ms: i64, |
|
| 189 | 196 | } |
|
| 190 | 197 | ||
| 198 | + | #[derive(Clone)] |
|
| 191 | 199 | pub struct ColumnView { |
|
| 192 | 200 | pub column: pdb::Column, |
|
| 193 | 201 | pub cards: Vec<CardView>, |
|
| … | 6 unchanged lines |
||
| 200 | 208 | /// Its cards are all finished work (a Done column): its note says |
|
| 201 | 209 | /// they are archived after a fortnight unless it says otherwise. |
|
| 202 | 210 | pub finished: bool, |
|
| 211 | + | /// More cards than its work-in-progress limit (#361). |
|
| 212 | + | pub over_wip: bool, |
|
| 213 | + | /// Done cards that reached it before the open iteration began, left |
|
| 214 | + | /// out: the board shows this iteration's done work (#361). |
|
| 215 | + | pub before_iteration: usize, |
|
| 203 | 216 | } |
|
| 204 | 217 | ||
| 205 | 218 | /// A column longer than this shows its newest cards only, with "show all" |
|
| … | 1 unchanged lines |
||
| 207 | 220 | pub const COLUMN_SHOWN: usize = 30; |
|
| 208 | 221 | ||
| 209 | 222 | /// How the board page shows its cards (#293). |
|
| 210 | - | #[derive(Deserialize, Default)] |
|
| 223 | + | #[derive(Deserialize, Default, Clone)] |
|
| 211 | 224 | pub struct BoardQuery { |
|
| 212 | 225 | /// The column shown whole. |
|
| 213 | 226 | #[serde(default)] |
|
| … | 11 unchanged lines |
||
| 225 | 238 | /// A past iteration: the cards archived under it, and its burn-up (#343). |
|
| 226 | 239 | #[serde(default)] |
|
| 227 | 240 | iteration: Option<i64>, |
|
| 241 | + | /// A saved view, by name (#361): its layout, filter, lanes and sort; |
|
| 242 | + | /// what the address says besides wins. |
|
| 243 | + | #[serde(default)] |
|
| 244 | + | pub view: Option<String>, |
|
| 245 | + | /// `board` (the default) or `table`. |
|
| 246 | + | #[serde(default)] |
|
| 247 | + | pub layout: Option<String>, |
|
| 248 | + | /// Swimlanes or groups: `repo`, `assignee`, `label`, `milestone`. |
|
| 249 | + | #[serde(default)] |
|
| 250 | + | pub lane: Option<String>, |
|
| 251 | + | /// The table's order (`title`, `reference`, `state`, `column`, `age`; |
|
| 252 | + | /// `-` first to reverse). |
|
| 253 | + | #[serde(default)] |
|
| 254 | + | pub sort: Option<String>, |
|
| 255 | + | /// Compact cards: title, reference, avatars, labels as dots. |
|
| 256 | + | #[serde(default)] |
|
| 257 | + | pub compact: Option<String>, |
|
| 258 | + | /// The table's page, from 1. |
|
| 259 | + | #[serde(default)] |
|
| 260 | + | pub page: Option<usize>, |
|
| 228 | 261 | } |
|
| 229 | 262 | ||
| 230 | 263 | impl BoardQuery { |
|
| 264 | + | /// This query over the saved view it names (#361): what the address |
|
| 265 | + | /// gives wins, the view fills the rest. |
|
| 266 | + | pub fn over(&self, saved: Option<&pdb::View>) -> BoardQuery { |
|
| 267 | + | let mut q = self.clone(); |
|
| 268 | + | if let Some(v) = saved { |
|
| 269 | + | let fill = |x: &mut Option<String>, from: &str| { |
|
| 270 | + | if x.is_none() && !from.is_empty() { |
|
| 271 | + | *x = Some(from.to_string()); |
|
| 272 | + | } |
|
| 273 | + | }; |
|
| 274 | + | fill(&mut q.q, &v.q); |
|
| 275 | + | fill(&mut q.layout, &v.layout); |
|
| 276 | + | fill(&mut q.lane, &v.lane); |
|
| 277 | + | fill(&mut q.sort, &v.sort); |
|
| 278 | + | } |
|
| 279 | + | q |
|
| 280 | + | } |
|
| 281 | + | pub fn layout(&self) -> &str { |
|
| 282 | + | match self.layout.as_deref() { |
|
| 283 | + | Some("table") => "table", |
|
| 284 | + | _ => "board", |
|
| 285 | + | } |
|
| 286 | + | } |
|
| 287 | + | pub fn lane(&self) -> &str { |
|
| 288 | + | self.lane.as_deref().filter(|l| matches!(*l, "repo" | "assignee" | "label" | "milestone")).unwrap_or("") |
|
| 289 | + | } |
|
| 290 | + | fn compact(&self) -> bool { |
|
| 291 | + | self.compact.as_deref().is_some_and(|c| !c.is_empty() && c != "0") |
|
| 292 | + | } |
|
| 231 | 293 | fn text(&self) -> String { |
|
| 232 | 294 | self.q.as_deref().map(str::trim).unwrap_or("").to_string() |
|
| 233 | 295 | } |
|
| … | 7 unchanged lines |
||
| 241 | 303 | ||
| 242 | 304 | /// The board filter's `label:`, `assignee:` and `state:` terms, and its words. |
|
| 243 | 305 | #[derive(Default)] |
|
| 244 | - | struct CardFilter { |
|
| 306 | + | pub struct CardFilter { |
|
| 245 | 307 | labels: Vec<String>, |
|
| 246 | 308 | assignees: Vec<String>, |
|
| 247 | 309 | state: String, |
|
| 248 | 310 | words: Vec<String>, |
|
| 311 | + | /// `repo:NAME`, `milestone:TITLE` (#361). |
|
| 312 | + | repos: Vec<String>, |
|
| 313 | + | milestones: Vec<String>, |
|
| 314 | + | /// `is:open` (open or draft) or `is:closed` (closed or landed). |
|
| 315 | + | is: String, |
|
| 316 | + | /// `age:>14d` and `age:<7d`: opened more than, or less than, so many |
|
| 317 | + | /// days ago. |
|
| 318 | + | older_than_days: Option<i64>, |
|
| 319 | + | newer_than_days: Option<i64>, |
|
| 249 | 320 | } |
|
| 250 | 321 | ||
| 251 | 322 | impl CardFilter { |
|
| 252 | 323 | fn of(view: &BoardQuery) -> CardFilter { |
|
| 253 | - | let mut f = CardFilter { state: view.state(), ..Default::default() }; |
|
| 254 | - | for tok in view.text().split_whitespace() { |
|
| 324 | + | let mut f = CardFilter::parse(&view.text()); |
|
| 325 | + | if f.state.is_empty() { |
|
| 326 | + | f.state = view.state(); |
|
| 327 | + | } |
|
| 328 | + | f |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | /// The filter bar's text (#361): words, and `label:`, `assignee:`, |
|
| 332 | + | /// `state:`, `repo:`, `milestone:`, `is:open|closed` and `age:>Nd` or |
|
| 333 | + | /// `age:<Nd`. A value with spaces takes quotes: `milestone:"Release 1"`. |
|
| 334 | + | pub fn parse(text: &str) -> CardFilter { |
|
| 335 | + | let mut f = CardFilter::default(); |
|
| 336 | + | for tok in tokens(text) { |
|
| 255 | 337 | match tok.split_once(':') { |
|
| 256 | 338 | Some(("label", v)) if !v.is_empty() => f.labels.push(v.to_lowercase()), |
|
| 257 | 339 | Some(("assignee", v)) if !v.is_empty() => f.assignees.push(v.trim_start_matches('@').to_lowercase()), |
|
| 258 | 340 | Some(("state", v)) if !v.is_empty() => f.state = v.to_lowercase(), |
|
| 341 | + | Some(("repo", v)) if !v.is_empty() => f.repos.push(v.rsplit('/').next().unwrap_or(v).to_lowercase()), |
|
| 342 | + | Some(("milestone", v)) if !v.is_empty() => f.milestones.push(v.to_lowercase()), |
|
| 343 | + | Some(("is", v)) if matches!(v, "open" | "closed") => f.is = v.to_string(), |
|
| 344 | + | Some(("age", v)) if age_days(v).is_some() => match age_days(v) { |
|
| 345 | + | Some((true, d)) => f.older_than_days = Some(d), |
|
| 346 | + | Some((false, d)) => f.newer_than_days = Some(d), |
|
| 347 | + | None => {} |
|
| 348 | + | }, |
|
| 259 | 349 | _ => f.words.push(tok.to_lowercase()), |
|
| 260 | 350 | } |
|
| 261 | 351 | } |
|
| 262 | 352 | f |
|
| 263 | 353 | } |
|
| 264 | 354 | ||
| 265 | - | fn is_empty(&self) -> bool { |
|
| 266 | - | self.labels.is_empty() && self.assignees.is_empty() && self.state.is_empty() && self.words.is_empty() |
|
| 355 | + | pub fn is_empty(&self) -> bool { |
|
| 356 | + | self.labels.is_empty() |
|
| 357 | + | && self.assignees.is_empty() |
|
| 358 | + | && self.state.is_empty() |
|
| 359 | + | && self.words.is_empty() |
|
| 360 | + | && self.repos.is_empty() |
|
| 361 | + | && self.milestones.is_empty() |
|
| 362 | + | && self.is.is_empty() |
|
| 363 | + | && self.older_than_days.is_none() |
|
| 364 | + | && self.newer_than_days.is_none() |
|
| 267 | 365 | } |
|
| 268 | 366 | ||
| 269 | 367 | /// Every term holds: each word in the title or reference, each label |
|
| 270 | - | /// and assignee among the card's, and its state. |
|
| 271 | - | fn keeps(&self, c: &CardView) -> bool { |
|
| 368 | + | /// and assignee among the card's, its state, repository, milestone and |
|
| 369 | + | /// age. |
|
| 370 | + | pub fn keeps(&self, c: &CardView, now: i64) -> bool { |
|
| 272 | 371 | let (title, reference) = (c.title.to_lowercase(), c.reference.to_lowercase()); |
|
| 372 | + | let age_days = (now - c.opened_ms) / DAY_MS; |
|
| 273 | 373 | self.words.iter().all(|w| title.contains(w) || reference.contains(w)) |
|
| 274 | 374 | && self.labels.iter().all(|l| c.labels.iter().any(|x| x.name.eq_ignore_ascii_case(l))) |
|
| 275 | 375 | && self.assignees.iter().all(|a| c.assignees.iter().any(|x| x.eq_ignore_ascii_case(a))) |
|
| 276 | 376 | && (self.state.is_empty() || c.state.eq_ignore_ascii_case(&self.state)) |
|
| 377 | + | && self.repos.iter().all(|r| c.repo.eq_ignore_ascii_case(r)) |
|
| 378 | + | && self.milestones.iter().all(|m| c.milestone.eq_ignore_ascii_case(m)) |
|
| 379 | + | && match self.is.as_str() { |
|
| 380 | + | "open" => matches!(c.state.as_str(), "open" | "draft"), |
|
| 381 | + | "closed" => matches!(c.state.as_str(), "closed" | "landed"), |
|
| 382 | + | _ => true, |
|
| 383 | + | } |
|
| 384 | + | && self.older_than_days.is_none_or(|d| age_days > d) |
|
| 385 | + | && self.newer_than_days.is_none_or(|d| age_days < d) |
|
| 386 | + | } |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /// The filter bar's words, a quoted value kept whole (`milestone:"Release 1"`). |
|
| 390 | + | fn tokens(text: &str) -> Vec<String> { |
|
| 391 | + | let mut out = Vec::new(); |
|
| 392 | + | let mut cur = String::new(); |
|
| 393 | + | let mut quoted = false; |
|
| 394 | + | for ch in text.chars() { |
|
| 395 | + | match ch { |
|
| 396 | + | '"' => quoted = !quoted, |
|
| 397 | + | c if c.is_whitespace() && !quoted => { |
|
| 398 | + | if !cur.is_empty() { |
|
| 399 | + | out.push(std::mem::take(&mut cur)); |
|
| 400 | + | } |
|
| 401 | + | } |
|
| 402 | + | c => cur.push(c), |
|
| 403 | + | } |
|
| 277 | 404 | } |
|
| 405 | + | if !cur.is_empty() { |
|
| 406 | + | out.push(cur); |
|
| 407 | + | } |
|
| 408 | + | out |
|
| 278 | 409 | } |
|
| 279 | 410 | ||
| 280 | - | /// Filter and cut the columns as `view` says. |
|
| 281 | - | fn shape(columns: &mut [ColumnView], view: &BoardQuery) { |
|
| 411 | + | /// `>14d` (older than 14 days: `(true, 14)`), `<7d` (newer: `(false, 7)`); |
|
| 412 | + | /// the `d` may be left off. |
|
| 413 | + | fn age_days(v: &str) -> Option<(bool, i64)> { |
|
| 414 | + | let (older, rest) = match v.as_bytes().first()? { |
|
| 415 | + | b'>' => (true, &v[1..]), |
|
| 416 | + | b'<' => (false, &v[1..]), |
|
| 417 | + | _ => return None, |
|
| 418 | + | }; |
|
| 419 | + | let n: i64 = rest.trim_end_matches('d').parse().ok()?; |
|
| 420 | + | (n >= 0).then_some((older, n)) |
|
| 421 | + | } |
|
| 422 | + | ||
| 423 | + | /// Filter and cut the columns as `view` says. With an iteration open, |
|
| 424 | + | /// the Done column `done` shows the cards that reached it since the |
|
| 425 | + | /// iteration began (#361); the rest are counted. |
|
| 426 | + | fn shape(columns: &mut [ColumnView], view: &BoardQuery, now: i64, done: Option<i64>, since: Option<i64>) { |
|
| 282 | 427 | let filter = CardFilter::of(view); |
|
| 283 | 428 | for col in columns.iter_mut() { |
|
| 284 | 429 | col.total = col.cards.len(); |
|
| 430 | + | if let (Some(d), Some(since), false) = (done, since, view.archived() || view.iteration.is_some()) { |
|
| 431 | + | if col.column.id == d { |
|
| 432 | + | let before = col.cards.len(); |
|
| 433 | + | col.cards.retain(|c| c.card.moved_ms >= since); |
|
| 434 | + | col.before_iteration = before - col.cards.len(); |
|
| 435 | + | col.total = col.cards.len(); |
|
| 436 | + | } |
|
| 437 | + | } |
|
| 438 | + | col.over_wip = col.column.wip_limit.is_some_and(|w| w >= 0 && col.total as i64 > w); |
|
| 285 | 439 | if !filter.is_empty() { |
|
| 286 | - | col.cards.retain(|c| filter.keeps(c)); |
|
| 440 | + | col.cards.retain(|c| filter.keeps(c, now)); |
|
| 287 | 441 | } else if col.cards.len() > COLUMN_SHOWN && view.all != Some(col.column.id) && !view.archived() && view.iteration.is_none() { |
|
| 288 | 442 | // Cards join a column at its end: the newest are the last. |
|
| 289 | 443 | let cut = col.cards.len() - COLUMN_SHOWN; |
|
| … | 1 unchanged lines |
||
| 291 | 445 | } |
|
| 292 | 446 | col.hidden = col.total - col.cards.len(); |
|
| 293 | 447 | } |
|
| 448 | + | } |
|
| 449 | + | ||
| 450 | + | /// A swimlane (#361): the board's columns with the cards of one |
|
| 451 | + | /// repository, assignee, label or milestone. |
|
| 452 | + | pub struct LaneView { |
|
| 453 | + | pub name: String, |
|
| 454 | + | pub columns: Vec<ColumnView>, |
|
| 455 | + | pub count: usize, |
|
| 294 | 456 | } |
|
| 295 | 457 | ||
| 458 | + | /// The lanes a card belongs to under `lane`: one, or one per assignee or |
|
| 459 | + | /// label; those without one share a lane named for it. |
|
| 460 | + | fn lanes_of(c: &CardView, lane: &str) -> Vec<String> { |
|
| 461 | + | let many = |v: Vec<String>, none: &str| if v.is_empty() { vec![none.to_string()] } else { v }; |
|
| 462 | + | match lane { |
|
| 463 | + | "repo" => vec![if c.repo.is_empty() { "(notes)".into() } else { c.repo.clone() }], |
|
| 464 | + | "assignee" => many(c.assignees.iter().map(|a| format!("@{a}")).collect(), "(nobody)"), |
|
| 465 | + | "label" => many(c.labels.iter().map(|l| l.name.clone()).collect(), "(no label)"), |
|
| 466 | + | "milestone" => vec![if c.milestone.is_empty() { "(no milestone)".into() } else { c.milestone.clone() }], |
|
| 467 | + | _ => vec![String::new()], |
|
| 468 | + | } |
|
| 469 | + | } |
|
| 470 | + | ||
| 471 | + | /// Split shaped columns into swimlanes by `lane`, named in order with |
|
| 472 | + | /// the lane of those without one last. |
|
| 473 | + | pub fn lanes(columns: &[ColumnView], lane: &str) -> Vec<LaneView> { |
|
| 474 | + | let mut names: Vec<String> = columns.iter().flat_map(|col| col.cards.iter().flat_map(|c| lanes_of(c, lane))).collect(); |
|
| 475 | + | names.sort_by_key(|n| (n.starts_with('('), n.to_lowercase())); |
|
| 476 | + | names.dedup(); |
|
| 477 | + | names |
|
| 478 | + | .into_iter() |
|
| 479 | + | .map(|name| { |
|
| 480 | + | let columns: Vec<ColumnView> = columns |
|
| 481 | + | .iter() |
|
| 482 | + | .map(|col| { |
|
| 483 | + | let mut col = col.clone(); |
|
| 484 | + | col.cards.retain(|c| lanes_of(c, lane).contains(&name)); |
|
| 485 | + | col.total = col.cards.len(); |
|
| 486 | + | col.hidden = 0; |
|
| 487 | + | col.before_iteration = 0; |
|
| 488 | + | col |
|
| 489 | + | }) |
|
| 490 | + | .collect(); |
|
| 491 | + | let count = columns.iter().map(|c| c.cards.len()).sum(); |
|
| 492 | + | LaneView { name, columns, count } |
|
| 493 | + | }) |
|
| 494 | + | .collect() |
|
| 495 | + | } |
|
| 496 | + | ||
| 497 | + | /// A card as a table row (#361), with its column and group. |
|
| 498 | + | pub struct RowView { |
|
| 499 | + | pub c: CardView, |
|
| 500 | + | pub column: String, |
|
| 501 | + | pub column_id: i64, |
|
| 502 | + | pub group: String, |
|
| 503 | + | /// The first row of its group, which carries the group's heading. |
|
| 504 | + | pub starts_group: bool, |
|
| 505 | + | } |
|
| 506 | + | ||
| 507 | + | /// Rows a table page shows. |
|
| 508 | + | pub const TABLE_PAGE: usize = 50; |
|
| 509 | + | ||
| 510 | + | /// Every shaped card as a row, grouped by `lane` and ordered by `sort` |
|
| 511 | + | /// (`title`, `reference`, `state`, `column`, `age`; a leading `-` |
|
| 512 | + | /// reverses), then the page `page` (from 1) of them and how many pages. |
|
| 513 | + | pub fn rows(columns: &[ColumnView], lane: &str, sort: &str, page: usize) -> (Vec<RowView>, usize) { |
|
| 514 | + | let mut all: Vec<RowView> = Vec::new(); |
|
| 515 | + | for (ci, col) in columns.iter().enumerate() { |
|
| 516 | + | for c in &col.cards { |
|
| 517 | + | for group in lanes_of(c, lane) { |
|
| 518 | + | all.push(RowView { c: c.clone(), column: col.column.name.clone(), column_id: ci as i64, group, starts_group: false }); |
|
| 519 | + | } |
|
| 520 | + | } |
|
| 521 | + | } |
|
| 522 | + | let (desc, key) = match sort.strip_prefix('-') { |
|
| 523 | + | Some(k) => (true, k), |
|
| 524 | + | None => (false, sort), |
|
| 525 | + | }; |
|
| 526 | + | all.sort_by(|a, b| { |
|
| 527 | + | let by = match key { |
|
| 528 | + | "title" => a.c.title.to_lowercase().cmp(&b.c.title.to_lowercase()), |
|
| 529 | + | "reference" => a.c.reference.cmp(&b.c.reference), |
|
| 530 | + | "state" => a.c.state.cmp(&b.c.state), |
|
| 531 | + | "column" => a.column_id.cmp(&b.column_id), |
|
| 532 | + | "age" => b.c.opened_ms.cmp(&a.c.opened_ms), |
|
| 533 | + | _ => a.column_id.cmp(&b.column_id).then(a.c.card.position.cmp(&b.c.card.position)), |
|
| 534 | + | }; |
|
| 535 | + | let by = if desc { by.reverse() } else { by }; |
|
| 536 | + | (a.group.starts_with('('), a.group.to_lowercase()).cmp(&(b.group.starts_with('('), b.group.to_lowercase())).then(by) |
|
| 537 | + | }); |
|
| 538 | + | let pages = all.len().div_ceil(TABLE_PAGE).max(1); |
|
| 539 | + | let page = page.clamp(1, pages); |
|
| 540 | + | let mut shown: Vec<RowView> = all.into_iter().skip((page - 1) * TABLE_PAGE).take(TABLE_PAGE).collect(); |
|
| 541 | + | let mut last: Option<String> = None; |
|
| 542 | + | for r in shown.iter_mut() { |
|
| 543 | + | r.starts_group = !lane.is_empty() && last.as_ref() != Some(&r.group); |
|
| 544 | + | last = Some(r.group.clone()); |
|
| 545 | + | } |
|
| 546 | + | (shown, pages) |
|
| 547 | + | } |
|
| 548 | + | ||
| 296 | 549 | /// An iteration as the board shows it (#343). |
|
| 297 | 550 | pub struct IterationView { |
|
| 298 | 551 | pub it: pdb::Iteration, |
|
| … | 90 unchanged lines |
||
| 389 | 642 | /// A new iteration's suggested first and last days: today and 13 days on. |
|
| 390 | 643 | next_first: String, |
|
| 391 | 644 | next_last: String, |
|
| 645 | + | /// The saved views as tabs, and the one shown (#361). |
|
| 646 | + | views: Vec<pdb::View>, |
|
| 647 | + | view_name: String, |
|
| 648 | + | /// `board` or `table`; lanes or groups; the table's order; compact cards. |
|
| 649 | + | layout: String, |
|
| 650 | + | lane: String, |
|
| 651 | + | sort: String, |
|
| 652 | + | compact: bool, |
|
| 653 | + | /// The board in swimlanes, when `lane` is set. |
|
| 654 | + | lanes: Vec<LaneView>, |
|
| 655 | + | /// The table's rows, page and pages. |
|
| 656 | + | rows: Vec<RowView>, |
|
| 657 | + | page: usize, |
|
| 658 | + | pages: usize, |
|
| 659 | + | /// The address's query for this look, without the page, for links. |
|
| 660 | + | look: String, |
|
| 392 | 661 | } |
|
| 393 | 662 | ||
| 394 | 663 | #[allow(clippy::too_many_arguments)] |
|
| … | 15 unchanged lines |
||
| 410 | 679 | .filter(|p| p.public || r.member) |
|
| 411 | 680 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 412 | 681 | let now = db::now_ms(); |
|
| 682 | + | let views = pdb::views(&app.db, project.id).await.map_err(e)?; |
|
| 683 | + | let saved = view.view.as_deref().and_then(|n| views.iter().find(|v| v.name == n)); |
|
| 684 | + | let look = view.over(saved); |
|
| 685 | + | let view = &look; |
|
| 413 | 686 | let iterations = pdb::iterations(&app.db, project.id).await.map_err(e)?; |
|
| 414 | 687 | let shown = view.iteration.and_then(|id| iterations.iter().find(|i| i.id == id && i.closed_ms.is_some()).cloned()); |
|
| 415 | 688 | // A past iteration's page is the archive of what was done in it. |
|
| … | 4 unchanged lines |
||
| 420 | 693 | col.cards.retain(|c| c.card.iteration_id == Some(it.id)); |
|
| 421 | 694 | } |
|
| 422 | 695 | } |
|
| 423 | - | shape(&mut columns, view); |
|
| 424 | - | let archived_total = columns.iter().map(|c| c.archived).sum(); |
|
| 425 | 696 | let done = pdb::done_column(&columns.iter().map(|c| c.column.clone()).collect::<Vec<_>>()); |
|
| 697 | + | let open_since = iterations.iter().find(|i| i.closed_ms.is_none()).map(|i| i.start_ms); |
|
| 698 | + | shape(&mut columns, view, now, done, open_since); |
|
| 699 | + | let archived_total = columns.iter().map(|c| c.archived).sum(); |
|
| 700 | + | let (layout, lane, sort) = (view.layout().to_string(), view.lane().to_string(), view.sort.clone().unwrap_or_default()); |
|
| 701 | + | let lanes = if layout == "board" && !lane.is_empty() { lanes(&columns, &lane) } else { Vec::new() }; |
|
| 702 | + | let (rows, pages) = if layout == "table" { rows(&columns, &lane, &sort, view.page.unwrap_or(1)) } else { (Vec::new(), 1) }; |
|
| 703 | + | let page = view.page.unwrap_or(1).clamp(1, pages); |
|
| 704 | + | let mut parts: Vec<String> = Vec::new(); |
|
| 705 | + | for (k, v) in [ |
|
| 706 | + | ("view", view.view.clone().unwrap_or_default()), |
|
| 707 | + | ("layout", if layout == "table" { layout.clone() } else { String::new() }), |
|
| 708 | + | ("q", view.text()), |
|
| 709 | + | ("lane", lane.clone()), |
|
| 710 | + | ("sort", sort.clone()), |
|
| 711 | + | ("compact", if view.compact() { "1".into() } else { String::new() }), |
|
| 712 | + | ] { |
|
| 713 | + | if !v.is_empty() { |
|
| 714 | + | parts.push(format!("{k}={}", super::settings::urlencode(&v))); |
|
| 715 | + | } |
|
| 716 | + | } |
|
| 717 | + | let look_query = parts.join("&"); |
|
| 426 | 718 | let (mut current, mut past) = (None, Vec::new()); |
|
| 427 | 719 | for it in iterations { |
|
| 428 | 720 | if it.closed_ms.is_none() { |
|
| … | 36 unchanged lines |
||
| 465 | 757 | shown_iteration, |
|
| 466 | 758 | next_first: day_of(now), |
|
| 467 | 759 | next_last: day_of(now + 13 * DAY_MS), |
|
| 760 | + | view_name: view.view.clone().filter(|n| views.iter().any(|v| &v.name == n)).unwrap_or_default(), |
|
| 761 | + | views, |
|
| 762 | + | layout, |
|
| 763 | + | lane, |
|
| 764 | + | sort, |
|
| 765 | + | compact: view.compact(), |
|
| 766 | + | lanes, |
|
| 767 | + | rows, |
|
| 768 | + | page, |
|
| 769 | + | pages, |
|
| 770 | + | look: look_query, |
|
| 468 | 771 | })) |
|
| 469 | 772 | } |
|
| 470 | 773 | ||
| … | 41 unchanged lines |
||
| 512 | 815 | state: String::new(), |
|
| 513 | 816 | labels: Vec::new(), |
|
| 514 | 817 | assignees: Vec::new(), |
|
| 818 | + | repo: String::new(), |
|
| 819 | + | milestone: String::new(), |
|
| 820 | + | opened_ms: card.created_ms, |
|
| 515 | 821 | }), |
|
| 516 | 822 | (kind, Some(rid), Some(n)) => { |
|
| 517 | 823 | // Cards of repositories the viewer cannot read are not shown at all. |
|
| … | 8 unchanged lines |
||
| 526 | 832 | state: s.state.clone(), |
|
| 527 | 833 | labels: s.labels.clone(), |
|
| 528 | 834 | assignees: s.assignees.clone(), |
|
| 835 | + | repo: row.name.clone(), |
|
| 836 | + | milestone: s.milestone.clone(), |
|
| 837 | + | opened_ms: s.opened_ms, |
|
| 529 | 838 | }); |
|
| 530 | 839 | } |
|
| 531 | 840 | _ => {} |
|
| … | 3 unchanged lines |
||
| 535 | 844 | let archived = archived_in(column.id); |
|
| 536 | 845 | let items: Vec<&CardView> = views.iter().filter(|v| v.card.kind != "note").collect(); |
|
| 537 | 846 | let finished = !items.is_empty() && items.iter().all(|v| matches!(v.state.as_str(), "closed" | "landed")); |
|
| 538 | - | columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished }); |
|
| 847 | + | columns.push(ColumnView { column, cards: views, hidden: 0, total, archived, finished, over_wip: false, before_iteration: 0 }); |
|
| 539 | 848 | } |
|
| 540 | 849 | let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
|
| 541 | 850 | repos.sort(); |
|
| … | 6 unchanged lines |
||
| 548 | 857 | state: String, |
|
| 549 | 858 | labels: Vec<idb::Label>, |
|
| 550 | 859 | assignees: Vec<String>, |
|
| 860 | + | milestone: String, |
|
| 861 | + | opened_ms: i64, |
|
| 551 | 862 | } |
|
| 552 | 863 | ||
| 553 | 864 | /// A repository's issues (or proposals) as cards show them, by number. |
|
| … | 9 unchanged lines |
||
| 563 | 874 | for (id, u) in idb::assignees_by_issue(&app.db, repo_id).await? { |
|
| 564 | 875 | assignees.entry(id).or_default().push(u); |
|
| 565 | 876 | } |
|
| 877 | + | let milestones: HashMap<i64, String> = idb::milestones(&app.db, repo_id).await?.into_iter().map(|m| (m.id, m.title)).collect(); |
|
| 566 | 878 | for i in idb::list(&app.db, repo_id).await? { |
|
| 567 | 879 | let mut a = assignees.remove(&i.id).unwrap_or_default(); |
|
| 568 | 880 | a.sort(); |
|
| 569 | - | let s = Shown { labels: labels.remove(&i.id).unwrap_or_default(), assignees: a, title: i.title, state: i.state }; |
|
| 881 | + | let milestone = i.milestone_id.and_then(|m| milestones.get(&m).cloned()).unwrap_or_default(); |
|
| 882 | + | let s = Shown { |
|
| 883 | + | labels: labels.remove(&i.id).unwrap_or_default(), |
|
| 884 | + | assignees: a, |
|
| 885 | + | title: i.title, |
|
| 886 | + | state: i.state, |
|
| 887 | + | milestone, |
|
| 888 | + | opened_ms: i.created_ms, |
|
| 889 | + | }; |
|
| 570 | 890 | out.push((i.number, s)); |
|
| 571 | 891 | } |
|
| 572 | 892 | } else { |
|
| … | 2 unchanged lines |
||
| 575 | 895 | labels.entry(id).or_default().push(l); |
|
| 576 | 896 | } |
|
| 577 | 897 | for p in db::proposals::list(&app.db, repo_id, &[]).await? { |
|
| 578 | - | let s = Shown { labels: labels.remove(&p.id).unwrap_or_default(), assignees: Vec::new(), title: p.title, state: p.state }; |
|
| 898 | + | let s = Shown { |
|
| 899 | + | labels: labels.remove(&p.id).unwrap_or_default(), |
|
| 900 | + | assignees: Vec::new(), |
|
| 901 | + | title: p.title, |
|
| 902 | + | state: p.state, |
|
| 903 | + | milestone: String::new(), |
|
| 904 | + | opened_ms: p.created_ms, |
|
| 905 | + | }; |
|
| 579 | 906 | out.push((p.number, s)); |
|
| 580 | 907 | } |
|
| 581 | 908 | } |
|
| … | 60 unchanged lines |
||
| 642 | 969 | start: String, |
|
| 643 | 970 | #[serde(default)] |
|
| 644 | 971 | end: String, |
|
| 972 | + | /// A column's work-in-progress limit; empty for none (#361). |
|
| 973 | + | #[serde(default)] |
|
| 974 | + | limit: String, |
|
| 975 | + | /// A view to save (with `name`): its layout, filter, lanes and order; |
|
| 976 | + | /// or the id of one to delete (#361). |
|
| 977 | + | #[serde(default)] |
|
| 978 | + | layout: String, |
|
| 979 | + | #[serde(default)] |
|
| 980 | + | q: String, |
|
| 981 | + | #[serde(default)] |
|
| 982 | + | lane: String, |
|
| 983 | + | #[serde(default)] |
|
| 984 | + | sort: String, |
|
| 985 | + | #[serde(default)] |
|
| 986 | + | view: Option<i64>, |
|
| 645 | 987 | } |
|
| 646 | 988 | ||
| 647 | 989 | /// `repo#12` or `#12` (with a repository chosen) to (repository, number). |
|
| … | 206 unchanged lines |
||
| 854 | 1196 | notice = Some(format!("Closed: {n} done card{} archived under it.", if n == 1 { "" } else { "s" })); |
|
| 855 | 1197 | } |
|
| 856 | 1198 | None => error = Some("That iteration is not open.".into()), |
|
| 1199 | + | } |
|
| 1200 | + | } |
|
| 1201 | + | "wip" => { |
|
| 1202 | + | let limit = f.limit.trim(); |
|
| 1203 | + | match (column, limit.parse::<i64>()) { |
|
| 1204 | + | (Some(col), _) if limit.is_empty() => pdb::set_wip_limit(&app.db, project.id, col, None).await.map_err(e)?, |
|
| 1205 | + | (Some(col), Ok(n)) if (0..=999).contains(&n) => pdb::set_wip_limit(&app.db, project.id, col, Some(n)).await.map_err(e)?, |
|
| 1206 | + | _ => error = Some("A work-in-progress limit is a number of cards, 0 to 999, or empty for none.".into()), |
|
| 1207 | + | } |
|
| 1208 | + | } |
|
| 1209 | + | "save_view" => { |
|
| 1210 | + | let name: String = f.name.trim().chars().take(40).collect(); |
|
| 1211 | + | let layout = if f.layout == "table" { "table" } else { "board" }; |
|
| 1212 | + | let lane = if matches!(f.lane.as_str(), "repo" | "assignee" | "label" | "milestone") { f.lane.as_str() } else { "" }; |
|
| 1213 | + | let sort: String = f.sort.trim().chars().take(20).collect(); |
|
| 1214 | + | let q: String = f.q.trim().chars().take(300).collect(); |
|
| 1215 | + | if name.is_empty() { |
|
| 1216 | + | error = Some("Name the view.".into()); |
|
| 1217 | + | } else { |
|
| 1218 | + | pdb::save_view(&app.db, project.id, &name, layout, &q, lane, &sort).await.map_err(e)?; |
|
| 1219 | + | return Ok(Redirect::to(&format!("/projects/{owner}/{number}?view={}", super::settings::urlencode(&name))).into_response()); |
|
| 1220 | + | } |
|
| 1221 | + | } |
|
| 1222 | + | "delete_view" => { |
|
| 1223 | + | if let Some(id) = f.view { |
|
| 1224 | + | pdb::delete_view(&app.db, project.id, id).await.map_err(e)?; |
|
| 857 | 1225 | } |
|
| 858 | 1226 | } |
|
| 859 | 1227 | "add_column" => { |
|
| … | 195 unchanged lines |
||
| 1055 | 1423 | .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id)) |
|
| 1056 | 1424 | .collect(), |
|
| 1057 | 1425 | ) |
|
| 1426 | + | } |
|
| 1427 | + | ||
| 1428 | + | #[cfg(test)] |
|
| 1429 | + | mod view_tests { |
|
| 1430 | + | use super::*; |
|
| 1431 | + | ||
| 1432 | + | const NOW: i64 = 100 * DAY_MS; |
|
| 1433 | + | ||
| 1434 | + | #[allow(clippy::too_many_arguments)] |
|
| 1435 | + | fn card( |
|
| 1436 | + | id: i64, |
|
| 1437 | + | title: &str, |
|
| 1438 | + | repo: &str, |
|
| 1439 | + | state: &str, |
|
| 1440 | + | assignees: &[&str], |
|
| 1441 | + | labels: &[&str], |
|
| 1442 | + | milestone: &str, |
|
| 1443 | + | age_days: i64, |
|
| 1444 | + | ) -> CardView { |
|
| 1445 | + | CardView { |
|
| 1446 | + | card: pdb::Card { |
|
| 1447 | + | id, |
|
| 1448 | + | project_id: 1, |
|
| 1449 | + | column_id: 1, |
|
| 1450 | + | position: id, |
|
| 1451 | + | kind: "issue".into(), |
|
| 1452 | + | repo_id: Some(1), |
|
| 1453 | + | number: Some(id), |
|
| 1454 | + | note: String::new(), |
|
| 1455 | + | added_by: String::new(), |
|
| 1456 | + | created_ms: 0, |
|
| 1457 | + | archived_ms: None, |
|
| 1458 | + | moved_ms: NOW - age_days * DAY_MS, |
|
| 1459 | + | iteration_id: None, |
|
| 1460 | + | }, |
|
| 1461 | + | reference: format!("andy/{repo}#{id}"), |
|
| 1462 | + | url: format!("/andy/{repo}/issue/{id}"), |
|
| 1463 | + | title: title.into(), |
|
| 1464 | + | state: state.into(), |
|
| 1465 | + | labels: labels |
|
| 1466 | + | .iter() |
|
| 1467 | + | .map(|l| idb::Label { id: 0, repo_id: 1, name: (*l).into(), colour: "#123456".into(), description: String::new() }) |
|
| 1468 | + | .collect(), |
|
| 1469 | + | assignees: assignees.iter().map(|a| (*a).to_string()).collect(), |
|
| 1470 | + | repo: repo.into(), |
|
| 1471 | + | milestone: milestone.into(), |
|
| 1472 | + | opened_ms: NOW - age_days * DAY_MS, |
|
| 1473 | + | } |
|
| 1474 | + | } |
|
| 1475 | + | ||
| 1476 | + | fn column(id: i64, name: &str, wip: Option<i64>, cards: Vec<CardView>) -> ColumnView { |
|
| 1477 | + | ColumnView { |
|
| 1478 | + | column: pdb::Column { |
|
| 1479 | + | id, |
|
| 1480 | + | project_id: 1, |
|
| 1481 | + | name: name.into(), |
|
| 1482 | + | position: id, |
|
| 1483 | + | archive_after_days: None, |
|
| 1484 | + | takes_finished: name == "Done", |
|
| 1485 | + | wip_limit: wip, |
|
| 1486 | + | }, |
|
| 1487 | + | total: cards.len(), |
|
| 1488 | + | cards, |
|
| 1489 | + | hidden: 0, |
|
| 1490 | + | archived: 0, |
|
| 1491 | + | finished: false, |
|
| 1492 | + | over_wip: false, |
|
| 1493 | + | before_iteration: 0, |
|
| 1494 | + | } |
|
| 1495 | + | } |
|
| 1496 | + | ||
| 1497 | + | fn board() -> Vec<ColumnView> { |
|
| 1498 | + | vec![ |
|
| 1499 | + | column( |
|
| 1500 | + | 1, |
|
| 1501 | + | "Doing", |
|
| 1502 | + | Some(1), |
|
| 1503 | + | vec![ |
|
| 1504 | + | card(1, "Crash on start", "oxy", "open", &["andy"], &["bug"], "Release 1", 20), |
|
| 1505 | + | card(2, "Faster pull", "hub", "open", &[], &["perf", "bug"], "", 3), |
|
| 1506 | + | ], |
|
| 1507 | + | ), |
|
| 1508 | + | column(2, "Done", None, vec![card(3, "Docs", "oxy", "closed", &["kim"], &[], "Release 1", 40)]), |
|
| 1509 | + | ] |
|
| 1510 | + | } |
|
| 1511 | + | ||
| 1512 | + | fn keeps(q: &str) -> Vec<i64> { |
|
| 1513 | + | let f = CardFilter::parse(q); |
|
| 1514 | + | board().iter().flat_map(|c| c.cards.iter()).filter(|c| f.keeps(c, NOW)).map(|c| c.card.id).collect() |
|
| 1515 | + | } |
|
| 1516 | + | ||
| 1517 | + | #[test] |
|
| 1518 | + | fn the_filter_bar_reads_every_term() { |
|
| 1519 | + | assert_eq!(keeps(""), [1, 2, 3]); |
|
| 1520 | + | assert_eq!(keeps("repo:oxy"), [1, 3]); |
|
| 1521 | + | assert_eq!(keeps("repo:andy/hub"), [2]); |
|
| 1522 | + | assert_eq!(keeps("assignee:@andy"), [1]); |
|
| 1523 | + | assert_eq!(keeps("label:bug label:perf"), [2]); |
|
| 1524 | + | assert_eq!(keeps(r#"milestone:"release 1""#), [1, 3]); |
|
| 1525 | + | assert_eq!(keeps("is:open"), [1, 2]); |
|
| 1526 | + | assert_eq!(keeps("is:closed"), [3]); |
|
| 1527 | + | assert_eq!(keeps("age:>14d"), [1, 3]); |
|
| 1528 | + | assert_eq!(keeps("age:<7"), [2]); |
|
| 1529 | + | assert_eq!(keeps("crash age:>14d"), [1]); |
|
| 1530 | + | assert_eq!(keeps("age:soon"), Vec::<i64>::new(), "an unreadable term is a word, which matches nothing"); |
|
| 1531 | + | assert!(CardFilter::parse(" ").is_empty()); |
|
| 1532 | + | } |
|
| 1533 | + | ||
| 1534 | + | #[test] |
|
| 1535 | + | fn wip_limits_and_this_iterations_done() { |
|
| 1536 | + | let mut cols = board(); |
|
| 1537 | + | cols[1].cards.push(card(4, "Old", "oxy", "closed", &[], &[], "", 1)); |
|
| 1538 | + | cols[1].cards[1].card.moved_ms = NOW - 30 * DAY_MS; |
|
| 1539 | + | let q = BoardQuery::default(); |
|
| 1540 | + | shape(&mut cols, &q, NOW, Some(2), Some(NOW - 50 * DAY_MS + 1)); |
|
| 1541 | + | assert!(cols[0].over_wip, "two cards over a limit of one"); |
|
| 1542 | + | assert!(!cols[1].over_wip); |
|
| 1543 | + | // An iteration that began 50 days ago: card 3 (in Done for 40 days) |
|
| 1544 | + | // and card 4 (30) both reached Done during it. |
|
| 1545 | + | assert_eq!((cols[1].cards.len(), cols[1].before_iteration), (2, 0)); |
|
| 1546 | + | let mut cols = board(); |
|
| 1547 | + | shape(&mut cols, &q, NOW, Some(2), Some(NOW - 10 * DAY_MS)); |
|
| 1548 | + | assert_eq!((cols[1].cards.len(), cols[1].before_iteration, cols[1].total), (0, 1, 0), "done before it began"); |
|
| 1549 | + | } |
|
| 1550 | + | ||
| 1551 | + | #[test] |
|
| 1552 | + | fn lanes_and_table_rows() { |
|
| 1553 | + | let cols = board(); |
|
| 1554 | + | let names: Vec<(String, usize)> = lanes(&cols, "label").into_iter().map(|l| (l.name, l.count)).collect(); |
|
| 1555 | + | assert_eq!(names, [("bug".to_string(), 2), ("perf".into(), 1), ("(no label)".into(), 1)]); |
|
| 1556 | + | let by_repo: Vec<String> = lanes(&cols, "repo").into_iter().map(|l| l.name).collect(); |
|
| 1557 | + | assert_eq!(by_repo, ["hub", "oxy"]); |
|
| 1558 | + | let (shown, pages) = super::rows(&cols, "", "title", 1); |
|
| 1559 | + | assert_eq!(pages, 1); |
|
| 1560 | + | assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [1, 3, 2]); |
|
| 1561 | + | let (shown, _) = super::rows(&cols, "", "-age", 1); |
|
| 1562 | + | assert_eq!(shown.iter().map(|r| r.c.card.id).collect::<Vec<_>>(), [3, 1, 2], "oldest first"); |
|
| 1563 | + | let (shown, _) = super::rows(&cols, "assignee", "", 1); |
|
| 1564 | + | let groups: Vec<(&str, bool)> = shown.iter().map(|r| (r.group.as_str(), r.starts_group)).collect(); |
|
| 1565 | + | assert_eq!(groups, [("@andy", true), ("@kim", true), ("(nobody)", true)]); |
|
| 1566 | + | // Paged: 120 cards are three pages of fifty. |
|
| 1567 | + | let many = vec![column(1, "Doing", None, (0..120).map(|i| card(i, "x", "oxy", "open", &[], &[], "", 1)).collect())]; |
|
| 1568 | + | let (p3, pages) = super::rows(&many, "", "", 3); |
|
| 1569 | + | assert_eq!((p3.len(), pages), (20, 3)); |
|
| 1570 | + | } |
|
| 1058 | 1571 | } |
modified crates/hub/tests/issues.rs +22 −0
| … | 970 unchanged lines |
||
| 971 | 971 | let issue = get(app, "/andy/demo/issue/1", None).await.body; |
|
| 972 | 972 | assert!(issue.contains("href=\"/projects/andy/1\">Roadmap</a> <span class=\"mute\">· Done"), "the issue shows where it is"); |
|
| 973 | 973 | ||
| 974 | + | // Views (#361): every card move is history; a WIP limit, a saved |
|
| 975 | + | // table view grouped by repository, lanes, and the filter's new terms. |
|
| 976 | + | let moves: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_card_moves").fetch_one(&app.db).await.unwrap(); |
|
| 977 | + | assert_eq!(moves, 4, "three cards added and one moved"); |
|
| 978 | + | act(format!("_csrf={csrf}&action=wip&column={}&limit=0", cols[2])).await; |
|
| 979 | + | let b = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| 980 | + | assert!(b.contains("class=\"over-wip\"") && b.contains("1 / 0"), "Done is over its limit of none in progress"); |
|
| 981 | + | let r = act(format!("_csrf={csrf}&action=save_view&name=By+repo&layout=table&lane=repo&q=is%3Aopen")).await; |
|
| 982 | + | assert_eq!(r.location.as_deref(), Some("/projects/andy/1?view=By%20repo"), "{}", r.body); |
|
| 983 | + | let t = get(app, "/projects/andy/1?view=By%20repo", Some(&andy)).await.body; |
|
| 984 | + | assert!(t.contains("class=\"board-table\"") && t.contains("aria-current=\"page\">By repo"), "the view's tab and its table"); |
|
| 985 | + | assert!(t.contains("<th colspan=\"8\" scope=\"colgroup\">secret</th>") && t.contains("secret issue"), "{t}"); |
|
| 986 | + | assert!(!t.contains("Write the docs"), "a note is not open work"); |
|
| 987 | + | let lanes = get(app, "/projects/andy/1?lane=repo", Some(&andy)).await.body; |
|
| 988 | + | assert!(lanes.contains("aria-label=\"demo\"") && lanes.contains("aria-label=\"(notes)\""), "{lanes}"); |
|
| 989 | + | let only = get(app, "/projects/andy/1?q=repo%3Asecret", Some(&andy)).await.body; |
|
| 990 | + | assert!(only.contains("secret issue") && !only.contains("demo issue"), "repo: filters"); |
|
| 991 | + | let view_id: i64 = sqlx::query_scalar("SELECT id FROM project_views").fetch_one(&app.db).await.unwrap(); |
|
| 992 | + | act(format!("_csrf={csrf}&action=delete_view&view={view_id}")).await; |
|
| 993 | + | assert!(!get(app, "/projects/andy/1", Some(&andy)).await.body.contains(">By repo <"), "deleted"); |
|
| 994 | + | act(format!("_csrf={csrf}&action=wip&column={}&limit=", cols[2])).await; |
|
| 995 | + | ||
| 974 | 996 | // Readers see public cards, never those of repositories they cannot read. |
|
| 975 | 997 | let anon = get(app, "/projects/andy/1", None).await.body; |
|
| 976 | 998 | assert!(anon.contains("demo issue") && anon.contains("Write the docs") && !anon.contains("secret issue"), "{anon}"); |
|
| … | 638 unchanged lines |
modified crates/oh/src/main.rs +27 −4
| … | 533 unchanged lines |
||
| 534 | 534 | /// Its archived cards instead (#293) |
|
| 535 | 535 | #[arg(long)] |
|
| 536 | 536 | archived: bool, |
|
| 537 | + | /// Only the cards this filter keeps, as the board's filter bar takes it: |
|
| 538 | + | /// words, repo:, assignee:, label:, milestone:, is:open|closed, age:>14d (#361) |
|
| 539 | + | #[arg(long, short = 'q', conflicts_with = "view")] |
|
| 540 | + | filter: Option<String>, |
|
| 541 | + | /// A saved view of the board, by name: its filter (#361) |
|
| 542 | + | #[arg(long)] |
|
| 543 | + | view: Option<String>, |
|
| 537 | 544 | }, |
|
| 538 | 545 | /// Create a board (owner, or an organisation's members) |
|
| 539 | 546 | Create { |
|
| … | 1839 unchanged lines |
||
| 2379 | 2386 | println!("{}", project_line(p)); |
|
| 2380 | 2387 | } |
|
| 2381 | 2388 | } |
|
| 2382 | - | ProjectCmd::View { number, archived } => { |
|
| 2383 | - | let path = format!("{base}/{number}"); |
|
| 2384 | - | let cards = format!("{path}/cards?archived=true"); |
|
| 2389 | + | ProjectCmd::View { number, archived, filter, view } => { |
|
| 2390 | + | let cards = format!("{base}/{number}/cards?archived=true"); |
|
| 2391 | + | let path = match (&filter, &view) { |
|
| 2392 | + | (Some(q), _) => format!("{base}/{number}?q={}", enc(q)), |
|
| 2393 | + | (None, Some(v)) => format!("{base}/{number}?view={}", enc(v)), |
|
| 2394 | + | (None, None) => format!("{base}/{number}"), |
|
| 2395 | + | }; |
|
| 2385 | 2396 | if json { |
|
| 2386 | 2397 | println!("{}", c.get_text(if archived { &cards } else { &path })?); |
|
| 2387 | 2398 | return Ok(0); |
|
| … | 5 unchanged lines |
||
| 2393 | 2404 | println!("{}", project_line(&b.project)); |
|
| 2394 | 2405 | if !b.project.description.is_empty() { |
|
| 2395 | 2406 | println!("{}", b.project.description); |
|
| 2407 | + | } |
|
| 2408 | + | if !b.filter.is_empty() { |
|
| 2409 | + | println!("{}", ui::dim(&format!("filter: {}", b.filter))); |
|
| 2396 | 2410 | } |
|
| 2411 | + | if !b.views.is_empty() && filter.is_none() && view.is_none() { |
|
| 2412 | + | let names: Vec<&str> = b.views.iter().map(|v| v.name.as_str()).collect(); |
|
| 2413 | + | println!("{}", ui::dim(&format!("views: {} (oh project view {number} --view NAME)", names.join(", ")))); |
|
| 2414 | + | } |
|
| 2397 | 2415 | for col in &b.columns { |
|
| 2398 | 2416 | println!(); |
|
| 2399 | - | println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}", col.cards.len(), col.id))); |
|
| 2417 | + | let wip = match col.wip_limit { |
|
| 2418 | + | Some(w) if col.cards.len() as i64 > w => format!(" · over its limit of {w}"), |
|
| 2419 | + | Some(w) => format!(" · limit {w}"), |
|
| 2420 | + | None => String::new(), |
|
| 2421 | + | }; |
|
| 2422 | + | println!("{} {}", ui::strong(&col.name), ui::dim(&format!("{} · column {}{wip}", col.cards.len(), col.id))); |
|
| 2400 | 2423 | if col.cards.is_empty() { |
|
| 2401 | 2424 | println!(" {}", ui::dim("(empty)")); |
|
| 2402 | 2425 | } |
|
| … | 467 unchanged lines |
modified docs/spec.md +1 −1
| … | 493 unchanged lines |
||
| 494 | 494 | ||
| 495 | 495 | ### 12.1 API |
|
| 496 | 496 | ||
| 497 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 497 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags` (`POST` creates one, repo:write; `DELETE /tags/{tag}` withdraws one, a maintainer, `409` while a release uses it; §9), `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/user/repos` (the caller's own, organisations' and collaborations, `?affiliation=&after=&limit=`, #302), `/user/orgs`, `/user/sky` (Your sky's data, the dashboard backdrop's: the caller's repositories and their events of the last 30 days as `[repo, kind, at_ms, n]`, for the Android app; oxygenhub-android#23; `/repos/{owner}/{name}/sky` gives one repository's, its pages' backdrop), `/user/issues` (the open issues assigned to the caller, in repositories it can see, newest-updated first, each with its repository and address; #359), `/user/flow/runs` (the newest Flow runs of the caller's repositories, each with its repository and jobs, `?before=MS&limit=`; oxygenhub-android#26), `/user/starter?owner=X` and `/repos/{owner}/{name}/starter` (#347: the assistants a new repository under X may ask, and, `POST {"description", "assistant"}` by its admin, an assistant's draft of an empty repository's starting files, committed in the background as the caller, the hub signing as their delegate; `GET` says `running`, `done` with the change and files, or `failed` with why), `/notifications` (the caller's inbox and unread count, each with its repository, reason, thread and label, #305; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, with its saved `views` and each column's `wip_limit`; `?q=` applies a filter as the board's filter bar reads it (words, `repo:`, `assignee:`, `label:`, `milestone:`, `state:`, `is:open|closed`, `age:>Nd|<Nd`) and `?view=NAME` a saved view's, #361, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one, `POST /columns` (`{"name"}`, at the right), `PATCH /columns/{column}` (by id or name: `{"name", "position"}`, 0 the leftmost) and `DELETE /columns/{column}` (its cards to the first remaining column; a board keeps one) for its columns, each answering the board (#366), `POST /cards/move` (#289: `{"from", "column", "except", "state", "dry_run"}`) to move a column's cards at once, but those `except` names (card ids or issue and proposal references) and, given `state`, only those whose issue or proposal is in one, all or none and audited once, answering what moved and what was kept; `POST /cards/archive` (#293: `{"from", "older_days", "except", "state", "dry_run"}`, the same selection and an age) to archive them, `GET /cards?archived=true` to list the archived ones (each with `archived_ms`) and `POST /cards/{id}/restore` to put one back; cards carry their `labels` and `assignees`; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV, Cobertura or JaCoCo report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs, each with its timings: `queued_ms`, `ready_ms`, the `phases` its runner reported (image, prepare, run, finish, in milliseconds) and its peak disk and memory; #348, `oh run view N --timings`), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`), `/flow/runs/{n}/jobs/{job}/reports` and `/flow/runs/{n}/jobs/{job}/report` (`?name=`; with its files, and `…/report/{path}` one file as published, sandboxed) and `/reports` (`?bookmark=`: the newest run of each) and `/reports/{name}` (`?id=` or `?bookmark=`; `…/files/{path}`): the reports Flow jobs publish (#348, docs/reports.md §6; `oh run report N JOB`, `oh report`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going: the queued ones, and those running too unless the repository keeps them or they are the default bookmark's, which always finish (#369)), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 498 | 498 | ||
| 499 | 499 | ### 12.2 Webhooks |
|
| 500 | 500 | ||
| … | 398 unchanged lines |
modified templates/project.html +80 −19
| … | 1 unchanged lines |
||
| 2 | 2 | {% block title %}{{ project.title }} · Projects · {{ project.owner }}{% endblock %} |
|
| 3 | 3 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 4 | {% macro burnup(v) %}<svg class="burnup" viewBox="0 0 {{ crate::web::projects::BURN_W }} {{ crate::web::projects::BURN_H }}" preserveAspectRatio="none" role="img" aria-label="Burn-up: {{ v.done }} card{% if v.done != 1 %}s{% endif %} done over {{ v.so_far }} of {{ v.days }} days"><line x1="0" y1="{{ crate::web::projects::BURN_H }}" x2="{{ crate::web::projects::BURN_W }}" y2="{{ crate::web::projects::BURN_H }}"></line><polyline points="{{ v.points }}"></polyline></svg>{% endmacro %} |
|
| 5 | + | {% macro card(c, colid) %} |
|
| 6 | + | <li class="card-item{% if c.card.kind == "note" %} note{% endif %}" tabindex="-1" data-card="{{ c.card.id }}"{% if !c.url.is_empty() %} data-href="{{ c.url }}"{% endif %}> |
|
| 7 | + | {% if c.card.kind == "note" %}<p>{{ c.title }}</p> |
|
| 8 | + | {% else %}<a href="{{ c.url }}">{{ c.title }}</a> |
|
| 9 | + | <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> |
|
| 10 | + | {% if compact %}{% if !c.labels.is_empty() %}<p class="card-dots">{% for l in c.labels %}<span class="dot" style="--c: {{ l.colour }}" title="{{ l.name }}"></span>{% endfor %}</p>{% endif %}{% else 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 %} |
|
| 11 | + | {% if let Some(t) = c.card.archived_ms %}<p class="small mute">archived <time title="{{ t|when_i }}">{{ t|ago_i }}</time></p>{% endif %} |
|
| 12 | + | {% if can_edit && archived %} |
|
| 13 | + | <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 }}"><button name="action" value="restore">restore</button></form> |
|
| 14 | + | {% else if can_edit %} |
|
| 15 | + | <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 }}"> |
|
| 16 | + | <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label> |
|
| 17 | + | <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == colid %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select> |
|
| 18 | + | <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="archive" title="Archive: off the board, kept and restorable">archive</button><button name="action" value="remove" aria-label="Remove from the project" title="Remove from the project">✕</button> |
|
| 19 | + | </form>{% endif %} |
|
| 20 | + | </li> |
|
| 21 | + | {% endmacro %} |
|
| 5 | 22 | {% block content %} |
|
| 6 | 23 | <div class="head"><h1>{{ project.title }} <small><a href="/projects/{{ project.owner }}">{{ project.owner }}'s projects</a> · #{{ project.number }}{% if !project.public %} · private{% endif %}{% if project.closed %} · closed{% endif %}</small></h1> |
|
| 7 | 24 | {% if !project.description.is_empty() %}<p class="mute">{{ project.description }}</p>{% endif %}</div> |
|
| 8 | 25 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 9 | 26 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 27 | + | <nav class="board-tabs" aria-label="Views of this board"> |
|
| 28 | + | <a class="board-tab{% if view_name.is_empty() %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}"{% if view_name.is_empty() %} aria-current="page"{% endif %}>Board</a> |
|
| 29 | + | {% for v in views %}<a class="board-tab{% if v.name == *view_name %} on{% endif %}" href="/projects/{{ project.owner }}/{{ project.number }}?view={{ v.name|urlencode }}"{% if v.name == *view_name %} aria-current="page"{% endif %}>{{ v.name }} <span class="small mute">{{ v.layout }}</span></a>{% endfor %} |
|
| 30 | + | </nav> |
|
| 10 | 31 | {% if let Some(v) = shown_iteration %} |
|
| 11 | 32 | <section class="iteration past" aria-label="Iteration {{ v.it.name }}"> |
|
| 12 | 33 | <div><h2>{{ v.it.name }}</h2><p class="small mute">{{ v.first }} to {{ v.last }} · {{ v.done }} card{% if v.done != 1 %}s{% endif %} done, archived when it closed{% if can_edit %}; restore one to put it back on the board{% endif %} · <a href="/projects/{{ project.owner }}/{{ project.number }}">back to the board</a></p></div> |
|
| … | 20 unchanged lines |
||
| 33 | 54 | </section> |
|
| 34 | 55 | {% endif %} |
|
| 35 | 56 | <form class="board-filter" method="get" action="/projects/{{ project.owner }}/{{ project.number }}" role="search">{% if let Some(v) = shown_iteration %}<input type="hidden" name="iteration" value="{{ v.it.id }}">{% else if archived %}<input type="hidden" name="archived" value="1">{% endif %} |
|
| 36 | - | <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"> |
|
| 57 | + | {% if !view_name.is_empty() %}<input type="hidden" name="view" value="{{ view_name }}">{% endif %} |
|
| 58 | + | <label class="visually-hidden" for="board-q">Filter cards</label><input type="search" id="board-q" name="q" value="{{ q }}" placeholder="filter: words, repo:, assignee:, label:, milestone:, is:open, age:>14d" title="Words in the title or reference; repo:NAME, assignee:NAME, label:NAME, milestone:TITLE (quote a title with spaces), state:STATE, is:open or is:closed, age:>14d or age:<7d. Press / to come here." data-board-filter> |
|
| 59 | + | <label class="visually-hidden" for="board-layout">Layout</label><select id="board-layout" name="layout"><option value="board"{% if layout == "board" %} selected{% endif %}>board</option><option value="table"{% if layout == "table" %} selected{% endif %}>table</option></select> |
|
| 60 | + | <label class="visually-hidden" for="board-lane">{% if layout == "table" %}Group by{% else %}Swimlanes{% endif %}</label><select id="board-lane" name="lane"><option value="">{% if layout == "table" %}no groups{% else %}no lanes{% endif %}</option>{% for l in ["repo", "assignee", "label", "milestone"] %}<option value="{{ l }}"{% if lane == *l %} selected{% endif %}>by {{ l }}</option>{% endfor %}</select> |
|
| 61 | + | {% if layout == "table" %}<label class="visually-hidden" for="board-sort">Order</label><select id="board-sort" name="sort"><option value="">board order</option>{% for o in ["title", "reference", "state", "column", "age", "-age"] %}<option value="{{ o }}"{% if sort == *o %} selected{% endif %}>{% if *o == "-age" %}oldest first{% else if *o == "age" %}newest first{% else %}by {{ o }}{% endif %}</option>{% endfor %}</select>{% else %}<label class="small"><input type="checkbox" name="compact" value="1"{% if compact %} checked{% endif %}> compact</label>{% endif %} |
|
| 37 | 62 | <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> |
|
| 38 | - | <button type="submit" class="small">FILTER</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}{% if archived %}?archived=1{% endif %}">clear</a>{% endif %} |
|
| 63 | + | <button type="submit" class="small">SHOW</button>{% if filtered %} <a class="small" href="/projects/{{ project.owner }}/{{ project.number }}{% if archived %}?archived=1{% endif %}">clear</a>{% endif %} |
|
| 39 | 64 | {% if !archived && archived_total > 0 %}<a class="small board-archived-link" href="/projects/{{ project.owner }}/{{ project.number }}?archived=1">{{ archived_total }} archived card{% if archived_total != 1 %}s{% endif %}</a>{% endif %} |
|
| 40 | 65 | </form> |
|
| 41 | - | <div class="board" role="list" aria-label="Columns"> |
|
| 66 | + | {% if can_edit && !archived %}<details class="board-save-view"><summary class="small">{% if view_name.is_empty() %}save as a view{% else %}save view{% endif %}</summary> |
|
| 67 | + | <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="layout" value="{{ layout }}"><input type="hidden" name="q" value="{{ q }}"><input type="hidden" name="lane" value="{{ lane }}"><input type="hidden" name="sort" value="{{ sort }}"> |
|
| 68 | + | <label for="view-name">Name</label><input type="text" id="view-name" name="name" value="{{ view_name }}" maxlength="40" required placeholder="My open work"> |
|
| 69 | + | <p class="small mute">Keeps this layout, filter{% if !lane.is_empty() %}, {% if layout == "table" %}groups{% else %}lanes{% endif %}{% endif %}{% if !sort.is_empty() %} and order{% endif %} as a tab anyone with the board sees; its address can be shared.</p> |
|
| 70 | + | <button name="action" value="save_view" class="small">SAVE</button> |
|
| 71 | + | </form> |
|
| 72 | + | {% for v in views %}{% if v.name == *view_name %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="view" value="{{ v.id }}"><button name="action" value="delete_view" class="small danger-btn" data-confirm="Delete the view {{ v.name }}? The cards are not touched.">DELETE VIEW</button></form>{% endif %}{% endfor %} |
|
| 73 | + | </details>{% endif %} |
|
| 74 | + | {% if layout == "table" %} |
|
| 75 | + | <div class="table-wrap"><table class="board-table" aria-label="Cards"> |
|
| 76 | + | <thead><tr><th scope="col">Title</th><th scope="col">Reference</th><th scope="col">State</th><th scope="col">Column</th><th scope="col">Assignees</th><th scope="col">Labels</th><th scope="col">Milestone</th><th scope="col">Opened</th></tr></thead> |
|
| 77 | + | <tbody> |
|
| 78 | + | {% for r in rows %} |
|
| 79 | + | {% if r.starts_group %}<tr class="group-row"><th colspan="8" scope="colgroup">{{ r.group }}</th></tr>{% endif %} |
|
| 80 | + | <tr class="card-item" tabindex="-1" data-card="{{ r.c.card.id }}"{% if !r.c.url.is_empty() %} data-href="{{ r.c.url }}"{% endif %}> |
|
| 81 | + | <td>{% if r.c.url.is_empty() %}{{ r.c.title }}{% else %}<a href="{{ r.c.url }}">{{ r.c.title }}</a>{% endif %}</td> |
|
| 82 | + | <td class="small mute">{{ r.c.reference }}</td> |
|
| 83 | + | <td><span class="state-{{ r.c.state }}">{{ r.c.state }}</span></td> |
|
| 84 | + | <td>{% if can_edit %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="card" value="{{ r.c.card.id }}"><label class="visually-hidden" for="tmv-{{ r.c.card.id }}">Column</label><select id="tmv-{{ r.c.card.id }}" name="column" data-autosubmit>{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.name == r.column %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select><button name="action" value="move" class="small">move</button></form>{% else %}{{ r.column }}{% endif %}</td> |
|
| 85 | + | <td class="small">{% for a in r.c.assignees %}@{{ a }} {% endfor %}</td> |
|
| 86 | + | <td>{% for l in r.c.labels %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span>{% endfor %}</td> |
|
| 87 | + | <td class="small">{{ r.c.milestone }}</td> |
|
| 88 | + | <td class="small mute"><time title="{{ r.c.opened_ms|when_i }}">{{ r.c.opened_ms|ago_i }}</time></td> |
|
| 89 | + | </tr> |
|
| 90 | + | {% endfor %} |
|
| 91 | + | </tbody> |
|
| 92 | + | </table></div> |
|
| 93 | + | {% if rows.is_empty() %}<p class="mute">No cards{% if filtered %} match this filter{% endif %}.</p>{% endif %} |
|
| 94 | + | {% if pages > 1 %}<nav class="pager small" aria-label="Pages">{% if page > 1 %}<a href="?{{ look }}&layout=table&page={{ page - 1 }}">← previous</a>{% endif %} page {{ page }} of {{ pages }} {% if page < pages %}<a href="?{{ look }}&layout=table&page={{ page + 1 }}">next →</a>{% endif %}</nav>{% endif %} |
|
| 95 | + | {% else if !lanes.is_empty() %} |
|
| 96 | + | {% for ln in lanes %} |
|
| 97 | + | <section class="lane" aria-label="{{ ln.name }}"> |
|
| 98 | + | <h2 class="lane-head">{{ ln.name }} <span class="count-chip">{{ ln.count }}</span></h2> |
|
| 99 | + | <div class="board{% if compact %} compact{% endif %}" role="list" aria-label="Columns of {{ ln.name }}"> |
|
| 100 | + | {% for col in ln.columns %} |
|
| 101 | + | <section class="board-col" role="listitem" aria-label="{{ col.column.name }}"><header><h3>{{ col.column.name }} <span class="count-chip">{{ col.cards.len() }}</span></h3></header> |
|
| 102 | + | <ul class="cards">{% for c in col.cards %}{% call card(c, col.column.id) %}{% endfor %}</ul> |
|
| 103 | + | </section> |
|
| 104 | + | {% endfor %} |
|
| 105 | + | </div> |
|
| 106 | + | </section> |
|
| 107 | + | {% endfor %} |
|
| 108 | + | {% else %} |
|
| 109 | + | <div class="board{% if compact %} compact{% endif %}" role="list" aria-label="Columns"> |
|
| 42 | 110 | {% for col in columns %} |
|
| 43 | 111 | <section class="board-col" role="listitem" aria-labelledby="col-{{ col.column.id }}"> |
|
| 44 | - | <header><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip">{% if filtered %}{{ col.cards.len() }} of {{ col.total }}{% else %}{{ col.total }}{% endif %}</span></h2> |
|
| 112 | + | <header{% if col.over_wip %} class="over-wip"{% endif %}><h2 id="col-{{ col.column.id }}">{{ col.column.name }} <span class="count-chip"{% if col.over_wip %} title="Over its limit of {% if let Some(w) = col.column.wip_limit %}{{ w }}{% endif %} cards in progress"{% endif %}>{% if filtered %}{{ col.cards.len() }} of {{ col.total }}{% else %}{{ col.total }}{% endif %}{% if let Some(w) = col.column.wip_limit %} / {{ w }}{% endif %}</span></h2> |
|
| 45 | 113 | {% if can_edit && !archived %}<form class="tiny" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"><button name="action" value="left" title="Move the column left" aria-label="Move {{ col.column.name }} left">←</button><button name="action" value="right" title="Move the column right" aria-label="Move {{ col.column.name }} right">→</button></form>{% endif %}</header> |
|
| 46 | 114 | {% if !archived %}{% match col.column.archive_after_days %}{% when Some(-1) %}{% when Some(d) %}<p class="small mute col-archives">archives cards {{ d }} day{% if *d != 1 %}s{% endif %} after they reach it</p>{% when None %}{% if col.finished %}<p class="small mute col-archives">finished cards are archived after {{ crate::db::projects::FINISHED_KEPT_DAYS }} days</p>{% endif %}{% endmatch %}{% endif %} |
|
| 115 | + | {% if col.before_iteration > 0 %}<p class="small mute col-hidden">{{ col.before_iteration }} done before this iteration, shown when it closes in its archive · <a href="/projects/{{ project.owner }}/{{ project.number }}?archived=1">archived cards</a></p>{% endif %} |
|
| 47 | 116 | {% if col.hidden > 0 && !filtered && !archived %}<p class="small mute col-hidden">{{ col.hidden }} older card{% if col.hidden != 1 %}s{% endif %} not shown · <a href="/projects/{{ project.owner }}/{{ project.number }}?all={{ col.column.id }}#col-{{ col.column.id }}">show all {{ col.total }}</a></p>{% endif %} |
|
| 48 | 117 | <ul class="cards"> |
|
| 49 | 118 | {% for c in col.cards %} |
|
| 50 | - | <li class="card-item{% if c.card.kind == "note" %} note{% endif %}"> |
|
| 51 | - | {% if c.card.kind == "note" %}<p>{{ c.title }}</p> |
|
| 52 | - | {% else %}<a href="{{ c.url }}">{{ c.title }}</a> |
|
| 53 | - | <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> |
|
| 54 | - | {% 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 %} |
|
| 55 | - | {% if let Some(t) = c.card.archived_ms %}<p class="small mute">archived <time title="{{ t|when_i }}">{{ t|ago_i }}</time></p>{% endif %} |
|
| 56 | - | {% if can_edit && archived %} |
|
| 57 | - | <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 }}"><button name="action" value="restore">restore</button></form> |
|
| 58 | - | {% else if can_edit %} |
|
| 59 | - | <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 }}"> |
|
| 60 | - | <label class="visually-hidden" for="mv-{{ c.card.id }}">Column</label> |
|
| 61 | - | <select id="mv-{{ c.card.id }}" name="column">{% for o in columns %}<option value="{{ o.column.id }}"{% if o.column.id == col.column.id %} selected{% endif %}>{{ o.column.name }}</option>{% endfor %}</select> |
|
| 62 | - | <button name="action" value="move">move</button><button name="action" value="up" aria-label="Up">↑</button><button name="action" value="down" aria-label="Down">↓</button><button name="action" value="archive" title="Archive: off the board, kept and restorable">archive</button><button name="action" value="remove" aria-label="Remove from the project" title="Remove from the project">✕</button> |
|
| 63 | - | </form>{% endif %} |
|
| 64 | - | </li> |
|
| 119 | + | {% call card(c, col.column.id) %} |
|
| 65 | 120 | {% endfor %} |
|
| 66 | 121 | </ul> |
|
| 67 | 122 | {% if can_edit && !archived %} |
|
| … | 8 unchanged lines |
||
| 76 | 131 | <details class="add-card"><summary class="small">column</summary> |
|
| 77 | 132 | <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 78 | 133 | <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" data-confirm="Remove this column? Its cards move to the first column.">REMOVE</button> |
|
| 134 | + | </form> |
|
| 135 | + | <form class="col-archive" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 136 | + | <label for="wip-{{ col.column.id }}">Work in progress limit</label> |
|
| 137 | + | <div class="inline-fields"><input type="number" id="wip-{{ col.column.id }}" name="limit" min="0" max="999" placeholder="none"{% if let Some(w) = col.column.wip_limit %} value="{{ w }}"{% endif %}><span class="small mute">cards</span><button name="action" value="wip" class="small">SAVE</button></div> |
|
| 79 | 138 | </form> |
|
| 80 | 139 | <form class="col-archive" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}"> |
|
| 81 | 140 | <label for="older-{{ col.column.id }}">Archive cards here more than</label> |
|
| … | 14 unchanged lines |
||
| 96 | 155 | <section class="board-col add-col"><form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="newcol">New column</label><input type="text" id="newcol" name="name" maxlength="40" required><button name="action" value="add_column" class="small">ADD COLUMN</button></form></section> |
|
| 97 | 156 | {% endif %} |
|
| 98 | 157 | </div> |
|
| 158 | + | {% endif %} |
|
| 159 | + | <p class="small mute board-keys">Keys: <kbd>j</kbd>/<kbd>k</kbd> move between cards, <kbd>Enter</kbd> opens one, <kbd>m</kbd> moves it to another column, <kbd>/</kbd> filters.</p> |
|
| 99 | 160 | {% if can_manage %} |
|
| 100 | 161 | <details class="panel"><summary>Settings</summary> |
|
| 101 | 162 | <form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 17 unchanged lines |
modified templates/style.css +23 −0
| … | 643 unchanged lines |
||
| 644 | 644 | .card-item { background: var(--panel); border: 1px solid var(--line); padding: 8px 10px; } |
|
| 645 | 645 | .card-item.note { border-left: 3px solid var(--warn); } |
|
| 646 | 646 | .card-item p { margin: 4px 0 0; } |
|
| 647 | + | /* Board views (#361): tabs, WIP limits, lanes, compact cards, the table. */ |
|
| 648 | + | .board-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; border-bottom: 1px solid var(--line); } |
|
| 649 | + | .board-tab { padding: 4px 10px; border: 1px solid transparent; border-bottom: none; color: var(--mute); text-decoration: none; } |
|
| 650 | + | .board-tab.on { border-color: var(--line); background: var(--panel); color: var(--ink); margin-bottom: -1px; } |
|
| 651 | + | .board-col > header.over-wip h2, .board-col > header.over-wip .count-chip { color: var(--warn); } |
|
| 652 | + | .board-col > header.over-wip { border-bottom: 2px solid var(--warn); } |
|
| 653 | + | .lane { margin: 0 0 14px; } |
|
| 654 | + | .lane-head { font-size: 14px; margin: 0 0 6px; } |
|
| 655 | + | .lane .board-col h3 { font-size: 13px; margin: 2px 0 6px; } |
|
| 656 | + | .board.compact .card-item { padding: 5px 8px; } |
|
| 657 | + | .board.compact .card-item > p.small, .board.compact .card-actions { display: none; } |
|
| 658 | + | .board.compact .card-item:focus-within .card-actions { display: flex; } |
|
| 659 | + | .card-dots { display: flex; gap: 3px; } |
|
| 660 | + | .card-dots .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--c); } |
|
| 661 | + | .card-item:focus { outline: 2px solid var(--acc); outline-offset: 1px; } |
|
| 662 | + | .table-wrap { overflow-x: auto; } |
|
| 663 | + | .board-table { border-collapse: collapse; width: 100%; font-size: 13px; } |
|
| 664 | + | .board-table th, .board-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } |
|
| 665 | + | .board-table thead th { color: var(--mute); font-weight: 600; } |
|
| 666 | + | .board-table tr.group-row th { background: var(--soft); padding-top: 8px; } |
|
| 667 | + | .board-table tr.card-item { background: none; border: none; } |
|
| 668 | + | .board-save-view { margin: 0 0 10px; } |
|
| 669 | + | .board-keys { margin-top: 8px; } |
|
| 647 | 670 | .card-item .card-labels { display: flex; flex-wrap: wrap; gap: 4px; } |
|
| 648 | 671 | .card-item .card-labels .label { margin-right: 0; text-decoration: none; } |
|
| 649 | 672 | .card-item .card-labels .label:hover, .card-item .card-labels .label:focus-visible { background: color-mix(in srgb, var(--c, var(--acc)) 14%, transparent); } |
|
| … | 870 unchanged lines |