add new global issues page - fixes #404

Mon, 05 Aug 2024 18:40:47 +0200

author
Mike Becker <universe@uap-core.de>
date
Mon, 05 Aug 2024 18:40:47 +0200
changeset 311
bf67e0ff7131
parent 310
bbf4eb9a71f8
child 312
b7554a6ef430

add new global issues page - fixes #404

src/main/kotlin/de/uapcore/lightpit/Constants.kt file | annotate | diff | comparison | revisions
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/entities/Issue.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.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/Feeds.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/kotlin/de/uapcore/lightpit/viewmodel/View.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/issues-feed.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issues.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/jsp/site.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/issue-filter.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/issue-list.jspf file | annotate | diff | comparison | revisions
src/main/webapp/issue-overview.js file | annotate | diff | comparison | revisions
src/main/webapp/project-details.js file | annotate | diff | comparison | revisions
--- a/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -62,11 +62,6 @@
     const val REQ_ATTR_BASE_HREF = "base_href"
 
     /**
-     * Key for the request attribute containing the RSS feed href.
-     */
-    const val REQ_ATTR_FEED_HREF = "feed_href"
-
-    /**
      * Key for the request attribute containing the full path information (servlet path + path info).
      */
     const val REQ_ATTR_PATH = "requestPath"
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -84,7 +84,7 @@
 
     /**
      * A list of additional style sheets.
-     *
+     * TODO: remove this unnecessary attribute and merge all style sheets into one global
      * @see Constants#REQ_ATTR_STYLESHEET
      */
     var styleSheets = emptyList<String>()
@@ -129,16 +129,6 @@
             }
         }
 
-    var feedPath: String? = null
-        set(value) {
-            field = value
-            if (value == null) {
-                request.removeAttribute(Constants.REQ_ATTR_FEED_HREF)
-            } else {
-                request.setAttribute(Constants.REQ_ATTR_FEED_HREF, baseHref + value)
-            }
-        }
-
     /**
      * The view object.
      *
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -73,7 +73,25 @@
 
     fun mergeCommitRefs(refs: List<CommitRef>)
 
+    /**
+     * Lists all issues.
+     * The result will only [includeDone] issues, if requested.
+     */
+    fun listIssues(includeDone: Boolean): List<Issue>
+
+    /**
+     * Lists issues for the specified [project].
+     * The result will only [includeDone] issues, if requested.
+     */
     fun listIssues(project: Project, includeDone: Boolean): List<Issue>
+
+    /**
+     * Lists all issues for the specified [project].
+     * The result will only [includeDone] issues, if requested.
+     * When a [specificVersion] or a [specificComponent] is requested,
+     * the result is filtered for [version] or [component] respectively.
+     * In both cases null means that only issues without version or component shall be returned.
+     */
     fun listIssues(
         project: Project,
         includeDone: Boolean,
@@ -97,19 +115,20 @@
     fun insertIssueRelation(rel: IssueRelation)
     fun deleteIssueRelation(rel: IssueRelation)
     fun listIssueRelations(issue: Issue): List<IssueRelation>
+    fun getIssueRelationMap(includeDone: Boolean): IssueRelationMap
     fun getIssueRelationMap(project: Project, includeDone: Boolean): IssueRelationMap
 
     fun insertHistoryEvent(issue: Issue, newId: Int = 0)
     fun insertHistoryEvent(issue: Issue, issueComment: IssueComment, newId: Int = 0)
 
     /**
-     * Lists the issue history of the project with [projectId] for the past [days].
+     * Lists the issue history, optionally restricted to [project], for the past [days].
      */
-    fun listIssueHistory(projectId: Int, days: Int): List<IssueHistoryEntry>
+    fun listIssueHistory(project: Project?, days: Int): List<IssueHistoryEntry>
 
     /**
-     * Lists the issue comment history of the project with [projectId] for the past [days].
+     * Lists the issue comment history, optionally restricted to [project], for the past [days].
      */
-    fun listIssueCommentHistory(projectId: Int, days: Int): List<IssueCommentHistoryEntry>
+    fun listIssueCommentHistory(project: Project?, days: Int): List<IssueCommentHistoryEntry>
     fun listCommitRefs(issue: Issue): List<CommitRef>
 }
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -550,6 +550,12 @@
         return i
     }
 
+    override fun listIssues(includeDone: Boolean): List<Issue> =
+        withStatement("$issueQuery where (? or phase < 2)") {
+            setBoolean(1, includeDone)
+            queryAll { it.extractIssue() }
+        }
+
     override fun listIssues(project: Project, includeDone: Boolean): List<Issue> =
         withStatement("$issueQuery where i.project = ? and (? or phase < 2)") {
             setInt(1, project.id)
@@ -713,17 +719,24 @@
     }
 
     override fun getIssueRelationMap(project: Project, includeDone: Boolean): IssueRelationMap =
+        getIssueRelationMapImpl(project, includeDone)
+
+    override fun getIssueRelationMap(includeDone: Boolean): IssueRelationMap =
+        getIssueRelationMapImpl(null, includeDone)
+
+    private fun getIssueRelationMapImpl(project: Project?, includeDone: Boolean): IssueRelationMap =
         withStatement(
             """
                 select r.from_issue, r.to_issue, r.type
                 from lpit_issue_relation r
                 join lpit_issue i on i.issueid = r.from_issue
                 join lpit_issue_phases p on i.status = p.status
-                where i.project = ? and (? or p.phase < 2)
+                where (? or i.project = ?) and (? or p.phase < 2)
                 """.trimIndent()
         ) {
-            setInt(1, project.id)
-            setBoolean(2, includeDone)
+            setBoolean(1, project == null)
+            setInt(2, project?.id ?: 0)
+            setBoolean(3, includeDone)
             queryAll { Pair(it.getInt("from_issue"), Pair(it.getInt("to_issue"), it.getEnum<RelationType>("type"))) }
         }.groupBy({it.first},{it.second})
     //</editor-fold>
@@ -804,24 +817,27 @@
 
     //<editor-fold desc="Issue History">
 
