Coverage for src/lexigram/admin/ui/filters/types/selection.py: 34%

170 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Selection-based filter types.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.admin.data.query import EqualSpec, InSpec 

8from lexigram.admin.ui.filters.base import Filter 

9from lexigram.serialization import dumps_str 

10from lexigram.ui import Zones 

11 

12if TYPE_CHECKING: 

13 from collections.abc import Callable 

14 

15 

16class SelectFilter(Filter): 

17 """ 

18 Dropdown select filter. 

19 

20 Example: 

21 SelectFilter(options={ 

22 "active": "Active", 

23 "inactive": "Inactive", 

24 "pending": "Pending", 

25 }) 

26 

27 # Or with lambda for dynamic options 

28 SelectFilter(options=lambda: get_active_statuses()) 

29 """ 

30 

31 def __init__( 

32 self, 

33 name: str = "", 

34 options: list[str] | dict[str, str] | Callable | None = None, 

35 label: str | None = None, 

36 multiple: bool = False, 

37 default: Any = None, 

38 ): 

39 """ 

40 Initialize select filter. 

41 

42 Args: 

43 name: Filter field name (auto-set by column if empty) 

44 options: List of values, dict of value:label pairs, or callable 

45 label: Display label 

46 multiple: Allow multiple selection 

47 default: Default value 

48 """ 

49 super().__init__(name, label) 

50 

51 # Store options or callback 

52 if options is None: 

53 self.options: dict[str, str] = {} 

54 self._options_callback = None 

55 elif callable(options): 

56 self.options = {} 

57 self._options_callback = options 

58 elif isinstance(options, list): 

59 self.options = {} 

60 for opt in options: 

61 if isinstance(opt, dict): 

62 val = str(opt.get("value", opt.get("id", ""))) 

63 lab = str(opt.get("label", opt.get("name", val))) 

64 self.options[val] = lab 

65 else: 

66 self.options[str(opt)] = str(opt) 

67 self._options_callback = None 

68 else: 

69 self.options = options 

70 self._options_callback = None 

71 

72 self._multiple = multiple 

73 if default is not None: 

74 self._default = default 

75 

76 def multiple(self, multiple: bool = True) -> SelectFilter: 

77 """Enable multiple selection.""" 

78 self._multiple = multiple 

79 return self 

80 

81 def get_options(self) -> dict[str, str]: 

82 """Get options, calling callback if dynamic.""" 

83 if self._options_callback: 

84 opts = self._options_callback() 

85 if isinstance(opts, list): 

86 result = {} 

87 for opt in opts: 

88 if isinstance(opt, dict): 

89 val = str(opt.get("value", opt.get("id", ""))) 

90 lab = str(opt.get("label", opt.get("name", val))) 

91 result[val] = lab 

92 else: 

93 result[str(opt)] = str(opt) 

94 return result 

95 return opts 

96 return self.options 

97 

98 def render(self, current_value: Any = None, url: str | None = None) -> str: 

99 """Render as select dropdown using atomic Select component.""" 

100 from lexigram.ui import Select 

101 

102 # Handle multiple values 

103 value = current_value if current_value is not None else self.value 

104 

105 # Get options (dynamic or static) 

106 options = self.get_options() 

107 

108 # Convert options dict to choices list format 

109 placeholder = self._placeholder or f"Select {self.label}" 

110 choices = [("", placeholder)] # Add placeholder as first option 

111 choices.extend([(kv[0], kv[1]) for kv in options.items()]) 

112 

113 # Get state and resource prefix 

114 state = getattr(self, "_state", None) 

115 resource_prefix = getattr(state, "_resource_prefix", None) if state else url 

116 base_url = resource_prefix.rstrip("/") if resource_prefix else "" 

117 

118 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback 

119 stored = self.get_htmx_attrs() 

120 if stored: 

121 htmx_attrs = stored 

122 elif state: 

123 params = state.to_query_params() 

124 params.pop(self.name, None) 

125 params.pop("page", None) 

126 params.pop("cursor", None) 

127 htmx_attrs = { 

128 "hx-get": f"{base_url}/", 

129 "hx-target": Zones.DATA.selector, 

130 "hx-swap": Zones.DATA.swap_mode.value, 

131 "hx-select": Zones.DATA.selector, 

132 "hx-push-url": "true", 

133 "hx-vals": dumps_str(params), 

134 } 

