# HG changeset patch # User Mike Becker # Date 1672423474 -3600 # Node ID aa22103809cd6f34ce9fb83b76ecd8f91a5d7ad2 # Parent c357c4e69b9ec575f7e66dbc7cc8d7c2ef7cbf6d #29 add possibility to relate issues diff -r c357c4e69b9e -r aa22103809cd setup/postgres/psql_create_tables.sql --- 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); diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt --- 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 fun listIssues(project: Project, version: Version?, component: Component?): List 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 + fun insertHistoryEvent(issue: Issue, newId: Int = 0) fun insertHistoryEvent(issue: Issue, issueComment: IssueComment, newId: Int = 0) diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt --- 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 = + withStatement("$issueQuery where i.project = ?") { + setInt(1, project.id) + queryAll { it.extractIssue() } + } + override fun listIssues(project: Project, version: Version?, component: Component?): List = 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 @@ // + // + 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 = 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) + } + // + // private fun ResultSet.extractIssueComment() = diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/entities/IssueRelation.kt --- /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 +) diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt --- 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? = 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 diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/types/RelationType.kt --- /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 diff -r c357c4e69b9e -r aa22103809cd src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt --- 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 { @@ -98,9 +95,18 @@ val issue: Issue, val comments: List, val project: Project, - val version: Version? = null, - val component: Component? = null + val version: Version?, + val component: Component?, + projectIssues: List, + val currentRelations: List, + /** + * 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 diff -r c357c4e69b9e -r aa22103809cd src/main/resources/localization/strings.properties --- 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 diff -r c357c4e69b9e -r aa22103809cd src/main/resources/localization/strings_de.properties --- 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 diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/WEB-INF/changelogs/changelog-de.jspf --- 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 @@

Version 1.0 (Vorschau)

    +
  • Vorgänge können nun miteinander verlinkt werden.
  • Neuer Status: Bereit (als Antwort auf Im Review).
  • Mehrfachauswahl für Versionen im Vorgang entfernt.
  • RSS Feeds für Projekte hinzugefügt.
  • diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/WEB-INF/changelogs/changelog.jspf --- 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 @@

    Version 1.0 (snapshot)

      +
    • Add possibility to relate issues.
    • Add issue status: Ready (following Review).
    • Remove multi selection of versions within an issue.
    • Add RSS feeds for projects.
    • diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/WEB-INF/jsp/issue-view.jsp --- 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 @@ -
      +
      +

      + +

      +
      + +
      + +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + +
      + + + + + + #${relation.to.id} - () + +
      +
      + +

      diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/WEB-INF/jsp/site.jsp --- 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 --%> - + <%-- Make the base href easily available at request scope --%> diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/issue-editor.js --- 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 */ diff -r c357c4e69b9e -r aa22103809cd src/main/webapp/projects.css --- 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; +}