implement adding and filtering for variants default tip

Sat, 01 Feb 2025 18:52:08 +0100

author
Mike Becker <universe@uap-core.de>
date
Sat, 01 Feb 2025 18:52:08 +0100
changeset 348
1dc9c405e9e2
parent 347
d1edd8d9c8a1

implement adding and filtering for variants

relates to #491

setup/postgres/psql_create_tables.sql file | annotate | diff | comparison | revisions
setup/postgres/psql_patch_1.5.0.sql file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt file | annotate | diff | comparison | revisions
src/main/resources/localization/strings.properties file | annotate | diff | comparison | revisions
src/main/resources/localization/strings_de.properties file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-details.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/variant-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/variants.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/project-header.jspf file | annotate | diff | comparison | revisions
--- a/setup/postgres/psql_create_tables.sql	Thu Jan 30 21:20:27 2025 +0100
+++ b/setup/postgres/psql_create_tables.sql	Sat Feb 01 18:52:08 2025 +0100
@@ -192,3 +192,11 @@
 );
 
 create unique index lpit_commit_ref_unique on lpit_commit_ref (issueid, commit_hash);
+
+create table lpit_issue_variant_status
+(
+    issueid   integer        not null references lpit_issue (issueid),
+    variant   integer        not null references lpit_variant (id),
+    status    issue_status   not null default 'InSpecification',
+    primary key (issueid, variant)
+);
--- a/setup/postgres/psql_patch_1.5.0.sql	Thu Jan 30 21:20:27 2025 +0100
+++ b/setup/postgres/psql_patch_1.5.0.sql	Sat Feb 01 18:52:08 2025 +0100
@@ -16,3 +16,11 @@
 );
 
 create unique index lpit_variant_node_unique on lpit_variant (project, node);
+
+create table lpit_issue_variant_status
+(
+    issueid   integer        not null references lpit_issue (issueid),
+    variant   integer        not null references lpit_variant (id),
+    status    issue_status   not null default 'InSpecification',
+    primary key (issueid, variant)
+);
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Sat Feb 01 18:52:08 2025 +0100
@@ -29,6 +29,7 @@
 import de.uapcore.lightpit.types.CommitRef
 import de.uapcore.lightpit.viewmodel.ComponentSummary
 import de.uapcore.lightpit.viewmodel.IssueSummary
+import de.uapcore.lightpit.viewmodel.VariantSummary
 import de.uapcore.lightpit.viewmodel.VersionSummary
 
 interface DataAccessObject {
@@ -60,7 +61,7 @@
     fun updateComponent(component: Component)
 
     fun listVariants(project: Project): List<Variant>
-    //fun listVariantSummaries(project: Project): List<VariantSummary>
+    fun listVariantSummaries(project: Project): List<VariantSummary>
     fun findVariant(id: Int): Variant?
     fun findVariantByNode(project: Project, node: String): Variant?
     fun insertVariant(variant: Variant)
@@ -95,9 +96,9 @@
     /**
      * Lists all issues for the specified [project].
      * The result will only [includeDone] issues, if requested.
-     * When a [specificVersion] or a [specificComponent] is requested,
-     * the result is filtered for [version] or [component] respectively.
-     * In both cases null means that only issues without version or component shall be returned.
+     * When a [specificVersion], [specificComponent], or [specificVariant] is requested,
+     * the result is filtered for [version], [component], or [variant] respectively.
+     * In those cases null means that only issues without version/component/variant shall be returned.
      */
     fun listIssues(
         project: Project,
@@ -105,7 +106,9 @@
         specificVersion: Boolean,
         version: Version?,
         specificComponent: Boolean,
-        component: Component?
+        component: Component?,
+        specificVariant: Boolean,
+        variant: Variant?
     ): List<Issue>
     fun findIssue(id: Int): Issue?
     fun insertIssue(issue: Issue): Int
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Sat Feb 01 18:52:08 2025 +0100
@@ -32,6 +32,7 @@
 import de.uapcore.lightpit.types.WebColor
 import de.uapcore.lightpit.viewmodel.ComponentSummary
 import de.uapcore.lightpit.viewmodel.IssueSummary
+import de.uapcore.lightpit.viewmodel.VariantSummary
 import de.uapcore.lightpit.viewmodel.VersionSummary
 import org.intellij.lang.annotations.Language
 import java.sql.Connection
@@ -394,14 +395,15 @@
             setInt(1, project.id)
             queryAll { it.extractVariant() }
         }
