prepare implementation of "variants" default tip

Thu, 30 Jan 2025 21:20:27 +0100

author
Mike Becker <universe@uap-core.de>
date
Thu, 30 Jan 2025 21:20:27 +0100
changeset 347
d1edd8d9c8a1
parent 346
860bbccf33e7

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