-    override fun listIssueHistory(projectId: Int, days: Int) =
+    override fun listIssueHistory(project: Project?, days: Int) =
         withStatement(
             """
-                select u.username as current_assignee, evt.*, evtdata.*
+                select p.name as project_name, u.username as current_assignee, evt.*, evtdata.*
                 from lpit_issue_history_event evt
                 join lpit_issue issue using (issueid)
+                join lpit_project p on project = p.projectid 
                 left join lpit_user u on u.userid = issue.assignee
                 join lpit_issue_history_data evtdata using (eventid)
-                where project = ?
+                where (? or project = ?)
                 and time > now() - (? * interval '1' day) 
                 order by time desc
             """.trimIndent()
         ) {
-            setInt(1, projectId)
-            setInt(2, days)
+            setBoolean(1, project == null)
+            setInt(2, project?.id ?: -1)
+            setInt(3, days)
             queryAll { rs->
                 with(rs) {
                     IssueHistoryEntry(
+                        project = getString("project_name"),
                         subject = getString("subject"),
                         time = getTimestamp("time"),
                         type = getEnum("type"),
@@ -840,7 +856,7 @@
             }
         }
 
-    override fun listIssueCommentHistory(projectId: Int, days: Int) =
+    override fun listIssueCommentHistory(project: Project?, days: Int) =
         withStatement(
             """
                 select u.username as current_assignee, evt.*, evtdata.*
@@ -848,13 +864,14 @@
                 join lpit_issue issue using (issueid)
                 left join lpit_user u on u.userid = issue.assignee
                 join lpit_issue_comment_history evtdata using (eventid)
-                where project = ?
+                where (? or project = ?)
                 and time > now() - (? * interval '1' day) 
                 order by time desc
             """.trimIndent()
         ) {
-            setInt(1, projectId)
-            setInt(2, days)
+            setBoolean(1, project == null)
+            setInt(2, project?.id ?: -1)
+            setInt(3, days)
             queryAll { rs->
                 with(rs) {
                     IssueCommentHistoryEntry(
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -52,23 +52,4 @@
      * An issue is overdue, if it is not done and the ETA is before the current time.
      */
     val overdue get() = status.phase != IssueStatusPhase.Done && eta?.before(Date(System.currentTimeMillis())) ?: false
-
-    fun hasChanged(reference: Issue) = !(component == reference.component &&
-            status == reference.status &&
-            category == reference.category &&
-            subject == reference.subject &&
-            description == reference.description &&
-            assignee == reference.assignee &&
-            eta == reference.eta &&
-            affected == reference.affected &&
-            resolved == reference.resolved)
-
-    fun compareEtaTo(date: Date?): Int {
-        val eta = this.eta
-        return if (eta == null && date == null) 0
-        else if (eta == null) 1
-        else if (date == null) -1
-        else eta.compareTo(date)
-    }
 }
-
--- a/src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/IssueHistoryEntry.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -32,6 +32,7 @@
 import java.sql.Timestamp
 
 class IssueHistoryEntry(
+    val project: String,
     val subject: String,
     val time: Timestamp,
     val type: IssueHistoryType,
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -0,0 +1,223 @@
+package de.uapcore.lightpit.logic
+
+import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.dateOptValidator
+import de.uapcore.lightpit.entities.Issue
+import de.uapcore.lightpit.entities.IssueComment
+import de.uapcore.lightpit.entities.IssueRelation
+import de.uapcore.lightpit.entities.Version
+import de.uapcore.lightpit.types.IssueCategory
+import de.uapcore.lightpit.types.IssueStatus
+import de.uapcore.lightpit.types.RelationType
+import de.uapcore.lightpit.viewmodel.IssueDetailView
+import de.uapcore.lightpit.viewmodel.PathInfos
+import de.uapcore.lightpit.viewmodel.PathInfosFull
+import de.uapcore.lightpit.viewmodel.projectNavMenu
+import java.sql.Date
+
+fun Issue.hasChanged(reference: Issue) = !(component == reference.component &&
+        status == reference.status &&
+        category == reference.category &&
+        subject == reference.subject &&
+        description == reference.description &&
+        assignee == reference.assignee &&
+        eta == reference.eta &&
+        affected == reference.affected &&
+        resolved == reference.resolved)
+
+fun Issue.compareEtaTo(date: Date?): Int {
+    val eta = this.eta
+    return if (eta == null && date == null) 0
+    else if (eta == null) 1
+    else if (date == null) -1
+    else eta.compareTo(date)
+}
+
+fun Issue.applyFormData(http: HttpRequest, dao: DataAccessObject): Issue = this.apply {
+    component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
+    category = IssueCategory.valueOf(http.param("category") ?: "")
+    status = IssueStatus.valueOf(http.param("status") ?: "")
+    subject = http.param("subject") ?: ""
+    description = http.param("description") ?: ""
+    assignee = http.param("assignee")?.toIntOrNull()?.let {
+        when (it) {
+            -1 -> null
+            -2 -> (component?.lead ?: project.owner)
+            else -> dao.findUser(it)
+        }
+    }
+    // TODO: process error messages
+    eta = http.param("eta", ::dateOptValidator, null, mutableListOf())
+
+    affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
+    resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
+}
+
+fun processIssueForm(issue: Issue, reference: Issue, http: HttpRequest, dao: DataAccessObject) {
+    if (issue.hasChanged(reference)) {
+        dao.updateIssue(issue)
+        dao.insertHistoryEvent(issue)
+    }
+    val newComment = http.param("comment")
+    if (!newComment.isNullOrBlank()) {
+        val comment = IssueComment(-1, issue.id).apply {
+            author = http.remoteUser?.let { dao.findUserByName(it) }
+            comment = newComment
+        }
+        val commentid = dao.insertComment(comment)
+        dao.insertHistoryEvent(issue, comment, commentid)
+    }
+}
+
+fun commitIssueComment(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) {
+    val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
+    if (issue == null) {
+        http.response.sendError(404)
+        return
+    }
+    if (processIssueComment(issue, http, dao)) {
+        http.renderCommit("${pathInfos.issuesHref}${issue.id}")
+    }
+}
+
+fun processIssueComment(issue:Issue, http: HttpRequest, dao: DataAccessObject): Boolean {
+    val commentId = http.param("commentid")?.toIntOrNull() ?: -1
+    if (commentId > 0) {
+        val comment = dao.findComment(commentId)
+        if (comment == null) {
+            http.response.sendError(404)
+            return false
+        }
+        val originalAuthor = comment.author?.username
+        if (originalAuthor != null && originalAuthor == http.remoteUser) {
+            val newComment = http.param("comment")
+            if (!newComment.isNullOrBlank()) {
+                comment.comment = newComment
+                dao.updateComment(comment)
+                dao.insertHistoryEvent(issue, comment)
+            }
+        } else {
+            http.response.sendError(403)
+            return false
+        }
+    } else {
+        val comment = IssueComment(-1, issue.id).apply {
+            author = http.remoteUser?.let { dao.findUserByName(it) }
+            comment = http.param("comment") ?: ""
+        }
+        val newId = dao.insertComment(comment)
+        dao.insertHistoryEvent(issue, comment, newId)
+    }
+    return true
+}
+
+fun renderIssueView(
+    http: HttpRequest,
+    dao: DataAccessObject,
+    issue: Issue,
+    pathInfos: PathInfos,
+    relationError: String? = null
+) {
+    val comments = dao.listComments(issue)
+
+    with(http) {
+        pageTitle = "#${issue.id} ${issue.subject} (${issue.project.name})"
+        view = IssueDetailView(
+            issue,
+            comments,
+            dao.listIssues(issue.project, true),
+            dao.listIssueRelations(issue),
+            dao.listCommitRefs(issue),
+            relationError,
+            pathInfos
+        )
+        if (pathInfos is PathInfosFull) {
+            navigationMenu = projectNavMenu(dao.listProjects(), pathInfos)
+        }
+        styleSheets = listOf("projects")
+        javascript = "issue-editor"
+        render("issue-view")
+    }
+}
+
+fun addIssueRelation(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) {
+    val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
+    if (issue == null) {
+        http.response.sendError(404)
+        return
+    }
+
+    // determine the relation type
+    val type: Pair<RelationType, Boolean>? = http.param("type")?.let {
+        try {
+            if (it.startsWith("!")) {
+                Pair(RelationType.valueOf(it.substring(1)), true)
+            } else {
+                Pair(RelationType.valueOf(it), false)
+            }
+        } catch (_: IllegalArgumentException) {
+            null
+        }
+    }
+
+    // if the relation type was invalid, send HTTP 500
+    if (type == null) {
+        http.response.sendError(500)
+        return
+    }
+
+    // determine the target issue
+    val targetIssue: Issue? = http.param("issue")?.let {
+        if (it.startsWith("#") && it.length > 1) {
+            it.substring(1).split(" ", limit = 2)[0].toIntOrNull()
+                ?.let(dao::findIssue)
+                ?.takeIf { target -> target.project.id == issue.project.id }
+        } else {
+            null
+        }
+    }
+
+    // check if the target issue is valid
+    if (targetIssue == null) {
+        renderIssueView(http, dao, issue, pathInfos, "issue.relations.target.invalid")
+        return
+    }
+
+    // commit the result
+    dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second))
+    http.renderCommit("${pathInfos.issuesHref}${issue.id}")
+}
+
+fun removeIssueRelation(http: HttpRequest, dao: DataAccessObject, pathInfos: PathInfos) {
+    val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
+    if (issue == null) {
+        http.response.sendError(404)
+        return
+    }
+
+    // determine relation
+    val type = http.param("type")?.let {
+        try {
+            RelationType.valueOf(it)}
+        catch (_:IllegalArgumentException) {null}
+    }
+    if (type == null) {
+        http.response.sendError(500)
+        return
+    }
+    val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let {
+        IssueRelation(
+            issue,
+            it,
+            type,
+            http.param("reverse")?.toBoolean() ?: false
+        )
+    }
+
+    // execute removal, if there is something to remove
+    rel?.run(dao::deleteIssueRelation)
+
+    // always pretend that the operation was successful - if there was nothing to remove, it's okay
+    http.renderCommit("${pathInfos.issuesHref}${issue.id}")
+}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/FeedServlet.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -32,6 +32,7 @@
 import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.entities.IssueCommentHistoryEntry
 import de.uapcore.lightpit.entities.IssueHistoryEntry
+import de.uapcore.lightpit.entities.Project
 import de.uapcore.lightpit.types.IssueHistoryType
 import de.uapcore.lightpit.viewmodel.CommentDiff
 import de.uapcore.lightpit.viewmodel.IssueDiff
@@ -79,6 +80,7 @@
     private fun fullContent(issue: IssueHistoryEntry) = IssueDiff(
         issue.issueid,
         issue.subject,
+        issue.project,
         issue.component,
         issue.status.name,
         issue.category.name,
@@ -162,21 +164,27 @@
         }
 
     private fun issues(http: HttpRequest, dao: DataAccessObject) {
-        val project = http.pathParams["project"]?.let { dao.findProjectByNode(it) }
-        if (project == null) {
-            http.response.sendError(404)
-            return
+        val projectNode = http.pathParams["project"].orEmpty()
+        val project: Project?
+        if (projectNode == "-") {
+            project = null
+        } else {
+            project = dao.findProjectByNode(projectNode)
+            if (project == null) {
+                http.response.sendError(404)
+                return
+            }
         }
         val assignees = http.param("assignee")?.split(',')
         val comments = http.param("comments") ?: "all"
 
         val days = http.param("days")?.toIntOrNull() ?: 30
 
-        val issuesFromDb = dao.listIssueHistory(project.id, days)
+        val issuesFromDb = dao.listIssueHistory(project, days)
         val issueHistory = if (assignees == null) issuesFromDb else
             issuesFromDb.filter { assignees.contains(it.currentAssignee) }
 
-        val commentsFromDb = dao.listIssueCommentHistory(project.id, days)
+        val commentsFromDb = dao.listIssueCommentHistory(project, days)
         val commentHistory = when (comments) {
             "all" -> commentsFromDb
             "new" -> commentsFromDb.filter { it.type == IssueHistoryType.NewComment }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -0,0 +1,102 @@
+package de.uapcore.lightpit.servlet
+
+import de.uapcore.lightpit.AbstractServlet
+import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.entities.Issue
+import de.uapcore.lightpit.logic.*
+import de.uapcore.lightpit.viewmodel.*
+import jakarta.servlet.annotation.WebServlet
+
+@WebServlet(urlPatterns = ["/issues/*"])
+class IssuesServlet : AbstractServlet() {
+
+    private val pathInfos = PathInfosOnlyIssues("issues/")
+
+    init {
+        get("/", this::issues)
+
+        get("/%issue", this::issue)
+        get("/%issue/edit", this::issueForm)
+        post("/%issue/comment", this::issueComment)
+        post("/%issue/relation", this::issueRelation)
+        get("/%issue/removeRelation", this::issueRemoveRelation)
+        post("/%issue/commit", this::issueCommit)
+    }
+
+    private fun issues(http: HttpRequest, dao: DataAccessObject) {
+        val filter = IssueFilter(http, dao)
+        val needRelationsMap = filter.onlyBlocker
+        val relationsMap = if (needRelationsMap) dao.getIssueRelationMap(filter.includeDone) else emptyMap()
+
+        val issues = dao.listIssues(filter.includeDone)
+            .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary))
+            .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>"))
+
+        with(http) {
+            pageTitle = i18n("issues")
+            view = IssueOverview(issues, filter)
+            styleSheets = listOf("projects")
+            javascript = "issue-overview"
+            render("issues")
+        }
+    }
+
+    private fun issue(http: HttpRequest, dao: DataAccessObject) {
+        val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
+        if (issue == null) {
+            http.response.sendError(404)
+            return
+        }
+        renderIssueView(http, dao, issue, pathInfos)
+    }
+
+    private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
+        val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
+        if (issue == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        with(http) {
+            view = IssueEditView(
+                issue,
+                dao.listVersions(issue.project),
+                dao.listComponents(issue.project),
+                dao.listUsers(),
+                issue.project
+            )
+            styleSheets = listOf("projects")
+            javascript = "issue-editor"
+            render("issue-form")
+        }
+    }
+
+    private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
+        commitIssueComment(http, dao, pathInfos)
+    }
+
+    private fun issueCommit(http: HttpRequest, dao: DataAccessObject) {
+        val reference = http.param("id")?.toIntOrNull()?.let(dao::findIssue)
+        if (reference == null) {
+            logger.warn("Cannot create issues while not in a project context.")
+            http.response.sendError(404)
+            return
+        }
+        val issue = Issue(reference.id, reference.project)
+        processIssueForm(issue, reference, http, dao)
+        if (http.param("save") != null) {
+            http.renderCommit("${pathInfos.issuesHref}${issue.id}")
+        } else {
+            http.renderCommit(pathInfos.issuesHref)
+        }
+    }
+
+    private fun issueRelation(http: HttpRequest, dao: DataAccessObject) {
+        addIssueRelation(http, dao, pathInfos)
+    }
+
+    private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) {
+        removeIssueRelation(http, dao, pathInfos)
+    }
+}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -27,8 +27,15 @@
 
 import de.uapcore.lightpit.*
 import de.uapcore.lightpit.dao.DataAccessObject
-import de.uapcore.lightpit.entities.*
-import de.uapcore.lightpit.types.*
+import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.Issue
+import de.uapcore.lightpit.entities.Project
+import de.uapcore.lightpit.entities.Version
+import de.uapcore.lightpit.logic.*
+import de.uapcore.lightpit.types.VcsType
+import de.uapcore.lightpit.types.VersionStatus
+import de.uapcore.lightpit.types.WebColor
+import de.uapcore.lightpit.types.parseCommitRefs
 import de.uapcore.lightpit.viewmodel.*
 import jakarta.servlet.annotation.WebServlet
 import java.sql.Date
@@ -92,13 +99,11 @@
         }
     }
 
-    private fun feedPath(project: Project) = "feed/${project.node}/issues.rss"
-
     private fun project(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.let {path ->
             val project = path.projectInfo.project
 
-            val filter = IssueFilter(http)
+            val filter = IssueFilter(http, dao)
 
             val needRelationsMap = filter.onlyBlocker
 
@@ -111,21 +116,14 @@
 
             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>")) &&
-                    (!filter.onlyBlocker || (relationsMap[it.id]?.any { (_,type) -> type.blocking }?:false)) &&
-                    (filter.status.isEmpty() || filter.status.contains(it.status)) &&
-                    (filter.category.isEmpty() || filter.category.contains(it.category)) &&
-                    (filter.onlyMine || filter.assignee.isEmpty() || filter.assignee.contains(it.assignee?.id ?: -1))
-                }
+                .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>"))
 
             with(http) {
                 pageTitle = project.name
-                view = ProjectDetails(path, issues, filter, dao.listUsers().sortedBy(User::shortDisplayname))
-                feedPath = feedPath(project)
+                view = ProjectDetails(path, issues, filter)
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
-                javascript = "project-details"
+                javascript = "issue-overview"
                 render("project-details")
             }
         }
@@ -196,10 +194,9 @@
                     path.projectInfo,
                     dao.listVersionSummaries(path.projectInfo.project)
                 )
