Coverage for src/lexigram/admin/controllers/search.py: 100%
48 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +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, and serves a full admin
5page when the endpoint is navigated to directly (the Search nav item).
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING
12from starlette.responses import HTMLResponse
14from lexigram.ui import Element, el, render_to_string
16if TYPE_CHECKING:
17 from starlette.requests import Request
19 from lexigram.admin.services.search_service import SearchResults, SearchService
22class SearchController:
23 """Controller for the global search endpoint.
25 Accepts a query via ``?q=...`` or ``?search=...``, delegates to
26 SearchService, and renders the aggregated results as HTML fragments
27 (HTMX swaps) or as a full admin page on direct navigation.
28 """
30 def __init__(self, search_service: SearchService) -> None:
31 self._search_service = search_service
33 async def search(self, request: Request) -> HTMLResponse:
34 """Handle GET /admin/search?q=... or ?search=... (plus optional ?rule=...)
36 Args:
37 request: Incoming HTTP request.
39 Returns:
40 HTMLResponse: an HTML fragment for HTMX requests (header search
41 input and command palette), or a full admin page for direct
42 navigation.
43 """
44 query = (
45 request.query_params.get("q", "")
46 or request.query_params.get("search", "")
47 or ""
48 )
49 rule = request.query_params.get("rule") or None
50 user = getattr(request.state, "user", None)
51 allowed = await self._search_service.allowed_resources_for(user)
52 results = await self._search_service.search(
53 query, rule=rule, allowed_resources=allowed
54 )
55 fragment = self._render_results(results)
57 if request.headers.get("hx-request") == "true":
58 return HTMLResponse(render_to_string(fragment))
59 return self._render_page(query, fragment, request, rule)
61 def _render_page(
62 self,
63 query: str,
64 fragment: Element,
65 request: Request,
66 rule: str | None = None,
67 ) -> HTMLResponse:
68 """Render the search page inside the admin shell.
70 The page embeds a search input (same HTMX wiring as the header) and a
71 query-builder organism (block-JSON rule applied to indexed resources),
72 plus a results container pre-filled with the current query's results,
73 so a directly-navigated URL renders server-side and subsequent edits
74 keep swapping into ``#search-results``.
75 """
76 from lexigram.admin.engine.renderer import AdminRenderer
77 from lexigram.ui.organisms.query_builder import QueryBuilder
79 catalog = self._search_service.get_search_field_catalog()
80 builder = QueryBuilder(
81 name="rule",
82 value=rule,
83 label="Filters",
84 fields=catalog or None,
85 )
87 content = el(
88 "div",
89 el(
90 "h1",
91 "Global Search",
92 class_="text-2xl font-bold mb-4 text-foreground",
93 ),
94 el(
95 "form",
96 el(
97 "div",
98 el(
99 "div",
100 el(
101 "input",
102 type="search",
103 name="q",
104 value=query or None,
105 placeholder="Search across resources…",
106 autocomplete="off",
107 hx_get="/admin/search",
108 hx_trigger="keyup changed delay:300ms",
109 hx_target="#search-results",
110 hx_include="#search-form",
111 class_="search-input w-full bg-background border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary",
112 ),
113 class_="relative",
114 ),
115 class_="mb-4",
116 ),
117 builder.render(),
118 id="search-form",
119 method="get",
120 action="/admin/search",
121 class_="mb-4",
122 ),
123 el(
124 "div",
125 fragment,
126 id="search-results",
127 class_="search-results-container",
128 ),
129 class_="p-6",
130 )
131 return AdminRenderer().render_page(
132 content,
133 request=request,
134 title="Global Search",
135 breadcrumbs=[{"label": "Search", "url": "/admin/search"}],
136 )
138 def _render_results(self, results: SearchResults) -> Element:
139 """Render search results as an HTML fragment.
141 When results are present the output is grouped by resource with
142 a header per group. When no results match a simple "no results"
143 placeholder is returned.
144 """
145 if not results.has_results:
146 return Element(
147 "div",
148 "No results found",
149 class_="search-results-empty text-center py-8 px-4 text-sm text-muted-foreground dark:text-muted-foreground",
150 )
152 sections: list[Element] = []
153 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 ''}"
154 sections.append(
155 el(
156 "div",
157 count_text,
158 class_="search-summary px-4 py-2 text-xs text-muted-foreground border-b border-border/50",
159 )
160 )
161 for resource_name in results.resource_counts:
162 resource_label: str = ""
163 for r in results.results:
164 if r.resource_name == resource_name:
165 resource_label = r.resource_label
166 break
168 items: list[Element] = []
169 for r in results.results:
170 if r.resource_name != resource_name:
171 continue
172 children = [
173 el(
174 "span",
175 r.title,
176 class_="search-result-title block text-sm font-medium text-foreground",
177 )
178 ]
179 if r.subtitle:
180 children.append(el("span", r.subtitle, class_="search-subtitle"))
181 children.append(
182 el(
183 "span",
184 resource_label,
185 class_="search-result-resource inline-block text-xs text-muted-foreground dark:text-muted-foreground mt-0.5",
186 )
187 )
188 items.append(
189 el(
190 "a",
191 *children,
192 href=r.url,
193 hx_get=r.url,
194 hx_target="body",
195 hx_push_url="true",
196 class_="search-result-item block px-4 py-3 hover:bg-muted dark:hover:bg-muted/50 focus:bg-muted focus:outline-none transition-colors",
197 )
198 )
200 sections.append(
201 el(
202 "div",
203 el(
204 "div",
205 resource_label,
206 class_="search-resource-header px-4 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground dark:text-muted-foreground bg-muted dark:bg-card/50",
207 ),
208 *items,
209 class_="search-resource-group border-b border-border/50 last:border-b-0",
210 )
211 )
213 return Element(
214 "div",
215 *sections,
216 class_="search-results rounded-xl shadow-lg bg-card overflow-hidden max-h-[70vh] overflow-y-auto",
217 )
220__all__ = ["SearchController"]