Coverage for src / lexigram / admin / controllers / search.py: 25%
32 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Global search controller for lexigram-admin.
3Provides the /admin/search endpoint consumed by the header search input
4hx-get request and the CommandPalette component.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING
11from starlette.responses import HTMLResponse
13if TYPE_CHECKING:
14 from starlette.requests import Request
16 from lexigram.admin.services.search_service import SearchResults, SearchService
19class SearchController:
20 """Controller for the global search endpoint.
22 Accepts a query via ``?q=...`` or ``?search=...``, delegates to
23 SearchService, and renders the aggregated results as HTML fragments
24 suitable for HTMX swap.
25 """
27 def __init__(self, search_service: SearchService) -> None:
28 self._search_service = search_service
30 async def search(self, request: Request) -> HTMLResponse:
31 """Handle GET /admin/search?q=... or ?search=...
33 Args:
34 request: Incoming HTTP request.
36 Returns:
37 HTMLResponse with rendered search results.
38 """
39 query = (
40 request.query_params.get("q", "")
41 or request.query_params.get("search", "")
42 or ""
43 )
44 results = await self._search_service.search(query)
45 html = self._render_results(results)
46 return HTMLResponse(html)
48 def _render_results(self, results: SearchResults) -> str:
49 """Render search results as an HTML fragment.
51 When results are present the output is grouped by resource with
52 a header per group. When no results match a simple "no results"
53 placeholder is returned.
54 """
55 if not results.has_results:
56 return (
57 '<div class="search-results-empty '
58 "text-center py-8 px-4 text-sm text-muted-foreground dark:text-muted-foreground"
59 '">No results found</div>'
60 )
62 sections: list[str] = []
63 count_text = f"{results.total_count} result{'s' if results.total_count != 1 else ''} across {results.group_count} resource{'s' if results.group_count != 1 else ''}"
64 sections.append(
65 '<div class="search-summary px-4 py-2 text-xs text-muted-foreground border-b border-border/50">'
66 f"{count_text}"
67 "</div>"
68 )
69 for resource_name in results.resource_counts:
70 resource_label: str = ""
71 for r in results.results:
72 if r.resource_name == resource_name:
73 resource_label = r.resource_label
74 break
76 items_html = ""
77 for r in results.results:
78 if r.resource_name != resource_name:
79 continue
80 subtitle_html = (
81 f'<span class="search-subtitle">{r.subtitle}</span>'
82 if r.subtitle
83 else ""
84 )
85 items_html += (
86 f'<a href="{r.url}" '
87 f'class="search-result-item '
88 f"block px-4 py-3 "
89 f"hover:bg-muted dark:hover:bg-muted/50 "
90 f"focus:bg-muted "
91 f'focus:outline-none transition-colors" '
92 f'hx-get="{r.url}" hx-target="body" hx-push-url="true">'
93 f'<span class="search-result-title '
94 f"block text-sm font-medium text-foreground"
95 f'">{r.title}</span>'
96 f"{subtitle_html}"
97 f'<span class="search-result-resource '
98 f"inline-block text-xs text-muted-foreground dark:text-muted-foreground mt-0.5"
99 f'">{resource_label}</span>'
100 f"</a>"
101 )
103 sections.append(
104 '<div class="search-resource-group '
105 "border-b border-border/50 last:border-b-0"
106 '">'
107 f'<div class="search-resource-header '
108 f"px-4 py-2 text-xs font-semibold uppercase tracking-wider "
109 f"text-muted-foreground dark:text-muted-foreground "
110 f"bg-muted dark:bg-card/50"
111 f'">{resource_label}</div>'
112 f"{items_html}"
113 "</div>"
114 )
116 return (
117 '<div class="search-results '
118 "rounded-xl shadow-lg bg-card "
119 "overflow-hidden max-h-[70vh] overflow-y-auto"
120 '">' + "".join(sections) + "</div>"
121 )
124__all__ = ["SearchController"]