Coverage for src / lexigram / admin / ui / state.py: 19%

238 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1from __future__ import annotations 

2 

3import contextlib 

4from dataclasses import dataclass 

5from typing import Any, ClassVar, Literal 

6 

7from htpy import input as htpy_input 

8 

9from lexigram.domain import DomainModel 

10from lexigram.logging import get_logger 

11from lexigram.validation import Field 

12 

13logger = get_logger(__name__) 

14 

15 

16@dataclass(init=False) 

17class TableState(DomainModel): 

18 """ 

19 Encapsulates the complete state of a DataTable. 

20 This state is derived from the URL and drives the UI rendering. 

21 """ 

22 

23 search: str = "" 

24 sort_by: str | None = None 

25 sort_order: Literal["asc", "desc"] = "asc" 

26 page: int = 1 

27 per_page: int = 20 

28 cursor: str | None = None 

29 filters: dict[str, Any] = Field(default_factory=dict) 

30 view: Literal["tabular", "grid", "calendar", "stacked"] = "tabular" 

31 layout: Literal["sidebar", "stack"] = "stack" 

32 column_order: list[str] | None = None 

33 group_by: str | None = None 

34 collapsed_groups: list[str] = Field(default_factory=list) 

35 include_deleted: bool = False 

36 

37 # Internal defaults for clean URL generation (not part of model fields) 

38 _defaults: ClassVar[dict] = {} 

39 

40 def __init__(self, **data: Any) -> None: 

41 super().__init__(**data) 

42 if "_defaults" not in self.__dict__: 

43 object.__setattr__(self, "_defaults", {}) 

44 

45 @classmethod 

46 def from_request(cls, request, defaults: dict | None = None) -> TableState: 

47 """ 

48 Create state from a request object (Starlette/ASGI compatible). 

49 """ 

50 defaults = defaults or {} 

51 

52 # Use request.query_params directly for better multi-value handling. 

53 # .get() on Starlette QueryParams returns the LAST value by default, 

54 # which is what we want for overrides (e.g. link ?view=grid overriding hidden input view=tabular). 

55 q = request.query_params 

56 

57 # Extract Standard Fields 

58 search = q.get("search") or "" 

59 sort_by = q.get("sort_by") or defaults.get("sort_by") 

60 sort_order = q.get("sort_order") or defaults.get("sort_order", "asc") 

61 

62 try: 

63 page = int(q.get("page", 1)) 

64 page = max(page, 1) 

65 except (ValueError, TypeError): 

66 page = 1 

67 

68 try: 

69 per_page = int(q.get("per_page", 20)) 

70 except (ValueError, TypeError): 

71 per_page = 20 

72 # Explicitly check for 'limit' as an alias often used in APIs 

73 with contextlib.suppress(ValueError, TypeError): 

74 per_page = int(q.get("limit", 20)) 

75 

76 view = q.get("data_view") or defaults.get("view", "tabular") 

77 layout = q.get("layout_type") or defaults.get("layout", "stack") 

78 cursor = q.get("cursor") or None 

79 

80 include_deleted_raw = q.get("include_deleted", "false") 

81 include_deleted = include_deleted_raw.lower() == "true" 

82 

83 # Defensive: resolve defaults (guard against inconsistent test defaults) 

84 default_sort_by = ( 

85 defaults.get("sort_by") 

86 if isinstance(defaults.get("sort_by"), str) 

87 else None 

88 ) 

89 default_sort_order = ( 

90 defaults.get("sort_order", "asc") 

91 if isinstance(defaults.get("sort_order", "asc"), str) 

92 else "asc" 

93 ) 

94 default_view = ( 

95 defaults.get("view", "tabular") 

96 if isinstance(defaults.get("view", "tabular"), str) 

97 else "tabular" 

98 ) 

99 # Normalize defaults with defensive checks and logging (guard against misconfigured app defaults) 

100 raw_default_layout = defaults.get("layout", "stack") 

101 if isinstance(raw_default_layout, str): 

102 if raw_default_layout in ("sidebar", "stack"): 

103 default_layout = raw_default_layout 

104 else: 

105 logger.warning( 

106 "Unknown default layout '%s' provided; using fallback 'stack'", 

107 raw_default_layout, 

108 ) 

109 default_layout = "stack" 

110 else: 

111 logger.warning( 

112 "Non-string default layout provided (%r); using fallback 'stack'", 

113 raw_default_layout, 

114 ) 

115 default_layout = "stack" 

116 

117 # Apply defaults/coercion 

118 if not isinstance(sort_by, (str, type(None))): 

