#29 add possibility to relate issues

Fri, 30 Dec 2022 19:04:34 +0100

author
Mike Becker <universe@uap-core.de>
date
Fri, 30 Dec 2022 19:04:34 +0100
changeset 263
aa22103809cd
parent 262
c357c4e69b9e
child 264
7d67245e5121

#29 add possibility to relate issues

setup/postgres/psql_create_tables.sql file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/IssueRelation.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/types/RelationType.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.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-view.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/site.jsp file | annotate | diff | comparison | revisions
src/main/webapp/issue-editor.js file | annotate | diff | comparison | revisions
src/main/webapp/projects.css file | annotate | diff | comparison | revisions
--- a/setup/postgres/psql_create_tables.sql	Fri Dec 30 13:21:09 2022 +0100
+++ b/setup/postgres/psql_create_tables.sql	Fri Dec 30 19:04:34 2022 +0100
@@ -149,3 +149,21 @@
     comment   text    not null
 );
 
+create type relation_type as enum (
+    'RelatesTo',
+    'TogetherWith',
+    'Before',
+    'SubtaskOf',
+    'Blocks',
+    'Tests',
+    'Duplicates'
+    );
+
+create table lpit_issue_relation
+(
+    from_issue integer       not null references lpit_issue (issueid) on delete cascade,
+    to_issue   integer       not null references lpit_issue (issueid) on delete cascade,
+    type       relation_type not null
+);
+
+create unique index lpit_issue_relation_unique on lpit_issue_relation (from_issue, to_issue, type);
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -70,6 +70,7 @@
     fun collectIssueSummary(project: Project): IssueSummary
     fun collectIssueSummary(assignee: User): IssueSummary
 
+    fun listIssues(project: Project): List<Issue>
     fun listIssues(project: Project, version: Version?, component: Component?): List<Issue>
     fun findIssue(id: Int): Issue?
     fun insertIssue(issue: Issue): Int
@@ -80,6 +81,13 @@
     fun insertComment(issueComment: IssueComment): Int
     fun updateComment(issueComment: IssueComment)
 
+    /**
+     * Inserts an issue relation, if it does not already exist.
+     */
+    fun insertIssueRelation(rel: IssueRelation)
+    fun deleteIssueRelation(rel: IssueRelation)
+    fun listIssueRelations(issue: Issue): List<IssueRelation>
+
     fun insertHistoryEvent(issue: Issue, newId: Int = 0)
     fun insertHistoryEvent(issue: Issue, issueComment: IssueComment, newId: Int = 0)
 
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -534,10 +534,15 @@
         return i
     }
 
