Coverage for src/lexigram/admin/ui/organisms/data_table/rendering.py: 86%

162 statements  

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

1"""Main rendering logic for data table component with HTMX/Alpine integration.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6from urllib.parse import urlencode 

7 

8from lexigram.admin.config import TableConfiguration 

9from lexigram.admin.ui.organisms.data_table.actions import ActionManager 

10from lexigram.admin.ui.organisms.data_table.layout import LayoutComposer 

11from lexigram.admin.ui.organisms.data_table.states import StateRenderer 

12from lexigram.admin.ui.organisms.data_table.views import ViewFactory 

13from lexigram.admin.ui.organisms.pagination import Pagination 

14from lexigram.serialization import dumps_str 

15from lexigram.ui import TableState, Zones, el, raw, render_to_string 

16 

17 

18class DataTableRenderer: 

19 """Main renderer for data table component.""" 

20 

21 def __init__( 

22 self, 

23 data: list[dict], 

24 config: TableConfiguration, 

25 state: TableState, 

26 total: int | None = None, 

27 user: Any = None, 

28 loading: bool = False, 

29 error: Any = None, 

30 next_cursor: str | None = None, 

31 summary: dict[str, Any] | None = None, 

32 props: dict[str, Any] | None = None, 

33 ): 

34 self.data = data 

35 self.config = config 

36 self.state = state 

37 self.total = total 

38 self.user = user 

39 self.loading = loading 

40 self.error = error 

41 self.next_cursor = next_cursor 

42 self.summary = summary 

43 self.props = props or {} 

44 

45 # Permission state: framework never binds a permission service at 

46 # construction time; async callers hoist checks and inject the dict. 

47 self.permissions = (props or {}).get("permissions") or { 

48 "can_view": True, 

49 "can_create": True, 

50 "can_update": True, 

51 "can_delete": True, 

52 } 

53 

54 self.action_manager = ActionManager(config, self.permissions) 

55 self.action_manager.configure_actions() 

56 

57 self.layout_composer = LayoutComposer(config, state) 

58 self.state_renderer = StateRenderer(config, state) 

59 

60 # Pre-compute IDs 

61 self._all_ids = self._extract_all_ids() 

62 

63 def _extract_all_ids(self) -> list[str]: 

64 """Extract all record IDs from data.""" 

65 ids = [] 

66 for item in self.data: 

67 if isinstance(item, dict): 

68 item_id = item.get("id", item.get("user_id", item.get("pk"))) 

69 elif hasattr(item, "id"): 

70 item_id = item.id 

71 elif hasattr(item, "user_id"): 

72 item_id = item.user_id 

73 elif hasattr(item, "pk"): 

74 item_id = item.pk 

75 elif hasattr(item, "__getitem__"): 

76 try: 

77 item_id = item[0] 

78 except (IndexError, TypeError): 

79 item_id = None 

80 else: 

81 item_id = None 

82 

83 if item_id is not None: 

84 ids.append(str(item_id)) 

85 else: 

86 ids.append("") 

87 

88 return ids 

89 

90 @property 

91 def all_ids_json(self) -> str: 

92 """JSON serialization of all IDs for Alpine.js.""" 

93 return dumps_str(self._all_ids) 

94 

95 def render(self) -> str: 

96 """Render the complete data table.""" 

97 # Toolbar sections 

98 toolbar = self._render_toolbar() 

99 header_section = toolbar.get("header", "") 

100 search_section = toolbar.get("search", "") 

101 filter_section = toolbar.get("filter", "") 

102 

103 is_htmx = self.props.get("htmx_request", False) 

104 

105 # Scope tabs — inline for full render, OOB for HTMX data-only 

106 tabs_html = "" 

107 if not (self.props.get("render_fragment") or is_htmx): 

108 tabs_html = self._render_scope_tabs() 

109 

110 # View content 

111 view_content = self._render_view_content() 

112 

113 # Pagination 

114 pagination_el = self._render_pagination() 

115 

116 # Hidden state inputs 

117 hidden_state_inputs = self._render_hidden_inputs() 

118 

119 # Table content 

120 table_content = el( 

121 "div", 

122 *hidden_state_inputs, 

123 view_content, 

124 pagination_el if pagination_el else "", 

125 id=Zones.DATA.id, 

126 ) 

127 

128 # HTMX wrapper 

129 table_wrapper = self._render_htmx_wrapper(table_content) 

130 

131 # Fragment logic 

132 if self.props.get("render_fragment", False): 

133 return render_to_string(table_wrapper) 

134 

135 # HTMX data-only request: return data zone + OOB control fragments 

136 if is_htmx: 

137 oob_fragments = self._render_oob_fragments() 

138 return render_to_string([table_wrapper, *oob_fragments]) 

139 

140 if self.props.get("render_controls", False): 

141 return render_to_string(header_section) 

142 

143 # Form wrapper 

144 inner_form = el( 

145 "form", 

146 table_wrapper, 

147 method="get", 

148 x_ref="bulkForm", 

149 class_="space-y-4", 

150 **{"@submit.prevent": ""}, 

151 ) 

152 

153 # Layout composition 

154 container = self.layout_composer.compose( 

155 search_section, 

156 filter_section, 

157 inner_form, 

158 ) 

159 

160 # Script and final markup 

161 script = self._render_script() 

162 

163 return render_to_string( 

164 [ 

165 script, 

166 el( 

167 "div", 

168 raw(header_section), 

169 raw(tabs_html), 

170 container, 

171 id=Zones.TABLE.id, 

172 x_data=f"{{ selectedIds: [], expandedIds: [], collapsedGroups: [], lastSelected: null, focusedId: null, hasActiveFiltersState: false, allIds: {self.all_ids_json}, ...window.LexigramTableLogic }}", 

173 **{ 

174 "@keydown.window": "handleKeydown($event)", 

175 "@htmx:after-swap.window": "$nextTick(() => updateActiveFiltersState())", 

176 "@input.window": f"if ($event.target.closest('{Zones.SEARCH.selector}') || $event.target.closest('{Zones.FILTERS.selector}')) $nextTick(() => updateActiveFiltersState())", 

177 "@change.window": f"if ($event.target.closest('{Zones.SEARCH.selector}') || $event.target.closest('{Zones.FILTERS.selector}')) $nextTick(() => updateActiveFiltersState())", 

178 }, 

179 ), 

180 ], 

181 ) 

182 

183 def _render_toolbar(self) -> dict[str, Any]: 

184 """Render toolbar sections.""" 

185 from lexigram.admin.ui.organisms.table.toolbar import TableToolbar 

186 

187 toolbar = TableToolbar(self.config, self.state) 

188 return { 

189 "header": toolbar.render_header(bulk_actions=self.config.bulk_actions) 

190 if self.config.resource_prefix 

191 else "", 

192 "search": toolbar.render_search() if self.config.resource_prefix else "", 

193 "filter": toolbar.render_filters(), 

194 } 

195 

196 def _render_scope_tabs(self, oob: bool = False) -> str: 

197 """Render Active/Trash scope tabs for soft-delete toggling.""" 

198 from lexigram.ui import HTMXAttrs 

199 

200 state = self.state 

201 prefix = self.config.resource_prefix or "" 

202 active = not state.include_deleted 

203 

204 active_state = state.with_include_deleted(False) 

205 trash_state = state.with_include_deleted(True) 

206 

207 active_url = active_state.to_url(prefix) 

208 trash_url = trash_state.to_url(prefix) 

209 

210 active_attrs = HTMXAttrs.for_full_refresh(active_state, prefix, push_url=True) 

211 trash_attrs = HTMXAttrs.for_full_refresh(trash_state, prefix, push_url=True) 

212 

213 def _tab(label: str, is_active: bool, url: str, htmx_attrs: dict) -> Any: 

214 """Render a single tab button or link.""" 

215 cls = ( 

216 "px-4 py-2 text-sm font-medium border-b-2 transition-colors " 

217 "border-primary-500 text-primary-600" 

218 if is_active 

219 else ( 

220 "px-4 py-2 text-sm font-medium border-b-2 transition-colors " 

221 "border-transparent text-muted-foreground hover:text-foreground " 

222 "hover:border-border" 

223 ) 

224 ) 

225 

226 if is_active: 

227 return el("span", label, class_=cls) 

228 

229 return el( 

230 "a", 

231 label, 

232 href=url, 

233 class_=cls, 

234 **htmx_attrs, 

235 ) 

236 

237 outer_attrs: dict[str, Any] = {"class_": "mb-4", "id": "table-scope-tabs"} 

238 if oob: 

239 outer_attrs["hx_swap_oob"] = "outerHTML" 

240 

241 return render_to_string( 

242 el( 

243 "div", 

244 el( 

245 "div", 

246 el( 

247 "nav", 

248 _tab("Active", active, active_url, active_attrs), 

249 _tab("Trash", not active, trash_url, trash_attrs), 

250 class_="flex space-x-8 border-b border-border", 

251 ), 

252 class_="px-6", 

253 ), 

254 **outer_attrs, 

255 ) 

256 ) 

257 

258 def _render_oob_fragments(self) -> list[Any]: 

259 """Render OOB fragments for toolbar controls during data-only HTMX requests. 

