Sun, 09 Mar 2025 15:57:52 +0100
add issue search box
fixes #494
--- a/src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt Sun Mar 09 15:57:52 2025 +0100 @@ -95,6 +95,24 @@ private fun sanitizedRequestPath(req: HttpServletRequest) = req.pathInfo ?: "/" + protected fun sanitizeJson(str: String): String { + var result = "\"" + for (i in str.indices) { + when (val c = str[i]) { + '\\', '"', '/' -> result += "\\$c" + '\t' -> result += "\\t" + '\n' -> result += "\\n" + '\r' -> result += "\\r" + else -> if (c < ' ' || (c in '\u0080'..'\u00bf') || (c in '\u2000'..'\u20ff')) { + result += "\\u%04x".format(c.code) + } else { + result += c + } + } + } + return result + "\"" + } + private fun doProcess( req: HttpServletRequest, resp: HttpServletResponse,
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Sun Mar 09 15:57:52 2025 +0100 @@ -207,6 +207,11 @@ forward("feed") } + fun renderJson(json: String) { + response.contentType = "application/json; charset=utf-8" + response.writer.write(json) + } + fun render(page: String? = null) { page?.let { contentPage = it } forward("site")
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Sun Mar 09 15:57:52 2025 +0100 @@ -94,6 +94,14 @@ fun listIssues(project: Project): List<Issue> /** + * Search for issue by subject and id using the case-insensitive [query]. + * Optionally search only in the specified [project]. + * The strings returned will have the format "#{id} - {subject}". + * Intended for search fields and auto-completion. + */ + fun searchIssues(query: String, project: Project? = null): List<String> + + /** * Lists all issues for the specified [project]. * The result will only [includeDone] issues, if requested. * When a [specificVersion], [specificComponent], or [specificVariant] is requested,
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Sun Mar 09 15:57:52 2025 +0100 @@ -747,6 +747,28 @@ // do not add variant data here - not needed in this use case! } + override fun searchIssues(query: String, project: Project?): List<String> { + // language=SQL + var subquery = "select concat('#', issueid, ' - ', subject) as title from lpit_issue" + if (project != null) { + // language=SQL + subquery = "$subquery where project = ?" + } + return withStatement(""" + with issue_titles as ($subquery) + select title from issue_titles + where title ilike concat('%', ?, '%') + """.trimIndent()) { + if (project == null) { + setString(1, query) + } else { + setInt(1, project.id) + setString(2, query) + } + queryAll { it.getString("title") } + } + } + override fun listIssues( project: Project, includeDone: Boolean,
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt Sun Mar 09 15:57:52 2025 +0100 @@ -16,6 +16,7 @@ init { get("/", this::issues) + get("/search", this::issueSearch) get("/%issue", this::issue) get("/%issue/edit", this::issueForm) post("/%issue/comment", this::issueComment) @@ -55,6 +56,20 @@ renderIssueView(http, dao, issue, pathInfos) } + private fun issueSearch(http: HttpRequest, dao: DataAccessObject) { + val query = http.param("q") + if (query.isNullOrBlank()) { + http.renderJson("[]") + return + } + http.renderJson( + dao.searchIssues( + query, + http.param("p")?.toIntOrNull()?.let(dao::findProject) + ).joinToString(",", "[", "]", transform = this::sanitizeJson) + ) + } + private fun issueForm(http: HttpRequest, dao: DataAccessObject) { val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) if (issue == null) {
--- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Sun Mar 09 15:57:52 2025 +0100 @@ -28,6 +28,7 @@ <ul> <li>Unterstützung für Software-Varianten hinzugefügt.</li> + <li>Suchfeld für Vorgänge hinzugefügt.</li> <li>Autor im RSS-Feed hinzugefügt.</li> <li>Projekt und Komponente sind nun in der Vorgangsansicht direkt verlinkt.</li> <li>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf Sun Mar 09 15:57:52 2025 +0100 @@ -28,6 +28,7 @@ <ul> <li>Add support for software variants.</li> + <li>Add search box for issues to the top menu.</li> <li>Add author to RSS feed.</li> <li>Add links to project and component in the tabular issue view.</li> <li>
--- a/src/main/webapp/WEB-INF/jsp/site.jsp Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/webapp/WEB-INF/jsp/site.jsp Sun Mar 09 15:57:52 2025 +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="versionSuffix" value="20250307"/> +<c:set scope="page" var="versionSuffix" value="20250309"/> <%-- Make the base href easily available at request scope --%> <c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/> @@ -85,8 +85,12 @@ <link rel="stylesheet" href="${cssFile}?v=${versionSuffix}" type="text/css"> </c:forEach> </c:if> + <script> + const baseHref='${baseHref}'; + </script> + <script src="issue-search.js?v=${versionSuffix}" type="text/javascript"></script> <c:if test="${not empty javascriptFile}"> - <script src="${javascriptFile}?v=${versionSuffix}" type="text/javascript"></script> + <script src="${javascriptFile}?v=${versionSuffix}" type="text/javascript"></script> </c:if> </head> <body> @@ -122,6 +126,11 @@ <fmt:message key="menu.about"/> </a> </div> + <div class="searchBox"> + 🔍 + <input id="search-box" list="search-box-list" autocomplete="off"> + <datalist id="search-box-list"></datalist> + </div> </div> <c:if test="${not empty navMenu}"> <div id="sideMenu">
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/webapp/issue-search.js Sun Mar 09 15:57:52 2025 +0100 @@ -0,0 +1,73 @@ +/* + * Copyright 2025 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. + */ + +function debounce(func){ + let timer; + return () => { + clearTimeout(timer); + timer = setTimeout(() => { func.apply(this); }, 300); + }; +} + +const issueSearch = debounce(() => issueSearchImpl()) +let searchBoxOldContent = ''; +function issueSearchImpl() { + const searchBox = document.getElementById('search-box'); + if (searchBoxOldContent !== searchBox.value) { + searchBoxOldContent = searchBox.value; + const req = new XMLHttpRequest(); + req.addEventListener("load", (evt) => { + const dataList = document.getElementById('search-box-list'); + dataList.innerHTML = ''; + JSON.parse(evt.target.responseText).forEach(function(item){ + const option = document.createElement('option'); + option.value = item; + dataList.appendChild(option); + }); + }); + req.open("GET", baseHref+'issues/search?q='+encodeURIComponent(searchBox.value)); + req.send(); + } +} + +// configure the search box +window.addEventListener('load', () => { + const searchBox = document.getElementById('search-box'); + searchBox.addEventListener('change', () => issueSearch()) + searchBox.addEventListener('keyup', (evt) => { + if (evt.code === 'Enter' || evt.code === 'NumpadEnter') { + let stext = searchBox.value.trim() + if (stext.length === 0) return; + if (stext[0] === '#') stext = stext.substring(1); + const snum = Number.parseInt(stext.split(' ')[0], 10); + if (snum !== Number.NaN) { + location.assign(baseHref+'issues/'+snum+'?in_project=true'); + } + } else { + issueSearch(); + } + }) +}); +
--- a/src/main/webapp/lightpit.css Sun Mar 09 13:54:46 2025 +0100 +++ b/src/main/webapp/lightpit.css Sun Mar 09 15:57:52 2025 +0100 @@ -82,12 +82,11 @@ grid-column: 1 / span 2; width: 100%; display: flex; - flex-flow: row wrap; + flex-flow: row nowrap; border-image-source: linear-gradient(to right, #606060, rgba(60, 60, 60, .25)); border-image-slice: 1; border-bottom-style: solid; border-bottom-width: thin; - font-size: 1.2rem; background: #e0e0e5; } @@ -111,12 +110,23 @@ } #mainMenu .menuEntry { + font-size: 1.2rem; padding: .25em 1em .25em 1em; border-right-style: solid; border-right-width: thin; border-right-color: #9095a1; } +#mainMenu .searchBox { + margin-left: auto; + padding: .25em 1em .25em 0; +} + +#mainMenu .searchBox input { + width: 30em; + min-width: 10em; +} + #sideMenu .menuEntry { white-space: nowrap; padding-right: 2em;