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

164 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +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.permissions import PermissionManager 

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

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

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

15from lexigram.serialization import dumps_str 

16from lexigram.ui import TableState, Zones, el, render_to_string 

17 

18 

19class DataTableRenderer: 

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

21 

22 def __init__( 

23 self, 

24 data: list[dict], 

25 config: TableConfiguration, 

26 state: TableState, 

27 total: int | None = None, 

28 user: Any = None, 

29 loading: bool = False, 

30 error: Any = None, 

31 next_cursor: str | None = None, 

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

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

34 ): 

35 self.data = data 

36 self.config = config 

37 self.state = state 

38 self.total = total 

39 self.user = user 

40 self.loading = loading 

41 self.error = error 

42 self.next_cursor = next_cursor 

43 self.summary = summary 

44 self.props = props or {} 

45 

46 # Initialize managers 

47 self.permission_manager = PermissionManager(user, config.resource_name) 

48 self.permissions = self.permission_manager.check_permissions() 

49 

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

51 self.action_manager.configure_actions() 

52 

53 self.layout_composer = LayoutComposer(config, state) 

54 self.state_renderer = StateRenderer(config, state) 

55 

56 # Pre-compute IDs 

57 self._all_ids = self._extract_all_ids() 

58 

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

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

61 ids = [] 

62 for item in self.data: 

63 if isinstance(item, dict): 

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

65 elif hasattr(item, "id"): 

66 item_id = item.id 

67 elif hasattr(item, "user_id"): 

68 item_id = item.user_id 

69 elif hasattr(item, "pk"): 

70 item_id = item.pk 

71 elif hasattr(item, "__getitem__"): 

72 try: 

73 item_id = item[0] 

74 except (IndexError, TypeError): 

75 item_id = None 

76 else: 

77 item_id = None 

78 

79 if item_id is not None: 

80 ids.append(str(item_id)) 

81 else: 

82 ids.append("") 

83 

84 return ids 

85 

86 @property 

87 def all_ids_json(self) -> str: 

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

89 return dumps_str(self._all_ids) 

90 

91 def render(self) -> str: 

92 """Render the complete data table.""" 

93 # Toolbar sections 

94 toolbar = self._render_toolbar() 

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

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

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

98 

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

100 

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

102 tabs_html = "" 

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

104 tabs_html = self._render_scope_tabs() 

105 

106 # View content 

107 view_content = self._render_view_content() 

108 

109 # Pagination 

110 pagination_el = self._render_pagination() 

111 

112 # Hidden state inputs 

113 hidden_state_inputs = self._render_hidden_inputs() 

114 

115 # Table content 

116 table_content = el( 

117 "div", 

118 *hidden_state_inputs, 

119 view_content, 

120 pagination_el if pagination_el else "", 

121 id=Zones.DATA.id, 

122 ) 

123 

124 # HTMX wrapper 

125 table_wrapper = self._render_htmx_wrapper(table_content) 

126 

127 # Fragment logic 

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

129 return render_to_string(table_wrapper) 

130 

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

132 if is_htmx: 

133 oob_fragments = self._render_oob_fragments() 

134 return render_to_string([table_wrapper, *oob_fragments]) 

135 

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

137 return render_to_string(header_section) 

138 

139 # Form wrapper 

140 inner_form = el( 

141 "form", 

142 table_wrapper, 

143 method="get", 

144 x_ref="bulkForm", 

145 class_="space-y-4", 

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

147 ) 

148 

149 # Layout composition 

150 container = self.layout_composer.compose( 

151 search_section, 

152 filter_section, 

153 inner_form, 

154 ) 

155 

156 # Script and final markup 

157 script = self._render_script() 

158 

159 return render_to_string( 

160 [ 

161 script, 

162 el( 

163 "div", 

164 header_section, 

165 tabs_html, 

166 container, 

167 id=Zones.TABLE.id, 

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

169 **{ 

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

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

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

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

174 }, 

175 ), 

176 ], 

177 ) 

178 

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

180 """Render toolbar sections.""" 

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

182 

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

184 return { 

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

186 if self.config.resource_prefix 

187 else "", 

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

189 "filter": toolbar.render_filters(), 

190 } 

191 

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

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

194 from lexigram.ui import HTMXAttrs 

195 

196 state = self.state 

197 prefix = self.config.resource_prefix or "" 

198 active = not state.include_deleted 

199 

200 active_state = state.with_include_deleted(False) 

