Coverage for src / lexigram / admin / state / url.py: 0%

111 statements  

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

1"""URL state management for lexigram-admin. 

2 

3Provides utilities for managing URL state in HTMX applications, 

4including filters, sorting, pagination, and other query parameters. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import TYPE_CHECKING, Any 

11from urllib.parse import urlencode 

12 

13if TYPE_CHECKING: 

14 from starlette.requests import Request 

15 

16 

17@dataclass 

18class URLState: 

19 """Represents the current URL state for admin views. 

20 

21 Captures all query parameters that affect the view: 

22 - Pagination: page, per_page 

23 - Sorting: sort, order 

24 - Filtering: Various filter parameters 

25 - Search: q (search query) 

26 - View options: columns, view_mode 

27 

28 URLState is immutable - methods return new instances. 

29 """ 

30 

31 # Pagination 

32 page: int = 1 

33 per_page: int = 20 

34 cursor: str | None = None 

35 

36 # Sorting 

37 sort: str | None = None 

38 order: str = "asc" # "asc" or "desc" 

39 

40 # Search 

41 search: str | None = None 

42 

43 # Filters (field -> value or [values]) 

44 filters: dict[str, Any] = field(default_factory=dict) 

45 

46 # View options 

47 columns: list[str] = field(default_factory=list) 

48 view_mode: str = "table" # "table", "grid", "list" 

49 

50 # Expanded/selected items (for optimistic UI) 

51 expanded: list[str] = field(default_factory=list) 

52 selected: list[str] = field(default_factory=list) 

53 

54 @classmethod 

55 def from_request(cls, request: Request) -> URLState: 

56 """Create URLState from request query parameters.""" 

57 params = dict(request.query_params) 

58 

59 # Parse pagination 

60 page = int(params.get("page", 1)) 

61 per_page = int(params.get("per_page", 20)) 

62 cursor = params.get("cursor") 

63 

64 # Parse sorting 

65 sort = params.get("sort") 

66 order = params.get("order", "asc") 

67 if order not in ("asc", "desc"): 

68 order = "asc" 

69 

70 # Parse search 

71 search = params.get("q") or params.get("search") 

72 

73 # Parse filters (filter_field = value pattern) 

74 filters: dict[str, Any] = {} 

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

76 if key.startswith("filter_"): 

77 field_name = key[7:] 

78 # Handle multiple values 

79 if "," in value: 

80 filters[field_name] = value.split(",") 

81 else: 

82 filters[field_name] = value 

83 

84 # Parse columns 

85 columns: list[str] = [] 

86 if "columns" in params: 

87 columns = params["columns"].split(",") 

88 

89 # Parse view mode 

90 view_mode = params.get("view", "table") 

91 

92 # Parse expanded/selected 

93 expanded = ( 

94 params.get("expanded", "").split(",") if params.get("expanded") else [] 

95 ) 

96 selected = ( 

97 params.get("selected", "").split(",") if params.get("selected") else [] 

98 ) 

99 

100 return cls( 

101 page=page, 

102 per_page=per_page, 

103 cursor=cursor, 

104 sort=sort, 

105 order=order, 

106 search=search, 

107 filters=filters, 

108 columns=columns, 

109 view_mode=view_mode, 

110 expanded=expanded, 

111 selected=selected, 

112 ) 

113 

114 def to_query_string(self) -> str: 

115 """Convert to URL query string.""" 

116 params: dict[str, str] = {} 

117 

118 # Pagination (only if not defaults) 

119 if self.cursor: 

120 params["cursor"] = self.cursor 

121 elif self.page != 1: 

122 params["page"] = str(self.page) 

123 if self.per_page != 20: 

124 params["per_page"] = str(self.per_page) 

125 

126 # Sorting 

127 if self.sort: 

128 params["sort"] = self.sort 

129 if self.order != "asc": 

130 params["order"] = self.order 

131 

132 # Search 

133 if self.search: 

134 params["q"] = self.search 

135 

136 # Filters 

137 for field_name, value in self.filters.items(): 

138 if isinstance(value, list): 

139 params[f"filter_{field_name}"] = ",".join(str(v) for v in value) 

140 else: 

141 params[f"filter_{field_name}"] = str(value) 

142 

143 # Columns 

144 if self.columns: 

145 params["columns"] = ",".join(self.columns) 

146 

147 # View mode 

148 if self.view_mode != "table": 

149 params["view"] = self.view_mode 

150 

151 return urlencode(params) 

152 

153 def to_dict(self) -> dict[str, Any]: 

154 """Convert to dictionary.""" 

155 return { 

156 "page": self.page, 

157 "per_page": self.per_page, 

158 "sort": self.sort, 

159 "order": self.order, 

160 "search": self.search, 

161 "filters": self.filters, 

162 "columns": self.columns, 

163 "view_mode": self.view_mode, 

164 "expanded": self.expanded, 

165 "selected": self.selected, 

166 } 

167 

168 # Immutable update methods 

169 

170 def with_page(self, page: int) -> URLState: 

171 """Return new state with different page.""" 

172 return URLState( 

173 page=page, 

174 per_page=self.per_page, 

175 sort=self.sort, 

176 order=self.order, 

177 search=self.search, 

178 filters=dict(self.filters), 

179 columns=list(self.columns), 

180 view_mode=self.view_mode, 

181 expanded=list(self.expanded), 

182 selected=list(self.selected), 

183 ) 

184 

185 def with_sort(self, sort: str, order: str = "asc") -> URLState: 

186 """Return new state with different sort.""" 

187 return URLState( 

188 page=1, # Reset to first page on sort change 

189 per_page=self.per_page, 

190 sort=sort, 

191 order=order, 

192 search=self.search, 

193 filters=dict(self.filters), 

194 columns=list(self.columns), 

195 view_mode=self.view_mode, 

196 expanded=list(self.expanded), 

197 selected=list(self.selected), 

198 ) 

199 

200 def with_filter(self, field: str, value: Any) -> URLState: 

201 """Return new state with filter added/updated.""" 

202 new_filters = dict(self.filters) 

203 if value is None: 

204 new_filters.pop(field, None) 

205 else: 

206 new_filters[field] = value 

207 

208 return URLState( 

209 page=1, # Reset to first page on filter change 

210 per_page=self.per_page, 

211 sort=self.sort, 

212 order=self.order, 

213 search=self.search, 

214 filters=new_filters, 

215 columns=list(self.columns), 

216 view_mode=self.view_mode, 

217 expanded=list(self.expanded), 

218 selected=list(self.selected), 

219 ) 

220 

221 def with_search(self, search: str | None) -> URLState: 

222 """Return new state with search term.""" 

223 return URLState( 

224 page=1, # Reset to first page on search 

225 per_page=self.per_page, 

226 sort=self.sort, 

227 order=self.order, 

228 search=search, 

229 filters=dict(self.filters), 

230 columns=list(self.columns), 

231 view_mode=self.view_mode, 

232 expanded=list(self.expanded), 

233 selected=list(self.selected), 

234 ) 

235 

236 def clear_filters(self) -> URLState: 

237 """Return new state with all filters cleared.""" 

238 return URLState( 

239 page=1, 

240 per_page=self.per_page, 

241 sort=self.sort, 

242 order=self.order, 

243 search=self.search, 

244 filters={}, 

245 columns=list(self.columns), 

246 view_mode=self.view_mode, 

247 expanded=list(self.expanded), 

248 selected=list(self.selected), 

249 ) 

250 

251 def toggle_expanded(self, item_id: str) -> URLState: 

252 """Toggle an item's expanded state.""" 

