Projects: reachable from the site bar, every repository, and a New project button a024e800af4e
The project boards and their New project form existed at /projects/OWNER but nothing led there except a small link on profile and organisation pages. The site bar now has Projects (your own), every repository a Projects tab (its owner's boards), the list a New project button at the top, your own list links your organisations' boards, and someone who cannot make one is told who can. closes #119
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-09 17:36 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
a024e800af4ef944dad7c3b22853686c9006caec86e0606236118fb03a702942- Depends on
- 18e381ea88c2 43e8ca33bc5d 66fd9d6b1452 756fe44a0f4d d23464c0ac60 e1ec9fbf538e
- Depended on by
- 16ee5d19d220 c9aacc0e7dc1 7b26e6118bc0
- 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.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
- 15 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/hub/src/web/projects.rs +17 −1
| … | 51 unchanged lines |
||
| 52 | 52 | projects: Vec<pdb::Project>, |
|
| 53 | 53 | can_edit: bool, |
|
| 54 | 54 | error: Option<String>, |
|
| 55 | + | /// On your own page, the organisations you belong to: their boards |
|
| 56 | + | /// are one click away, and where a board for one is made (#119). |
|
| 57 | + | orgs: Vec<String>, |
|
| 55 | 58 | } |
|
| 56 | 59 | ||
| 60 | + | /// The organisations the viewer belongs to, when `owner` is the viewer. |
|
| 61 | + | async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec<String> { |
|
| 62 | + | match viewer.account() { |
|
| 63 | + | Some(a) if a.username == owner => { |
|
| 64 | + | crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default() |
|
| 65 | + | } |
|
| 66 | + | _ => vec![], |
|
| 67 | + | } |
|
| 68 | + | } |
|
| 69 | + | ||
| 57 | 70 | pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> { |
|
| 58 | 71 | let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
|
| 59 | 72 | let r = rights(&app, &shell, &viewer, &owner).await?; |
|
| … | 3 unchanged lines |
||
| 63 | 76 | .into_iter() |
|
| 64 | 77 | .filter(|p| p.public || r.member) |
|
| 65 | 78 | .collect(); |
|
| 66 | - | Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None })) |
|
| 79 | + | let orgs = own_orgs(&app, &viewer, &owner).await; |
|
| 80 | + | Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None, orgs })) |
|
| 67 | 81 | } |
|
| 68 | 82 | ||
| 69 | 83 | #[derive(Deserialize)] |
|
| … | 22 unchanged lines |
||
| 92 | 106 | let title = f.title.trim(); |
|
| 93 | 107 | if title.is_empty() || title.len() > 100 { |
|
| 94 | 108 | let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 109 | + | let orgs = own_orgs(&app, &viewer, &owner).await; |
|
| 95 | 110 | return Ok(Page(ProjectsPage { |
|
| 96 | 111 | shell, |
|
| 97 | 112 | owner, |
|
| 98 | 113 | projects, |
|
| 99 | 114 | can_edit: true, |
|
| 100 | 115 | error: Some("A project needs a title of up to 100 characters.".into()), |
|
| 116 | + | orgs, |
|
| 101 | 117 | }) |
|
| 102 | 118 | .into_response()); |
|
| 103 | 119 | } |
|
| … | 459 unchanged lines |
modified crates/hub/tests/issues.rs +11 −1
| … | 751 unchanged lines |
||
| 752 | 752 | .await |
|
| 753 | 753 | .unwrap(); |
|
| 754 | 754 | } |
|
| 755 | - | let csrf = csrf_of(&get(app, "/projects/andy", Some(&andy)).await.body); |
|
| 755 | + | // Found from anywhere (#119): the site bar, every repository's tabs, and |
|
| 756 | + | // a New project button on the list; a stranger is told who can make one. |
|
| 757 | + | let home = get(app, "/andy/demo", Some(&andy)).await.body; |
|
| 758 | + | assert!( |
|
| 759 | + | home.contains("href=\"/projects/andy\">Projects</a>") && home.contains("title=\"andy's project boards\">Projects</a>"), |
|
| 760 | + | "site bar and tab" |
|
| 761 | + | ); |
|
| 762 | + | let list = get(app, "/projects/andy", Some(&andy)).await.body; |
|
| 763 | + | assert!(list.contains("href=\"#new\">+ NEW PROJECT</a>") && list.contains("id=\"new\""), "the button and the form"); |
|
| 764 | + | assert!(get(app, "/projects/andy", Some(&bob)).await.body.contains("Only andy"), "who can make one"); |
|
| 765 | + | let csrf = csrf_of(&list); |
|
| 756 | 766 | let r = post(app, "/projects/andy", Some(&andy), &format!("_csrf={csrf}&title=Roadmap&description=Next+steps")).await; |
|
| 757 | 767 | assert_eq!(r.location.as_deref(), Some("/projects/andy/1"), "{}", r.body); |
|
| 758 | 768 | let board = get(app, "/projects/andy/1", Some(&andy)).await.body; |
|
| … | 85 unchanged lines |
modified templates/base.html +1 −1
| … | 31 unchanged lines |
||
| 32 | 32 | <a href="/">{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a> |
|
| 33 | 33 | <a href="/explore">Explore</a> |
|
| 34 | 34 | <a href="/help">Help</a> |
|
| 35 | - | {% if shell.user.is_some() %}<a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %} |
|
| 35 | + | {% if let Some(u) = shell.user %}<a href="/projects/{{ u }}">Projects</a><a href="/notifications">Notifications</a><a href="/settings">Settings</a>{% endif %} |
|
| 36 | 36 | <span class="grow"></span> |
|
| 37 | 37 | {% if shell.is_admin %}<a class="admin" href="/admin">Admin</a>{% endif %} |
|
| 38 | 38 | <a class="dim" href="/about" title="OxygenHub {{ shell.version }} ({{ shell.build }})">v{{ shell.version }}</a> |
|
| … | 25 unchanged lines |
modified templates/projects.html +4 −3
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Projects · {{ owner }}{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | - | <div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1></div> |
|
| 4 | + | <div class="head"><h1>Projects <small><a href="/{{ owner }}">{{ owner }}</a></small></h1>{% if can_edit %}<a class="go" href="#new">+ NEW PROJECT</a>{% endif %}</div> |
|
| 5 | 5 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | - | {% if projects.is_empty() %}<p class="empty">No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories.{% endif %}</p>{% else %} |
|
| 6 | + | {% if !orgs.is_empty() %}<p class="small mute">Your organisations' projects: {% for o in orgs %}<a href="/projects/{{ o }}">{{ o }}</a>{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %} |
|
| 7 | + | {% if projects.is_empty() %}<p class="empty">No projects yet.{% if can_edit %} A project is a board of issues, proposals and notes from {{ owner }}'s repositories: make one below.{% else %} Only {{ owner }}{% if shell.user.is_some() %} (or its members){% endif %} can make one.{% endif %}</p>{% else %} |
|
| 7 | 8 | <ul class="rows"> |
|
| 8 | 9 | {% for p in projects %} |
|
| 9 | 10 | <li><div><a class="name" href="/projects/{{ owner }}/{{ p.number }}">{{ p.title }}</a>{% if !p.public %} <span class="tag">private</span>{% endif %}{% if p.closed %} <span class="tag dim">closed</span>{% endif %} |
|
| … | 2 unchanged lines |
||
| 12 | 13 | </ul> |
|
| 13 | 14 | {% endif %} |
|
| 14 | 15 | {% if can_edit %} |
|
| 15 | - | <form class="card" method="post" action="/projects/{{ owner }}"> |
|
| 16 | + | <form class="card" id="new" method="post" action="/projects/{{ owner }}"> |
|
| 16 | 17 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 17 | 18 | <h2>New project</h2> |
|
| 18 | 19 | <label for="title">Title</label><input type="text" id="title" name="title" maxlength="100" required> |
|
| … | 7 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| 8 | 8 | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a> |
|
| 9 | + | <a href="/projects/{{ repo.owner }}" title="{{ repo.owner }}'s project boards">Projects</a> |
|
| 9 | 10 | {% if repo.wiki || repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/wiki"{% if tab == "wiki" %} class="on" aria-current="page"{% endif %}>Wiki</a>{% endif %} |
|
| 10 | 11 | <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a> |
|
| 11 | 12 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a> |
|
| … | 64 unchanged lines |