Merge pull request #422 from christianlouis/copilot/fix-search-field-bug

fix(ui): fix search input clearing on keystroke; add dedicated /search page
This commit is contained in:
Christian Krakau-Louis
2026-02-26 11:45:44 +01:00
committed by GitHub
7 changed files with 378 additions and 2 deletions
+2
View File
@@ -12,6 +12,7 @@ from app.views.general import router as general_router
from app.views.google_drive import router as google_drive_router
from app.views.license_routes import router as license_router # Add the license router
from app.views.onedrive import router as onedrive_router
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.status import router as status_router
from app.views.wizard import router as wizard_router
@@ -27,3 +28,4 @@ router.include_router(google_drive_router)
router.include_router(license_router) # Include the license router
router.include_router(settings_router)
router.include_router(filemanager_router)
router.include_router(search_router)
+38
View File
@@ -0,0 +1,38 @@
"""
Search view for dedicated full-text document search page.
Provides a Google-style search experience with content previews,
powered by Meilisearch via the existing ``/api/search`` backend.
"""
import logging
from typing import Optional
from fastapi import Query, Request
from app.views.base import APIRouter, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/search")
@require_login
def search_page(
request: Request,
q: Optional[str] = Query(None, max_length=512, description="Full-text search query"),
):
"""Render the dedicated search page.
The page loads with an empty search box (or pre-filled when *q* is given).
Actual searching is performed client-side via ``fetch('/api/search')`` so
that results stream in without a full page reload.
"""
return templates.TemplateResponse(
"search.html",
{
"request": request,
"query": q or "",
},
)
+25 -1
View File
@@ -39,6 +39,7 @@ DocuElevate features a simple navigation system with the following main sections
- **Home**: Dashboard and overview
- **Upload**: For adding new documents to the system
- **Files**: For viewing and managing processed documents
- **Search**: Dedicated full-text search across all document content
- **About**: Information about DocuElevate
## Uploading Documents
@@ -87,10 +88,33 @@ If configured, DocuElevate can automatically fetch documents from email attachme
The **Files** page provides access to all processed documents:
1. Navigate to the **Files** page
2. Use the search box to find specific documents
2. Use the search box to find specific documents by filename, or the full-text search bar to search document content, metadata, and tags
3. Click on any file to view its details
4. Sort the list by any column by clicking on the column header
## Searching Documents
DocuElevate provides two ways to search your documents:
### Full-Text Search on the Files Page
The **Files** page includes a full-text search bar (labelled "Full-Text Search") that searches across OCR-extracted text, AI metadata, tags, sender, recipient, and document type. Type at least 2 characters and results appear automatically below the search bar.
### Dedicated Search Page
For a more focused search experience, use the **Search** page accessible from the main navigation:
1. Navigate to the **Search** page
2. Type your query into the search box — results appear automatically as you type (or press Enter)
3. Results are displayed in a Google-style format showing:
- **Document title** (linked to the file detail page)
- **Filename**
- **Document type**, **sender**, and **tag** badges
- **Content preview** with highlighted matching terms
4. Use pagination to browse through large result sets
The search page is also accessible via URL with a pre-filled query: `/search?q=invoice`
### File Detail View
When you click on a file, you'll see a comprehensive detail view with the following sections:
+2
View File
@@ -59,6 +59,7 @@
<a href="/" class="text-gray-700 hover:text-gray-900">Home</a>
<a href="/upload" class="text-gray-700 hover:text-gray-900">Upload</a>
<a href="/files" class="text-gray-700 hover:text-gray-900">Files</a>
<a href="/search" class="text-gray-700 hover:text-gray-900">Search</a>
<!-- Admin dropdown (shown only for admin users via JS) -->
<div id="adminMenuContainer" class="relative hidden">
@@ -150,6 +151,7 @@
<a href="/" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Home</a>
<a href="/upload" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Upload</a>
<a href="/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Files</a>
<a href="/search" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">Search</a>
<!-- Admin section in mobile menu (shown only for admin users via JS) -->
<div id="mobileAdminSection" class="hidden">
+4 -1
View File
@@ -936,7 +936,10 @@
function debounceSearch(value) {
clearTimeout(_searchDebounceTimer);
if (!value || value.trim().length < 2) {
clearFullTextSearch();
// Only hide the results panel; do NOT clear the input value so the
// user can continue typing characters until the query is long enough.
var _p = document.getElementById('search-results-panel');
if (_p) _p.style.display = 'none';
return;
}
_searchDebounceTimer = setTimeout(() => {
+266
View File
@@ -0,0 +1,266 @@
{% extends "base.html" %}
{% block title %}Search Documents - DocuElevate{% endblock %}
{% block head_extra %}
<script src="/static/js/common.js"></script>
<style>
.search-container { max-width: 800px; margin: 0 auto; }
.search-box {
display: flex; gap: 0.5rem; align-items: center;
margin-bottom: 1.5rem;
}
.search-box input {
flex: 1; padding: 0.75rem 1rem;
border: 1px solid #d1d5db; border-radius: 0.5rem;
font-size: 1rem; outline: none;
}
.search-box input:focus {
border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
.search-box button {
padding: 0.75rem 1.5rem; background-color: #3b82f6;
color: white; border: none; border-radius: 0.5rem;
font-size: 1rem; cursor: pointer; font-weight: 600;
white-space: nowrap;
}
.search-box button:hover { background-color: #2563eb; }
.search-summary {
font-size: 0.875rem; color: #6b7280;
margin-bottom: 1rem;
}
/* Google-style result cards */
.search-result {
margin-bottom: 1.5rem;
}
.search-result-title a {
font-size: 1.125rem; font-weight: 600;
color: #1a0dab; text-decoration: none;
}
.search-result-title a:hover { text-decoration: underline; }
.search-result-url {
font-size: 0.8rem; color: #006621; margin-top: 0.1rem;
word-break: break-all;
}
.search-result-meta {
display: flex; flex-wrap: wrap; gap: 0.4rem;
margin-top: 0.3rem;
}
.search-result-badge {
display: inline-block; padding: 0.1rem 0.5rem;
border-radius: 9999px; font-size: 0.7rem; font-weight: 500;
}
.badge-type { background: #eff6ff; color: #1d4ed8; }
.badge-tag { background: #f0fdf4; color: #15803d; }
.badge-sender { background: #fef3c7; color: #92400e; }
.search-result-snippet {
font-size: 0.875rem; color: #4d5156;
line-height: 1.5; margin-top: 0.35rem;
word-break: break-word;
}
.search-result-snippet mark {
background: #fef08a; font-weight: 600;
border-radius: 2px; padding: 0 1px;
}
.search-pagination {
display: flex; justify-content: center;
gap: 0.5rem; margin-top: 2rem;
}
.search-pagination button {
padding: 0.4rem 1rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; font-size: 0.875rem;
cursor: pointer; background: white; color: #374151;
}
.search-pagination button:hover { background: #f3f4f6; }
.search-pagination span {
padding: 0.4rem 0.75rem; font-size: 0.875rem; color: #6b7280;
}
.search-empty {
text-align: center; padding: 3rem 1rem; color: #9ca3af;
}
.search-empty i { font-size: 3rem; margin-bottom: 1rem; display: block; }
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="search-container">
<h1 class="text-3xl font-bold mb-6"><i class="fas fa-search text-blue-500"></i> Document Search</h1>
<!-- Search box -->
<div class="search-box">
<input
type="text"
id="search-input"
placeholder="Search documents by content, sender, tags, type..."
value="{{ query }}"
autofocus
>
<button type="button" id="search-btn">
<i class="fas fa-search"></i> Search
</button>
</div>
<!-- Summary -->
<div id="search-summary" class="search-summary" style="display:none;"></div>
<!-- Results -->
<div id="search-results"></div>
<!-- Pagination -->
<div id="search-pagination" class="search-pagination" style="display:none;"></div>
</div>
</div>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const resultsDiv = document.getElementById('search-results');
const summaryDiv = document.getElementById('search-summary');
const paginationDiv = document.getElementById('search-pagination');
let _debounce = null;
let _currentPage = 1;
const PER_PAGE = 20;
function doSearch(page) {
const q = searchInput.value.trim();
if (!q) {
resultsDiv.innerHTML = '';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
return;
}
_currentPage = page || 1;
// Update URL without reload
const url = new URL(window.location);
url.searchParams.set('q', q);
window.history.replaceState({}, '', url);
// Loading indicator
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x"></i><p style="margin-top:0.75rem;">Searching…</p></div>';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
const params = new URLSearchParams({ q, page: _currentPage, per_page: PER_PAGE });
fetch('/api/search?' + params.toString())
.then(r => { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); })
.then(data => renderResults(data, q))
.catch(err => {
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search is temporarily unavailable. Please try again in a moment.</p><p style="font-size:0.75rem;color:#9ca3af;margin-top:0.25rem;">' + escapeHtml(err.message) + '</p></div>';
});
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
/**
* Sanitize Meilisearch highlighted HTML: allow only <mark> tags,
* escape everything else to prevent XSS from indexed content.
*/
function sanitizeHighlight(html) {
// Temporarily replace <mark> and </mark> with placeholders
var safe = html
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
// Escape all remaining HTML
safe = escapeHtml(safe);
// Restore the <mark> tags
safe = safe
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
return safe;
}
function renderResults(data, q) {
const { results, total, page, pages } = data;
// Summary
summaryDiv.textContent = total + ' result' + (total !== 1 ? 's' : '') + ' for "' + q + '"';
summaryDiv.style.display = 'block';
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty"><i class="fas fa-search"></i><p>No documents found matching your query.</p></div>';
paginationDiv.style.display = 'none';
return;
}
resultsDiv.innerHTML = results.map(function(hit) {
var fmt = hit._formatted || {};
var title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
var filename = hit.original_filename || '';
var snippet = fmt.ocr_text || '';
var tags = Array.isArray(hit.tags) ? hit.tags : (hit.tags ? [hit.tags] : []);
var docType = hit.document_type || '';
var sender = hit.sender || hit.absender || '';
var fileUrl = '/files/' + hit.file_id;
// Build badges
var badges = '';
if (docType) badges += '<span class="search-result-badge badge-type">' + escapeHtml(docType) + '</span>';
if (sender) badges += '<span class="search-result-badge badge-sender"><i class="fas fa-user"></i> ' + escapeHtml(sender) + '</span>';
tags.forEach(function(t) { badges += '<span class="search-result-badge badge-tag">' + escapeHtml(t) + '</span>'; });
// Snippet: use highlighted text, truncate if very long
var snippetHtml = '';
if (snippet) {
var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet;
snippetHtml = '<div class="search-result-snippet">…' + sanitizeHighlight(trimmed) + '…</div>';
}
// Sanitize title (may contain <mark> highlights from _formatted)
var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title);
return '<div class="search-result">' +
'<div class="search-result-title"><a href="' + escapeHtml(fileUrl) + '">' + safeTitle + '</a></div>' +
'<div class="search-result-url">' + escapeHtml(filename) + '</div>' +
(badges ? '<div class="search-result-meta">' + badges + '</div>' : '') +
snippetHtml +
'</div>';
}).join('');
// Pagination
if (pages > 1) {
var btns = [];
if (page > 1) btns.push('<button data-page="' + (page - 1) + '">« Previous</button>');
btns.push('<span>Page ' + page + ' of ' + pages + '</span>');
if (page < pages) btns.push('<button data-page="' + (page + 1) + '">Next »</button>');
paginationDiv.innerHTML = btns.join('');
paginationDiv.style.display = 'flex';
} else {
paginationDiv.style.display = 'none';
}
}
// Event delegation for pagination buttons
paginationDiv.addEventListener('click', function(e) {
var btn = e.target.closest('button[data-page]');
if (btn) doSearch(parseInt(btn.getAttribute('data-page'), 10));
});
// Event listeners
searchBtn.addEventListener('click', function() { doSearch(1); });
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { doSearch(1); }
});
searchInput.addEventListener('input', function() {
clearTimeout(_debounce);
var val = searchInput.value.trim();
if (val.length < 2) {
resultsDiv.innerHTML = '';
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
return;
}
_debounce = setTimeout(function() { doSearch(1); }, 400);
});
// If q was provided via URL, search immediately
if (searchInput.value.trim().length >= 2) {
doSearch(1);
}
</script>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
"""Tests for the dedicated search page view (app/views/search.py)."""
import pytest
@pytest.mark.unit
class TestSearchPage:
"""Tests for GET /search view endpoint."""
def test_search_page_renders(self, client):
"""GET /search returns 200 with search template."""
response = client.get("/search")
assert response.status_code == 200
assert "Document Search" in response.text
def test_search_page_with_query(self, client):
"""GET /search?q=invoice pre-fills the search input."""
response = client.get("/search?q=invoice")
assert response.status_code == 200
assert 'value="invoice"' in response.text
def test_search_page_empty_query(self, client):
"""GET /search?q= renders page without error."""
response = client.get("/search?q=")
assert response.status_code == 200
assert "Document Search" in response.text
def test_search_page_contains_search_elements(self, client):
"""GET /search contains required search UI elements."""
response = client.get("/search")
assert response.status_code == 200
assert 'id="search-input"' in response.text
assert 'id="search-btn"' in response.text
assert 'id="search-results"' in response.text
assert "/api/search" in response.text
def test_search_page_query_too_long(self, client):
"""GET /search?q=<very long> returns 422 for exceeding max_length."""
long_query = "a" * 600
response = client.get(f"/search?q={long_query}")
assert response.status_code == 422