add possibility to show issues w/o version or component - fixes #335

Mon, 30 Oct 2023 14:44:36 +0100

author
Mike Becker <universe@uap-core.de>
date
Mon, 30 Oct 2023 14:44:36 +0100
changeset 292
703591e739f4
parent 291
bcf05cccac6f
child 293
953c757c368f

add possibility to show issues w/o version or component - fixes #335

src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt 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/kotlin/de/uapcore/lightpit/viewmodel/Issues.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/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/changelogs/changelog-de.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/changelogs/changelog.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-form.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/project-details.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/issue-list.jspf file | annotate | diff | comparison | revisions
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -26,6 +26,7 @@
 package de.uapcore.lightpit
 
 import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.entities.HasNode
 import de.uapcore.lightpit.viewmodel.NavMenu
 import de.uapcore.lightpit.viewmodel.View
 import jakarta.servlet.http.HttpServletRequest
@@ -38,6 +39,14 @@
 typealias MappingMethod = (HttpRequest, DataAccessObject) -> Unit
 typealias PathParameters = Map<String, String>
 
+sealed class OptionalPathInfo<in T : HasNode>(info: T) {
+    class Specific<T: HasNode>(val elem: T) : OptionalPathInfo<T>(elem)
+    data object All : OptionalPathInfo<HasNode>(object : HasNode { override val node = "-"})
+    data object None : OptionalPathInfo<HasNode>(object : HasNode { override val node = "~"})
+    data object NotFound : OptionalPathInfo<HasNode>(object : HasNode { override val node = ""})
+    val node = info.node
+}
+
 sealed interface ValidationResult<T>
 class ValidationError<T>(val message: String): ValidationResult<T>
 class ValidatedValue<T>(val result: T): ValidationResult<T>
@@ -173,6 +182,18 @@
         }
     }
 
+
+    fun <T : HasNode> lookupPathParam(paramName: String, list: List<T>): OptionalPathInfo<T> {
+        return when (val node = this.pathParams[paramName]) {
+            null -> OptionalPathInfo.All
+            "-" -> OptionalPathInfo.All
+            "~" -> OptionalPathInfo.None
+            else -> list.find { it.node == node }
+                ?.let { OptionalPathInfo.Specific(it) }
+                ?: OptionalPathInfo.NotFound
+        }
+    }
+
     val body: String by lazy {
         request.reader.lineSequence().joinToString("\n")
     }
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -74,7 +74,14 @@
     fun mergeCommitRefs(refs: List<CommitRef>)
 
     fun listIssues(project: Project, includeDone: Boolean): List<Issue>
-    fun listIssues(project: Project, includeDone: Boolean, version: Version?, component: Component?): List<Issue>
+    fun listIssues(
+        project: Project,
+        includeDone: Boolean,
+        specificVersion: Boolean,
+        version: Version?,
+        specificComponent: Boolean,
+        component: Component?
+    ): List<Issue>
     fun findIssue(id: Int): Issue?
     fun insertIssue(issue: Issue): Int
     fun updateIssue(issue: Issue)
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -557,7 +557,14 @@
             queryAll { it.extractIssue() }
         }
 
-    override fun listIssues(project: Project, includeDone: Boolean, version: Version?, component: Component?): List<Issue> =
+    override fun listIssues(
+        project: Project,
+        includeDone: Boolean,
+        specificVersion: Boolean,
+        version: Version?,
+        specificComponent: Boolean,
+        component: Component?
+    ): List<Issue> =
         withStatement(
             """$issueQuery where i.project = ? and
                 (? or phase < 2) and
@@ -565,21 +572,16 @@
                 (not ? or component = ?) and (not ? or component is null)
             """.trimIndent()
         ) {
-            fun <T : Entity> applyFilter(search: T?, fflag: Int, nflag: Int, idcol: Int) {
-                if (search == null) {
-                    setBoolean(fflag, false)
-                    setBoolean(nflag, false)
-                    setInt(idcol, 0)
-                } else {
-                    setBoolean(fflag, true)
-                    setBoolean(nflag, false)
-                    setInt(idcol, search.id)
-                }
-            }
             setInt(1, project.id)
             setBoolean(2, includeDone)
-            applyFilter(version, 3, 5, 4)
-            applyFilter(component, 6, 8, 7)
+
+            setBoolean(3, specificVersion && version != null)
+            setInt(4, version?.id ?: 0)
+            setBoolean(5, specificVersion && version == null)
+
+            setBoolean(6, specificComponent && component != null)
+            setInt(7, component?.id ?: 0)
+            setBoolean(8, specificComponent && component == null)
 
             queryAll { it.extractIssue() }
         }
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -25,11 +25,8 @@
 
 package de.uapcore.lightpit.servlet
 