119 logger.warning("Invalid default sort_by %r; ignoring", sort_by) 

120 sort_by = default_sort_by 

121 

122 if sort_order not in ("asc", "desc"): 

123 logger.warning( 

124 "Invalid default sort_order %r; using '%s'", 

125 sort_order, 

126 default_sort_order, 

127 ) 

128 sort_order = default_sort_order 

129 

130 # Normalize view default 

131 raw_default_view = defaults.get("view", "tabular") 

132 if isinstance(raw_default_view, str) and raw_default_view in ( 

133 "tabular", 

134 "grid", 

135 "calendar", 

136 "stacked", 

137 ): 

138 default_view = raw_default_view 

139 else: 

140 if not isinstance(raw_default_view, str): 

141 logger.warning( 

142 "Non-string default view provided (%r); using 'tabular'", 

143 raw_default_view, 

144 ) 

145 else: 

146 logger.warning( 

147 "Unknown default view '%s' provided; using 'tabular'", 

148 raw_default_view, 

149 ) 

150 default_view = "tabular" 

151 

152 # Normalize incoming 'view' param 

153 if not isinstance(view, str) or view not in ( 

154 "tabular", 

155 "grid", 

156 "calendar", 

157 "stacked", 

158 ): 

159 logger.debug( 

160 "Invalid or missing request view %r; using default %r", 

161 view, 

162 default_view, 

163 ) 

164 view = default_view 

165 

166 # GuardProtocol the final layout value 

167 if not isinstance(layout, str) or layout not in ( 

168 "sidebar", 

169 "stack", 

170 ): 

171 logger.debug( 

172 "Invalid or missing request layout %r; using default %r", 

173 layout, 

174 default_layout, 

175 ) 

176 layout = default_layout 

177 

178 # Extract Filters 

179 # We assume any param not in this blocklist is a filter 

180 known_keys = { 

181 "collapsed_groups", 

182 "col_order", 

183 "data_view", 

184 "filters", 

185 "flash_message", 

186 "flash_type", 

187 "group_by", 

188 "hx-current-url", 

189 "hx-request", 

190 "hx-target", 

191 "hx-trigger", 

192 "ids", 

193 "include_deleted", 

194 "layout_type", 

195 "limit", 

196 "next", 

197 "page", 

198 "per_page", 

199 "render_fragment", 

200 "search", 

201 "select_all", 

202 "sort_by", 

203 "sort_order", 

204 } 

205 

206 col_order_raw = q.get("col_order") 

207 col_order = ( 

208 col_order_raw.split(",") if col_order_raw else defaults.get("column_order") 

209 ) 

210 

211 group_by = q.get("group_by", defaults.get("group_by")) 

212 collapsed_raw = q.get("collapsed_groups", "") 

213 collapsed_groups = collapsed_raw.split(",") if collapsed_raw else [] 

214 

215 # For filters, we also want the last value if duplicated 

216 def _coerce_value(val: str) -> Any: 

217 # Normalize booleans 

218 if isinstance(val, str): 

219 low = val.lower() 

220 if low == "true": 

221 return True 

222 if low == "false": 

223 return False 

224 # Try integer 

225 try: 

226 if val.isdigit() or (val.startswith("-") and val[1:].isdigit()): 

227 return int(val) 

228 except (ValueError, TypeError): 

229 pass 

230 # Try float 

231 try: 

232 if "." in val: 

233 return float(val) 

234 except (ValueError, TypeError): 

235 pass 

236 return val 

237 

238 filters = {} 

239 for k in q: 

240 if k in known_keys: 

241 continue 

242 

243 filter_key = k[7:] if k.startswith("filter_") else k 

244 

245 # Support both Starlette QueryParams (with getlist) and plain dicts 

246 if hasattr(q, "getlist"): 

247 values = q.getlist(k) 

248 else: 

249 v = q.get(k) 

250 values = [v] if v is not None else [] 

251 

252 if not values: 

253 continue 

254 

255 # Filter out empty strings 

256 values = list(filter(lambda v: v is not None and v != "", values)) 

257 if not values: 

258 continue 

259 

260 # Deduplicate values 

261 unique_values = [] 

262 seen = set() 

263 for v in values: 

264 # If it's a string representation of a list, repair it 

265 if isinstance(v, str) and v.startswith("[") and v.endswith("]"): 

266 import ast 

267 

268 try: 

269 parsed = ast.literal_eval(v) 

270 if isinstance(parsed, list): 

271 for item in parsed: 

272 val = _coerce_value(str(item)) 

273 if val not in seen: 

274 unique_values.append(val) 

275 seen.add(val) 

