Lists: filters, sort orders, pages, and counts on the tabs 1ac84c62ba53
The Proposals and Issues tabs show how many are open. The issue list gains author: beside label:, assignee: and milestone:, and sorts by newest (pinned first), oldest, recently updated or most comments. The proposal list gains a filter (author:, label:, target: and words), its labels, and sorts by recently updated, newest or oldest. Both page 25 at a time with links that keep the filter and sort.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 09:49 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
1ac84c62ba5339fce067c2888f3112f749b2f357ec6f3dd32567a970e2fbeee7- Depends on
- 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 93be9ce4653e d23464c0ac60 de505df5cf8d
- Depended on by
- 166f6e9bf4f1 0ee5c3fb32eb a9f1c0bf403a 4a68f1064b08 16ee5d19d220 5d90e0e045e2 d0fbea2eb46e 9008bf6c2b8c e1ec9fbf538e 756fe44a0f4d 42fa95540468 64caed18236a
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 42 atoms, 1 tree operation, 12 files
Browse the tree at this change
added templates/pager.html +7 −0
| 1 | + | {% if pager.pages > 1 %} |
|
| 2 | + | <nav class="pager small" aria-label="Pages"> |
|
| 3 | + | {% if pager.page > 1 %}<a class="go" href="{{ pager.url(pager.page - 1) }}" rel="prev">← newer</a>{% endif %} |
|
| 4 | + | <span class="mute">page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} in all</span> |
|
| 5 | + | {% if pager.page < pager.pages %}<a class="go" href="{{ pager.url(pager.page + 1) }}" rel="next">older →</a>{% endif %} |
|
| 6 | + | </nav> |
|
| 7 | + | {% endif %} |
modified crates/hub/src/db/issues.rs +14 −0
| … | 259 unchanged lines |
||
| 260 | 260 | } |
|
| 261 | 261 | ||
| 262 | 262 | /// Labels of every issue of a repository, for the list page. |
|
| 263 | + | /// Every proposal's labels in a repository, for filtering the list. |
|
| 264 | + | pub async fn labels_by_proposal(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> { |
|
| 265 | + | let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as( |
|
| 266 | + | "SELECT pl.proposal_id, l.id, l.repo_id, l.name, l.colour, l.description FROM proposal_labels pl JOIN labels l ON l.id = pl.label_id JOIN proposals p ON p.id = pl.proposal_id WHERE p.repo_id = ? ORDER BY l.name", |
|
| 267 | + | ) |
|
| 268 | + | .bind(repo_id) |
|
| 269 | + | .fetch_all(pool) |
|
| 270 | + | .await?; |
|
| 271 | + | Ok(rows |
|
| 272 | + | .into_iter() |
|
| 273 | + | .map(|(proposal_id, id, repo_id, name, colour, description)| (proposal_id, Label { id, repo_id, name, colour, description })) |
|
| 274 | + | .collect()) |
|
| 275 | + | } |
|
| 276 | + | ||
| 263 | 277 | pub async fn labels_by_issue(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<(i64, Label)>> { |
|
| 264 | 278 | let rows: Vec<(i64, i64, i64, String, String, String)> = sqlx::query_as( |
|
| 265 | 279 | "SELECT il.issue_id, l.id, l.repo_id, l.name, l.colour, l.description FROM issue_labels il JOIN labels l ON l.id = il.label_id JOIN issues i ON i.id = il.issue_id WHERE i.repo_id = ? ORDER BY l.name", |
|
| … | 107 unchanged lines |
modified crates/hub/src/db/repos.rs +16 −1
| … | 23 unchanged lines |
||
| 24 | 24 | /// Space-separated topics (migration 0010). |
|
| 25 | 25 | #[sqlx(default)] |
|
| 26 | 26 | pub topics: String, |
|
| 27 | + | /// Counts for the tabs; filled by `get` only. |
|
| 28 | + | #[sqlx(default)] |
|
| 29 | + | pub open_issues: i64, |
|
| 30 | + | #[sqlx(default)] |
|
| 31 | + | pub open_proposals: i64, |
|
| 27 | 32 | } |
|
| 28 | 33 | ||
| 29 | 34 | impl RepoRow { |
|
| … | 48 unchanged lines |
||
| 78 | 83 | sqlx::query_as("SELECT * FROM repositories WHERE owner = ? ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await |
|
| 79 | 84 | } |
|
| 80 | 85 | ||
| 86 | + | /// One repository, with its open issue and proposal counts for the tabs. |
|
| 81 | 87 | pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> { |
|
| 82 | - | sqlx::query_as("SELECT * FROM repositories WHERE owner = ? AND name = ?").bind(owner).bind(name).fetch_optional(pool).await |
|
| 88 | + | sqlx::query_as( |
|
| 89 | + | "SELECT r.*, \ |
|
| 90 | + | (SELECT COUNT(*) FROM issues i WHERE i.repo_id = r.id AND i.state = 'open') AS open_issues, \ |
|
| 91 | + | (SELECT COUNT(*) FROM proposals p WHERE p.repo_id = r.id AND p.state IN ('open', 'draft')) AS open_proposals \ |
|
| 92 | + | FROM repositories r WHERE r.owner = ? AND r.name = ?", |
|
| 93 | + | ) |
|
| 94 | + | .bind(owner) |
|
| 95 | + | .bind(name) |
|
| 96 | + | .fetch_optional(pool) |
|
| 97 | + | .await |
|
| 83 | 98 | } |
|
| 84 | 99 | ||
| 85 | 100 | pub async fn create(pool: &Pool, owner: &str, name: &str, description: &str, visibility: Visibility) -> sqlx::Result<RepoRow> { |
|
| … | 164 unchanged lines |
modified crates/hub/src/web/issues.rs +39 −2
| … | 46 unchanged lines |
||
| 47 | 47 | state: String, |
|
| 48 | 48 | q: String, |
|
| 49 | 49 | counts: (usize, usize), |
|
| 50 | + | sort: String, |
|
| 51 | + | pager: super::Pager, |
|
| 50 | 52 | } |
|
| 51 | 53 | ||
| 52 | 54 | #[derive(Deserialize)] |
|
| 53 | 55 | pub struct ListQuery { |
|
| 54 | 56 | state: Option<String>, |
|
| 55 | 57 | q: Option<String>, |
|
| 58 | + | sort: Option<String>, |
|
| 59 | + | page: Option<usize>, |
|
| 56 | 60 | } |
|
| 57 | 61 | ||
| 62 | + | /// The list's sort orders: `newest` (pinned first, the default), |
|
| 63 | + | /// `oldest`, `updated`, `comments`. |
|
| 64 | + | pub const SORTS: &[(&str, &str)] = |
|
| 65 | + | &[("newest", "newest"), ("oldest", "oldest"), ("updated", "recently updated"), ("comments", "most comments")]; |
|
| 66 | + | ||
| 58 | 67 | /// `label:bug assignee:andy milestone:v1 state:closed words…` |
|
| 59 | 68 | struct Filter { |
|
| 60 | 69 | state: String, |
|
| 61 | 70 | labels: Vec<String>, |
|
| 62 | 71 | assignees: Vec<String>, |
|
| 72 | + | authors: Vec<String>, |
|
| 63 | 73 | milestone: Option<String>, |
|
| 64 | 74 | words: Vec<String>, |
|
| 65 | 75 | } |
|
| 66 | 76 | ||
| 67 | 77 | fn parse_filter(state: Option<String>, q: &str) -> Filter { |
|
| 68 | - | let mut f = Filter { state: state.unwrap_or_else(|| "open".into()), labels: vec![], assignees: vec![], milestone: None, words: vec![] }; |
|
| 78 | + | let mut f = Filter { |
|
| 79 | + | state: state.unwrap_or_else(|| "open".into()), |
|
| 80 | + | labels: vec![], |
|
| 81 | + | assignees: vec![], |
|
| 82 | + | authors: vec![], |
|
| 83 | + | milestone: None, |
|
| 84 | + | words: vec![], |
|
| 85 | + | }; |
|
| 69 | 86 | for tok in q.split_whitespace() { |
|
| 70 | 87 | match tok.split_once(':') { |
|
| 71 | 88 | Some(("label", v)) => f.labels.push(v.to_lowercase()), |
|
| 72 | 89 | Some(("assignee", v)) => f.assignees.push(v.to_lowercase()), |
|
| 90 | + | Some(("author", v)) => f.authors.push(v.to_lowercase()), |
|
| 73 | 91 | Some(("milestone", v)) => f.milestone = Some(v.to_lowercase()), |
|
| 74 | 92 | Some(("state", v)) => f.state = v.to_string(), |
|
| 75 | 93 | _ => f.words.push(tok.to_lowercase()), |
|
| … | 33 unchanged lines |
||
| 109 | 127 | let keep = (f.state == "all" || i.state == f.state) |
|
| 110 | 128 | && f.labels.iter().all(|l| labels.iter().any(|x| x.name.to_lowercase() == *l)) |
|
| 111 | 129 | && f.assignees.iter().all(|a| assignees.iter().any(|x| x.to_lowercase() == *a)) |
|
| 130 | + | && (f.authors.is_empty() || f.authors.contains(&i.author.to_lowercase())) |
|
| 112 | 131 | && f.milestone.as_ref().is_none_or(|m| milestone.as_ref().is_some_and(|x| x.to_lowercase() == *m)) |
|
| 113 | 132 | && f.words.iter().all(|w| i.title.to_lowercase().contains(w) || i.body.to_lowercase().contains(w)); |
|
| 114 | 133 | if keep { |
|
| … | 5 unchanged lines |
||
| 120 | 139 | rows.push(IssueLine { issue: i, labels, assignees, milestone, comments }); |
|
| 121 | 140 | } |
|
| 122 | 141 | } |
|
| 123 | - | Ok(Page(IssuesPage { shell, repo: RepoShell::new(&app, &row, "issues").with_role(role), rows, state: f.state, q: qtext, counts })) |
|
| 142 | + | let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "newest".into()); |
|
| 143 | + | match sort.as_str() { |
|
| 144 | + | "oldest" => rows.sort_by_key(|r| r.issue.number), |
|
| 145 | + | "updated" => rows.sort_by_key(|r| std::cmp::Reverse(r.issue.updated_ms)), |
|
| 146 | + | "comments" => rows.sort_by_key(|r| (std::cmp::Reverse(r.comments), std::cmp::Reverse(r.issue.number))), |
|
| 147 | + | _ => {} |
|
| 148 | + | } |
|
| 149 | + | let pager = super::Pager::new(rows.len(), q.page, &[("state", &f.state), ("q", &qtext), ("sort", &sort)]); |
|
| 150 | + | let rows = pager.slice(rows); |
|
| 151 | + | Ok(Page(IssuesPage { |
|
| 152 | + | shell, |
|
| 153 | + | repo: RepoShell::new(&app, &row, "issues").with_role(role), |
|
| 154 | + | rows, |
|
| 155 | + | state: f.state, |
|
| 156 | + | q: qtext, |
|
| 157 | + | counts, |
|
| 158 | + | sort, |
|
| 159 | + | pager, |
|
| 160 | + | })) |
|
| 124 | 161 | } |
|
| 125 | 162 | ||
| 126 | 163 | // ------------------------------------------------------------------- new |
|
| … | 785 unchanged lines |
modified crates/hub/src/web/mod.rs +56 −0
| … | 282 unchanged lines |
||
| 283 | 283 | pub clone_url: String, |
|
| 284 | 284 | /// The viewer's role, for showing settings and write actions. |
|
| 285 | 285 | pub role: Option<Role>, |
|
| 286 | + | /// Open issues and proposals, shown on their tabs. |
|
| 287 | + | pub open_issues: i64, |
|
| 288 | + | pub open_proposals: i64, |
|
| 286 | 289 | } |
|
| 287 | 290 | ||
| 288 | 291 | impl RepoShell { |
|
| … | 8 unchanged lines |
||
| 297 | 300 | tab, |
|
| 298 | 301 | clone_url: format!("{}/{}/{}", app.config.base_url(), row.owner, row.name), |
|
| 299 | 302 | role: None, |
|
| 303 | + | open_issues: row.open_issues, |
|
| 304 | + | open_proposals: row.open_proposals, |
|
| 300 | 305 | } |
|
| 301 | 306 | } |
|
| 302 | 307 | ||
| … | 12 unchanged lines |
||
| 315 | 320 | } |
|
| 316 | 321 | ||
| 317 | 322 | /// Render a template as an HTML response. |
|
| 323 | + | /// One page of a long list: which page, how many, and links that keep |
|
| 324 | + | /// the rest of the query. |
|
| 325 | + | #[derive(Clone, Debug)] |
|
| 326 | + | pub struct Pager { |
|
| 327 | + | pub page: usize, |
|
| 328 | + | pub pages: usize, |
|
| 329 | + | pub total: usize, |
|
| 330 | + | /// The query without `page`, each pair encoded, joined with `&`. |
|
| 331 | + | query: String, |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | impl Pager { |
|
| 335 | + | pub const PER_PAGE: usize = 25; |
|
| 336 | + | ||
| 337 | + | /// `page` counts from 1; out-of-range pages clamp to the last. |
|
| 338 | + | pub fn new(total: usize, page: Option<usize>, query: &[(&str, &str)]) -> Pager { |
|
| 339 | + | let pages = total.div_ceil(Self::PER_PAGE).max(1); |
|
| 340 | + | let page = page.unwrap_or(1).clamp(1, pages); |
|
| 341 | + | let query = query |
|
| 342 | + | .iter() |
|
| 343 | + | .filter(|(_, v)| !v.is_empty()) |
|
| 344 | + | .map(|(k, v)| format!("{k}={}", settings::urlencode(v).replace('/', "%2F"))) |
|
| 345 | + | .collect::<Vec<_>>() |
|
| 346 | + | .join("&"); |
|
| 347 | + | Pager { page, pages, total, query } |
|
| 348 | + | } |
|
| 349 | + | ||
| 350 | + | /// This page's items. |
|
| 351 | + | pub fn slice<T>(&self, all: Vec<T>) -> Vec<T> { |
|
| 352 | + | all.into_iter().skip((self.page - 1) * Self::PER_PAGE).take(Self::PER_PAGE).collect() |
|
| 353 | + | } |
|
| 354 | + | ||
| 355 | + | pub fn url(&self, page: usize) -> String { |
|
| 356 | + | if self.query.is_empty() { |
|
| 357 | + | format!("?page={page}") |
|
| 358 | + | } else { |
|
| 359 | + | format!("?{}&page={page}", self.query) |
|
| 360 | + | } |
|
| 361 | + | } |
|
| 362 | + | ||
| 363 | + | /// The same query with another sort, back on page one. |
|
| 364 | + | pub fn sorted(&self, sort: &str) -> String { |
|
| 365 | + | let rest: Vec<&str> = self.query.split('&').filter(|p| !p.is_empty() && !p.starts_with("sort=")).collect(); |
|
| 366 | + | let mut q = rest.join("&"); |
|
| 367 | + | if !q.is_empty() { |
|
| 368 | + | q.push('&'); |
|
| 369 | + | } |
|
| 370 | + | format!("?{q}sort={sort}") |
|
| 371 | + | } |
|
| 372 | + | } |
|
| 373 | + | ||
| 318 | 374 | pub struct Page<T: Template>(pub T); |
|
| 319 | 375 | ||
| 320 | 376 | impl<T: Template> IntoResponse for Page<T> { |
|
| … | 176 unchanged lines |
modified crates/hub/src/web/proposals.rs +56 −2
| … | 29 unchanged lines |
||
| 30 | 30 | filter: String, |
|
| 31 | 31 | counts: (usize, usize, usize), |
|
| 32 | 32 | bookmarks: Vec<String>, |
|
| 33 | + | q: String, |
|
| 34 | + | sort: String, |
|
| 35 | + | pager: super::Pager, |
|
| 36 | + | labels: std::collections::HashMap<i64, Vec<db::issues::Label>>, |
|
| 33 | 37 | } |
|
| 34 | 38 | ||
| 39 | + | impl ProposalsPage { |
|
| 40 | + | fn labels_of(&self, id: &i64) -> &[db::issues::Label] { |
|
| 41 | + | self.labels.get(id).map(Vec::as_slice).unwrap_or(&[]) |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | ||
| 35 | 45 | #[derive(Deserialize)] |
|
| 36 | 46 | pub struct ListQuery { |
|
| 37 | 47 | state: Option<String>, |
|
| 48 | + | q: Option<String>, |
|
| 49 | + | sort: Option<String>, |
|
| 50 | + | page: Option<usize>, |
|
| 38 | 51 | } |
|
| 39 | 52 | ||
| 53 | + | /// The list's sort orders. |
|
| 54 | + | pub const SORTS: &[(&str, &str)] = &[("updated", "recently updated"), ("newest", "newest"), ("oldest", "oldest")]; |
|
| 55 | + | ||
| 40 | 56 | pub async fn list( |
|
| 41 | 57 | State(app): State<Shared>, |
|
| 42 | 58 | viewer: Viewer, |
|
| … | 9 unchanged lines |
||
| 52 | 68 | all.iter().filter(|p| p.state == "closed").count(), |
|
| 53 | 69 | ); |
|
| 54 | 70 | let filter = q.state.unwrap_or_else(|| "open".into()); |
|
| 55 | - | let rows = all |
|
| 71 | + | let mut labels: std::collections::HashMap<i64, Vec<db::issues::Label>> = std::collections::HashMap::new(); |
|
| 72 | + | for (id, l) in db::issues::labels_by_proposal(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 73 | + | labels.entry(id).or_default().push(l); |
|
| 74 | + | } |
|
| 75 | + | // `author:bob label:ui target:main words…` |
|
| 76 | + | let qtext = q.q.clone().unwrap_or_default(); |
|
| 77 | + | let (mut authors, mut wanted, mut targets, mut words) = (vec![], vec![], vec![], vec![]); |
|
| 78 | + | for tok in qtext.split_whitespace() { |
|
| 79 | + | match tok.split_once(':') { |
|
| 80 | + | Some(("author", v)) => authors.push(v.to_lowercase()), |
|
| 81 | + | Some(("label", v)) => wanted.push(v.to_lowercase()), |
|
| 82 | + | Some(("target", v)) => targets.push(v.to_string()), |
|
| 83 | + | _ => words.push(tok.to_lowercase()), |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | let mut rows: Vec<pdb::ProposalRow> = all |
|
| 56 | 87 | .into_iter() |
|
| 57 | 88 | .filter(|p| match filter.as_str() { |
|
| 58 | 89 | "open" => p.is_open(), |
|
| … | 1 unchanged lines |
||
| 60 | 91 | "closed" => p.state == "closed", |
|
| 61 | 92 | _ => true, |
|
| 62 | 93 | }) |
|
| 94 | + | .filter(|p| authors.is_empty() || authors.contains(&p.author.to_lowercase())) |
|
| 95 | + | .filter(|p| targets.is_empty() || targets.contains(&p.target)) |
|
| 96 | + | .filter(|p| wanted.iter().all(|w| labels.get(&p.id).is_some_and(|ls| ls.iter().any(|l| l.name.to_lowercase() == *w)))) |
|
| 97 | + | .filter(|p| words.iter().all(|w| p.title.to_lowercase().contains(w) || p.body.to_lowercase().contains(w))) |
|
| 63 | 98 | .collect(); |
|
| 99 | + | let sort = q.sort.filter(|s| SORTS.iter().any(|(k, _)| k == s)).unwrap_or_else(|| "updated".into()); |
|
| 100 | + | match sort.as_str() { |
|
| 101 | + | "newest" => rows.sort_by_key(|p| std::cmp::Reverse(p.number)), |
|
| 102 | + | "oldest" => rows.sort_by_key(|p| p.number), |
|
| 103 | + | _ => rows.sort_by_key(|p| std::cmp::Reverse(p.updated_ms)), |
|
| 104 | + | } |
|
| 105 | + | let pager = super::Pager::new(rows.len(), q.page, &[("state", &filter), ("q", &qtext), ("sort", &sort)]); |
|
| 106 | + | let rows = pager.slice(rows); |
|
| 64 | 107 | let bookmarks = app |
|
| 65 | 108 | .repos |
|
| 66 | 109 | .read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())) |
|
| 67 | 110 | .await |
|
| 68 | 111 | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 69 | - | Ok(Page(ProposalsPage { shell, repo: RepoShell::new(&app, &row, "proposals").with_role(role), rows, filter, counts, bookmarks })) |
|
| 112 | + | Ok(Page(ProposalsPage { |
|
| 113 | + | shell, |
|
| 114 | + | repo: RepoShell::new(&app, &row, "proposals").with_role(role), |
|
| 115 | + | rows, |
|
| 116 | + | filter, |
|
| 117 | + | counts, |
|
| 118 | + | bookmarks, |
|
| 119 | + | q: qtext, |
|
| 120 | + | sort, |
|
| 121 | + | pager, |
|
| 122 | + | labels, |
|
| 123 | + | })) |
|
| 70 | 124 | } |
|
| 71 | 125 | ||
| 72 | 126 | // ------------------------------------------------------------------- new |
|
| … | 1040 unchanged lines |
modified crates/hub/tests/issues.rs +68 −0
| … | 587 unchanged lines |
||
| 588 | 588 | let by_topic = get(app, "/explore?topic=6502", None).await.body; |
|
| 589 | 589 | assert!(by_topic.contains("/andy/gamma\"") && !by_topic.contains("/andy/beta\""), "{by_topic}"); |
|
| 590 | 590 | } |
|
| 591 | + | ||
| 592 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 593 | + | async fn lists_filter_sort_page_and_count() { |
|
| 594 | + | let h = hub().await; |
|
| 595 | + | let app = &h.app; |
|
| 596 | + | let andy = register(app, "andy").await; |
|
| 597 | + | let _bob = register(app, "bob").await; |
|
| 598 | + | app.repos.create(&RepoName::parse("andy/demo").unwrap(), None).unwrap(); |
|
| 599 | + | let repo = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 600 | + | for n in 0..30 { |
|
| 601 | + | let author = if n % 3 == 0 { "bob" } else { "andy" }; |
|
| 602 | + | db::issues::create( |
|
| 603 | + | &app.db, |
|
| 604 | + | db::issues::NewIssue { repo_id: repo.id, title: &format!("Issue number {n}"), body: "", author_id: None, author }, |
|
| 605 | + | ) |
|
| 606 | + | .await |
|
| 607 | + | .unwrap(); |
|
| 608 | + | } |
|
| 609 | + | let ui = db::issues::upsert_label(&app.db, repo.id, "ui", "#00f0ff", "").await.unwrap(); |
|
| 610 | + | for (title, author, target) in [("Faster menus", "bob", "main"), ("New theme", "andy", "main"), ("Release prep", "andy", "stable")] { |
|
| 611 | + | let p = db::proposals::create( |
|
| 612 | + | &app.db, |
|
| 613 | + | db::proposals::NewProposal { |
|
| 614 | + | repo_id: repo.id, |
|
| 615 | + | title, |
|
| 616 | + | body: "", |
|
| 617 | + | author_id: None, |
|
| 618 | + | author, |
|
| 619 | + | draft: false, |
|
| 620 | + | target, |
|
| 621 | + | source_kind: "changes", |
|
| 622 | + | source_repo_id: None, |
|
| 623 | + | source_bookmark: "", |
|
| 624 | + | source_changes: "", |
|
| 625 | + | }, |
|
| 626 | + | ) |
|
| 627 | + | .await |
|
| 628 | + | .unwrap(); |
|
| 629 | + | if title == "New theme" { |
|
| 630 | + | db::issues::set_proposal_label(&app.db, p.id, ui.id, true).await.unwrap(); |
|
| 631 | + | } |
|
| 632 | + | } |
|
| 633 | + | ||
| 634 | + | // The tabs count what is open. |
|
| 635 | + | let page = get(app, "/andy/demo/issues", Some(&andy)).await; |
|
| 636 | + | assert!(page.body.contains(r#"Issues <span class="count-chip">30</span>"#), "{}", page.body); |
|
| 637 | + | assert!(page.body.contains(r#"Proposals <span class="count-chip">3</span>"#)); |
|
| 638 | + | ||
| 639 | + | // Pages of 25, newest first; the second page holds the rest. |
|
| 640 | + | let rows = |b: &str| b.matches("/andy/demo/issue/").count(); |
|
| 641 | + | assert_eq!(rows(&page.body), 25); |
|
| 642 | + | assert!(page.body.contains("page 1 of 2") && page.body.contains("page=2"), "a pager"); |
|
| 643 | + | let second = get(app, "/andy/demo/issues?page=2", Some(&andy)).await; |
|
| 644 | + | assert_eq!(rows(&second.body), 5); |
|
| 645 | + | assert!(second.body.contains("Issue number 0<"), "the oldest are last"); |
|
| 646 | + | let oldest = get(app, "/andy/demo/issues?sort=oldest", Some(&andy)).await; |
|
| 647 | + | assert!(oldest.body.find("Issue number 0<").unwrap() < oldest.body.find("Issue number 1<").unwrap()); |
|
| 648 | + | let bobs = get(app, "/andy/demo/issues?q=author:bob", Some(&andy)).await; |
|
| 649 | + | assert_eq!(rows(&bobs.body), 10, "{}", bobs.body); |
|
| 650 | + | ||
| 651 | + | // Proposals: by author, label and target. |
|
| 652 | + | let by_bob = get(app, "/andy/demo/proposals?q=author:bob", Some(&andy)).await.body; |
|
| 653 | + | assert!(by_bob.contains("Faster menus") && !by_bob.contains("New theme")); |
|
| 654 | + | let ui_only = get(app, "/andy/demo/proposals?q=label:ui", Some(&andy)).await.body; |
|
| 655 | + | assert!(ui_only.contains("New theme") && !ui_only.contains("Faster menus") && ui_only.contains(r#"class="label""#)); |
|
| 656 | + | let stable = get(app, "/andy/demo/proposals?q=target:stable", Some(&andy)).await.body; |
|
| 657 | + | assert!(stable.contains("Release prep") && !stable.contains("New theme")); |
|
| 658 | + | } |
modified docs/spec.md +2 −2
| … | 236 unchanged lines |
||
| 237 | 237 | | `/{owner}/{name}/compare/{a}...{b}` | Changes in `b` not in `a`, and the diff between the two states | |
|
| 238 | 238 | | `/{owner}/{name}/bookmarks` | Bookmarks with heads, ahead/behind the default, last update, attestation status | |
|
| 239 | 239 | | `/{owner}/{name}/conflicts` | Every bookmark whose state carries conflicts, each with its regions (§6.10) | |
|
| 240 | - | | `/{owner}/{name}/proposals`, `/proposal/{n}` | §7 | |
|
| 241 | - | | `/{owner}/{name}/issues`, `/issue/{n}` | §8 | |
|
| 240 | + | | `/{owner}/{name}/proposals`, `/proposal/{n}` | §7. The list filters by state and by `q` (`author:`, `label:`, `target:` and words), sorts by recently updated (default), newest or oldest, and pages 25 at a time | |
|
| 241 | + | | `/{owner}/{name}/issues`, `/issue/{n}` | §8. The list filters by state and by `q` (`author:`, `label:`, `assignee:`, `milestone:` and words), sorts by newest (pinned first, default), oldest, recently updated or most comments, and pages 25 at a time. The Proposals and Issues tabs show how many are open | |
|
| 242 | 242 | | `/{owner}/{name}/releases`, `/tags` | §9 | |
|
| 243 | 243 | | `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) | |
|
| 244 | 244 | | `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) | |
|
| … | 305 unchanged lines |
modified templates/issues.html +3 −1
| … | 5 unchanged lines |
||
| 6 | 6 | {% block content %} |
|
| 7 | 7 | <div class="head"><h1>Issues <small>{{ counts.0 }} open · {{ counts.1 }} closed</small></h1></div> |
|
| 8 | 8 | <div class="bar"> |
|
| 9 | - | <form class="find" method="get" action="{{ repo.url }}/issues"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, label:bug, assignee:andy, milestone:v1, state:closed"></form> |
|
| 9 | + | <form class="find" method="get" action="{{ repo.url }}/issues"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, author:bob, label:bug, assignee:andy, milestone:v1, state:closed"><input type="hidden" name="state" value="{{ state }}"><input type="hidden" name="sort" value="{{ sort }}"></form> |
|
| 10 | 10 | <span class="modes"> |
|
| 11 | 11 | <a href="?state=open"{% if state == "open" %} class="on"{% endif %}>open</a> |
|
| 12 | 12 | <a href="?state=closed"{% if state == "closed" %} class="on"{% endif %}>closed</a> |
|
| 13 | 13 | <a href="?state=all"{% if state == "all" %} class="on"{% endif %}>all</a> |
|
| 14 | 14 | </span> |
|
| 15 | + | <span class="modes small" aria-label="Sort">{% for (k, label) in crate::web::issues::SORTS %}<a href="{{ pager.sorted(k) }}"{% if sort == *k %} class="on"{% endif %}>{{ label }}</a>{% endfor %}</span> |
|
| 15 | 16 | <a class="go small" href="{{ repo.url }}/labels">labels</a> |
|
| 16 | 17 | <a class="go small" href="{{ repo.url }}/milestones">milestones</a> |
|
| 17 | 18 | {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/issues/new">new issue</a>{% endif %} |
|
| … | 12 unchanged lines |
||
| 30 | 31 | </li> |
|
| 31 | 32 | {% endfor %} |
|
| 32 | 33 | </ul> |
|
| 34 | + | {% include "pager.html" %} |
|
| 33 | 35 | {% endif %} |
|
| 34 | 36 | {% endblock %} |
modified templates/proposals.html +9 −3
| … | 6 unchanged lines |
||
| 7 | 7 | <div class="head"><h1>Proposals <small>changes offered for a bookmark; they never go stale</small></h1></div> |
|
| 8 | 8 | <div class="bar"> |
|
| 9 | 9 | <span class="modes"> |
|
| 10 | - | <a href="?state=open"{% if filter == "open" %} class="on"{% endif %}>{{ counts.0 }} open</a> |
|
| 11 | - | <a href="?state=landed"{% if filter == "landed" %} class="on"{% endif %}>{{ counts.1 }} landed</a> |
|
| 12 | - | <a href="?state=closed"{% if filter == "closed" %} class="on"{% endif %}>{{ counts.2 }} closed</a> |
|
| 10 | + | <a href="?state=open&q={{ q }}"{% if filter == "open" %} class="on"{% endif %}>{{ counts.0 }} open</a> |
|
| 11 | + | <a href="?state=landed&q={{ q }}"{% if filter == "landed" %} class="on"{% endif %}>{{ counts.1 }} landed</a> |
|
| 12 | + | <a href="?state=closed&q={{ q }}"{% if filter == "closed" %} class="on"{% endif %}>{{ counts.2 }} closed</a> |
|
| 13 | 13 | </span> |
|
| 14 | 14 | {% if shell.user.is_some() %}<a class="go small" href="{{ repo.url }}/proposals/new">new proposal</a>{% endif %} |
|
| 15 | 15 | </div> |
|
| 16 | + | <div class="bar"> |
|
| 17 | + | <form class="find" method="get" action="{{ repo.url }}/proposals"><input type="search" name="q" value="{{ q }}" placeholder="Filter: text, author:bob, label:ui, target:main"><input type="hidden" name="state" value="{{ filter }}"><input type="hidden" name="sort" value="{{ sort }}"></form> |
|
| 18 | + | <span class="modes small" aria-label="Sort">{% for (k, label) in crate::web::proposals::SORTS %}<a href="{{ pager.sorted(k) }}"{% if sort == *k %} class="on"{% endif %}>{{ label }}</a>{% endfor %}</span> |
|
| 19 | + | </div> |
|
| 16 | 20 | {% if rows.is_empty() %} |
|
| 17 | 21 | <p class="empty">No {{ filter }} proposals. Push a bookmark, then <a href="{{ repo.url }}/proposals/new">propose it</a> for {{ repo.default_bookmark }}.{% if bookmarks.len() > 1 %} Bookmarks here: {% for b in bookmarks %}<a href="{{ repo.url }}/proposals/new?source={{ b }}">{{ b }}</a>{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %}</p> |
|
| 18 | 22 | {% else %} |
|
| … | 4 unchanged lines |
||
| 23 | 27 | <a class="name" href="{{ repo.url }}/proposal/{{ p.number }}"><span class="mute">#{{ p.number }}</span> <strong>{{ p.title }}</strong></a> |
|
| 24 | 28 | {% if p.state == "draft" %}<span class="tag">draft</span>{% endif %} |
|
| 25 | 29 | {% if p.conflicted %}<span class="tag bad">conflicts</span>{% endif %} |
|
| 30 | + | {% for l in labels_of(p.id) %}<span class="label" style="--c: {{ l.colour }}">{{ l.name }}</span>{% endfor %} |
|
| 26 | 31 | <p class="small">{{ p.author }} wants <span class="lbl lbl-bm">{{ p.source_bookmark }}</span> into <span class="lbl lbl-bm">{{ p.target }}</span> · {{ p.projected_changes().len()|plural("change", "changes") }}</p> |
|
| 27 | 32 | </div> |
|
| 28 | 33 | <div class="right small">updated <time title="{{ p.updated_ms|when_i }}">{{ p.updated_ms|ago_i }}</time></div> |
|
| 29 | 34 | </li> |
|
| 30 | 35 | {% endfor %} |
|
| 31 | 36 | </ul> |
|
| 37 | + | {% include "pager.html" %} |
|
| 32 | 38 | {% endif %} |
|
| 33 | 39 | {% endblock %} |
modified templates/repo_tabs.html +2 −2
| … | 3 unchanged lines |
||
| 4 | 4 | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.visibility != "public" %} <span class="tag">{{ repo.visibility }}</span>{% endif %}</span> |
|
| 5 | 5 | <a href="{{ repo.url }}"{% if tab == "code" %} class="on"{% endif %}>Code</a> |
|
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on"{% endif %}>Changes</a> |
|
| 7 | - | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a> |
|
| 8 | - | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues</a> |
|
| 7 | + | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| 8 | + | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a> |
|
| 9 | 9 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a> |
|
| 10 | 10 | <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a> |
|
| 11 | 11 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a> |
|
| … | 62 unchanged lines |
modified templates/style.css +2 −0
| … | 361 unchanged lines |
||
| 362 | 362 | .topic:hover { text-decoration: none; background: color-mix(in srgb, var(--acc) 24%, transparent); } |
|
| 363 | 363 | .explore-bar input[type=search] { flex: 1 1 240px; } |
|
| 364 | 364 | .counts-line span { margin-right: 2px; } |
|
| 365 | + | .count-chip { display: inline-block; min-width: 1.6em; padding: 0 6px; margin-left: 4px; border-radius: 9px; background: var(--soft); color: var(--mute); font-size: 11px; text-align: center; } |
|
| 366 | + | .pager { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 14px 0; } |