201 trash_state = state.with_include_deleted(True) 

202 

203 active_url = active_state.to_url(prefix) 

204 trash_url = trash_state.to_url(prefix) 

205 

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

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

208 

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

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

211 cls = ( 

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

213 "border-primary-500 text-primary-600" 

214 if is_active 

215 else ( 

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

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

218 "hover:border-border" 

219 ) 

220 ) 

221 

222 if is_active: 

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

224 

225 return el( 

226 "a", 

227 label, 

228 href=url, 

229 class_=cls, 

230 **htmx_attrs, 

231 ) 

232 

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

234 if oob: 

235 outer_attrs["hx_swap_oob"] = "outerHTML" 

236 

237 return render_to_string( 

238 el( 

239 "div", 

240 el( 

241 "div", 

242 el( 

243 "nav", 

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

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

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

247 ), 

248 class_="px-6", 

249 ), 

250 **outer_attrs, 

251 ) 

252 ) 

253 

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

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

256 

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

258 """ 

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

260 

261 fragments: list[Any] = [] 

262 

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

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

265 switchers = toolbar.render_switchers_oob() 

266 if switchers is not None: 

267 fragments.append(switchers) 

268 

269 # Scope tabs as OOB fragment 

270 tabs = self._render_scope_tabs(oob=True) 

271 if tabs: 

272 fragments.append(tabs) 

273 

274 return fragments 

275 

276 def _render_view_content(self) -> Any: 

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

278 if self.loading: 

279 return self.state_renderer.render_skeleton() 

280 if self.error: 

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

282 if not self.data: 

283 return self.state_renderer.render_empty() 

284 view_strategy = ViewFactory.create_view( 

285 self.state.view or "tabular", 

286 self.data, 

287 self.config, 

288 self.state, 

289 self.total, 

290 self.summary, 

291 self.user, 

292 self.config.resource_name, 

293 ) 

294 return view_strategy.render() 

295 

296 def _render_pagination(self) -> Any: 

297 """Render pagination if needed.""" 

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

299 return None 

300 

301 params = self.state.to_query_params() 

302 params.pop("page", None) 

303 params.pop("per_page", None) 

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

305 base_url = ( 

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

307 ) 

308 

309 return Pagination( 

310 page=self.state.page, 

311 total=self.total, 

312 per_page=self.state.per_page, 

313 base_url=base_url, 

314 extra_query=base_query, 

315 hx_target=Zones.DATA.selector, 

316 hx_swap="innerHTML", 

317 show_size_selector=True, 

318 next_cursor=self.next_cursor, 

319 state=self.state, 

320 ) 

321 

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

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

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

325 if self.config.enable_search: 

326 exclude_keys.append("search") 

327 

328 if self.config.filters: 

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

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

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

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

333 

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

335 

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

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

338 from lexigram.ui import HTMXAttrs 

339 

340 htmx_attrs = {} 

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

342 htmx_attrs = HTMXAttrs.for_data_refresh( 

343 self.state, 

344 self.config.resource_prefix, 

345 push_url=False, 

346 ) 

347 

348 return el( 

349 "div", 

350 table_content, 

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

352 hx_trigger="refreshTable from:body", 

353 hx_disinherit="hx-select", 

354 **htmx_attrs, 

355 ) 

356 

357 def _render_script(self) -> str: 

358 """Render Alpine.js script.""" 

359 from lexigram.ui import DataTableScriptRenderer 

360 

361 return DataTableScriptRenderer.render(self._all_ids) 

362 

363 def render_bulk_actions(self) -> Any: 

364 """Render bulk actions bar.""" 

365 if not self.config.bulk_actions: 

366 return None 

367 

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

369 render_bulk_action_button, 

370 ) 

371 

372 bulk_buttons = [] 

373 for action in self.config.bulk_actions: 

374 if not action.is_visible(None): 

375 continue 

376 btn = render_bulk_action_button(action) 

377 if btn: 

378 bulk_buttons.append(btn) 

379 

380 return el( 

381 "div", 

382 el( 

383 "div", 

384 el( 

385 "span", 

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

387 " items selected", 

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

389 ), 

390 *bulk_buttons, 

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

392 ), 

393 role="alert", 

394 aria_live="polite", 

395 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", 

396 x_show="selectedIds.length > 0", 

397 style="display: none;", 

398 **{ 

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

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

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

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

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

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

405 }, 

406 )