prepare implementation of "variants"

3 weeks ago

author
Mike Becker <universe@uap-core.de>
date
Thu, 30 Jan 2025 21:20:27 +0100 (3 weeks ago)
changeset 347
d1edd8d9c8a1
parent 346
860bbccf33e7
child 348
1dc9c405e9e2

prepare implementation of "variants"

introduces the class, implements basic DAO stuff, and
adds the new path parameter

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/entities/Variant.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Variants.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/components.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-view.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issues-feed.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/projects.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/versions.jsp file | annotate | diff | comparison | revisions
--- a/setup/postgres/psql_create_tables.sql	Thu Jan 30 19:47:21 2025 +0100
+++ b/setup/postgres/psql_create_tables.sql	Thu Jan 30 21:20:27 2025 +0100
@@ -58,6 +58,20 @@
 
 create unique index lpit_component_node_unique on lpit_component (project, node);
 
+create table lpit_variant
+(
+    id          serial primary key,
+    project     integer not null references lpit_project (projectid),
+    name        text    not null,
+    node        text    not null,
+    color       char(6) not null default '000000',
+    ordinal     integer not null default 0,
+    description text,
+    active      boolean not null default true
+);
+
+create unique index lpit_variant_node_unique on lpit_variant (project, node);
+
 create type issue_status as enum (
     'InSpecification',
     'ToDo',
--- a/setup/postgres/psql_patch_1.5.0.sql	Thu Jan 30 19:47:21 2025 +0100
+++ b/setup/postgres/psql_patch_1.5.0.sql	Thu Jan 30 21:20:27 2025 +0100
@@ -2,3 +2,17 @@
 
 alter table lpit_issue_history_event
     add userid integer null references lpit_user (userid) on delete set null;
+
+create table lpit_variant
+(
+    id          serial primary key,
+    project     integer not null references lpit_project (projectid),
+    name        text    not null,
+    node        text    not null,
+    color       char(6) not null default '000000',
+    ordinal     integer not null default 0,
+    description text,
+    active      boolean not null default true
+);
+
+create unique index lpit_variant_node_unique on lpit_variant (project, node);
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -59,6 +59,13 @@
     fun insertComponent(component: Component)
     fun updateComponent(component: Component)
 
+    fun listVariants(project: Project): List<Variant>
+    //fun listVariantSummaries(project: Project): List<VariantSummary>
+    fun findVariant(id: Int): Variant?
+    fun findVariantByNode(project: Project, node: String): Variant?
+    fun insertVariant(variant: Variant)
+    fun updateVariant(variant: Variant)
+
     /**
      * Lists all projects ordered by name.
      */
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -354,6 +354,114 @@
 
     //</editor-fold>
 
+    //<editor-fold desc="Variant">
+    //language=SQL
+    private val variantQuery =
+        """
+        select id, project, name, node, color, ordinal, description, active
+        from lpit_variant
+        """.trimIndent()
+
+    private fun ResultSet.extractVariant(): Variant =
+        Variant(getInt("id"), getInt("project")).apply {
+            name = getString("name")
+            node = getString("node")
+            color = try {
+                WebColor(getString("color"))
+            } catch (ex: IllegalArgumentException) {
+                WebColor("000000")
+            }
+            ordinal = getInt("ordinal")
+            description = getString("description")
+            active = getBoolean("active")
+        }
+
+    private fun PreparedStatement.setVariant(index: Int, variant: Variant): Int {
+        with(variant) {
+            var i = index
+            setStringSafe(i++, name)
+            setStringSafe(i++, node)
+            setStringSafe(i++, color.hex)
+            setInt(i++, ordinal)
+            setStringOrNull(i++, description)
+            setBoolean(i++, active)
+            return i
+        }
+    }
+
+    override fun listVariants(project: Project): List<Variant> =
+        withStatement("$variantQuery where project = ? order by ordinal, lower(name)") {
+            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)
+                group by variant, phase
+            ),
+            summary as (
+                select v.id, phase, total
+                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,
+                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 = ?
+            order by ordinal, name
+            """.trimIndent()
+        ) {
+            setInt(1, project.id)
+            queryAll { rs ->
+                ComponentSummary(rs.extractComponent()).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)
+            querySingle { it.extractVariant() }
+        }
+
+    override fun findVariantByNode(project: Project, node: String): Variant? =
+        withStatement("$variantQuery where project = ? and node = ?") {
+            setInt(1, project.id)
+            setString(2, node)
+            querySingle { it.extractVariant() }
+        }
+
+    override fun insertVariant(variant: Variant) {
+        withStatement("insert into lpit_variant (name, node, color, ordinal, description, active, project) values (?, ?, ?, ?, ?, ?, ?)") {
+            val col = setVariant(1, variant)
+            setInt(col, variant.projectid)
+            executeUpdate()
+        }
+    }
+
+    override fun updateVariant(variant: Variant) {
+        withStatement("update lpit_variant set name = ?, node = ?, color = ?, ordinal = ?, description = ?, active = ? where id = ?") {
+            val col = setVariant(1, variant)
+            setInt(col, variant.id)
+            executeUpdate()
+        }
+    }
+
+    //</editor-fold>
+
     //<editor-fold desc="Project">
 
     //language=SQL
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Variant.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2025 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.
+ */
+
+package de.uapcore.lightpit.entities
+
+import de.uapcore.lightpit.types.WebColor
+
+data class Variant(override val id: Int, val projectid: Int) : Entity, HasNode {
+    var name: String = ""
+    override var node: String = name
+    var ordinal = 0
+    var color = WebColor("000000")
+    var description: String? = null
+    var active = true
+}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -11,7 +11,7 @@
 @WebServlet(urlPatterns = ["/issues/*"])
 class IssuesServlet : AbstractServlet() {
 
-    private val pathInfos = PathInfosOnlyIssues("issues/")
+    private val pathInfos = PathInfosSimple()
 
     init {
         get("/", this::issues)
@@ -49,7 +49,7 @@
             return
         }
         if (http.param("in_project") != null) {
-            http.response.sendRedirect("${http.baseHref}projects/${issue.project.node}/issues/-/-/${issue.id}")
+            http.response.sendRedirect("${http.baseHref}${PathInfosSimple(issue.project)}${issue.id}")
             return
         }
         renderIssueView(http, dao, issue, pathInfos)
@@ -62,7 +62,7 @@
             return
         }
         if (http.param("in_project") != null) {
-            http.response.sendRedirect("${http.baseHref}projects/${issue.project.node}/issues/-/-/${issue.id}/edit")
+            http.response.sendRedirect("${http.baseHref}${PathInfosSimple(issue.project)}${issue.id}/edit")
             return
         }
 
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -46,7 +46,7 @@
     init {
         get("/", this::projects)
         get("/%project", this::project)
-        get("/%project/issues/%version/%component/", this::project)
+        get("/%project/issues/%version/%component/%variant/", this::project)
         get("/%project/edit", this::projectForm)
         get("/-/create", this::projectForm)
         post("/-/commit", this::projectCommit)
@@ -62,13 +62,13 @@
         get("/%project/components/-/create", this::componentForm)
         post("/%project/components/-/commit", this::componentCommit)
 
-        get("/%project/issues/%version/%component/%issue", this::issue)
-        get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
-        post("/%project/issues/%version/%component/%issue/comment", this::issueComment)
-        post("/%project/issues/%version/%component/%issue/relation", this::issueRelation)
-        get("/%project/issues/%version/%component/%issue/removeRelation", this::issueRemoveRelation)
-        get("/%project/issues/%version/%component/-/create", this::issueForm)
-        post("/%project/issues/%version/%component/-/commit", this::issueCommit)
+        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)
+        post("/%project/issues/%version/%component/%variant/%issue/relation", this::issueRelation)
+        get("/%project/issues/%version/%component/%variant/%issue/removeRelation", this::issueRemoveRelation)
+        get("/%project/issues/%version/%component/%variant/-/create", this::issueForm)
+        post("/%project/issues/%version/%component/%variant/-/commit", this::issueCommit)
     }
 
     private fun projects(http: HttpRequest, dao: DataAccessObject) {
@@ -78,6 +78,7 @@
                 project = it,
                 versions = dao.listVersions(it),
                 components = emptyList(), // not required in this view
+                variants = emptyList(), // not required in this view
                 issueSummary = dao.collectIssueSummary(it)
             )
         }
@@ -101,7 +102,7 @@
 
     private fun project(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let {path ->
-            val project = path.projectInfo.project
+            val project = path.project
 
             val filter = IssueFilter(http, dao)
 
@@ -137,7 +138,7 @@
             http.render("project-form")
         } else {
             withPathInfo(http, dao)?.let { path ->
-                http.view = ProjectEditView(path.projectInfo.project, dao.listUsers())
+                http.view = ProjectEditView(path.project, dao.listUsers())
                 http.navigationMenu = projectNavMenu(dao.listProjects(), path)
                 http.render("project-form")
             }
@@ -173,13 +174,13 @@
     private fun vcsAnalyze(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let { path ->
             // if analysis is not configured, reject the request
-            if (path.projectInfo.project.vcs == VcsType.None) {
+            if (path.project.vcs == VcsType.None) {
                 http.response.sendError(404)
                 return
             }
 
             // obtain the list of issues for this project to filter cross-project references
-            val knownIds = dao.listIssues(path.projectInfo.project, true).map { it.id }
+            val knownIds = dao.listIssues(path.project, true).map { it.id }
 
             // read the provided commit log and merge only the refs that relate issues from the current project
             dao.mergeCommitRefs(parseCommitRefs(http.body).filter { knownIds.contains(it.issueId) })
@@ -189,10 +190,10 @@
     private fun versions(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let { path ->
             with(http) {
-                pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.versions")}"
+                pageTitle = "${path.project.name} - ${i18n("navmenu.versions")}"
                 view = VersionsView(
                     path.projectInfo,
-                    dao.listVersionSummaries(path.projectInfo.project)
+                    dao.listVersionSummaries(path.project)
                 )
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
@@ -205,7 +206,7 @@
     private fun versionForm(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let { path ->
             val version = if (path.versionInfo is OptionalPathInfo.Specific)
-                path.versionInfo.elem else Version(-1, path.projectInfo.project.id)
+                path.versionInfo.elem else Version(-1, path.project.id)
 
             with(http) {
                 view = VersionEditView(path.projectInfo, version)
@@ -265,10 +266,10 @@
     private fun components(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let { path ->
             with(http) {
-                pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.components")}"
+                pageTitle = "${path.project.name} - ${i18n("navmenu.components")}"
                 view = ComponentsView(
                     path.projectInfo,
-                    dao.listComponentSummaries(path.projectInfo.project)
+                    dao.listComponentSummaries(path.project)
                 )
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
@@ -281,7 +282,7 @@
     private fun componentForm(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let { path ->
             val component = if (path.componentInfo is OptionalPathInfo.Specific)
-                path.componentInfo.elem else Component(-1, path.projectInfo.project.id)
+                path.componentInfo.elem else Component(-1, path.project.id)
 
             with(http) {
                 view = ComponentEditView(path.projectInfo, component, dao.listUsers())
@@ -337,7 +338,7 @@
         withPathInfo(http, dao)?.let { path ->
             val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) ?: Issue(
                 -1,
-                path.projectInfo.project,
+                path.project,
             )
 
             // for new issues set some defaults
@@ -391,7 +392,7 @@
         withPathInfo(http, dao)?.run {
             val issue = Issue(
                 http.param("id")?.toIntOrNull() ?: -1,
-                projectInfo.project
+                project
             ).applyFormData(http, dao)
 
             val openId = if (issue.id < 0) {
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -60,6 +60,7 @@
     sequence {
         val cnode = pathInfos.componentInfo.node
         val vnode = pathInfos.versionInfo.node
+        val varnode = pathInfos.variantInfo.node
         for (project in projects) {
             val active = project == pathInfos.projectInfo.project
             yield(
@@ -84,7 +85,7 @@
                         level = 2,
                         caption = "navmenu.all",
                         resolveCaption = true,
-                        href = "projects/${project.node}/issues/-/${cnode}/",
+                        href = "projects/${project.node}/issues/-/${cnode}/${varnode}/",
                         iconColor = "#000000",
                         active = vnode == "-",
                     )
@@ -94,7 +95,7 @@
                         level = 2,
                         caption = "navmenu.none",
                         resolveCaption = true,
-                        href = "projects/${project.node}/issues/~/${cnode}/",
+                        href = "projects/${project.node}/issues/~/${cnode}/${varnode}/",
                         iconColor = "#000000",
                         active = vnode == "~",
                     )
@@ -106,7 +107,7 @@
                             level = 2,
                             caption = version.name,
                             title = "version.status.${version.status}",
-                            href = "projects/${project.node}/issues/${version.node}/${cnode}/",
+                            href = "projects/${project.node}/issues/${version.node}/${cnode}/${varnode}/",
                             iconColor = "version-${version.status}",
                             active = version.node == vnode
                         )
@@ -125,7 +126,7 @@
                         level = 2,
                         caption = "navmenu.all",
                         resolveCaption = true,
-                        href = "projects/${project.node}/issues/${vnode}/-/",
+                        href = "projects/${project.node}/issues/${vnode}/-/${varnode}/",
                         iconColor = "#000000",
                         active = cnode == "-",
                     )
@@ -135,7 +136,7 @@
                         level = 2,
                         caption = "navmenu.none",
                         resolveCaption = true,
-                        href = "projects/${project.node}/issues/${vnode}/~/",
+                        href = "projects/${project.node}/issues/${vnode}/~/${varnode}/",
                         iconColor = "#000000",
                         active = cnode == "~",
                     )
@@ -146,12 +147,52 @@
                         NavMenuEntry(
                             level = 2,
                             caption = component.name,
-                            href = "projects/${project.node}/issues/${vnode}/${component.node}/",
+                            href = "projects/${project.node}/issues/${vnode}/${component.node}/${varnode}/",
                             iconColor = "${component.color}",
                             active = component.node == cnode
                         )
                     )
                 }
+                yield(
+                    NavMenuEntry(
+                        level = 1,
+                        caption = "navmenu.variants",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/variants/"
+                    )
+                )
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.all",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/${vnode}/${cnode}/-/",
+                        iconColor = "#000000",
+                        active = varnode == "-",
+                    )
+                )
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.none",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/${vnode}/${cnode}/~/",
+                        iconColor = "#000000",
+                        active = varnode == "~",
+                    )
+                )
+                for (variant in pathInfos.projectInfo.variants) {
+                    if (!variant.active && variant.node != cnode) continue
+                    yield(
+                        NavMenuEntry(
+                            level = 2,
+                            caption = variant.name,
+                            href = "projects/${project.node}/issues/${vnode}/${cnode}/${variant.node}/",
+                            iconColor = "${variant.color}",
+                            active = variant.node == varnode
+                        )
+                    )
+                }
             }
         }
     }.toList()
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -29,15 +29,25 @@
 import de.uapcore.lightpit.OptionalPathInfo
 import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.Project
+import de.uapcore.lightpit.entities.Variant
 import de.uapcore.lightpit.entities.Version
 
-abstract class PathInfos(val issuesHref: String)
-class PathInfosOnlyIssues(issuesHref: String): PathInfos(issuesHref)
+abstract class PathInfos(val issuesHref: String) {
+    override fun toString(): String {
+        return issuesHref
+    }
+}
+class PathInfosSimple(project: Project? = null)
+    : PathInfos(if (project == null) "issues/" else "projects/${project.node}/issues/-/-/-/")
 data class PathInfosFull(
     val projectInfo: ProjectInfo,
-    val versionInfo: OptionalPathInfo<Version>,
-    val componentInfo: OptionalPathInfo<Component>
-): PathInfos("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/")
+    val versionInfo: OptionalPathInfo<Version> = OptionalPathInfo.All,
+    val componentInfo: OptionalPathInfo<Component> = OptionalPathInfo.All,
+    val variantInfo: OptionalPathInfo<Variant> = OptionalPathInfo.All,
+): PathInfos("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/${variantInfo.node}/") {
+    val project = projectInfo.project
+}
 
 private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
     val pathParam = http.pathParams["project"] ?: return null
@@ -45,11 +55,13 @@
 
     val versions: List<Version> = dao.listVersions(project)
     val components: List<Component> = dao.listComponents(project)
+    val variants: List<Variant> = dao.listVariants(project)
 
     return ProjectInfo(
         project,
         versions,
         components,
+        variants,
         dao.collectIssueSummary(project)
     )
 }
@@ -63,11 +75,12 @@
 
     val version = http.lookupPathParam("version", projectInfo.versions)
     val component = http.lookupPathParam("component", projectInfo.components)
+    val variant = http.lookupPathParam("variant", projectInfo.variants)
 
     if (version == OptionalPathInfo.NotFound || component == OptionalPathInfo.NotFound) {
         http.response.sendError(404)
         return null
     }
 
-    return PathInfosFull(projectInfo, version, component)
+    return PathInfosFull(projectInfo, version, component, variant)
 }
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -35,6 +35,7 @@
      */
     var versions: List<Version>,
     var components: List<Component>,
+    var variants: List<Variant>,
     var issueSummary: IssueSummary
 ) {
     val latestVersion = versions.firstOrNull { it.status.isReleased }
@@ -54,6 +55,7 @@
     val issueSummary = IssueSummary()
     val versionInfo: VersionInfo?
     val componentDetails: Component?
+    val variantDetails: Variant?
 
     init {
         feedHref = "feed/${projectInfo.project.node}/issues.rss"
@@ -66,6 +68,10 @@
             is OptionalPathInfo.Specific -> cinfo.elem
             else -> null
         }
+        variantDetails = when (val varinfo = pathInfos.variantInfo){
+            is OptionalPathInfo.Specific -> varinfo.elem
+            else -> null
+        }
     }
 }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Variants.kt	Thu Jan 30 21:20:27 2025 +0100
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.Variant
+
+class VariantSummary(
+    val variant: Variant,
+) {
+    val issueSummary = IssueSummary()
+}
+
+class VariantsView(
+    val projectInfo: ProjectInfo,
+    val variantInfos: List<VariantSummary>
+) : View()
+
+class VariantEditView(
+    val projectInfo: ProjectInfo,
+    val variant: Variant
+) : EditView()
--- a/src/main/resources/localization/strings.properties	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/resources/localization/strings.properties	Thu Jan 30 21:20:27 2025 +0100
@@ -143,6 +143,7 @@
 navmenu.all=all
 navmenu.components=Components
 navmenu.none=none
+navmenu.variants=Variants
 navmenu.versions=Versions
 no-projects=Welcome to LightPIT. Start off by creating a new project!
 no-users=No developers have been configured yet.
--- a/src/main/resources/localization/strings_de.properties	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/resources/localization/strings_de.properties	Thu Jan 30 21:20:27 2025 +0100
@@ -143,6 +143,7 @@
 navmenu.all=Alle
 navmenu.components=Komponenten
 navmenu.none=Keine
+navmenu.variants=Varianten
 navmenu.versions=Versionen
 no-projects=Wilkommen bei LightPIT. Beginnen Sie mit der Erstellung eines Projektes!
 no-users=Bislang wurden keine Entwickler hinterlegt.
--- a/src/main/webapp/WEB-INF/jsp/components.jsp	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/components.jsp	Thu Jan 30 21:20:27 2025 +0100
@@ -37,7 +37,7 @@
 <div>
     <a href="./projects/${project.node}/components/-/create" class="button"><fmt:message key="button.component.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>
+    <a href="./projects/${project.node}/issues/-/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a>
 </div>
 
 <h2><fmt:message key="progress" /></h2>
@@ -76,7 +76,7 @@
             <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/components/${componentInfo.component.node}/edit">&#x270e;</a></td>
             <td rowspan="2">
                 <div class="navmenu-icon" style="background-color: ${componentInfo.component.color}"></div>
-                <a href="./projects/${project.node}/issues/-/${componentInfo.component.node}/"
+                <a href="./projects/${project.node}/issues/-/${componentInfo.component.node}/-/"
                         <c:if test="${not componentInfo.component.active}">style="text-decoration: line-through;"</c:if>
                 >
                     <c:out value="${componentInfo.component.name}"/>
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Thu Jan 30 21:20:27 2025 +0100
@@ -67,14 +67,14 @@
     <tr>
         <th><fmt:message key="project"/></th>
         <td>
-            <a href="./projects/${issue.project.node}/issues/-/-/">
+            <a href="./projects/${issue.project.node}/issues/-/-/-/">
                 <c:out value="${issue.project.name}" />
             </a>
         </td>
         <th><fmt:message key="component"/></th>
         <td>
             <c:if test="${not empty issue.component}">
-                <a href="./projects/${issue.project.node}/issues/-/${issue.component.node}/">
+                <a href="./projects/${issue.project.node}/issues/-/${issue.component.node}/-/">
                     <c:out value="${issue.component.name}"/>
                 </a>
             </c:if>
--- a/src/main/webapp/WEB-INF/jsp/issues-feed.jsp	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issues-feed.jsp	Thu Jan 30 21:20:27 2025 +0100
@@ -30,7 +30,7 @@
     <c:if test="${not empty viewmodel.project}">
     <title><c:out value="${viewmodel.project.name}"/>|<fmt:message key="feed.issues.title"/></title>
     <link>${baseHref}projects/${viewmodel.project.node}</link>
-    <c:set var="issueHref" value="${baseHref}projects/${viewmodel.project.node}/issues/-/-/"/>
+    <c:set var="issueHref" value="${baseHref}projects/${viewmodel.project.node}/issues/-/-/-/"/>
     </c:if>
     <c:if test="${empty viewmodel.project}">
     <title><fmt:message key="feed.issues.title"/></title>
--- a/src/main/webapp/WEB-INF/jsp/projects.jsp	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/projects.jsp	Thu Jan 30 21:20:27 2025 +0100
@@ -79,12 +79,12 @@
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.latestVersion}">
-                        <a href="./projects/${project.node}/issues/${projectInfo.latestVersion.node}/-/"><c:out value="${projectInfo.latestVersion.name}"/></a>
+                        <a href="./projects/${project.node}/issues/${projectInfo.latestVersion.node}/-/-/"><c:out value="${projectInfo.latestVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.nextVersion}">
-                        <a href="./projects/${project.node}/issues/${projectInfo.nextVersion.node}/-/"><c:out value="${projectInfo.nextVersion.name}"/></a>
+                        <a href="./projects/${project.node}/issues/${projectInfo.nextVersion.node}/-/-/"><c:out value="${projectInfo.nextVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">${projectInfo.issueSummary.open}</td>
--- a/src/main/webapp/WEB-INF/jsp/versions.jsp	Thu Jan 30 19:47:21 2025 +0100
+++ b/src/main/webapp/WEB-INF/jsp/versions.jsp	Thu Jan 30 21:20:27 2025 +0100
@@ -36,7 +36,7 @@
 <div>
     <a href="./projects/${project.node}/versions/-/create" class="button"><fmt:message key="button.version.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>
+    <a href="./projects/${project.node}/issues/-/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a>
 </div>
 
 <h2><fmt:message key="progress"/></h2>
@@ -82,7 +82,7 @@
             <td rowspan="2" style="width: 2em;"><a
                     href="./projects/${project.node}/versions/${versionInfo.version.node}/edit">&#x270e;</a></td>
             <td rowspan="2">
-                <a href="./projects/${project.node}/issues/${versionInfo.version.node}/-/">
+                <a href="./projects/${project.node}/issues/${versionInfo.version.node}/-/-/">
                     <c:out value="${versionInfo.version.name}"/>
                 </a>
                 <div class="version-tag version-${versionInfo.version.status}"

mercurial