-                feedPath = feedPath(path.projectInfo.project)
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
-                javascript = "project-details"
+                javascript = "issue-overview"
                 render("versions")
             }
         }
@@ -212,7 +209,6 @@
 
             with(http) {
                 view = VersionEditView(path.projectInfo, version)
-                feedPath = feedPath(path.projectInfo.project)
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
                 render("version-form")
@@ -274,10 +270,9 @@
                     path.projectInfo,
                     dao.listComponentSummaries(path.projectInfo.project)
                 )
-                feedPath = feedPath(path.projectInfo.project)
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
-                javascript = "project-details"
+                javascript = "issue-overview"
                 render("components")
             }
         }
@@ -290,7 +285,6 @@
 
             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")
@@ -334,36 +328,8 @@
             http.response.sendError(404)
             return
         }
-        renderIssueView(http, dao, issue)
-    }
-
-    private fun renderIssueView(
-        http: HttpRequest,
-        dao: DataAccessObject,
-        issue: Issue,
-        relationError: String? = null
-    ) {
-        withPathInfo(http, dao)?.let {path ->
-            val comments = dao.listComments(issue)
-
-            with(http) {
-                pageTitle = "#${issue.id} ${issue.subject} (${path.projectInfo.project.name})"
-                view = IssueDetailView(
-                    path,
-                    issue,
-                    comments,
-                    path.projectInfo.project,
-                    dao.listIssues(path.projectInfo.project, true),
-                    dao.listIssueRelations(issue),
-                    relationError,
-                    dao.listCommitRefs(issue)
-                )
-                feedPath = feedPath(path.projectInfo.project)
-                navigationMenu = projectNavMenu(dao.listProjects(), path)
-                styleSheets = listOf("projects")
-                javascript = "issue-editor"
-                render("issue-view")
-            }
+        withPathInfo(http, dao)?.let { path ->
+            renderIssueView(http, dao, issue, path)
         }
     }
 
@@ -400,7 +366,6 @@
                     path.projectInfo.project,
                     path
                 )