-import de.uapcore.lightpit.AbstractServlet
-import de.uapcore.lightpit.HttpRequest
-import de.uapcore.lightpit.boolValidator
+import de.uapcore.lightpit.*
 import de.uapcore.lightpit.dao.DataAccessObject
-import de.uapcore.lightpit.dateOptValidator
 import de.uapcore.lightpit.entities.*
 import de.uapcore.lightpit.types.*
 import de.uapcore.lightpit.viewmodel.*
@@ -86,53 +83,6 @@
         }
     }
 
-    private fun activeProjectNavMenu(
-        projects: List<Project>,
-        projectInfo: ProjectInfo,
-        selectedVersion: Version? = null,
-        selectedComponent: Component? = null
-    ) =
-        projectNavMenu(
-            projects,
-            projectInfo.versions,
-            projectInfo.components,
-            projectInfo.project,
-            selectedVersion,
-            selectedComponent
-        )
-
-    private sealed interface LookupResult<T>
-    private class NotFound<T> : LookupResult<T>
-    private data class Found<T>(val elem: T?) : LookupResult<T>
-
-    private fun <T : HasNode> HttpRequest.lookupPathParam(paramName: String, list: List<T>): LookupResult<T> {
-        val node = pathParams[paramName]
-        return if (node == null || node == "-") {
-            Found(null)
-        } else {
-            val result = list.find { it.node == node }
-            if (result == null) {
-                NotFound()
-            } else {
-                Found(result)
-            }
-        }
-    }
-
-    private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
-        val project = dao.findProjectByNode(http.pathParams["project"] ?: "") ?: return null
-
-        val versions: List<Version> = dao.listVersions(project)
-        val components: List<Component> = dao.listComponents(project)
-
-        return ProjectInfo(
-            project,
-            versions,
-            components,
-            dao.collectIssueSummary(project)
-        )
-    }
-
     private fun sanitizeNode(name: String): String {
         val san = name.replace(Regex("[/\\\\]"), "-")
         return if (san.startsWith(".")) {
@@ -144,46 +94,9 @@
 
     private fun feedPath(project: Project) = "feed/${project.node}/issues.rss"
 
-    private data class PathInfos(
-        val projectInfo: ProjectInfo,
-        val version: Version?,
-        val component: Component?
-    ) {
-        val project = projectInfo.project
-        val issuesHref by lazyOf("projects/${project.node}/issues/${version?.node ?: "-"}/${component?.node ?: "-"}/")
-    }
-
-    private fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return null
-        }
-
-        val version = when (val result = http.lookupPathParam("version", projectInfo.versions)) {
-            is NotFound -> {
-                http.response.sendError(404)
-                return null
-            }
-            is Found -> {
-                result.elem
-            }
-        }
-        val component = when (val result = http.lookupPathParam("component", projectInfo.components)) {
-            is NotFound -> {
-                http.response.sendError(404)
-                return null
-            }
-            is Found -> {
-                result.elem
-            }
-        }
-
-        return PathInfos(projectInfo, version, component)
-    }
-
     private fun project(http: HttpRequest, dao: DataAccessObject) {
-        withPathInfo(http, dao)?.run {
+        withPathInfo(http, dao)?.let {path ->
+            val project = path.projectInfo.project
 
             val filter = IssueFilter(http)
 
@@ -191,7 +104,12 @@
 
             val relationsMap = if (needRelationsMap) dao.getIssueRelationMap(project, filter.includeDone) else emptyMap()
 
-            val issues = dao.listIssues(project, filter.includeDone, version, component)
+            val specificVersion = path.versionInfo !is OptionalPathInfo.All
+            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 issues = dao.listIssues(project, filter.includeDone, specificVersion, version, specificComponent, component)
                 .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary))
                 .filter {
                     (!filter.onlyMine || (it.assignee?.username ?: "") == (http.remoteUser ?: "<Anonymous>")) &&
@@ -202,14 +120,9 @@
 
             with(http) {
                 pageTitle = project.name
-                view = ProjectDetails(projectInfo, issues, filter, version, component)
+                view = ProjectDetails(path, issues, filter)
                 feedPath = feedPath(project)
-                navigationMenu = activeProjectNavMenu(
-                    dao.listProjects(),
-                    projectInfo,
-                    version,
-                    component
-                )
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
                 javascript = "project-details"
                 render("project-details")
@@ -218,23 +131,18 @@
     }
 
     private fun projectForm(http: HttpRequest, dao: DataAccessObject) {
+        http.styleSheets = listOf("projects")
         if (!http.pathParams.containsKey("project")) {
             http.view = ProjectEditView(Project(-1), dao.listUsers())
             http.navigationMenu = projectNavMenu(dao.listProjects())
+            http.render("project-form")
         } else {
-            val projectInfo = obtainProjectInfo(http, dao)
-            if (projectInfo == null) {
-                http.response.sendError(404)
-                return
+            withPathInfo(http, dao)?.let { path ->
+                http.view = ProjectEditView(path.projectInfo.project, dao.listUsers())
+                http.navigationMenu = projectNavMenu(dao.listProjects(), path)
+                http.render("project-form")
             }
-            http.view = ProjectEditView(projectInfo.project, dao.listUsers())
-            http.navigationMenu = activeProjectNavMenu(
-                dao.listProjects(),
-                projectInfo
-            )
         }
-        http.styleSheets = listOf("projects")
-        http.render("project-form")
     }
 
     private fun projectCommit(http: HttpRequest, dao: DataAccessObject) {
@@ -264,77 +172,50 @@
     }
 
     private fun vcsAnalyze(http: HttpRequest, dao: DataAccessObject) {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return
-        }
+        withPathInfo(http, dao)?.let { path ->
+            // if analysis is not configured, reject the request
+            if (path.projectInfo.project.vcs == VcsType.None) {
+                http.response.sendError(404)
+                return
+            }
 
-        // if analysis is not configured, reject the request
-        if (projectInfo.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 }
 
-        // obtain the list of issues for this project to filter cross-project references
-        val knownIds = dao.listIssues(projectInfo.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) })
+            // 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) })
+        }
     }
 
     private fun versions(http: HttpRequest, dao: DataAccessObject) {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return
-        }
-
-        with(http) {
-            pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.versions")}"
-            view = VersionsView(
-                projectInfo,
-                dao.listVersionSummaries(projectInfo.project)
-            )
-            feedPath = feedPath(projectInfo.project)
-            navigationMenu = activeProjectNavMenu(
-                dao.listProjects(),
-                projectInfo
-            )
-            styleSheets = listOf("projects")
-            javascript = "project-details"
-            render("versions")
+        withPathInfo(http, dao)?.let { path ->
+            with(http) {
+                pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.versions")}"
+                view = VersionsView(
+                    path.projectInfo,
+                    dao.listVersionSummaries(path.projectInfo.project)
+                )
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                javascript = "project-details"
+                render("versions")
+            }
         }
     }
 
     private fun versionForm(http: HttpRequest, dao: DataAccessObject) {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return
-        }
+        withPathInfo(http, dao)?.let { path ->
+            val version = if (path.versionInfo is OptionalPathInfo.Specific)
+                path.versionInfo.elem else Version(-1, path.projectInfo.project.id)
 
-        val version: Version
-        when (val result = http.lookupPathParam("version", projectInfo.versions)) {
-            is NotFound -> {
-                http.response.sendError(404)
-                return
+            with(http) {
+                view = VersionEditView(path.projectInfo, version)
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                render("version-form")
             }
-            is Found -> {
-                version = result.elem ?: Version(-1, projectInfo.project.id)
-            }
-        }
-
-        with(http) {
-            view = VersionEditView(projectInfo, version)
-            feedPath = feedPath(projectInfo.project)
-            navigationMenu = activeProjectNavMenu(
-                dao.listProjects(),
-                projectInfo,
-                selectedVersion = version
-            )
-            styleSheets = listOf("projects")
-            render("version-form")
         }
     }
 
@@ -385,57 +266,34 @@
     }
 
     private fun components(http: HttpRequest, dao: DataAccessObject) {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return
-        }
-
-        with(http) {
-            pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.components")}"
-            view = ComponentsView(
-                projectInfo,
-                dao.listComponentSummaries(projectInfo.project)
-            )
-            feedPath = feedPath(projectInfo.project)
-            navigationMenu = activeProjectNavMenu(
-                dao.listProjects(),
-                projectInfo
-            )
-            styleSheets = listOf("projects")
-            javascript = "project-details"
-            render("components")
+        withPathInfo(http, dao)?.let { path ->
+            with(http) {
+                pageTitle = "${path.projectInfo.project.name} - ${i18n("navmenu.components")}"
+                view = ComponentsView(
+                    path.projectInfo,
+                    dao.listComponentSummaries(path.projectInfo.project)
+                )
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                javascript = "project-details"
+                render("components")
+            }
         }
     }
 
     private fun componentForm(http: HttpRequest, dao: DataAccessObject) {
-        val projectInfo = obtainProjectInfo(http, dao)
-        if (projectInfo == null) {
-            http.response.sendError(404)
-            return
-        }
+        withPathInfo(http, dao)?.let { path ->
+            val component = if (path.componentInfo is OptionalPathInfo.Specific)
+                path.componentInfo.elem else Component(-1, path.projectInfo.project.id)
 
-        val component: Component
-        when (val result = http.lookupPathParam("component", projectInfo.components)) {
-            is NotFound -> {
-                http.response.sendError(404)
-                return
+            with(http) {
+                view = ComponentEditView(path.projectInfo, component, dao.listUsers())
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
+                styleSheets = listOf("projects")
+                render("component-form")
             }
-            is Found -> {
-                component = result.elem ?: Component(-1, projectInfo.project.id)
-            }
-        }
-
-        with(http) {
-            view = ComponentEditView(projectInfo, component, dao.listUsers())
-            feedPath = feedPath(projectInfo.project)
-            navigationMenu = activeProjectNavMenu(
-                dao.listProjects(),
-                projectInfo,
-                selectedComponent = component
-            )
-            styleSheets = listOf("projects")
-            render("component-form")
         }
     }
 
@@ -484,29 +342,23 @@
         issue: Issue,
         relationError: String? = null
     ) {
-        withPathInfo(http, dao)?.run {
+        withPathInfo(http, dao)?.let {path ->
             val comments = dao.listComments(issue)
 
             with(http) {
-                pageTitle = "${projectInfo.project.name}: #${issue.id} ${issue.subject}"
+                pageTitle = "${path.projectInfo.project.name}: #${issue.id} ${issue.subject}"
                 view = IssueDetailView(
+                    path,
                     issue,
                     comments,
-                    project,
-                    version,
-                    component,
-                    dao.listIssues(project, true),
+                    path.projectInfo.project,
+                    dao.listIssues(path.projectInfo.project, true),
                     dao.listIssueRelations(issue),
                     relationError,
                     dao.listCommitRefs(issue)
                 )
-                feedPath = feedPath(projectInfo.project)
-                navigationMenu = activeProjectNavMenu(
-                    dao.listProjects(),
-                    projectInfo,
-                    version,
-                    component
-                )
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
                 javascript = "issue-editor"
                 render("issue-view")
@@ -515,23 +367,25 @@
     }
 
     private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
-        withPathInfo(http, dao)?.run {
+        withPathInfo(http, dao)?.let { path ->
             val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) ?: Issue(
                 -1,
-                project,
+                path.projectInfo.project,
             )
 
             // for new issues set some defaults
             if (issue.id < 0) {
                 // pre-select component, if available in the path info
-                issue.component = component
+                if (path.componentInfo is OptionalPathInfo.Specific) {
+                    issue.component = path.componentInfo.elem
+                }
 
                 // pre-select version, if available in the path info
-                if (version != null) {
-                    if (version.status.isReleased) {
-                        issue.affected = version
+                if (path.versionInfo is OptionalPathInfo.Specific) {
+                    if (path.versionInfo.elem.status.isReleased) {
+                        issue.affected = path.versionInfo.elem
                     } else {
-                        issue.resolved = version
+                        issue.resolved = path.versionInfo.elem
                     }
                 }
             }
@@ -539,20 +393,14 @@
             with(http) {
                 view = IssueEditView(
                     issue,
-                    projectInfo.versions,
-                    projectInfo.components,
+                    path.projectInfo.versions,
+                    path.projectInfo.components,
                     dao.listUsers(),
-                    project,
-                    version,
-                    component
+                    path.projectInfo.project,
+                    path
                 )
-                feedPath = feedPath(projectInfo.project)
-                navigationMenu = activeProjectNavMenu(
-                    dao.listProjects(),
-                    projectInfo,
-                    version,
-                    component
-                )
+                feedPath = feedPath(path.projectInfo.project)
+                navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
                 javascript = "issue-editor"
                 render("issue-form")
@@ -606,7 +454,7 @@
         withPathInfo(http, dao)?.run {
             val issue = Issue(
                 http.param("id")?.toIntOrNull() ?: -1,
-                project
+                projectInfo.project
             ).apply {
                 component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
                 category = IssueCategory.valueOf(http.param("category") ?: "")
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -102,11 +102,10 @@
 data class CommitLink(val url: String, val hash: String, val message: String)
 
 class IssueDetailView(
+    val pathInfos: PathInfos,
     val issue: Issue,
     val comments: List<IssueComment>,
     val project: Project,
-    val version: Version?,
-    val component: Component?,
     projectIssues: List<Issue>,
     val currentRelations: List<IssueRelation>,
     /**
@@ -168,8 +167,7 @@
     val components: List<Component>,
     val users: List<User>,
     val project: Project, // TODO: allow null values to create issues from the IssuesServlet
-    val version: Version? = null,
-    val component: Component? = null
+    val pathInfos: PathInfos
 ) : EditView() {
 
     val versionsUpcoming: List<Version>
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -25,6 +25,7 @@
 
 package de.uapcore.lightpit.viewmodel
 
+import de.uapcore.lightpit.OptionalPathInfo
 import de.uapcore.lightpit.entities.Component
 import de.uapcore.lightpit.entities.Project
 import de.uapcore.lightpit.entities.Version
@@ -44,19 +45,26 @@
 
 class NavMenu(val entries: List<NavMenuEntry>)
 
-fun projectNavMenu(
-    projects: List<Project>,
-    versions: List<Version> = emptyList(),
-    components: List<Component> = emptyList(),
-    selectedProject: Project? = null,
-    selectedVersion: Version? = null,
-    selectedComponent: Component? = null
-) = NavMenu(
+fun projectNavMenu(projects: List<Project>) = NavMenu(
     sequence {
-        val cnode = selectedComponent?.node ?: "-"
-        val vnode = selectedVersion?.node ?: "-"
         for (project in projects) {
-            val active = project == selectedProject
+            yield(
+                NavMenuEntry(
+                    level = 0,
+                    caption = project.name,
+                    href = "projects/${project.node}",
+                )
+            )
+        }
+    }.toList()
+)
+
+fun projectNavMenu(projects: List<Project>, pathInfos: PathInfos) = NavMenu(
+    sequence {
+        val cnode = pathInfos.componentInfo.node
+        val vnode = pathInfos.versionInfo.node
+        for (project in projects) {
+            val active = project == pathInfos.projectInfo.project
             yield(
                 NavMenuEntry(
                     level = 0,
@@ -80,10 +88,22 @@
                         caption = "navmenu.all",
                         resolveCaption = true,
                         href = "projects/${project.node}/issues/-/${cnode}/",
-                        iconColor = "#000000"
+                        iconColor = "#000000",
+                        active = vnode == "-",
                     )
                 )
-                for (version in versions.filter { it.status != VersionStatus.Deprecated }) {
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.none",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/~/${cnode}/",
+                        iconColor = "#000000",
+                        active = vnode == "~",
+                    )
+                )
+                for (version in pathInfos.projectInfo.versions) {
+                    if (version.status == VersionStatus.Deprecated && vnode != version.node) continue
                     yield(
                         NavMenuEntry(
                             level = 2,
@@ -91,7 +111,7 @@
                             title = "version.status.${version.status}",
                             href = "projects/${project.node}/issues/${version.node}/${cnode}/",
                             iconColor = "version-${version.status}",
-                            active = version == selectedVersion
+                            active = version.node == vnode
                         )
                     )
                 }
@@ -109,18 +129,29 @@
                         caption = "navmenu.all",
                         resolveCaption = true,
                         href = "projects/${project.node}/issues/${vnode}/-/",
-                        iconColor = "#000000"
+                        iconColor = "#000000",
+                        active = cnode == "-",
                     )
                 )
-                for (component in components) {
-                    if (!component.active && component != selectedComponent) continue
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.none",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/${vnode}/~/",
+                        iconColor = "#000000",
+                        active = cnode == "~",
+                    )
+                )
+                for (component in pathInfos.projectInfo.components) {
+                    if (!component.active && component.node != cnode) continue
                     yield(
                         NavMenuEntry(
                             level = 2,
                             caption = component.name,
                             href = "projects/${project.node}/issues/${vnode}/${component.node}/",
                             iconColor = "${component.color}",
-                            active = component == selectedComponent
+                            active = component.node == cnode
                         )
                     )
                 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 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.HttpRequest
+import de.uapcore.lightpit.OptionalPathInfo
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.Version
+
+data class PathInfos(
+    val projectInfo: ProjectInfo,
+    val versionInfo: OptionalPathInfo<Version>,
+    val componentInfo: OptionalPathInfo<Component>
+) {
+    val issuesHref by lazyOf("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/")
+}
+
+private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
+    val pathParam = http.pathParams["project"] ?: return null
+    val project = dao.findProjectByNode(pathParam) ?: return null
+
+    val versions: List<Version> = dao.listVersions(project)
+    val components: List<Component> = dao.listComponents(project)
+
+    return ProjectInfo(
+        project,
+        versions,
+        components,
+        dao.collectIssueSummary(project)
+    )
+}
+
+fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
+    val projectInfo = obtainProjectInfo(http, dao)
+    if (projectInfo == null) {
+        http.response.sendError(404)
+        return null
+    }
+
+    val version = http.lookupPathParam("version", projectInfo.versions)
+    val component = http.lookupPathParam("component", projectInfo.components)
+
+    if (version == OptionalPathInfo.NotFound || component == OptionalPathInfo.NotFound) {
+        http.response.sendError(404)
+        return null
+    }
+
+    return PathInfos(projectInfo, version, component)
+}
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Mon Oct 30 14:44:36 2023 +0100
@@ -25,6 +25,7 @@
 
 package de.uapcore.lightpit.viewmodel
 
+import de.uapcore.lightpit.OptionalPathInfo
 import de.uapcore.lightpit.entities.*
 
 class ProjectInfo(
@@ -45,18 +46,25 @@
 ) : View()
 
 class ProjectDetails(
-    val projectInfo: ProjectInfo,
+    val pathInfos: PathInfos,
     val issues: List<Issue>,
     val filter: IssueFilter,
-    val version: Version? = null,
-    val component: Component? = null
 ) : View() {
+    val projectInfo = pathInfos.projectInfo
     val issueSummary = IssueSummary()
     val versionInfo: VersionInfo?
+    val componentDetails: Component?
 
     init {
         issues.forEach(issueSummary::add)
-        versionInfo = version?.let { VersionInfo(it, issues) }
+        versionInfo = when (val vinfo = pathInfos.versionInfo){
+            is OptionalPathInfo.Specific -> VersionInfo(vinfo.elem, issues)
+            else -> null
+        }
+        componentDetails = when (val cinfo = pathInfos.componentInfo){
+            is OptionalPathInfo.Specific -> cinfo.elem
+            else -> null
+        }
     }
 }
 
--- a/src/main/resources/localization/strings.properties	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/resources/localization/strings.properties	Mon Oct 30 14:44:36 2023 +0100
@@ -141,6 +141,7 @@
 menu.users=Developer
 navmenu.all=all
 navmenu.components=Components
+navmenu.none=none
 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	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/resources/localization/strings_de.properties	Mon Oct 30 14:44:36 2023 +0100
@@ -141,6 +141,7 @@
 menu.users=Entwickler
 navmenu.all=Alle
 navmenu.components=Komponenten
+navmenu.none=Keine
 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/changelogs/changelog-de.jspf	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Mon Oct 30 14:44:36 2023 +0100
@@ -24,6 +24,16 @@
   --%>
 <%@ page contentType="text/html;charset=UTF-8" %>
 
+<h3>Version 1.2 (Vorschau)</h3>
+
+<ul>
+    <li>
+        Im Seitenmenü können nun alle Vorgänge gewählt werden, die
+        keiner Version oder Komponente zugeordnet sind.
+    </li>
+    <li>Einige kleinere Fehlerbehebungen im Zusammenhang mit dem Seitenmenü.</li>
+</ul>
+
 <h3>Version 1.1.2</h3>
 
 <ul>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Mon Oct 30 14:44:36 2023 +0100
@@ -24,6 +24,16 @@
   --%>
 <%@ page contentType="text/html;charset=UTF-8" %>
 
+<h3>Version 1.2 (snapshot)</h3>
+
+<ul>
+    <li>
+        The left menu now allows selection of issues without assigned
+        version or component.
+    </li>
+    <li>Several minor bugfixes regarding the left menu.</li>
+</ul>
+
 <h3>Version 1.1.2</h3>
 
 <ul>
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Mon Oct 30 14:44:36 2023 +0100
@@ -32,10 +32,7 @@
 
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 <c:set var="project" scope="page" value="${viewmodel.project}"/>
-<c:set var="component" scope="page" value="${viewmodel.component}"/>
-<c:set var="version" scope="page" value="${viewmodel.version}"/>
-
-<c:set var="issuesHref" value="./projects/${project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/"/>
+<c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
 
 <form action="${issuesHref}-/commit" method="post">
     <input type="hidden" name="project" value="${issue.project.id}" />
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Mon Oct 30 14:44:36 2023 +0100
@@ -32,11 +32,9 @@
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueDetailView" scope="request"/>
 
 <c:set var="project" scope="page" value="${viewmodel.project}"/>
-<c:set var="component" scope="page" value="${viewmodel.component}"/>
-<c:set var="version" scope="page" value="${viewmodel.version}"/>
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 
-<c:set var="issuesHref" scope="page" value="./projects/${project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/"/>
+<c:set var="issuesHref" scope="page" value="./${viewmodel.pathInfos.issuesHref}"/>
 
 <table class="issue-view fullwidth">
     <colgroup>
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Mon Oct 30 14:44:36 2023 +0100
@@ -31,12 +31,12 @@
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectDetails" scope="request" />
 
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
-<c:set var="component" scope="page" value="${viewmodel.component}"/>
-<c:set var="version" scope="page" value="${viewmodel.version}"/>
+<c:set var="component" scope="page" value="${viewmodel.componentDetails}"/>
+<c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
 <%@include file="../jspf/project-header.jspf"%>
 
 <div>
-    <a href="./projects/${project.node}/issues/${empty version ? '-' : version.node}/${empty component ? '-' : component.node}/-/create" class="button"><fmt:message key="button.issue.create"/></a>
+    <a href=".${issuesHref}-/create" class="button"><fmt:message key="button.issue.create"/></a>
     <button onclick="toggleProjectDetails()" id="toggle-details-button"><fmt:message key="button.project.details"/></button>
 </div>
 
--- a/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Mon Oct 30 10:06:22 2023 +0100
+++ b/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Mon Oct 30 14:44:36 2023 +0100
@@ -1,7 +1,6 @@
 <%--
 issues: List<Issue>
-version: Version?
-component: Component?
+issuesHref: String
 --%>
 <table class="fullwidth datatable medskip">
     <colgroup>
@@ -23,7 +22,7 @@
         <tr>
             <td>
                 <span class="phase-${issue.status.phase.number}">
-                    <a href="./projects/${issue.project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/${issue.id}">
+                    <a href="./${issuesHref}${issue.id}">
                         #${issue.id}&nbsp;-&nbsp;<c:out value="${issue.subject}" />
                     </a>
                 </span>

mercurial