276 continue 

277 except (TypeError, ValueError): 

278 pass 

279 

280 val = _coerce_value(v) 

281 if val not in seen: 

282 unique_values.append(val) 

283 seen.add(val) 

284 values = unique_values 

285 

286 if len(values) > 1: 

287 filters[filter_key] = values 

288 elif values: 

289 filters[filter_key] = values[0] 

290 

291 state = cls( 

292 search=search, 

293 sort_by=sort_by, 

294 sort_order=sort_order, 

295 page=page, 

296 per_page=per_page, 

297 cursor=cursor, 

298 filters=filters, 

299 view=view, 

300 layout=layout, 

301 column_order=col_order, 

302 group_by=group_by, 

303 collapsed_groups=collapsed_groups, 

304 include_deleted=include_deleted, 

305 ) 

306 object.__setattr__(state, "_defaults", defaults or {}) 

307 return state 

308 

309 def to_query_params(self, exclude: list[str] | None = None) -> dict: 

310 """ 

311 Export state to dictionary suitable for URL generation. 

312 Only includes non-default and non-empty values to keep URLs clean. 

313 """ 

314 exclude = exclude or [] 

315 params = {} 

316 

317 # Mapping of query param names to state attribute names for default lookup 

318 key_map = { 

319 "data_view": "view", 

320 "layout_type": "layout", 

321 } 

322 

323 def add(key, val, default=None) -> Any: 

324 if key in exclude: 

325 return 

326 

327 # Use resource defaults if available for cleaner URLs 

328 # Map query key to internal state key for defaults lookup 

329 state_key = key_map.get(key, key) 

330 res_default = self._defaults.get(state_key, default) 

331 

332 # Stringify for comparison if needed 

333 val_str = str(val) if val is not None else "" 

334 def_str = str(res_default) if res_default is not None else "" 

335 

336 # Only add if strictly non-default and non-empty 

337 if val is not None and val_str != def_str and val != "": 

338 params[key] = val 

339 

340 add("search", self.search, "") 

341 add("page", self.page, 1) 

342 add("per_page", self.per_page, 20) 

343 add("sort_by", self.sort_by, "") 

344 add("sort_order", self.sort_order, "asc") 

345 add("data_view", self.view, "tabular") 

346 add("layout_type", self.layout, "stack") 

347 

348 if self.cursor: 

349 add("cursor", self.cursor) 

350 if self.column_order: 

351 add("col_order", ",".join(self.column_order)) 

352 if self.group_by: 

353 add("group_by", self.group_by) 

354 if self.collapsed_groups: 

355 add("collapsed_groups", ",".join(self.collapsed_groups)) 

356 add("include_deleted", self.include_deleted, False) 

357 

358 # Add filters with prefix (non-empty only) 

359 for k, v in self.filters.items(): 

360 param_key = f"filter_{k}" 

361 if ( 

362 param_key not in exclude 

363 and k not in exclude 

364 and v is not None 

365 and v != "" 

366 ): 

367 params[param_key] = v 

368 

369 return params 

370 

371 def to_url(self, base_path: str = "") -> str: 

372 """Return a canonical URL (path + query) for this TableState.""" 

373 from urllib.parse import urlencode 

374 

375 params = self.to_query_params() 

376 if not base_path: 

377 base_path = "" 

378 query = urlencode(params, doseq=True) 

379 url = base_path or "" 

380 if query: 

381 url = f"{url}?{query}" 

382 return url 

383 

384 def model_copy(self, *args, **kwargs) -> TableState: 

385 """Override model_copy to preserve internal defaults.""" 

386 new_state = super().model_copy(*args, **kwargs) 

387 object.__setattr__(new_state, "_defaults", getattr(self, "_defaults", {})) 

388 return new_state 

389 

390 # These methods return NEW TableState instances with modified values. 

391 # The original state is not modified (immutable pattern). 

392 

393 def with_page(self, page: int) -> TableState: 

394 """ 

395 Return a copy with a new page number. 

396 

397 Resets cursor for offset-based pagination. 

398 

399 Example: 

400 new_state = state.with_page(2) 

401 attrs = HTMXAttrs.for_data_refresh(new_state, prefix) 

402 """ 

403 return self.model_copy(update={"page": page, "cursor": None}) 

404 

405 def with_per_page(self, per_page: int) -> TableState: 

406 """ 

407 Return a copy with a new per_page value. 

408 

409 Resets to page 1 since row counts change. 

410 """ 

411 return self.model_copy(update={"per_page": per_page, "page": 1, "cursor": None}) 

412 

413 def with_search(self, search: str) -> TableState: 