-/*
+
     override fun listVariantSummaries(project: Project): List<VariantSummary> =
         withStatement(
             """
             with issues as (
-                select variant, phase, count(issueid) as total
-                from lpit_issue
-                join lpit_issue_phases using (status)
+                select variant, phase, count(i.issueid) as total
+                from lpit_issue i
+                join lpit_issue_variant_status vs on i.issueid = vs.issueid
+                join lpit_issue_phases p on p.status = vs.status
                 group by variant, phase
             ),
             summary as (
@@ -409,28 +411,26 @@
                 from lpit_variant v
                 left join issues i on v.id = i.variant
             )
-            select c.id, project, name, node, color, ordinal, description, active,
-                userid, username, givenname, lastname, mail,
+            select v.id, project, name, node, color, ordinal, description, active,
                 open.total as open, wip.total as wip, done.total as done
-            from lpit_component c
-            left join lpit_user on lead = userid
-            left join summary open on c.id = open.id and open.phase = 0
-            left join summary wip on c.id = wip.id and wip.phase = 1
-            left join summary done on c.id = done.id and done.phase = 2
-            where c.project = ?
+            from lpit_variant v
+            left join summary open on v.id = open.id and open.phase = 0
+            left join summary wip on v.id = wip.id and wip.phase = 1
+            left join summary done on v.id = done.id and done.phase = 2
+            where v.project = ?
             order by ordinal, name
             """.trimIndent()
         ) {
             setInt(1, project.id)
             queryAll { rs ->
-                ComponentSummary(rs.extractComponent()).apply {
+                VariantSummary(rs.extractVariant()).apply {
                     issueSummary.open = rs.getInt("open")
                     issueSummary.active = rs.getInt("wip")
                     issueSummary.done = rs.getInt("done")
                 }
             }
         }
-*/
+
     override fun findVariant(id: Int): Variant? =
         withStatement("$variantQuery where id = ?") {
             setInt(1, id)
@@ -619,6 +619,29 @@
         left join lpit_user on userid = assignee 
         """.trimIndent()
 
+    //language=SQL
+    private val issueQueryForVariant =
+        """
+        select i.issueid, i.project,
+            p.projectid as project_projectid,
+            p.name as project_name,
+            p.node as project_node,
+            p.ordinal as project_ordinal,
+            p.description as project_description,
+            p.vcs as project_vcs,
+            p.repourl as project_repourl,
+            component, c.name as componentname, c.node as componentnode, c.color as componentcolor,
+            vs.status, phase, category, subject, i.description,
+            userid, username, givenname, lastname, mail,
+            created, updated, eta, affected, resolved
+        from lpit_issue i
+        join lpit_project p on i.project = projectid
+        join lpit_issue_variant_status vs on vs.issueid = i.issueid
+        join lpit_issue_phases ph on ph.status = vs.status
+        left join lpit_component c on component = c.id
+        left join lpit_user on userid = assignee
+        """.trimIndent()
+
     private fun ResultSet.extractIssue(): Issue {
         val proj = extractProject("project_")
         val comp = getInt("component").let {
@@ -681,15 +704,46 @@
         specificVersion: Boolean,
         version: Version?,
         specificComponent: Boolean,
-        component: Component?
-    ): List<Issue> =
-        withStatement(
-            """$issueQuery where i.project = ? and
+        component: Component?,
+        specificVariant: Boolean,
+        variant: Variant?
+    ): List<Issue> {
+        // base query
+        var sql = if (variant != null) issueQueryForVariant else issueQuery
+
+        // prepare a filter when only issues without a variant are requested
+        if (specificVariant && variant == null) {
+            // language=SQL
+            sql = """with variants_per_issue(issueid, variants) as (
+                            select issueid, count(variant)
+                            from lpit_issue_variant_status
+                            right join lpit_issue using (issueid)
+                            group by issueid
+            ) $sql join variants_per_issue using (issueid)
+            """.trimIndent()
+        }
+
+        // add component and version queries
+        // language=SQL
+        sql = """$sql where i.project = ? and
                 (? or phase < 2) and
                 (not ? or ? in (resolved, affected)) and (not ? or (resolved is null and affected is null)) and
                 (not ? or component = ?) and (not ? or component is null)
             """.trimIndent()
-        ) {
+
+        // if specific variant requested, add respective clause
+        if (variant != null) {
+            // language=SQL
+            sql = "$sql and vs.variant = ?"
+        }
+
+        // if only issues without variants are requested, use the prepared filter
+        if (specificVariant && variant == null) {
+            // language=SQL
+            sql ="$sql and variants = 0"
+        }
+
+        return withStatement(sql) {
             setInt(1, project.id)
             setBoolean(2, includeDone)
 
@@ -701,8 +755,13 @@
             setInt(7, component?.id ?: 0)
             setBoolean(8, specificComponent && component == null)
 
+            if (variant != null) {
+                setInt(9, variant.id)
+            }
+
             queryAll { it.extractIssue() }
         }
+    }
 
     override fun findIssue(id: Int): Issue? =
         withStatement("$issueQuery where issueid = ?") {
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Sat Feb 01 18:52:08 2025 +0100
@@ -27,10 +27,7 @@
 
 import de.uapcore.lightpit.*
 import de.uapcore.lightpit.dao.DataAccessObject
-import de.uapcore.lightpit.entities.Component
-import de.uapcore.lightpit.entities.Issue
-import de.uapcore.lightpit.entities.Project
-import de.uapcore.lightpit.entities.Version
+import de.uapcore.lightpit.entities.*
 import de.uapcore.lightpit.logic.*
 import de.uapcore.lightpit.types.VcsType
 import de.uapcore.lightpit.types.VersionStatus
@@ -62,6 +59,11 @@
         get("/%project/components/-/create", this::componentForm)
         post("/%project/components/-/commit", this::componentCommit)
 
+        get("/%project/variants/", this::variants)
+        get("/%project/variants/%variant/edit", this::variantForm)
+        get("/%project/variants/-/create", this::variantForm)
+        post("/%project/variants/-/commit", this::variantCommit)
+
         get("/%project/issues/%version/%component/%variant/%issue", this::issue)
         get("/%project/issues/%version/%component/%variant/%issue/edit", this::issueForm)
         post("/%project/issues/%version/%component/%variant/%issue/comment", this::issueComment)
@@ -114,8 +116,14 @@
             val version = if (path.versionInfo is OptionalPathInfo.Specific) path.versionInfo.elem else null
             val specificComponent = path.componentInfo !is OptionalPathInfo.All
             val component = if (path.componentInfo is OptionalPathInfo.Specific) path.componentInfo.elem else null
+            val specificVariant = path.variantInfo !is OptionalPathInfo.All
+            val variant = if (path.variantInfo is OptionalPathInfo.Specific) path.variantInfo.elem else null
 
-            val issues = dao.listIssues(project, filter.includeDone, specificVersion, version, specificComponent, component)
+            val issues = dao.listIssues(project, filter.includeDone,
+                specificVersion, version,
+                specificComponent, component,
+                specificVariant, variant
+            )
                 .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary))
                 .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>"))
 