260 

261 Returns a list of htpy elements with hx-swap-oob attributes. 

262 """ 

263 from lexigram.admin.ui.organisms.table.toolbar import TableToolbar 

264 

265 fragments: list[Any] = [] 

266 

267 # Toolbar switchers + clear button (existing OOB method) 

268 toolbar = TableToolbar(self.config, self.state) 

269 switchers = toolbar.render_switchers_oob() 

270 if switchers is not None: 

271 fragments.append(switchers) 

272 

273 # Scope tabs as OOB fragment 

274 tabs = self._render_scope_tabs(oob=True) 

275 if tabs: 

276 fragments.append(tabs) 

277 

278 return fragments 

279 

280 def _render_view_content(self) -> Any: 

281 """Render the appropriate view content based on state.""" 

282 if self.loading: 

283 return self.state_renderer.render_skeleton() 

284 if self.error: 

285 return self.state_renderer.render_error(self.error) 

286 if not self.data: 

287 return self.state_renderer.render_empty() 

288 view_strategy = ViewFactory.create_view( 

289 self.state.view or "tabular", 

290 self.data, 

291 self.config, 

292 self.state, 

293 self.total, 

294 self.summary, 

295 self.user, 

296 self.config.resource_name, 

297 ) 

298 return view_strategy.render() 

299 

300 def _render_pagination(self) -> Any: 

301 """Render pagination if needed.""" 

302 if self.total is None or self.total <= 0: 

303 return None 

304 

305 params = self.state.to_query_params() 

306 params.pop("page", None) 

307 params.pop("per_page", None) 

308 base_query = "&" + urlencode(params) if params else "" 

309 base_url = ( 

310 f"{self.config.resource_prefix}/" if self.config.resource_prefix else "" 

311 ) 

312 

313 return Pagination( 

314 page=self.state.page, 

315 total=self.total, 

316 per_page=self.state.per_page, 

317 base_url=base_url, 

318 extra_query=base_query, 

319 hx_target=Zones.DATA.selector, 

320 hx_swap="innerHTML", 

321 show_size_selector=True, 

322 next_cursor=self.next_cursor, 

323 state=self.state, 

324 ) 

325 

326 def _render_hidden_inputs(self) -> list[Any]: 

327 """Render hidden inputs for state preservation.""" 

328 exclude_keys = ["page", "per_page"] 

329 if self.config.enable_search: 

330 exclude_keys.append("search") 

331 

332 if self.config.filters: 

333 if isinstance(self.config.filters, list): 

334 exclude_keys.extend([f.name for f in self.config.filters]) 

335 elif isinstance(self.config.filters, dict): 

336 exclude_keys.extend(self.config.filters.keys()) 

337 

338 return self.state.render_hidden_inputs(exclude=exclude_keys) 

339 

340 def _render_htmx_wrapper(self, table_content: Any) -> Any: 

341 """Render HTMX wrapper with appropriate attributes.""" 

342 from lexigram.ui import HTMXAttrs 

343 

344 htmx_attrs = {} 

345 if self.state and self.config.resource_prefix: 

346 htmx_attrs = HTMXAttrs.for_data_refresh( 

347 self.state, 

348 self.config.resource_prefix, 

349 push_url=False, 

350 ) 

351 

352 return el( 

353 "div", 

354 table_content, 

355 id=Zones.TABLE.id + "-inner", 

356 hx_trigger="refreshTable from:body", 

357 hx_disinherit="hx-select", 

358 **htmx_attrs, 

359 ) 

360 

361 def _render_script(self) -> str: 

362 """Render Alpine.js script.""" 

363 from lexigram.ui import DataTableScriptRenderer 

364 

365 return DataTableScriptRenderer.render(self._all_ids) 

366 

367 def render_bulk_actions(self) -> Any: 

368 """Render bulk actions bar.""" 

369 if not self.config.bulk_actions: 

370 return None 

371 

372 from lexigram.admin.ui.organisms.data_table.actions import ( 

373 render_bulk_action_button, 

374 ) 

375 

376 bulk_buttons = [] 

377 for action in self.config.bulk_actions: 

378 if not action.is_visible(None): 

379 continue 

380 btn = render_bulk_action_button(action) 

381 if btn: 

382 bulk_buttons.append(btn) 

383 

384 return el( 

385 "div", 

386 el( 

387 "div", 

388 el( 

389 "span", 

390 el("strong", x_text="selectedIds.length"), 

391 " items selected", 

392 class_="text-sm font-medium text-primary-600 dark:text-primary-400 mr-2", 

393 ), 

394 *bulk_buttons, 

395 class_="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 py-3 flex items-center justify-between", 

396 ), 

397 role="alert", 

398 aria_live="polite", 

399 class_="fixed bottom-0 left-0 right-0 bg-card dark:bg-background border-t border-border shadow-lg transition-transform duration-200 z-50", 

400 x_show="selectedIds.length > 0", 

401 style="display: none;", 

402 **{ 

403 "x-transition:enter": "transform ease-out duration-200", 

404 "x-transition:enter-start": "translate-y-full", 

405 "x-transition:enter-end": "translate-y-0", 

406 "x-transition:leave": "transform ease-in duration-200", 

407 "x-transition:leave-start": "translate-y-0", 

408 "x-transition:leave-end": "translate-y-full", 

409 }, 

410 )