add issue search box

Sun, 09 Mar 2025 15:57:52 +0100

author
Mike Becker <universe@uap-core.de>
date
Sun, 09 Mar 2025 15:57:52 +0100
changeset 358
e46bef1bdddd
parent 357
8509308fbbe9
child 359
842bb8976b0f

add issue search box

fixes #494

src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt 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/site.jsp file | annotate | diff | comparison | revisions
src/main/webapp/issue-search.js file | annotate | diff | comparison | revisions
src/main/webapp/lightpit.css file | annotate | diff | comparison | revisions
--- 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">
+                &#x1F50D;
+                <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;

mercurial