@@ -323,6 +331,62 @@
         http.renderCommit("projects/${project.node}/components/")
     }
 
+    private fun variants(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.let { path ->
+            with(http) {
+                pageTitle = "${path.project.name} - ${i18n("navmenu.variants")}"
+                view = VariantsView(
+                    path.projectInfo,
+                    dao.listVariantSummaries(path.project)
+                )
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                javascript = "issue-overview"
+                render("variants")
+            }
+        }
+    }
+
+    private fun variantForm(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.let { path ->
+            val variant = if (path.variantInfo is OptionalPathInfo.Specific)
+                path.variantInfo.elem else Variant(-1, path.project.id)
+
+            with(http) {
+                view = VariantEditView(path.projectInfo, variant)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                render("variant-form")
+            }
+        }
+    }
+
+    private fun variantCommit(http: HttpRequest, dao: DataAccessObject) {
+        val idParams = obtainIdAndProject(http, dao) ?: return
+        val (id, project) = idParams
+
+        val variant = Variant(id, project.id).apply {
+            name = http.param("name") ?: ""
+            node = http.param("node") ?: ""
+            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
+            color = WebColor(http.param("color") ?: "#000000")
+            description = http.param("description")
+            active = http.param("active", ::boolValidator, true, mutableListOf())
+            // intentional defaults
+            if (node.isBlank()) node = name
+            // sanitizing
+            node = sanitizeNode(node)
+        }
+
+        if (id < 0) {
+            dao.insertVariant(variant)
+        } else {
+            dao.updateVariant(variant)
+        }
+
+        http.renderCommit("projects/${project.node}/variants/")
+    }
+
     private fun issue(http: HttpRequest, dao: DataAccessObject) {
         val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
         if (issue == null) {
--- a/src/main/resources/localization/strings.properties	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/resources/localization/strings.properties	Sat Feb 01 18:52:08 2025 +0100
@@ -43,6 +43,7 @@
 button.remove=Remove
 button.save=Save
 button.user.create=Add Developer
+button.variant.create=New Variant
 button.version.create=New Version
 button.version.edit=Edit Version
 commit.redirect-link=If redirection does not work, click the following link:
@@ -175,6 +176,9 @@
 validation.date.format=Illegal date format.
 validation.username.null=Username is mandatory.
 validation.username.unique=Username is already taken.
+variant.active=Active
+variant.color=Color
+variant=Variant
 version.eol=End of Life
 version.latest=Latest Version
 version.next=Next Version
--- a/src/main/resources/localization/strings_de.properties	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/resources/localization/strings_de.properties	Sat Feb 01 18:52:08 2025 +0100
@@ -43,6 +43,7 @@
 button.remove=Entfernen
 button.save=Speichern
 button.user.create=Neuer Entwickler
+button.variant.create=Neue Variante
 button.version.create=Neue Version
 button.version.edit=Version Bearbeiten
 commit.redirect-link=Falls die Weiterleitung nicht klappt, klicken Sie bitte hier:
@@ -175,6 +176,9 @@
 validation.date.format=Datumsformat wird nicht unterst\u00fctzt.
 validation.username.null=Benutzername ist ein Pflichtfeld.
 validation.username.unique=Der Benutzername wird bereits verwendet.
+variant.active=Aktiv
+variant.color=Farbe
+variant=Variante
 version.eol=Supportende
 version.latest=Neuste Version
 version.next=N\u00e4chste Version
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Sat Feb 01 18:52:08 2025 +0100
@@ -32,6 +32,7 @@
 
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
 <c:set var="component" scope="page" value="${viewmodel.componentDetails}"/>
+<c:set var="variant" scope="page" value="${viewmodel.variantDetails}"/>
 <c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
 <%@include file="../jspf/project-header.jspf"%>
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/variant-form.jsp	Sat Feb 01 18:52:08 2025 +0100
@@ -0,0 +1,92 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2021 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@page pageEncoding="UTF-8" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.VariantEditView" scope="request" />
+<c:set var="variant" scope="page" value="${viewmodel.variant}"/>
+<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
+
+<form action="./projects/${project.node}/variants/-/commit" method="post">
+    <table class="formtable" style="width: 70ch">
+        <colgroup>
+            <col>
+            <col style="width: 100%">
+        </colgroup>
+        <tbody>
+        <tr>
+            <th><fmt:message key="project"/></th>
+            <td>
+                <c:out value="${project.name}" />
+                <input type="hidden" name="projectid" value="${project.id}" />
+            </td>
+        </tr>
+        <tr>
+            <th><label for="variant-name"><fmt:message key="variant"/></label></th>
+            <td><input id="variant-name" name="name" type="text" maxlength="20" required value="<c:out value="${variant.name}"/>" /></td>
+        </tr>
+        <tr title="<fmt:message key="node.tooltip"/>">
+            <th><label for="variant-node"><fmt:message key="node"/></label></th>
+            <td><input id="variant-node" name="node" type="text" maxlength="20" value="<c:out value="${variant.node}"/>" /></td>
+        </tr>
+        <tr>
+            <th><label for="variant-color"><fmt:message key="variant.color"/></label></th>
+            <td><input id="variant-color" name="color" type="color" required value="${variant.color}" /></td>
+        </tr>
+        <tr title="<fmt:message key="ordinal.tooltip" />">
+            <th><label for="variant-ordinal"><fmt:message key="ordinal"/></label></th>
+            <td>
+                <input id="variant-ordinal" name="ordinal" type="number" value="${variant.ordinal}"/>
+            </td>
+        </tr>
+        <tr>
+            <th class="vtop"><label for="variant-description"><fmt:message key="description"/></label></th>
+            <td>
+                <textarea id="variant-description" name="description" rows="5"><c:out value="${variant.description}"/></textarea>
+            </td>
+        </tr>
+        <tr>
+            <th><label for="variant-active"><fmt:message key="variant.active"/></label></th>
+            <td>
+                <input type="checkbox" id="variant-active" name="active" <c:if test="${variant.active}">checked</c:if> >
+            </td>
+        </tr>
+        </tbody>
+        <tfoot>
+        <tr>
+            <td colspan="2">
+                <input type="hidden" name="id" value="${variant.id}"/>
+                <a href="./projects/${project.node}/variants/" class="button">
+                    <fmt:message key="button.cancel"/>
+                </a>
+                <button type="submit"><fmt:message key="button.okay"/></button>
+            </td>
+        </tr>
+        </tfoot>
+    </table>
+</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/variants.jsp	Sat Feb 01 18:52:08 2025 +0100
@@ -0,0 +1,98 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2021 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@page pageEncoding="UTF-8" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.VariantsView" scope="request" />
+
+<c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
+
+<%@include file="../jspf/project-header.jspf"%>
+
+<div>
+    <a href="./projects/${project.node}/variants/-/create" class="button"><fmt:message key="button.variant.create"/></a>
+    <button onclick="toggleProjectDetails()" id="toggle-details-button"><fmt:message key="button.project.details"/></button>
+    <a href="./projects/${project.node}/issues/-/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a>
+</div>
+
+<h2><fmt:message key="progress" /></h2>
+
+<c:set var="summary" value="${viewmodel.projectInfo.issueSummary}" />
+<%@include file="../jspf/issue-summary.jspf"%>
+
+<table id="version-list" class="datatable medskip fullwidth">
+    <colgroup>
+        <col>
+        <col style="width: 20%">
+        <col style="width: 44%">
+        <col style="width: 12%">
+        <col style="width: 12%">
+        <col style="width: 12%">
+    </colgroup>
+    <thead>
+    <tr>
+        <th colspan="3"></th>
+        <th colspan="3" class="hcenter">
+            <fmt:message key="issues"/>
+        </th>
+    </tr>
+    <tr>
+        <th></th>
+        <th><fmt:message key="variant"/></th>
+        <th><fmt:message key="description"/></th>
+        <th class="hcenter"><fmt:message key="issues.open" /></th>
+        <th class="hcenter"><fmt:message key="issues.active" /></th>
+        <th class="hcenter"><fmt:message key="issues.done" /></th>
+    </tr>
+    </thead>
+    <tbody>
+        <c:forEach var="variantInfo" items="${viewmodel.variantInfos}" >
+        <tr>
+            <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/variants/${variantInfo.variant.node}/edit">&#x270e;</a></td>
+            <td rowspan="2">
+                <div class="navmenu-icon" style="background-color: ${variantInfo.variant.color}"></div>
+                <a href="./projects/${project.node}/issues/-/-/${variantInfo.variant.node}/"
+                        <c:if test="${not variantInfo.variant.active}">style="text-decoration: line-through;"</c:if>
+                >
+                    <c:out value="${variantInfo.variant.name}"/>
+                </a>
+            </td>
+            <td rowspan="2"><c:out value="${variantInfo.variant.description}"/> </td>
+            <td class="hright">${variantInfo.issueSummary.open}</td>
+            <td class="hright">${variantInfo.issueSummary.active}</td>
+            <td class="hright">${variantInfo.issueSummary.done}</td>
+        </tr>
+        <tr>
+            <td colspan="3">
+                <c:set var="summary" value="${variantInfo.issueSummary}"/>
+                <%@include file="../jspf/issue-progress.jspf" %>
+            </td>
+        </tr>
+        </c:forEach>
+    </tbody>
+</table>
\ No newline at end of file
--- a/src/main/webapp/WEB-INF/jspf/project-header.jspf	Thu Jan 30 21:20:27 2025 +0100
+++ b/src/main/webapp/WEB-INF/jspf/project-header.jspf	Sat Feb 01 18:52:08 2025 +0100
@@ -1,6 +1,7 @@
 <%--
 project: Project
 component: Component (optional)
+variant: Variant (optional)
 --%>
 <div id="project-details-header-reduced" style="display:none" class="table project-attributes">
     <div class="row">
@@ -64,4 +65,12 @@
             </div>
         </div>
     </c:if>
+    <c:if test="${not empty variant}">
+        <div class="row">
+            <div class="caption"><fmt:message key="variant"/>:</div>
+            <div><c:out value="${variant.name}"/></div>
+            <div class="caption"><fmt:message key="description"/>:</div>
+            <div><c:out value="${variant.description}"/></div>
+        </div>
+    </c:if>
 </div>

mercurial