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]

Reply via email to