-                feedPath = feedPath(path.projectInfo.project)
                 navigationMenu = projectNavMenu(dao.listProjects(), path)
                 styleSheets = listOf("projects")
                 javascript = "issue-editor"
@@ -410,44 +375,8 @@
     }
 
     private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
-        withPathInfo(http, dao)?.run {
-            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
-            if (issue == null) {
-                http.response.sendError(404)
-                return
-            }
-
-            val commentId = http.param("commentid")?.toIntOrNull() ?: -1
-            if (commentId > 0) {
-                val comment = dao.findComment(commentId)
-                if (comment == null) {
-                    http.response.sendError(404)
-                    return
-                }
-                val originalAuthor = comment.author?.username
-                if (originalAuthor != null && originalAuthor == http.remoteUser) {
-                    val newComment = http.param("comment")
-                    if (!newComment.isNullOrBlank()) {
-                        comment.comment = newComment
-                        dao.updateComment(comment)
-                        dao.insertHistoryEvent(issue, comment)
-                    } else {
-                        logger.debug("Not updating comment ${comment.id} because nothing changed.")
-                    }
-                } else {
-                    http.response.sendError(403)
-                    return
-                }
-            } else {
-                val comment = IssueComment(-1, issue.id).apply {
-                    author = http.remoteUser?.let { dao.findUserByName(it) }
-                    comment = http.param("comment") ?: ""
-                }
-                val newId = dao.insertComment(comment)
-                dao.insertHistoryEvent(issue, comment, newId)
-            }
-
-            http.renderCommit("${issuesHref}${issue.id}")
+        withPathInfo(http, dao)?.let {path ->
+            commitIssueComment(http, dao, path)
         }
     }
 
