Coverage for src/lexigram/admin/ui/molecules/filter_bar.py: 6%

78 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1"""Filter bar component using existing UI components.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from lexigram.ui import Component, el 

8 

9 

10class FilterBar(Component): 

11 """Filter bar with various input types using existing UI components.""" 

12 

13 def __init__( 

14 self, 

15 filters: dict[str, dict[str, Any]] | None = None, 

16 current_values: dict[str, Any] | None = None, 

17 resource_prefix: str | None = None, 

18 display: str = "auto", 

19 state: Any | None = None, 

20 **props: Any, 

21 ) -> None: 

22 """Initialize filter bar. 

23 

24 Add `display` param: 'auto'|'vertical'|'horizontal'. 

25 

26 Args: 

27 filters: Dict of {field_name: {"type": "select|text|checkbox|date", "options": [...], ...}} 

28 or a list of Filter objects 

29 current_values: Dict of {field_name: current_value} 

30 resource_prefix: Resource URL prefix for HTMX requests 

31 state: Optional `TableState` instance to allow filters to include table HTMX attrs 

32 """ 

33 super().__init__(**props) 

34 self.filters = filters or {} 

35 self.current_values = current_values or {} 

36 self.resource_prefix = resource_prefix 

37 # Display mode: auto, vertical, horizontal 

38 self.display = props.get("display", display) 

39 # Store optional TableState for child filters to use 

40 self.state = state 

41 

42 def _detect_filter_type(self, field_name: str, options: Any) -> str: 

43 """Auto-detect filter type based on field name and options.""" 

44 # Check field name patterns 

45 if "date" in field_name.lower() or "time" in field_name.lower(): 

46 return "date" 

47 if "is_" in field_name.lower() or field_name.lower() in [ 

48 "active", 

49 "enabled", 

50 "published", 

51 ]: 

52 return "checkbox" 

53 

54 # Check options 

55 if isinstance(options, list): 

56 if len(options) == 2 and {str(o).lower() for o in options} <= { 

57 "true", 

58 "false", 

59 "yes", 

60 "no", 

61 "1", 

62 "0", 

63 }: 

64 return "checkbox" 

65 if len(options) <= 10: 

66 return "select" 

67 

68 return "text" 

69 

70 def render(self) -> Any: 

71 if not self.filters: 

72 return "" 

73 

74 filter_controls = [] 

75 

76 # If filters is a list, assume they are Filter objects 

77 if isinstance(self.filters, list): 

78 for f in self.filters: 

79 # Check visibility if context available 

80 if hasattr(f, "is_visible") and not f.is_visible(): 

81 continue 

82 

83 # Get current value for this filter 

84 current_val = self.current_values.get(f.name, f.get_default()) 

85 

86 # Set state so filters can generate canonical baked hx-vals 

87 if self.state and hasattr(f, "set_state"): 

88 self.state.set_resource_prefix(self.resource_prefix) 

89 f.set_state(self.state) 

90 

91 # Schema fields (from lexigram.admin.schema) use render_filter 

92 if hasattr(f, "render_filter"): 

93 rendered = f.render_filter(current_val) 

94 if rendered is not None: 

95 filter_controls.append(rendered) 

96 # Form fields use bind/render pattern 

97 elif hasattr(f, "bind"): 

98 bound_f = f.bind(current_val) 

99 if self.state and hasattr(bound_f, "set_state"): 

100 bound_f.set_state(self.state) 

101 filter_controls.append(bound_f.render()) 

102 # Filter objects (from ui/filters/) use render() with value 

103 elif hasattr(f, "render"): 

104 if self.state and hasattr(f, "set_state"): 

105 f.set_state(self.state) 

106 filter_controls.append( 

107 f.render( 

108 current_val, 

109 url=self.resource_prefix.rstrip("/") 

110 if self.resource_prefix 

111 else None, 

112 ), 

113 ) 

114 else: 

115 filter_controls.append( 

116 el("div", f"Filter {f.name} missing render method"), 

117 ) 

118 

119 return self._wrap_container(filter_controls) 

120 

121 # Dict of {field_name: {"type": ..., "options": [...], ...}} format 

122 from lexigram.admin.ui.filters.types import SelectFilter, ToggleFilter 

123 

124 for field_name, filter_config in self.filters.items(): 

125 current_val = self.current_values.get(field_name, "") 

126 

127 # Create a temporary filter object for rendering 

128 if isinstance(filter_config, dict): 

129 f_type = filter_config.get("type", "select") 

130 options = filter_config.get("options", []) 

131 if f_type == "checkbox": 

132 f = ToggleFilter(name=field_name) 

133 else: # Default to select 

134 f = SelectFilter(name=field_name, options=options) # type: ignore[assignment] 

135 else: 

136 continue 

137 

138 # Attach state so filters can include canonical table inputs 

139 f.set_state(getattr(self, "state", None)) 

140 

141 filter_controls.append( 

142 f.render( 

143 current_val, 

144 url=self.resource_prefix.rstrip("/") 

145 if self.resource_prefix 

146 else None, 

147 ), 

148 ) 

149 

150 return self._wrap_container(filter_controls) 

151 

152 def _active_filter_count(self) -> int: 

153 """Count filter values that are non-empty (active).""" 

154 return sum(1 for v in self.current_values.values() if v not in (None, "", [])) 

155 

156 def _wrap_container(self, filter_controls: list[Any]) -> str: 

157 """Helper to wrap controls in container.""" 

158 if not filter_controls: 

159 return "" 

160 

161 active_count = self._active_filter_count() 

162 

163 # Determine container class based on display preference 

164 if self.display == "vertical": 

165 container_cls = "flex flex-col gap-2 w-full" 

166 else: 

167 # horizontal is default: responsive stack on mobile, wrap on desktop 

168 container_cls = "flex flex-col md:flex-row md:flex-wrap gap-2 items-stretch md:items-end" 

169 

170 # Wrap each control so it is full-width on mobile and auto width on md+ (horizontal) screens 

171 wrapped_controls = [ 

172 el("div", ctrl, class_="w-full md:w-auto") for ctrl in filter_controls 

173 ] 

174 

175 # Filter icon (funnel) with active count badge 

176 filter_icon = el( 

177 "svg", 

178 el( 

179 "path", 

180 **{ 

181 "d": "M4 6h16M4 12h12M4 18h8", 

182 "stroke-linecap": "round", 

183 "stroke-linejoin": "round", 

184 "stroke-width": "2", 

185 }, 

186 ), 

187 xmlns="http://www.w3.org/2000/svg", 

188 fill="none", 

189 viewBox="0 0 24 24", 

190 stroke="currentColor", 

191 class_="w-5 h-5", 

192 ) 

193 

194 badge: Any = "" 

195 if active_count > 0: 

196 badge = el( 

197 "span", 

198 str(active_count), 

199 class_="absolute -top-1.5 -right-1.5 bg-primary text-primary-foreground text-[10px] font-bold rounded-full min-w-[16px] h-4 flex items-center justify-center px-1 leading-none", 

200 ) 

201 

202 toggle_btn = el( 

203 "button", 

204 el( 

205 "div", 

206 filter_icon, 

207 badge, 

208 class_="relative inline-flex items-center justify-center", 

209 ), 

210 type="button", 

211 class_="p-2 hover:bg-muted rounded-lg transition-colors text-muted-foreground hover:text-muted-foreground dark:hover:text-foreground", 

212 aria_label="Toggle filters", 

213 **{"@click": "showFilters = !showFilters"}, 

214 ) 

215 

216 return el( 

217 "div", 

218 el( 

219 "div", 

220 el( 

221 "div", 

222 *wrapped_controls, 

223 class_=f"{container_cls} flex-1", 

224 **{"x-show": "showFilters"}, 

225 ), 

226 el( 

227 "div", 

228 toggle_btn, 

229 class_="flex-shrink-0 flex items-center h-10 md:hidden", 

230 ), 

231 class_="flex items-start gap-2", 

232 ), 

233 x_data="{ showFilters: window.innerWidth >= 768 }", 

234 class_="bg-card p-3 rounded-xl shadow-sm border border-border mb-1", 

235 **self.props, 

236 )