Mon, 05 Aug 2024 18:40:47 +0200
add new global issues page - fixes #404
--- 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)