@@ -456,25 +385,7 @@
             val issue = Issue(
                 http.param("id")?.toIntOrNull() ?: -1,
                 projectInfo.project
-            ).apply {
-                component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
-                category = IssueCategory.valueOf(http.param("category") ?: "")
-                status = IssueStatus.valueOf(http.param("status") ?: "")
-                subject = http.param("subject") ?: ""
-                description = http.param("description") ?: ""
-                assignee = http.param("assignee")?.toIntOrNull()?.let {
-                    when (it) {
-                        -1 -> null
-                        -2 -> (component?.lead ?: projectInfo.project.owner)
-                        else -> dao.findUser(it)
-                    }
-                }
-                // TODO: process error messages
-                eta = http.param("eta", ::dateOptValidator, null, mutableListOf())
-
-                affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
-                resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
-            }
+            ).applyFormData(http, dao)
 
             val openId = if (issue.id < 0) {
                 val id = dao.insertIssue(issue)
@@ -486,23 +397,7 @@
                     http.response.sendError(404)
                     return
                 }
-
-                if (issue.hasChanged(reference)) {
-                    dao.updateIssue(issue)
-                    dao.insertHistoryEvent(issue)
-                } else {
-                    logger.debug("Not updating issue ${issue.id} because nothing changed.")
-                }
-
-                val newComment = http.param("comment")
-                if (!newComment.isNullOrBlank()) {
-                    val comment = IssueComment(-1, issue.id).apply {
-                        author = http.remoteUser?.let { dao.findUserByName(it) }
-                        comment = newComment
-                    }
-                    val commentid = dao.insertComment(comment)
-                    dao.insertHistoryEvent(issue, comment, commentid)
-                }
+                processIssueForm(issue, reference, http, dao)
                 issue.id
             }
 
@@ -517,86 +412,14 @@
     }
 
     private fun issueRelation(http: HttpRequest, dao: DataAccessObject) {
-        withPathInfo(http, dao)?.run {
-            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
-            if (issue == null) {
-                http.response.sendError(404)
-                return
-            }
-            
-            // determine the relation type
-            val type: Pair<RelationType, Boolean>? = http.param("type")?.let {
-                try {
-                    if (it.startsWith("!")) {
-                        Pair(RelationType.valueOf(it.substring(1)), true)
-                    } else {
-                        Pair(RelationType.valueOf(it), false)
-                    }
-                } catch (_: IllegalArgumentException) {
-                    null
-                }
-            }
-            
-            // if the relation type was invalid, send HTTP 500
-            if (type == null) {
-                http.response.sendError(500)
-                return
-            }
-            
-            // determine the target issue
-            val targetIssue: Issue? = http.param("issue")?.let {
-                if (it.startsWith("#") && it.length > 1) {
-                    it.substring(1).split(" ", limit = 2)[0].toIntOrNull()
-                        ?.let(dao::findIssue)
-                        ?.takeIf { target -> target.project.id == issue.project.id }
-                } else {
-                    null
-                }
-            }
-
-            // check if the target issue is valid
-            if (targetIssue == null) {
-                renderIssueView(http, dao, issue, "issue.relations.target.invalid")
-                return
-            }
-            
-            // commit the result
-            dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second))
-            http.renderCommit("${issuesHref}${issue.id}")
+        withPathInfo(http, dao)?.let {path ->
+            addIssueRelation(http, dao, path)
         }
     }
 
     private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) {
-        withPathInfo(http, dao)?.run {
-            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
-            if (issue == null) {
-                http.response.sendError(404)
-                return
-            }
-
-            // determine relation
-            val type = http.param("type")?.let {
-                try {RelationType.valueOf(it)}
-                catch (_:IllegalArgumentException) {null}
-            }
-            if (type == null) {
-                http.response.sendError(500)
-                return
-            }
-            val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let {
-                IssueRelation(
-                    issue,
-                    it,
-                    type,
-                    http.param("reverse")?.toBoolean() ?: false
-                )
-            }
-
-            // execute removal, if there is something to remove
-            rel?.run(dao::deleteIssueRelation)
-
-            // always pretend that the operation was successful - if there was nothing to remove, it's okay
-            http.renderCommit("${issuesHref}${issue.id}")
+        withPathInfo(http, dao)?.let {path ->
+            removeIssueRelation(http, dao, path)
         }
     }
 }
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Feeds.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Feeds.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -33,6 +33,7 @@
 class IssueDiff(
     val id: Int,
     val currentSubject: String,
+    val project: String,
     var component: String,
     var status: String,
     var category: String,
@@ -59,7 +60,7 @@
 )
 
 class IssueFeed(
-    val project: Project,
+    val project: Project?,
     val entries: List<IssueFeedEntry>
 ) : View() {
     val lastModified: Timestamp =
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -32,7 +32,9 @@
 import com.vladsch.flexmark.util.data.MutableDataSet
 import com.vladsch.flexmark.util.data.SharedDataKeys
 import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.entities.*
+import de.uapcore.lightpit.logic.compareEtaTo
 import de.uapcore.lightpit.types.*
 import kotlin.math.roundToInt
 
@@ -101,18 +103,29 @@
 
 data class CommitLink(val url: String, val hash: String, val message: String)
 
+class IssueOverview(
+    val issues: List<Issue>,
+    val filter: IssueFilter
+) : View() {
+    val issueSummary = IssueSummary()
+
+    init {
+        feedHref = "feed/-/issues.rss"
+        issues.forEach(issueSummary::add)
+    }
+}
+
 class IssueDetailView(
-    val pathInfos: PathInfos,
     val issue: Issue,
     val comments: List<IssueComment>,
-    val project: Project,
     projectIssues: List<Issue>,
     val currentRelations: List<IssueRelation>,
+    commitRefs: List<CommitRef>,
     /**
      * Optional resource key to an error message for the relation editor.
      */
-    val relationError: String?,
-    commitRefs: List<CommitRef>
+    val relationError: String? = null,
+    val pathInfos: PathInfos? = null
 ) : View() {
     val relationTypes = RelationType.entries
     val linkableIssues = projectIssues.filterNot { it.id == issue.id }
@@ -135,9 +148,9 @@
             comment.commentFormatted = formatMarkdown(comment.comment)
         }
 
-        val commitBaseUrl = project.repoUrl
-        commitLinks = (if (commitBaseUrl == null || project.vcs == VcsType.None) emptyList() else commitRefs.map {
-            CommitLink(buildCommitUrl(commitBaseUrl, project.vcs, it.hash), it.hash, it.message)
+        val commitBaseUrl = issue.project.repoUrl
+        commitLinks = (if (commitBaseUrl == null || issue.project.vcs == VcsType.None) emptyList() else commitRefs.map {
+            CommitLink(buildCommitUrl(commitBaseUrl, issue.project.vcs, it.hash), it.hash, it.message)
         })
     }
 
@@ -166,8 +179,8 @@
     val versions: List<Version>,
     val components: List<Component>,
     val users: List<User>,
-    val project: Project, // TODO: allow null values to create issues from the IssuesServlet
-    val pathInfos: PathInfos
+    val project: Project,
+    val pathInfos: PathInfos? = null
 ) : EditView() {
 
     val versionsUpcoming: List<Version>
@@ -194,10 +207,11 @@
     }
 }
 