+    override fun listIssues(project: Project): List<Issue> =
+        withStatement("$issueQuery where i.project = ?") {
+            setInt(1, project.id)
+            queryAll { it.extractIssue() }
+        }
+
     override fun listIssues(project: Project, version: Version?, component: Component?): List<Issue> =
         withStatement(
-            """$issueQuery where
-                (not ? or i.project = ?) and 
+            """$issueQuery where i.project = ? and 
                 (not ? or ? in (resolved, affected)) and (not ? or (resolved is null and affected is null)) and
                 (not ? or component = ?) and (not ? or component is null)
             """.trimIndent()
@@ -553,10 +558,9 @@
                     setInt(idcol, search.id)
                 }
             }
-            setBoolean(1, true)
-            setInt(2, project.id)
-            applyFilter(version, 3, 5, 4)
-            applyFilter(component, 6, 8, 7)
+            setInt(1, project.id)
+            applyFilter(version, 2, 4, 3)
+            applyFilter(component, 5, 7, 6)
 
             queryAll { it.extractIssue() }
         }
@@ -629,6 +633,53 @@
 
     //</editor-fold>
 
+    //<editor-fold desc="Issue Relations">
+    override fun insertIssueRelation(rel: IssueRelation) {
+        withStatement(
+            """
+            insert into lpit_issue_relation (from_issue, to_issue, type)
+            values (?, ?, ?::relation_type)
+            on conflict do nothing
+            """.trimIndent()
+        ) {
+            if (rel.reverse) {
+                setInt(2, rel.from.id)
+                setInt(1, rel.to.id)
+            } else {
+                setInt(1, rel.from.id)
+                setInt(2, rel.to.id)
+            }
+            setEnum(3, rel.type)
+            executeUpdate()
+        }
+    }
+
+    override fun deleteIssueRelation(rel: IssueRelation) {
+        withStatement("delete from lpit_issue_relation where from_issue = ? and to_issue = ? and type=?::relation_type") {
+            if (rel.reverse) {
+                setInt(2, rel.from.id)
+                setInt(1, rel.to.id)
+            } else {
+                setInt(1, rel.from.id)
+                setInt(2, rel.to.id)
+            }
+            setEnum(3, rel.type)
+            executeUpdate()
+        }
+    }
+
+    override fun listIssueRelations(issue: Issue): List<IssueRelation> = buildList {
+        withStatement("select to_issue, type from lpit_issue_relation where from_issue = ?") {
+            setInt(1, issue.id)
+            queryAll { IssueRelation(issue, findIssue(it.getInt("to_issue"))!!, it.getEnum("type"), false) }
+        }.forEach(this::add)
+        withStatement("select from_issue, type from lpit_issue_relation where to_issue = ?") {
+            setInt(1, issue.id)
+            queryAll { IssueRelation(issue, findIssue(it.getInt("from_issue"))!!, it.getEnum("type"), true) }
+        }.forEach(this::add)
+    }
+    //</editor-fold>
+
     //<editor-fold desc="IssueComment">
 
     private fun ResultSet.extractIssueComment() =
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/IssueRelation.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ */
+
+package de.uapcore.lightpit.entities
+
+import de.uapcore.lightpit.types.RelationType
+
+class IssueRelation(
+    val from: Issue,
+    val to: Issue,
+    val type: RelationType,
+    val reverse: Boolean = false
+)
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -31,10 +31,7 @@
 import de.uapcore.lightpit.dao.DataAccessObject
 import de.uapcore.lightpit.dateOptValidator
 import de.uapcore.lightpit.entities.*
-import de.uapcore.lightpit.types.IssueCategory
-import de.uapcore.lightpit.types.IssueStatus
-import de.uapcore.lightpit.types.VersionStatus
-import de.uapcore.lightpit.types.WebColor
+import de.uapcore.lightpit.types.*
 import de.uapcore.lightpit.viewmodel.*
 import jakarta.servlet.annotation.WebServlet
 import java.sql.Date
@@ -63,6 +60,8 @@
         get("/%project/issues/%version/%component/%issue", this::issue)
         get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
         post("/%project/issues/%version/%component/%issue/comment", this::issueComment)
+        post("/%project/issues/%version/%component/%issue/relation", this::issueRelation)
+        get("/%project/issues/%version/%component/%issue/removeRelation", this::issueRemoveRelation)
         get("/%project/issues/%version/%component/-/create", this::issueForm)
         post("/%project/issues/%version/%component/-/commit", this::issueCommit)
     }
@@ -440,18 +439,35 @@
     }
 
     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)
+    }
+
+    private fun renderIssueView(
+        http: HttpRequest,
+        dao: DataAccessObject,
+        issue: Issue,
+        relationError: String? = null
+    ) {
         withPathInfo(http, dao)?.run {
-            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
-            if (issue == null) {
-                http.response.sendError(404)
-                return
-            }
-
             val comments = dao.listComments(issue)
 
             with(http) {
                 pageTitle = "${projectInfo.project.name}: #${issue.id} ${issue.subject}"
-                view = IssueDetailView(issue, comments, project, version, component)
+                view = IssueDetailView(
+                    issue,
+                    comments,
+                    project,
+                    version,
+                    component,
+                    dao.listIssues(project),
+                    dao.listIssueRelations(issue),
+                    relationError
+                )
                 feedPath = feedPath(projectInfo.project)
                 navigationMenu = activeProjectNavMenu(
                     dao.listProjects(),
@@ -468,7 +484,7 @@
 
     private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.run {
-            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1) ?: Issue(
+            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) ?: Issue(
                 -1,
                 project,
             )
@@ -514,7 +530,7 @@
 
     private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
         withPathInfo(http, dao)?.run {
-            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
+            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
             if (issue == null) {
                 http.response.sendError(404)
                 return
@@ -616,4 +632,88 @@
             }
         }
     }
+
+    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}")
+        }
+    }
+
+    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}")
+        }
+    }
 }
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/types/RelationType.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 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.types
+
+enum class RelationType(val bidi: Boolean) {
+    RelatesTo(true),
+    TogetherWith(true),
+    Before(false),
+    SubtaskOf(false),
+    Blocks(false),
+    Tests(false),
+    Duplicates(false)
+}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Fri Dec 30 19:04:34 2022 +0100
@@ -32,10 +32,7 @@
 import com.vladsch.flexmark.util.data.MutableDataSet
 import com.vladsch.flexmark.util.data.SharedDataKeys
 import de.uapcore.lightpit.entities.*
-import de.uapcore.lightpit.types.IssueCategory
-import de.uapcore.lightpit.types.IssueStatus
-import de.uapcore.lightpit.types.IssueStatusPhase
-import de.uapcore.lightpit.types.VersionStatus
+import de.uapcore.lightpit.types.*
 import kotlin.math.roundToInt
 
 class IssueSorter(private vararg val criteria: Criteria) : Comparator<Issue> {
@@ -98,9 +95,18 @@
     val issue: Issue,
     val comments: List<IssueComment>,
     val project: Project,
-    val version: Version? = null,
-    val component: Component? = null
+    val version: Version?,
+    val component: Component?,
+    projectIssues: List<Issue>,
+    val currentRelations: List<IssueRelation>,
+    /**
+     * Optional resource key to an error message for the relation editor.
+     */
+    val relationError: String?
 ) : View() {
+    val relationTypes = RelationType.values()
+    val linkableIssues = projectIssues.filterNot { it.id == issue.id }
+
     private val parser: Parser
     private val renderer: HtmlRenderer
 
--- a/src/main/resources/localization/strings.properties	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/resources/localization/strings.properties	Fri Dec 30 19:04:34 2022 +0100
@@ -24,6 +24,7 @@
 app.changelog=Changelog
 app.license.title=License
 app.name=Lightweight Project and Issue Tracking
+button.add=Add
 button.back=Back
 button.cancel=Cancel
 button.comment.edit=Edit Comment
@@ -38,6 +39,7 @@
 button.okay=OK
 button.project.create=New Project
 button.project.edit=Edit Project
+button.remove=Remove
 button.user.create=Add Developer
 button.version.create=New Version
 button.version.edit=Edit Version
@@ -83,6 +85,24 @@
 issue.description=Description
 issue.eta=ETA
 issue.id=Issue ID
+issue.relations=Relations
+issue.relations.issue=Issue
+issue.relations.target.invalid=Target issue cannot be linked.
+issue.relations.type=Type
+issue.relations.type.RelatesTo=relates to
+issue.relations.type.RelatesTo.rev=relates to
+issue.relations.type.TogetherWith=resolve together with
+issue.relations.type.TogetherWith.rev=resolve together with
+issue.relations.type.Before=resolve before
+issue.relations.type.Before.rev=resolve after
+issue.relations.type.SubtaskOf=subtask of
+issue.relations.type.SubtaskOf.rev=subtask
+issue.relations.type.Blocks=blocks
+issue.relations.type.Blocks.rev=blocked by
+issue.relations.type.Tests=tests
+issue.relations.type.Tests.rev=tested by
+issue.relations.type.Duplicates=duplicates
+issue.relations.type.Duplicates.rev=duplicated by
 issue.resolved-versions=Target
 issue.status.Done=Done
 issue.status.Duplicate=Duplicate
--- a/src/main/resources/localization/strings_de.properties	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/resources/localization/strings_de.properties	Fri Dec 30 19:04:34 2022 +0100
@@ -23,6 +23,8 @@
 
 app.changelog=Versionshistorie
 app.license.title=Lizenz (Englisch)
+app.name=Lightweight Project and Issue Tracking
+button.add=Hinzuf\u00fcgen
 button.back=Zur\u00fcck
 button.cancel=Abbrechen
 button.comment.edit=Absenden
@@ -37,6 +39,7 @@
 button.okay=OK
 button.project.create=Neues Projekt
 button.project.edit=Projekt Bearbeiten
+button.remove=Entfernen
 button.user.create=Neuer Entwickler
 button.version.create=Neue Version
 button.version.edit=Version Bearbeiten
@@ -82,6 +85,24 @@
 issue.description=Beschreibung
 issue.eta=Zieldatum
 issue.id=Vorgangs-ID
+issue.relations=Beziehungen
+issue.relations.issue=Vorgang
+issue.relations.target.invalid=Vorgang kann nicht verkn\u00fcpft werden.
+issue.relations.type=Typ
+issue.relations.type.RelatesTo=verwandt mit
+issue.relations.type.RelatesTo.rev=verwandt mit
+issue.relations.type.TogetherWith=l\u00f6se zusammen mit
+issue.relations.type.TogetherWith.rev=l\u00f6se zusammen mit
+issue.relations.type.Before=l\u00f6se vor
+issue.relations.type.Before.rev=l\u00f6se nach
+issue.relations.type.SubtaskOf=Unteraufgabe von
+issue.relations.type.SubtaskOf.rev=Unteraufgabe
+issue.relations.type.Blocks=blockiert
+issue.relations.type.Blocks.rev=blockiert von
+issue.relations.type.Tests=testet
+issue.relations.type.Tests.rev=getestet durch
+issue.relations.type.Duplicates=Duplikat von
+issue.relations.type.Duplicates.rev=Duplikat
 issue.resolved-versions=Ziel
 issue.status.Done=Erledigt
 issue.status.Duplicate=Duplikat
--- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf	Fri Dec 30 19:04:34 2022 +0100
@@ -27,6 +27,7 @@
 <h3>Version 1.0 (Vorschau)</h3>
 
 <ul>
+    <li>Vorgänge können nun miteinander verlinkt werden.</li>
     <li>Neuer Status: Bereit (als Antwort auf Im Review).</li>
     <li>Mehrfachauswahl für Versionen im Vorgang entfernt.</li>
     <li>RSS Feeds für Projekte hinzugefügt.</li>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf	Fri Dec 30 19:04:34 2022 +0100
@@ -27,6 +27,7 @@
 <h3>Version 1.0 (snapshot)</h3>
 
 <ul>
+    <li>Add possibility to relate issues.</li>
     <li>Add issue status: Ready (following Review).</li>
     <li>Remove multi selection of versions within an issue.</li>
     <li>Add RSS feeds for projects.</li>
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Fri Dec 30 19:04:34 2022 +0100
@@ -155,7 +155,73 @@
     </a>
 </div>
 
-<hr class="comments-separator"/>
+<hr class="issue-view-separator"/>
+<h2>
+    <fmt:message key="issue.relations"/>
+</h2>
+<form id="relation-form" action="${issuesHref}${issue.id}/relation" method="post">
+<c:if test="${not empty viewmodel.relationError}">
+    <div class="error-box">
+        <fmt:message key="${viewmodel.relationError}"/>
+    </div>
+</c:if>
+<table class="issue-view relation-editor fullwidth">
+    <colgroup>
+        <col>
+        <col>
+        <col class="fullwidth">
+    </colgroup>
+    <thead>
+    <tr>
+        <th></th>
+        <th><fmt:message key="issue.relations.type"/></th>
+        <th><fmt:message key="issue.relations.issue"/></th>
+    </tr>
+    </thead>
+    <tbody>
+    <tr>
+        <td>
+            <button type="submit"><fmt:message key="button.add"/></button>
+        </td>
+        <td>
+            <select name="type">
+                <c:forEach var="type" items="${viewmodel.relationTypes}">
+                    <option value="${type}"><fmt:message key="issue.relations.type.${type}"/></option>
+                    <c:if test="${not type.bidi}">
+                    <option value="!${type}"><fmt:message key="issue.relations.type.${type}.rev"/></option>
+                    </c:if>
+                </c:forEach>
+            </select>
+        </td>
+        <td>
+            <input name="issue" list="linkable-issues">
+            <datalist id="linkable-issues">
+                <c:forEach var="linkableIssue" items="${viewmodel.linkableIssues}">
+                    <option value="#${linkableIssue.id} - <c:out value="${linkableIssue.subject}"/> (<fmt:message key="issue.status.${linkableIssue.status}" />)"></option>
+                </c:forEach>
+            </datalist>
+        </td>
+    </tr>
+    <c:forEach var="relation" items="${viewmodel.currentRelations}">
+        <tr>
+            <td>
+                <a href="${issuesHref}${issue.id}/removeRelation?to=${relation.to.id}&type=${relation.type}&reverse=${relation.reverse}" class="button submit">
+                    <fmt:message key="button.remove"/>
+                </a>
+            </td>
+            <td><fmt:message key="issue.relations.type.${relation.type}${relation.reverse?'.rev':''}"/></td>
+            <td>
+                <a href="${issuesHref}${relation.to.id}">
+                    #${relation.to.id} - <c:out value="${relation.to.subject}"/> (<fmt:message key="issue.status.${relation.to.status}" />)
+                </a>
+            </td>
+        </tr>
+    </c:forEach>
+    </tbody>
+</table>
+</form>
+
+<hr class="issue-view-separator"/>
 <h2>
     <fmt:message key="issue.comments"/>
     <c:if test="${not empty viewmodel.comments}">
--- a/src/main/webapp/WEB-INF/jsp/site.jsp	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/WEB-INF/jsp/site.jsp	Fri Dec 30 19:04:34 2022 +0100
@@ -31,7 +31,7 @@
 <%@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="versionSuffiuniversex" value="20210818b"/>
+<c:set scope="page" var="versionSuffix" value="20221230"/>
 
 <%-- Make the base href easily available at request scope --%>
 <c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/>
--- a/src/main/webapp/issue-editor.js	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/issue-editor.js	Fri Dec 30 19:04:34 2022 +0100
@@ -24,7 +24,7 @@
  */
 
 /**
- * Replaces the formatted comment text with an text area.
+ * Replaces the formatted comment text with a text area.
  *
  * @param {number} id the ID of the comment
  */
--- a/src/main/webapp/projects.css	Fri Dec 30 13:21:09 2022 +0100
+++ b/src/main/webapp/projects.css	Fri Dec 30 19:04:34 2022 +0100
@@ -138,7 +138,7 @@
     background: darkgray;
 }
 
-hr.comments-separator {
+hr.issue-view-separator {
     border-image-source: linear-gradient(to right, rgba(60, 60, 60, .1), rgba(96, 96, 96, 1), rgba(60, 60, 60, .1));
     border-image-slice: 1;
     border-width: thin;
@@ -189,3 +189,16 @@
 table.issue-view th {
     white-space: nowrap;
 }
+
+table.relation-editor input,
+table.relation-editor button,
+table.relation-editor .button {
+    box-sizing: border-box;
+    width: 100%;
+}
+
+table.relation-editor button,
+table.relation-editor .button {
+    text-align: center;
+    padding: .1em .25em .1em .25em;
+}

mercurial