414 """ 

415 Return a copy with a new search term. 

416 

417 Resets to page 1 since results change. 

418 """ 

419 return self.model_copy(update={"search": search, "page": 1, "cursor": None}) 

420 

421 def with_filter(self, key: str, value: Any) -> TableState: 

422 """ 

423 Return a copy with an updated filter value. 

424 

425 Resets to page 1 since results change. 

426 

427 Example: 

428 new_state = state.with_filter("status", "active") 

429 """ 

430 new_filters = {**self.filters, key: value} 

431 return self.model_copy( 

432 update={"filters": new_filters, "page": 1, "cursor": None}, 

433 ) 

434 

435 def without_filter(self, key: str) -> TableState: 

436 """ 

437 Return a copy with a filter removed. 

438 

439 Resets to page 1 since results change. 

440 """ 

441 new_filters = {k: v for k, v in self.filters.items() if k != key} 

442 return self.model_copy( 

443 update={"filters": new_filters, "page": 1, "cursor": None}, 

444 ) 

445 

446 def with_sort(self, column: str) -> TableState: 

447 """ 

448 Return a copy with sort toggled on the given column. 

449 

450 If already sorting by this column, toggles direction. 

451 Otherwise, sets ascending sort on the column. 

452 

453 Example: 

454 new_state = state.with_sort("name") # asc 

455 new_state = new_state.with_sort("name") # desc 

456 """ 

457 if self.sort_by == column: 

458 new_order = "desc" if self.sort_order == "asc" else "asc" 

459 return self.model_copy(update={"sort_order": new_order}) 

460 return self.model_copy(update={"sort_by": column, "sort_order": "asc"}) 

461 

462 def with_view( 

463 self, 

464 view: Literal["tabular", "grid", "calendar", "stacked"], 

465 ) -> TableState: 

466 """Return a copy with a new view type.""" 

467 return self.model_copy(update={"view": view}) 

468 

469 def with_layout(self, layout: Literal["sidebar", "stack"]) -> TableState: 

470 """Return a copy with a new layout type.""" 

471 return self.model_copy(update={"layout": layout}) 

472 

473 def with_include_deleted(self, include_deleted: bool) -> TableState: 

474 """Return a copy with a new include_deleted value. 

475 

476 Resets to page 1 since results change. 

477 

478 Example: 

479 new_state = state.with_include_deleted(True) 

480 """ 

481 return self.model_copy( 

482 update={"include_deleted": include_deleted, "page": 1, "cursor": None}, 

483 ) 

484 

485 def clear_filters(self) -> TableState: 

486 """ 

487 Return a copy with all filters and search cleared. 

488 

489 Resets to page 1. 

490 """ 

491 return self.model_copy( 

492 update={ 

493 "filters": {}, 

494 "search": "", 

495 "page": 1, 

496 "cursor": None, 

497 }, 

498 ) 

499 

500 def clear_sort(self) -> TableState: 

501 """Return a copy with sorting cleared.""" 

502 return self.model_copy(update={"sort_by": None, "sort_order": "asc"}) 

503 

504 def set_resource_prefix(self, prefix: str) -> None: 

505 """Set the resource prefix for URL generation.""" 

506 object.__setattr__(self, "_resource_prefix", prefix) 

507 

508 def get_resource_prefix(self) -> str | None: 

509 """Get the resource prefix for URL generation.""" 

510 return getattr(self, "_resource_prefix", None) 

511 

512 # === Hidden Input Rendering === 

513 

514 def render_hidden_inputs(self, exclude: list[str] | None = None) -> list: 

515 """ 

516 Render hidden inputs for state preservation. 

517 

518 Used as a fallback when baked URLs aren't possible (e.g., form submissions 

519 that need to preserve table state). 

520 

521 These inputs should be placed INSIDE the TABLE zone. 

522 

523 Args: 

524 exclude: Optional list of field names to skip (avoid duplication with UI inputs) 

525 

526 Returns: 

527 List of htpy input elements 

528 """ 

529 inputs = [] 

530 excluded_keys = set(exclude or []) 

531 

532 params = self.to_query_params() 

533 for key, value in params.items(): 

534 if key in excluded_keys: 

535 continue 

536 

537 # Handle multi-value fields by rendering multiple hidden inputs 

538 values = value if isinstance(value, (list, tuple)) else [value] 

539 for v in values: 

540 inputs.append( 

541 htpy_input( 

542 type="hidden", 

543 name=key, 

544 value=str(v), 

545 data_state="true", # Mark as state input for debugging 

546 ), 

547 ) 

548 

549 return inputs