253 new_expanded = list(self.expanded) 

254 if item_id in new_expanded: 

255 new_expanded.remove(item_id) 

256 else: 

257 new_expanded.append(item_id) 

258 

259 return URLState( 

260 page=self.page, 

261 per_page=self.per_page, 

262 sort=self.sort, 

263 order=self.order, 

264 search=self.search, 

265 filters=dict(self.filters), 

266 columns=list(self.columns), 

267 view_mode=self.view_mode, 

268 expanded=new_expanded, 

269 selected=list(self.selected), 

270 ) 

271 

272 def toggle_selected(self, item_id: str) -> URLState: 

273 """Toggle an item's selected state.""" 

274 new_selected = list(self.selected) 

275 if item_id in new_selected: 

276 new_selected.remove(item_id) 

277 else: 

278 new_selected.append(item_id) 

279 

280 return URLState( 

281 page=self.page, 

282 per_page=self.per_page, 

283 sort=self.sort, 

284 order=self.order, 

285 search=self.search, 

286 filters=dict(self.filters), 

287 columns=list(self.columns), 

288 view_mode=self.view_mode, 

289 expanded=list(self.expanded), 

290 selected=new_selected, 

291 ) 

292 

293 def select_all(self, ids: list[str]) -> URLState: 

294 """Select all given IDs.""" 

295 return URLState( 

296 page=self.page, 

297 per_page=self.per_page, 

298 sort=self.sort, 

299 order=self.order, 

300 search=self.search, 

301 filters=dict(self.filters), 

302 columns=list(self.columns), 

303 view_mode=self.view_mode, 

304 expanded=list(self.expanded), 

305 selected=list(ids), 

306 ) 

307 

308 def clear_selection(self) -> URLState: 

309 """Clear all selections.""" 

310 return URLState( 

311 page=self.page, 

312 per_page=self.per_page, 

313 sort=self.sort, 

314 order=self.order, 

315 search=self.search, 

316 filters=dict(self.filters), 

317 columns=list(self.columns), 

318 view_mode=self.view_mode, 

319 expanded=list(self.expanded), 

320 selected=[], 

321 ) 

322 

323 

324def url_for_state( 

325 base_url: str, 

326 state: URLState, 

327 **overrides: Any, 

328) -> str: 

329 """Generate URL with state as query parameters. 

330 

331 Args: 

332 base_url: Base URL without query string 

333 state: Current URL state 

334 **overrides: Override specific state values 

335 

336 Returns: 

337 Full URL with query string 

338 """ 

339 # Apply overrides 

340 if overrides: 

341 state_dict = state.to_dict() 

342 state_dict.update(overrides) 

343 state = URLState(**state_dict) 

344 

345 query_string = state.to_query_string() 

346 if query_string: 

347 return f"{base_url}?{query_string}" 

348 return base_url 

349 

350 

351def htmx_url_attributes( 

352 url: str, 

353 target: str = "#content", 

354 swap: str = "innerHTML", 

355 push_url: bool = True, 

356) -> str: 

357 """Generate HTMX attributes for URL navigation. 

358 

359 Args: 

360 url: Target URL 

361 target: HTMX target selector 

362 swap: HTMX swap strategy 

363 push_url: Whether to push URL to history 

364 

365 Returns: 

366 String of HTMX attributes 

367 """ 

368 attrs = [ 

369 f'hx-get="{url}"', 

370 f'hx-target="{target}"', 

371 f'hx-swap="{swap}"', 

372 ] 

373 

374 if push_url: 

375 attrs.append(f'hx-push-url="{url}"') 

376 

377 return " ".join(attrs)