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

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. 

5""" 

6 

7from __future__ import annotations 

8 

9from typing import TYPE_CHECKING 

10 

11from starlette.responses import HTMLResponse 

12 

13if TYPE_CHECKING: 

14 from starlette.requests import Request 

15 

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

17 

18 

19class SearchController: 

20 """Controller for the global search endpoint. 

21 

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

26 

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

28 self._search_service = search_service 

29 

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

31 """Handle GET /admin/search?q=... or ?search=... 

32 

33 Args: 

34 request: Incoming HTTP request. 

35 

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) 

47 

48 def _render_results(self, results: SearchResults) -> str: 

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

50 

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 ) 

61 

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 

75 

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 ) 

102 

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 ) 

115 

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 ) 

122 

123 

124__all__ = ["SearchController"]