asf-tooling commented on issue #917:
URL:
https://github.com/apache/tooling-trusted-releases/issues/917#issuecomment-4410037488
<!-- gofannon-issue-triage-bot v2 -->
**Automated triage** — analyzed at `main@2da7807a`
**Type:** `refactor` • **Classification:** `actionable` •
**Confidence:** `medium`
**Application domain(s):** `project_committee_management`,
`release_lifecycle`, `shared_infrastructure`
### Summary
Issue #917 requests reorganizing the project page into a tabbed/navigable
layout. The issue body (edited by @dave2wave) shows items 3-6
(Compose/Vote/Finish/Trusted Publishing policy forms) are completed via
sub-issues, leaving: (1) tab/navigation infrastructure, (2) Releases tab with
cycle parameters (references #912), (3) Metadata tab grouping
description+categories+languages (references #913), (4) header with committee
info, (5) read-only text areas as code blocks, and (6) URL-based section
navigation. The current `view` function in `atr/get/projects.py` renders all
sections linearly without tabs.
### Where this lives in the code today
#### `atr/get/projects.py` — `view` (lines 47-95)
_needs modification_
This is the main view function that builds the project page linearly - needs
to be restructured into tabs with URL-based navigation.
```python
@get.typed
async def view(
session: web.Committer, _projects: Literal["projects"], project_key:
safe.ProjectKey
) -> web.WerkzeugResponse | str:
"""
URL: /projects/<project_key>
"""
async with db.session() as data:
project = await data.project(
key=str(project_key), _committee=True,
_committee_public_signing_keys=True, _release_policy=True
).demand(base.ASFQuartException(f"Project {project_key} not found",
errorcode=404))
is_committee_member = project.committee and
(user.is_committee_member(project.committee, session.uid))
is_privileged = session.is_admin
can_edit = is_committee_member or is_privileged
candidate_drafts = await interaction.candidate_drafts(project)
candidates = await interaction.candidates(project)
previews = await interaction.previews(project)
full_releases = await interaction.full_releases(project)
page = htm.Block()
page_styles = """
.page-remove-tag {
font-size: 0.65em;
padding: 0.2em 0.3em;
cursor: pointer;
}
"""
page.style[page_styles]
title_row = htm.div(".row")[
htm.div(".col-md")[htm.h1[project.display_name]],
htm.div(".col-sm-auto")[htm.span(".badge.text-bg-secondary")[project.status.value.lower()]]
if (project.status.value.lower() != "active")
else "",
]
page.append(title_row)
page.p(".mb-4")[
htm.a(".btn.btn-sm.btn-outline-primary",
href=util.as_url(start.selected, project_key=str(project.key)))[
"Start a new release"
]
]
page.append(_render_project_label_card(project))
page.append(_render_pmc_card(project))
page.append(_render_description_card(project))
```
#### `atr/get/projects.py` — `_render_pmc_card` (lines 483-493)
_needs modification_
Currently a card in the page body - should be elevated to a persistent
header per the issue requirements.
```python
def _render_pmc_card(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["PMC"]]
if project.committee:
committee_link = htm.a(href=util.as_url(committees.view,
name=project.committee.key))[
project.committee.display_name
]
card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")[committee_link]]
else:
card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")["No
committee"]]
return card.collect()
```
#### `atr/get/projects.py` — `_render_policy_readonly` (lines 496-522)
_needs modification_
The read-only view currently shows a simple table - text areas should be
rendered as code blocks per the issue.
```python
def _render_policy_readonly(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["Release policy"]]
email_content = (
htm.a(href=f"mailto:{project.policy_mailto_addresses[0]}")[project.policy_mailto_addresses[0]]
if project.policy_mailto_addresses
else "Not set"
)
tbody = htm.tbody[
htm.tr[
htm.th(".border-0.w-25")["Email"],
htm.td(".text-break.border-0")[email_content],
],
htm.tr[
htm.th(".border-0")["Vote mode"],
htm.td(".text-break.border-0")[_vote_mode_label(project.policy_vote_mode)],
],
htm.tr[
htm.th(".border-0")["Minimum voting period"],
htm.td(".text-break.border-0")[f"{project.policy_min_hours}h"],
],
]
card.div(".card-body")[htm.div(".card.h-100.border")[htm.div(".card-body")[htm.table(".table.mb-0")[tbody]]]]
return card.collect()
```
#### `atr/get/projects.py` — `_render_categories_section` (lines 292-298)
_needs modification_
Currently a standalone card - should be grouped under the 'Metadata' tab.
```python
def _render_categories_section(project: sql.Project) -> htm.Element:
card = htm.Block(htm.div, classes=".card.mb-4")
card.div(".card-header.bg-light")[htm.h3(".mb-2")["Categories"]]
current_categories = project.category.split(", ") if project.category
else []
category_badges = []
for cat in current_categories:
```
#### `atr/get/projects.py` — `_render_releases_sections` (lines 532-543)
_needs modification_
Currently renders release sections linearly - should be the content of the
'Releases' tab, organized by cycles.
```python
async def _render_releases_sections(
project: sql.Project,
candidate_drafts: list[sql.Release],
candidates: list[sql.Release],
previews: list[sql.Release],
full_releases: list[sql.Release],
) -> htm.Element:
sections = htm.Block(htm.div)
if candidate_drafts:
sections.h2["Draft candidate releases"]
draft_buttons = []
```
#### `atr/models/sql.py` — `Project` (lines 714-726)
_currently does this_
The Project model already has version_method, version_pattern, cycle_match,
branch_template fields for cycle support (from #912), which the Releases tab
would display/edit.
```python
class Project(sqlmodel.SQLModel, table=True):
key: str = sqlmodel.Field(primary_key=True, unique=True,
**example("example"))
name: str | None = sqlmodel.Field(default=None, **example("Apache
Example"))
status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE,
**example(ProjectStatus.ACTIVE))
super_project_key: str | None = sqlmodel.Field(default=None,
foreign_key="project.key")
super_project: Optional["Project"] = sqlmodel.Relationship()
description: str | None = sqlmodel.Field(default=None,
**example("Example is a simple example project"))
category: str | None = sqlmodel.Field(default=None,
**example("data,storage"))
programming_languages: str | None = sqlmodel.Field(default=None,
**example("c,python"))
version_method: VersionMethod =
sqlmodel.Field(default=VersionMethod.SIMPLE, **example(VersionMethod.SIMPLE))
version_pattern: str | None = sqlmodel.Field(default=None,
**example(r"^\d+\.\d+\.\d+$"))
cycle_match: str | None = sqlmodel.Field(default=None,
**example(r"^(\d+)\.\d+\.\d+$"))
branch_template: str | None = sqlmodel.Field(default=None,
**example("release-{cycle}"))
```
### Where new code would go
- `atr/get/projects.py` — within view function
The view function needs tab navigation structure wrapping existing section
renderers
- `atr/static/js/src/project-tabs.js` — new file
JavaScript to handle tab switching and reading/updating URL parameters for
section navigation
### Proposed approach
The refactoring should restructure the `view` function in
`atr/get/projects.py` to: (1) render a persistent header with project name,
status badge, committee key/name with link, and project label; (2) render
Bootstrap nav-pills (or nav-tabs) for sections: Releases, Metadata, Compose,
Vote, Finish, Trusted Publishing; (3) wrap existing section renderers in
tab-pane divs with IDs; (4) read a `?section=` query parameter (via
`quart.request.args`) to set the initial active tab; (5) add a small JS file
that updates the URL query param on tab switch. The Metadata tab groups
description, categories, and languages. For read-only mode, text areas should
be rendered as `<pre><code>...</code></pre>` instead of disabled textareas. The
Releases tab content depends on #912 (cycles), so initially it should show the
existing release listing plus the 'Start release' button, with cycle-based
views added once #912 lands.
The diff below provides the structural skeleton for the tab layout. The
individual tab content functions already exist (compose form, vote form, etc.)
and just need to be wrapped in the appropriate pane divs.
### Suggested patches
#### `atr/get/projects.py`
Restructure the view function to add tab navigation, persistent header, and
section-based URL routing
````diff
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -18,6 +18,7 @@
from __future__ import annotations
from typing import Literal
+import quart
import asfquart.base as base
import htpy
@@ -130,6 +131,10 @@
"""
URL: /projects/<project_key>
"""
+ # Determine active section from URL parameter
+ active_section = quart.request.args.get("section", "releases")
+ valid_sections = ("releases", "metadata", "compose", "vote", "finish",
"trusted_publishing")
+ if active_section not in valid_sections:
+ active_section = "releases"
+
async with db.session() as data:
project = await data.project(
key=str(project_key), _committee=True,
_committee_public_signing_keys=True, _release_policy=True
@@ -155,38 +160,80 @@
page.style[page_styles]
- title_row = htm.div(".row")[
- htm.div(".col-md")[htm.h1[project.display_name]],
-
htm.div(".col-sm-auto")[htm.span(".badge.text-bg-secondary")[project.status.value.lower()]]
+ # -- Header: project name, status, committee link --
+ committee_link = ""
+ if project.committee:
+ committee_link = htm.span(".text-muted.ms-2")[
+ "Committee: ",
+ htm.a(href=util.as_url(committees.view,
name=project.committee.key))[
+ project.committee.display_name
+ ],
+ f" ({project.committee.key})",
+ ]
+
+ header_row = htm.div(".row.align-items-center.mb-3")[
+ htm.div(".col-md")[
+ htm.h1(".mb-1")[project.display_name],
+ htm.div(".d-flex.align-items-center.gap-2")[
+ htm.code(".fs-6")[str(project.key)],
+ committee_link,
+ ],
+ ],
+
htm.div(".col-sm-auto")[htm.span(".badge.text-bg-secondary")[project.status.value.lower()]]
if (project.status.value.lower() != "active")
else "",
]
- page.append(title_row)
+ page.append(header_row)
- page.p(".mb-4")[
- htm.a(".btn.btn-sm.btn-outline-primary",
href=util.as_url(start.selected, project_key=str(project.key)))[
- "Start a new release"
- ]
- ]
+ # -- Tab navigation --
+ tab_items = [
+ ("releases", "Releases"),
+ ("metadata", "Metadata"),
+ ("compose", "Compose"),
+ ("vote", "Vote"),
+ ("finish", "Finish"),
+ ("trusted_publishing", "Trusted Publishing"),
+ ]
- page.append(_render_project_label_card(project))
- page.append(_render_pmc_card(project))
- page.append(_render_description_card(project))
+ nav_items = []
+ for section_id, section_label in tab_items:
+ is_active = section_id == active_section
+ nav_items.append(
+ htm.li(".nav-item")[
+ htm.a(
+ f".nav-link{'.active' if is_active else ''}",
+ href=f"?section={section_id}",
+ data_section=section_id,
+ )[section_label]
+ ]
+ )
+ page.append(htm.ul(".nav.nav-pills.mb-4")[*nav_items])
- if project.status == sql.ProjectStatus.ACTIVE:
- if can_edit:
- page.append(await _render_compose_form(project))
- page.append(await _render_vote_form(project))
- page.append(await _render_finish_form(project))
- page.append(await _render_trusted_publishing_form(project))
- else:
- page.append(_render_policy_readonly(project))
+ # -- Tab panes --
+ def _show(section_id: str) -> str:
+ return "" if section_id == active_section else ".d-none"
- if can_edit:
- page.append(_render_categories_section(project))
- page.append(_render_languages_section(project))
+ # Releases tab
+ with page.block(htm.div, classes=f"#tab-releases{_show('releases')}")
as releases_tab:
+ releases_tab.p(".mb-4")[
+ htm.a(".btn.btn-sm.btn-outline-primary",
href=util.as_url(start.selected, project_key=str(project.key)))[
+ "Start a new release"
+ ]
+ ]
+ if is_committee_member or is_privileged:
+ releases_tab.append(
+ await _render_releases_sections(project, candidate_drafts,
candidates, previews, full_releases)
+ )
- if is_committee_member or is_privileged:
- page.append(await _render_releases_sections(project,
candidate_drafts, candidates, previews, full_releases))
+ # Metadata tab
+ with page.block(htm.div, classes=f"#tab-metadata{_show('metadata')}")
as metadata_tab:
+ metadata_tab.append(_render_description_card(project))
+ if can_edit:
+ metadata_tab.append(_render_categories_section(project))
+ metadata_tab.append(_render_languages_section(project))
+ else:
+ metadata_tab.append(_render_metadata_readonly(project))
- if project.created_by == session.uid:
- page.append(await _render_delete_section(project))
+ # Policy tabs (Compose, Vote, Finish, Trusted Publishing)
+ if project.status == sql.ProjectStatus.ACTIVE:
+ if can_edit:
+ page.append(htm.div(f"#tab-compose{_show('compose')}")[await
_render_compose_form(project)])
+ page.append(htm.div(f"#tab-vote{_show('vote')}")[await
_render_vote_form(project)])
+ page.append(htm.div(f"#tab-finish{_show('finish')}")[await
_render_finish_form(project)])
+
page.append(htm.div(f"#tab-trusted_publishing{_show('trusted_publishing')}")[await
_render_trusted_publishing_form(project)])
+ else:
+
page.append(htm.div(f"#tab-compose{_show('compose')}")[_render_policy_readonly(project,
"compose")])
+
page.append(htm.div(f"#tab-vote{_show('vote')}")[_render_policy_readonly(project,
"vote")])
+
page.append(htm.div(f"#tab-finish{_show('finish')}")[_render_policy_readonly(project,
"finish")])
+
page.append(htm.div(f"#tab-trusted_publishing{_show('trusted_publishing')}")[_render_policy_readonly(project,
"trusted_publishing")])
- if project.committee:
- if (project.committee.key in session.committees) or
is_privileged:
- page.p[
- htm.a(
- ".btn.btn-sm.btn-outline-primary",
- href=util.as_url(add_project,
committee_key=project.committee.key),
- )["Create a sibling project"]
- ]
+ # Actions (delete, sibling) - always visible at bottom
+ if is_committee_member or is_privileged:
+ if project.created_by == session.uid:
+ page.append(await _render_delete_section(project))
+ if project.committee:
+ if (project.committee.key in session.committees) or
is_privileged:
+ page.p[
+ htm.a(
+ ".btn.btn-sm.btn-outline-primary",
+ href=util.as_url(add_project,
committee_key=project.committee.key),
+ )["Create a sibling project"]
+ ]
content = page.collect()
- javascripts = ["copy-variable"] if can_edit else []
+ javascripts = ["copy-variable", "project-tabs"] if can_edit else
["project-tabs"]
return await template.blank(
title=f"{project.display_name}",
description=f"Information regarding {project.display_name}.",
content=content,
javascripts=javascripts,
)
````
#### `atr/get/projects.py`
Add a _render_metadata_readonly helper for non-PMC users showing metadata as
read-only with code blocks for text content
````diff
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -310,6 +310,33 @@
return card.collect()
+def _render_metadata_readonly(project: sql.Project) -> htm.Element:
+ """Render metadata (description, categories, languages) in read-only
mode."""
+ section = htm.Block(htm.div)
+
+ # Description as code block
+ if project.description:
+ section.append(htm.div(".card.mb-4")[
+
htm.div(".card-header.bg-light")[htm.h3(".mb-2")["Description"]],
+
htm.div(".card-body")[htm.pre(".bg-light.p-3.rounded")[htm.code[project.description]]],
+ ])
+
+ # Categories (read-only badges)
+ if project.category:
+ categories = project.category.split(", ")
+ badges = [htm.span(".badge.bg-primary.me-1")[cat] for cat in
categories]
+ section.append(htm.div(".card.mb-4")[
+ htm.div(".card-header.bg-light")[htm.h3(".mb-2")["Categories"]],
+
htm.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-2")[*badges]],
+ ])
+
+ # Languages (read-only badges)
+ if project.programming_languages:
+ languages = project.programming_languages.split(", ")
+ badges = [htm.span(".badge.bg-success.me-1")[lang] for lang in
languages]
+ section.append(htm.div(".card.mb-4")[
+ htm.div(".card-header.bg-light")[htm.h3(".mb-2")["Programming
languages"]],
+
htm.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-2")[*badges]],
+ ])
+
+ return section.collect()
````
#### `atr/static/js/src/project-tabs.js`
JavaScript to handle tab switching by toggling d-none class and updating URL
parameter without full page reload
````diff
--- /dev/null
+++ b/atr/static/js/src/project-tabs.js
@@ -0,0 +1,30 @@
+// Project page tab navigation
+(function () {
+ "use strict";
+
+ document.addEventListener("DOMContentLoaded", function () {
+ const navLinks = document.querySelectorAll(".nav-pills .nav-link");
+
+ navLinks.forEach(function (link) {
+ link.addEventListener("click", function (e) {
+ e.preventDefault();
+ const section = this.getAttribute("data-section");
+ if (!section) return;
+
+ // Update active nav link
+ navLinks.forEach(function (l) { l.classList.remove("active"); });
+ this.classList.add("active");
+
+ // Show/hide tab panes
+ document.querySelectorAll("[id^='tab-']").forEach(function (pane) {
+ pane.classList.add("d-none");
+ });
+ const target = document.getElementById("tab-" + section);
+ if (target) {
+ target.classList.remove("d-none");
+ }
+
+ // Update URL without reload
+ const url = new URL(window.location);
+ url.searchParams.set("section", section);
+ history.replaceState(null, "", url);
+ });
+ });
+ });
+})();
````
### Open questions
- The _render_policy_readonly function currently takes only a `project`
argument - the diff references adding a `section` parameter to differentiate
which policy to show readonly. The actual implementation needs to render
different fields per section (compose vs vote vs finish vs trusted_publishing)
in code-block format.
- The Releases tab should eventually show cycles per #912 - how should the
cycle editing UI integrate here? Should it wait for #912 to land first?
- The Metadata tab references #913 - are there additional metadata fields
planned beyond description/categories/languages?
- The htm.Block context manager usage (with page.block(...)) may need
verification - I'm inferring from patterns in the existing code but the exact
API for nested blocks should be confirmed.
- How should POST form redirects work with tabs? Currently they redirect to
the project page without a section parameter - they should include
`?section=<relevant_tab>` to return the user to the correct tab after saving.
### Files examined
- `atr/get/projects.py`
- `atr/post/projects.py`
- `atr/shared/projects.py`
- `atr/templates/projects.html`
- `atr/storage/writers/project.py`
- `atr/templates/project-select.html`
- `atr/models/sql.py`
- `atr/db/__init__.py`
### Related issues
This issue appears related to: #920.
_Both address reorganization of project and committee pages with improved
navigation and sections_
---
*Draft from a triage agent. A human reviewer should validate before merging
any change. The agent did not run tests or verify diffs apply.*
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]