-class IssueFilter(http: HttpRequest) {
+class IssueFilter(http: HttpRequest, dao: DataAccessObject) {
 
     val issueStatus = IssueStatus.entries
     val issueCategory = IssueCategory.entries
+    val users = dao.listUsers().sortedBy(User::shortDisplayname)
     val sortCriteria = IssueSorter.Field.entries.flatMap { listOf(IssueSorter.Criteria(it, true), IssueSorter.Criteria(it, false)) }
     val flagIncludeDone = "f.0"
     val flagMine = "f.1"
@@ -293,3 +307,16 @@
             ?: emptyList()
     }
 }
+
+fun issueFilterFunction(
+    filter: IssueFilter,
+    relationsMap: IssueRelationMap,
+    currentUserName: String
+): (issue: Issue) -> Boolean =
+    {
+        (!filter.onlyMine || (it.assignee?.username ?: "") == currentUserName) &&
+                (!filter.onlyBlocker || (relationsMap[it.id]?.any { (_, type) -> type.blocking } ?: false)) &&
+                (filter.status.isEmpty() || filter.status.contains(it.status)) &&
+                (filter.category.isEmpty() || filter.category.contains(it.category)) &&
+                (filter.onlyMine || filter.assignee.isEmpty() || filter.assignee.contains(it.assignee?.id ?: -1))
+    }
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -56,7 +56,7 @@
     }.toList()
 )
 
