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

1"""Global search controller for lexigram-admin. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING 

11 

12from starlette.responses import HTMLResponse 

13 

14from lexigram.ui import Element, el, render_to_string 

15 

16if TYPE_CHECKING: 

17 from starlette.requests import Request 

18 

19 from lexigram.admin.services.search_service import SearchResults, SearchService 

20 

21 

22class SearchController: 

23 """Controller for the global search endpoint. 

24 

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 """ 

29 

30 def __init__(self, search_service: SearchService) -> None: 

31 self._search_service = search_service 

32 

33 async def search(self, request: Request) -> HTMLResponse: 

34 """Handle GET /admin/search?q=... or ?search=... (plus optional ?rule=...) 

35 

36 Args: 

37 request: Incoming HTTP request. 

38 

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) 

56 

57 if request.headers.get("hx-request") == "true": 

58 return HTMLResponse(render_to_string(fragment)) 

59 return self._render_page(query, fragment, request, rule) 

60 

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. 

69 

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 

78 

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 ) 

86 

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 ) 

137 

138 def _render_results(self, results: SearchResults) -> Element: 

139 """Render search results as an HTML fragment. 

140 

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 ) 

151 

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 

167 

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 ) 

199 

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 ) 

212 

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 ) 

218 

219 

220__all__ = ["SearchController"]