OXYGENHUB

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