135 else: 

136 htmx_attrs = { 

137 "hx-get": f"{base_url}/", 

138 "hx-trigger": "change", 

139 "hx-target": Zones.DATA.selector, 

140 "hx-swap": Zones.DATA.swap_mode.value, 

141 "hx-select": Zones.DATA.selector, 

142 "hx-push-url": "true", 

143 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}", 

144 "hx-params": "*", 

145 } 

146 

147 # Convert to component-friendly names (hx_*) 

148 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()} 

149 

150 select = Select( 

151 name=f"filter_{self.name}", 

152 choices=choices, 

153 value=value, 

154 label=self.label, 

155 **comp_attrs, # type: ignore[arg-type] 

156 ) 

157 

158 return select.render() 

159 

160 def apply(self, query: Any, value: Any) -> Any: 

161 """ 

162 Apply filter to query. 

163 """ 

164 if not value: 

165 return query 

166 

167 # Handle multiple values 

168 if self._multiple and isinstance(value, list): 

169 # Assuming SQLAlchemy-style query 

170 return query.filter(getattr(query.model, self.name).in_(value)) 

171 # Single value 

172 return query.filter(**{self.name: value}) 

173 

174 def from_url_param(self, param: Any) -> Any: 

175 """Parse value from URL param. Handles multiple if enabled.""" 

176 if param is None or param == "": 

177 return None 

178 

179 if self._multiple: 

180 if isinstance(param, list): 

181 return param 

182 return list( 

183 filter( 

184 lambda p: p.strip(), 

185 (p.strip() for p in str(param).split(",")), 

186 ), 

187 ) 

188 

189 return param 

190 

191 def to_spec(self, value: Any) -> Any | None: 

192 """Convert to EqualSpec or InSpec.""" 

193 

194 parsed = self.from_url_param(value) 

195 if parsed is None: 

196 return None 

197 

198 if self._multiple and isinstance(parsed, list): 

199 return InSpec(self.name, parsed) 

200 

201 return EqualSpec(self.name, parsed) 

202 

203 

204class MultiSelectFilter(Filter): 

205 """ 

206 Multi-select filter with checkboxes. 

207 

208 Example: 

209 MultiSelectFilter( 

210 options={ 

211 "training": "Training", 

212 "grooming": "Grooming", 

213 "vet_visit": "Vet Visit", 

214 }, 

215 label="Tags" 

216 ) 

217 

218 # Or with lambda for dynamic options 

219 MultiSelectFilter( 

220 options=lambda: get_active_tags(), 

221 label="Tags" 

222 ) 

223 """ 

224 

225 def __init__( 

226 self, 

227 name: str = "", 

228 options: list[str] | dict[str, str] | Callable | None = None, 

229 label: str | None = None, 

230 ): 

231 """ 

232 Initialize multi-select filter. 

233 

234 Args: 

235 name: Filter field name (auto-set by column if empty) 

236 options: List of values, dict of value:label pairs, or callable 

237 label: Display label 

238 """ 

239 super().__init__(name, label) 

240 

241 # Convert list to dict if needed 

242 if options is None: 

243 self.options: dict[str, str] = {} 

244 elif isinstance(options, list): 

245 self.options = {} 

246 for opt in options: 

247 if isinstance(opt, dict): 

248 val = str(opt.get("value", opt.get("id", ""))) 

249 lab = str(opt.get("label", opt.get("name", val))) 

250 self.options[val] = lab 

251 else: 

252 self.options[str(opt)] = str(opt) 

253 elif callable(options): 

254 self._options_callback = options 

255 self.options = {} 

256 else: 

257 self.options = options 

258 

259 self._options_callback = options if callable(options) else None # type: ignore[assignment] 

260 

261 def get_consumed_params(self) -> list[str]: 

262 """Multi-select filter uses the name with [] suffix.""" 

263 return [self.name, f"{self.name}[]"] 

264 

265 def get_options(self) -> dict[str, str]: 

266 """Get options, calling callback if dynamic.""" 

267 if self._options_callback: # type: ignore[truthy-function] 