-fun projectNavMenu(projects: List<Project>, pathInfos: PathInfos) = NavMenu(
+fun projectNavMenu(projects: List<Project>, pathInfos: PathInfosFull) = NavMenu(
     sequence {
         val cnode = pathInfos.componentInfo.node
         val vnode = pathInfos.versionInfo.node
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/PathInfos.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -31,13 +31,13 @@
 import de.uapcore.lightpit.entities.Component
 import de.uapcore.lightpit.entities.Version
 
-data class PathInfos(
+abstract class PathInfos(val issuesHref: String)
+class PathInfosOnlyIssues(issuesHref: String): PathInfos(issuesHref)
+data class PathInfosFull(
     val projectInfo: ProjectInfo,
     val versionInfo: OptionalPathInfo<Version>,
     val componentInfo: OptionalPathInfo<Component>
-) {
-    val issuesHref by lazyOf("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/")
-}
+): PathInfos("projects/${projectInfo.project.node}/issues/${versionInfo.node}/${componentInfo.node}/")
 
 private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
     val pathParam = http.pathParams["project"] ?: return null
@@ -54,7 +54,7 @@
     )
 }
 
-fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
+fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfosFull? {
     val projectInfo = obtainProjectInfo(http, dao)
     if (projectInfo == null) {
         http.response.sendError(404)
@@ -69,5 +69,5 @@
         return null
     }
 
-    return PathInfos(projectInfo, version, component)
+    return PathInfosFull(projectInfo, version, component)
 }
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -46,10 +46,9 @@
 ) : View()
 
 class ProjectDetails(
-    val pathInfos: PathInfos,
+    val pathInfos: PathInfosFull,
     val issues: List<Issue>,
-    val filter: IssueFilter,
-    val users: List<User>
+    val filter: IssueFilter
 ) : View() {
     val projectInfo = pathInfos.projectInfo
     val issueSummary = IssueSummary()
@@ -57,6 +56,7 @@
     val componentDetails: Component?
 
     init {
+        feedHref = "feed/${projectInfo.project.node}/issues.rss"
         issues.forEach(issueSummary::add)
         versionInfo = when (val vinfo = pathInfos.versionInfo){
             is OptionalPathInfo.Specific -> VersionInfo(vinfo.elem, issues)
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt	Mon Aug 05 18:40:47 2024 +0200
@@ -25,7 +25,9 @@
 
 package de.uapcore.lightpit.viewmodel
 
-abstract class View
+abstract class View {
+    var feedHref = ""
+}
 abstract class EditView : View() {
     var errorMessages: List<String> = emptyList()
 }
--- a/src/main/resources/localization/strings.properties	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/resources/localization/strings.properties	Mon Aug 05 18:40:47 2024 +0200
@@ -137,6 +137,7 @@
 language.browser = Browser language
 language.browser.unavailable = Browser language not available.
 menu.about=About
+menu.issues=Issues
 menu.languages=Language
 menu.projects=Projects
 menu.users=Developer
--- a/src/main/resources/localization/strings_de.properties	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/resources/localization/strings_de.properties	Mon Aug 05 18:40:47 2024 +0200
@@ -137,6 +137,7 @@
 language.browser = Browsersprache
 language.browser.unavailable = Browsersprache nicht verf\u00fcgbar.
 menu.about=Info
+menu.issues=Vorg\u00e4nge
 menu.languages=Sprache
 menu.projects=Projekte
 menu.users=Entwickler
--- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Mon Aug 05 18:40:47 2024 +0200
@@ -27,6 +27,7 @@
 <h3>Version 1.3.0 - Vorschau</h3>
 
 <ul>
+    <li>Neue globale Vorgangsseite hinzugefügt.</li>
     <li>Filter für Bearbeiter hinzugefügt.</li>
     <li>Automatische Zuweisung von Vorgängen bezieht neben der Leitung für eine Komponente nun auch die Leitung des Projektes ein.</li>
     <li>Der "OK" Button im Vorgangseditor führt nun zurück zur Vorgangsübersicht.</li>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Mon Aug 05 18:40:47 2024 +0200
@@ -27,6 +27,7 @@
 <h3>Version 1.3.0 - preview</h3>
 
 <ul>
+    <li>Add new Issues page to globally list all issues across all projects.</li>
     <li>Add filter for assignee.</li>
     <li>Automatic assignment of issue now uses the project lead as fallback when no component lead is available.</li>
     <li>The "OK" button in the issue editor now leads to the issue overview.</li>
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -32,7 +32,10 @@
 
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 <c:set var="project" scope="page" value="${viewmodel.project}"/>
-<c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
+<c:set var="issuesHref" value="./issues/"/>
+<c:if test="${not empty viewmodel.pathInfos}">
+    <c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
+</c:if>
 
 <form action="${issuesHref}-/commit" method="post">
     <input type="hidden" name="project" value="${issue.project.id}" />
@@ -160,9 +163,11 @@
         <tfoot>
         <tr>
             <td colspan="2">
+                <input type="hidden" name="id" value="${issue.id}"/>
+                <c:if test="${not empty viewmodel.pathInfos}">
                 <input type="checkbox" id="more" name="more" <c:if test="${more}">checked</c:if> />
                 <label for="more"><fmt:message key="button.issue.create.another"/> </label>
-                <input type="hidden" name="id" value="${issue.id}"/>
+                </c:if>
                 <c:if test="${issue.id ge 0}">
                 <a href="${issuesHref}${issue.id}" class="button">
                     <fmt:message key="button.cancel"/>
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -31,10 +31,13 @@
 
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueDetailView" scope="request"/>
 
-<c:set var="project" scope="page" value="${viewmodel.project}"/>
+<c:set var="project" scope="page" value="${viewmodel.issue.project}"/>
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 
-<c:set var="issuesHref" scope="page" value="./${viewmodel.pathInfos.issuesHref}"/>
+<c:set var="issuesHref" value="./issues/"/>
+<c:if test="${not empty viewmodel.pathInfos}">
+    <c:set var="issuesHref" value="./${viewmodel.pathInfos.issuesHref}"/>
+</c:if>
 
 <table class="issue-view fullwidth">
     <colgroup>
--- a/src/main/webapp/WEB-INF/jsp/issues-feed.jsp	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jsp/issues-feed.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -27,11 +27,17 @@
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueFeed" scope="request"/>
 <channel>
-    <title>
-        <c:out value="${viewmodel.project.name}"/>
-        |<fmt:message key="feed.issues.title"/></title>
+    <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:if>
+    <c:if test="${empty viewmodel.project}">
+    <title><fmt:message key="feed.issues.title"/></title>
+    <link>${baseHref}issues/</link>
+    <c:set var="issueHref" value="${baseHref}issues/"/>
+    </c:if>
     <description><fmt:message key="feed.issues.description"/></description>
-    <link>${baseHref}projects/${viewmodel.project.node}</link>
     <language>${pageContext.response.locale.language}</language>
     <pubDate><fmt:formatDate value="${viewmodel.lastModified}" pattern="EEE, dd MMM yyyy HH:mm:ss zzz"/></pubDate>
     <lastBuildDate><fmt:formatDate value="${viewmodel.lastModified}"
@@ -42,10 +48,13 @@
             <c:choose>
                 <c:when test="${not empty entry.issue}">
                     <c:set var="issue" value="${entry.issue}"/>
-                    <c:set var="link" value="${baseHref}projects/${viewmodel.project.node}/issues/-/-/${issue.id}"/>
+                    <c:set var="link" value="${issueHref}${issue.id}"/>
                     <title>[<fmt:message key="feed.issues.type.${entry.type}"/>] #${issue.id} - <c:out value="${issue.currentSubject}"/></title>
                     <description><![CDATA[
                         <h1>#${issue.id} - ${issue.subject}</h1>
+                        <c:if test="${empty viewmodel.project}">
+                        <div><b><fmt:message key="project"/></b>: ${issue.project}</div>
+                        </c:if>
                         <div><b><fmt:message key="component"/></b>: ${issue.component}</div>
                         <div><b><fmt:message key="issue.category"/></b>: ${issue.category}</div>
                         <div><b><fmt:message key="issue.status"/></b>: ${issue.status}</div>
@@ -60,7 +69,7 @@
                 </c:when>
                 <c:when test="${not empty entry.comment}">
                     <c:set var="comment" value="${entry.comment}"/>
-                    <c:set var="link" value="${baseHref}projects/${viewmodel.project.node}/issues/-/-/${comment.issueid}"/>
+                    <c:set var="link" value="${issueHref}${comment.issueid}"/>
                     <title>[<fmt:message key="feed.issues.type.${entry.type}"/>] #${comment.issueid} - <c:out value="${comment.currentSubject}"/></title>
                     <description><![CDATA[
                         <div style="white-space: pre-wrap;">${comment.comment}</div>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jsp/issues.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -0,0 +1,47 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2021 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+<%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
+
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueOverview" scope="request" />
+
+<c:set var="issuesHref" value="./issues/"/>
+
+<h3><fmt:message key="issue.filter" /></h3>
+<%@include file="../jspf/issue-filter.jspf"%>
+
+<h2><fmt:message key="issues" /> <a class="rss-feed" href="./feed/-/issues.rss"><img src="./rss.svg" alt="Feed" style="width: 0.75em; height: 0.75em;"></a></h2>
+
+<c:set var="summary" value="${viewmodel.issueSummary}"/>
+<c:set var="issues" value="${viewmodel.issues}"/>
+<c:set var="showVersionInfo" value="true"/>
+<c:set var="showProjectInfo" value="true"/>
+<%@include file="../jspf/issue-summary.jspf"%>
+<c:if test="${not empty issues}">
+    <%@include file="../jspf/issue-list.jspf"%>
+</c:if>
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -49,6 +49,7 @@
 <%@include file="../jspf/issue-summary.jspf"%>
 
 <c:set var="showVersionInfo" value="false"/>
+<c:set var="showProjectInfo" value="false"/>
 <c:choose>
     <c:when test="${empty viewmodel.versionInfo}">
         <h2>
--- a/src/main/webapp/WEB-INF/jsp/site.jsp	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jsp/site.jsp	Mon Aug 05 18:40:47 2024 +0200
@@ -31,14 +31,11 @@
 <%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
 
 <%-- Version suffix for forcing browsers to update the CSS / JS files --%>
-<c:set scope="page" var="versionSuffix" value="20240731"/>
+<c:set scope="page" var="versionSuffix" value="20240804"/>
 
 <%-- Make the base href easily available at request scope --%>
 <c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/>
 
-<%-- The feed URL for this page. --%>
-<c:set scope="page" var="feedHref" value="${requestScope[Constants.REQ_ATTR_FEED_HREF]}"/>
-
 <%-- Define an alias for the request path --%>
 <c:set scope="page" var="requestPath" value="${requestScope[Constants.REQ_ATTR_PATH]}"/>
 
@@ -77,8 +74,8 @@
         <meta http-equiv="refresh" content="0; URL=${redirectLocation}">
     </c:if>
     <link rel="stylesheet" href="lightpit.css?v=${versionSuffix}" type="text/css">
-    <c:if test="${not empty feedHref}">
-        <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="${feedHref}"/>
+    <c:if test="${not empty requestScope[Constants.REQ_ATTR_VIEWMODEL].feedHref}">
+        <link rel="alternate" type="application/rss+xml" title="RSS Feed" href="${requestScope[Constants.REQ_ATTR_VIEWMODEL].feedHref}"/>
     </c:if>
     <c:if test="${not empty extraCss}">
         <c:forEach items="${extraCss}" var="cssFile">
@@ -99,6 +96,12 @@
                 </a>
             </div>
             <div class="menuEntry"
+                 <c:if test="${fn:startsWith(requestPath, '/issues/')}">data-active</c:if> >
+                <a href="issues/">
+                    <fmt:message key="menu.issues"/>
+                </a>
+            </div>
+            <div class="menuEntry"
                  <c:if test="${fn:startsWith(requestPath, '/users/')}">data-active</c:if> >
                 <a href="users/">
                     <fmt:message key="menu.users"/>
--- a/src/main/webapp/WEB-INF/jspf/issue-filter.jspf	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jspf/issue-filter.jspf	Mon Aug 05 18:40:47 2024 +0200
@@ -58,7 +58,7 @@
                 <option value="u.-1" <c:if test="${viewmodel.filter.containsAssignee(null) }">selected</c:if>>
                     <fmt:message key="placeholder.null-assignee" />
                 </option>
-                <c:forEach var="user" items="${viewmodel.users}">
+                <c:forEach var="user" items="${viewmodel.filter.users}">
                     <option value="u.${user.id}" <c:if test="${viewmodel.filter.containsAssignee(user) }">selected</c:if>>
                         <c:out value="${user.shortDisplayname}"/>
                     </option>
--- a/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Mon Aug 05 17:41:56 2024 +0200
+++ b/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Mon Aug 05 18:40:47 2024 +0200
@@ -2,9 +2,13 @@
 issues: List<Issue>
 issuesHref: String
 showVersionInfo: boolean
+showProjectInfo: boolean
 --%>
 <table class="fullwidth datatable medskip">
     <colgroup>
+        <c:if test="${showProjectInfo}">
+        <col style="width: 10%" />
+        </c:if>
         <col style="width: auto" />
         <col style="width: 10%" />
         <col style="width: 10%" />
@@ -12,6 +16,9 @@
     </colgroup>
     <thead>
         <tr>
+            <c:if test="${showProjectInfo}">
+            <th><fmt:message key="project"/></th>
+            </c:if>
             <th><fmt:message key="issue.subject"/></th>
             <th><fmt:message key="issue.eta"/></th>
             <th><fmt:message key="issue.updated"/></th>
@@ -21,6 +28,13 @@
     <tbody>
     <c:forEach var="issue" items="${issues}">
         <tr>
+            <c:if test="${showProjectInfo}">
+            <td>
+                <a href="./projects/${issue.project.node}">
+                    <c:out value="${issue.project.name}"/>
+                </a>
+            </td>
+            </c:if>
             <td>
                 <span class="phase-${issue.status.phase.number}">
                     <a href="${issuesHref}${issue.id}">
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/issue-overview.js	Mon Aug 05 18:40:47 2024 +0200
@@ -0,0 +1,88 @@
+/*
+ * 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.
+ */
+
+/**
+ * Hides and shows the project details.
+ *
+ * Following elements are required on the page (element ID):
+ *
+ * - toggle-details-button
+ * - project-details-header-reduced
+ * - project-details-header
+ *
+ */
+projectDetailsVisible = true
+function toggleProjectDetails() {
+    const button = document.getElementById('toggle-details-button')
+
+    if (!button) {
+        // no project details available
+        window.projectDetailsVisible = false
+        return
+    }
+
+    const reduced = document.getElementById('project-details-header-reduced')
+    const full = document.getElementById('project-details-header')
+
+    const v = !window.projectDetailsVisible
+    window.projectDetailsVisible = v
+
+    if (v) {
+        button.dataset.toggle = 'true'
+        reduced.style.display = 'none'
+        full.style.display = 'block'
+    } else {
+        delete button.dataset.toggle
+        reduced.style.display = 'block'
+        full.style.display = 'none'
+    }
+}
+
+function toggleFilterDetails() {
+    const filters = document.getElementById('more-filters')
+    const toggle = document.getElementById('show-more-filters')
+    if (toggle.checked) {
+        filters.style.display = 'flex'
+    } else {
+        filters.style.display = 'none'
+    }
+}
+
+function toggleAssigneeOnlyMine() {
+    const filters = document.getElementById('filter-assignee')
+    const toggle = document.getElementById('filter-only-mine')
+    if (toggle.checked) {
+        filters.disabled = true;
+    } else {
+        filters.disabled = false;
+    }
+}
+
+function toggleDetails() {
+    toggleProjectDetails()
+    toggleFilterDetails()
+}
+
+window.addEventListener('load', function() { toggleDetails() }, false)
--- a/src/main/webapp/project-details.js	Mon Aug 05 17:41:56 2024 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,81 +0,0 @@
-/*
- * 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.
- */
-
-/**
- * Hides and shows the project details.
- *
- * Following elements are required on the page (element ID):
- *
- * - toggle-details-button
- * - project-details-header-reduced
- * - project-details-header
- *
- */
-projectDetailsVisible = true
-function toggleProjectDetails() {
-    const button = document.getElementById('toggle-details-button')
-    const reduced = document.getElementById('project-details-header-reduced')
-    const full = document.getElementById('project-details-header')
-
-    const v = !window.projectDetailsVisible
-    window.projectDetailsVisible = v
-
-    if (v) {
-        button.dataset.toggle = 'true'
-        reduced.style.display = 'none'
-        full.style.display = 'block'
-    } else {
-        delete button.dataset.toggle
-        reduced.style.display = 'block'
-        full.style.display = 'none'
-    }
-}
-
-function toggleFilterDetails() {
-    const filters = document.getElementById('more-filters')
-    const toggle = document.getElementById('show-more-filters')
-    if (toggle.checked) {
-        filters.style.display = 'flex'
-    } else {
-        filters.style.display = 'none'
-    }
-}
-
-function toggleAssigneeOnlyMine() {
-    const filters = document.getElementById('filter-assignee')
-    const toggle = document.getElementById('filter-only-mine')
-    if (toggle.checked) {
-        filters.disabled = true;
-    } else {
-        filters.disabled = false;
-    }
-}
-
-function toggleDetails() {
-    toggleProjectDetails()
-    toggleFilterDetails()
-}
-
-window.addEventListener('load', function() { toggleDetails() }, false)

mercurial