268 opts = self._options_callback() 

269 if isinstance(opts, list): 

270 result = {} 

271 for opt in opts: 

272 if isinstance(opt, dict): 

273 val = str(opt.get("value", opt.get("id", ""))) 

274 lab = str(opt.get("label", opt.get("name", val))) 

275 result[val] = lab 

276 else: 

277 result[str(opt)] = str(opt) 

278 return result 

279 return opts 

280 return self.options 

281 

282 def render(self, current_value: Any = None, url: str | None = None) -> str: 

283 """Render as checkboxes using atomic components.""" 

284 from lexigram.ui import Checkbox 

285 from lexigram.ui.core.base import el 

286 

287 # Get options (dynamic or static) 

288 options = self.get_options() 

289 

290 # Normalize current values to list 

291 selected = [] 

292 if current_value: 

293 if isinstance(current_value, list): 

294 selected = current_value 

295 else: 

296 selected = [current_value] 

297 

298 # Get state and resource prefix 

299 state = getattr(self, "_state", None) 

300 resource_prefix = getattr(state, "_resource_prefix", None) if state else url 

301 base_url = resource_prefix.rstrip("/") if resource_prefix else "" 

302 

303 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback 

304 stored = self.get_htmx_attrs() 

305 if stored: 

306 htmx_attrs = stored 

307 elif state: 

308 params = state.to_query_params() 

309 params.pop(self.name, None) 

310 params.pop(f"{self.name}[]", None) 

311 params.pop("page", None) 

312 params.pop("cursor", None) 

313 htmx_attrs = { 

314 "hx-get": f"{base_url}/", 

315 "hx-target": Zones.DATA.selector, 

316 "hx-swap": Zones.DATA.swap_mode.value, 

317 "hx-select": Zones.DATA.selector, 

318 "hx-push-url": "true", 

319 "hx-vals": dumps_str(params), 

320 } 

321 else: 

322 htmx_attrs = { 

323 "hx-get": f"{base_url}/", 

324 "hx-trigger": "change", 

325 "hx-target": Zones.DATA.selector, 

326 "hx-swap": Zones.DATA.swap_mode.value, 

327 "hx-select": Zones.DATA.selector, 

328 "hx-push-url": "true", 

329 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}", 

330 "hx-params": "*", 

331 } 

332 

333 # Convert to component-friendly names (hx_*) 

334 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()} 

335 

336 # Create checkbox for each option 

337 checkboxes = [] 

338 for value, label in options.items(): 

339 checkbox = Checkbox( 

340 name=f"filter_{self.name}[]", # Array notation for multiple values 

341 value=value, 

342 label=label, 

343 checked=value in selected, 

344 **comp_attrs, 

345 ) 

346 checkboxes.append(checkbox.render()) 

347 

348 # Wrap in container with label 

349 return el( 

350 "div", 

351 el( 

352 "label", 

353 self.label, 

354 class_="block text-sm font-medium text-foreground mb-1.5", 

355 ), 

356 el("div", *checkboxes, class_="space-y-2"), 

357 class_="filter-item mb-2", 

358 ) 

359 

360 def apply(self, query: Any, value: Any) -> Any: 

361 """ 

362 Apply multi-select filter to query. 

363 """ 

364 if not value: 

365 return query 

366 

367 # Ensure value is a list 

368 if not isinstance(value, list): 

369 value = [value] 

370 

371 # Filter with IN clause 

372 return query.filter(getattr(query.model, self.name).in_(value)) 

373 

374 def from_url_param(self, param: Any) -> Any: 

375 """Parse multiple values from URL param.""" 

376 if param is None or param == "": 

377 return None 

378 

379 if isinstance(param, list): 

380 return param 

381 

382 # HTMX often sends multiple values as a comma-separated list or multiple params 

383 return list( 

384 filter( 

385 lambda p: p.strip(), 

386 (p.strip() for p in str(param).split(",")), 

387 ), 

388 ) 

389 

390 def to_spec(self, value: Any) -> Any | None: 

391 """Convert to InSpec.""" 

392 

393 parsed = self.from_url_param(value) 

394 if not parsed: 

395 return None 

396 return InSpec(self.name, parsed)