Coverage for src / lexigram / admin / ui / organisms / table / views / tabular.py: 41%

229 statements  

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

1from __future__ import annotations 

2 

3from abc import ABC, abstractmethod 

4from typing import Any 

5 

6from lexigram.ui import Checkbox, el 

7 

8HEADER_HEIGHT = 50 

9 

10 

11class AbstractDataView(ABC): 

12 """Abstract Strategy for Data Visualization.""" 

13 

14 def __init__( 

15 self, 

16 data: list[dict], 

17 config: Any, 

18 state: Any, 

19 total: int = 0, 

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

21 user: Any = None, 

22 resource_name: str | None = None, 

23 ): 

24 self.data = data 

25 self.config = config 

26 self.state = state 

27 self.total = total 

28 self.summary = summary 

29 self.user = user 

30 self.resource_name = resource_name 

31 

32 # Apply column ordering if present in state 

33 if self.state.column_order: 

34 ordered_cols = [] 

35 col_map = {col.name: col for col in self.config.columns} 

36 for name in self.state.column_order: 

37 if name in col_map: 

38 ordered_cols.append(col_map.pop(name)) 

39 # Append any remaining columns not in the order list 

40 ordered_cols.extend(col_map.values()) 

41 self.config.columns = ordered_cols 

42 

43 @abstractmethod 

44 def render(self) -> Any: 

45 pass 

46 

47 

48class TabularView(AbstractDataView): 

49 """Render data as a standard HTML Table.""" 

50 

51 def render(self) -> Any: 

52 thead = self.render_header() 

53 tbody = el( 

54 "tbody", 

55 *self.render_rows(), 

56 class_="bg-card divide-y divide-border", 

57 ) 

58 tfoot = self.render_summary() if self.summary else "" 

59 

60 density_class = getattr( 

61 self.config, "density_css_class", "table-density-normal" 

62 ) 

63 

64 table_el = el( 

65 "table", 

66 thead, 

67 tbody, 

68 tfoot, 

69 class_="min-w-full divide-y divide-border border-separate border-spacing-0", 

70 style="table-layout: auto; min-width: 100%; width: max-content;", 

71 ) 

72 

73 return el( 

74 "div", 

75 table_el, 

76 class_=f"overflow-x-auto overflow-y-auto shadow-sm ring-1 ring-border dark:ring-border rounded-lg bg-muted/50 {density_class}", 

77 style="max-height: min(70vh, calc(100vh - var(--admin-table-offset, 18rem))); min-height: 200px;", 

78 ) 

79 

80 def render_header(self) -> Any: 

81 current_sort = self.state.sort_by 

82 current_order = self.state.sort_order 

83 

84 # 1. Header Logic 

85 header_cells = [] 

86 left_offset = 0 

87 

88 # Checkbox header 

89 if self.config.resource_prefix and self.config.bulk_actions: 

90 all_ids = [] 

91 for item in self.data: 

92 item_id = "" 

93 if isinstance(item, dict): 

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

95 elif hasattr(item, "id"): 

96 item_id = item.id 

97 elif hasattr(item, "user_id"): 

98 item_id = item.user_id 

99 elif hasattr(item, "pk"): 

100 item_id = item.pk 

101 elif hasattr(item, "__getitem__"): 

102 try: 

103 item_id = item[0] 

104 except (IndexError, TypeError): 

105 item_id = "" 

106 

107 all_ids.append(str(item_id) if item_id is not None else "") 

108 

109 # If any column is pinned left, bulk checkbox should also be pinned 

110 is_pinned = any( 

111 getattr(col, "_pinned", None) == "left" for col in self.config.columns 

112 ) 

113 style = "" 

114 cls = ( 

115 "px-6 py-3 text-left w-12 sticky top-0 z-30 bg-muted dark:bg-background" 

116 ) 

117 if is_pinned: 

118 style = f"left: {left_offset}px" 

119 cls += " border-r border-border" 

120 left_offset += 48 # Approximate w-12 width 

121 

122 select_all_attrs: dict[str, Any] = { 

123 "aria-label": "Select all rows on this page", 

124 ":checked": "allIds.length > 0 && selectedIds.length === allIds.length", 

125 "x-effect": "$el.indeterminate = selectedIds.length > 0 && selectedIds.length < allIds.length", 

126 "@change": "handleSelectAll($event)", 

127 } 

128 header_cells.append( 

129 el( 

130 "th", 

131 Checkbox(name="select_all", **select_all_attrs), 

132 class_=cls, 

133 style=style, 

134 ), 

135 ) 

136 elif self.config.resource_prefix: 

137 pass 

138 

139 # Spacer for expandable 

140 if self.config.expandable_relationship: 

141 header_cells.append( 

142 el( 

143 "th", 

144 "", 

145 class_="px-6 py-3 text-left w-12 sticky top-0 z-20 bg-muted dark:bg-background", 

146 ), 

147 ) 

148 

149 for col in self.config.columns: 

150 if not col.is_visible(user=self.user, resource_name=self.resource_name): 

151 continue 

152 header_th = col.render_header( 

153 current_sort, 

154 current_order, 

155 state=self.state, 

156 resource_prefix=getattr(self.config, "resource_prefix", ""), 

157 ) 

158 

159 # Ensure standard headers are also sticky 

160 if hasattr(header_th, "attrs"): 

161 header_th.attrs["class_"] = ( 

162 header_th.attrs.get("class_", "") 

163 + " sticky top-0 z-20 bg-muted dark:bg-background group" 

164 ) 

165 header_th.attrs["data-col-name"] = col.name 

166 

167 # Style handling: apply explicit width styles when provided 

168 # Otherwise, if grow() is enabled, mark the inner wrapper as fluid (w-full + min-w-0) 

169 col_width = getattr(col, "_width", None) 

170 col_grow = getattr(col, "_grow", True) 

171 

172 if col_width is not None: 

173 # Numeric widths => treat as rem units; string widths passthrough 

174 if isinstance(col_width, (int, float)): 

175 style_val = f"{float(col_width)}rem" 

176 else: 

177 style_val = str(col_width) 

178 existing_style = header_th.attrs.get("style", "") 

179 header_th.attrs["style"] = ( 

180 existing_style + f"; width: {style_val}; min-width: {style_val}" 

181 ).strip("; ") 

182 elif col_grow: 

183 # Try to add grow classes to the inner wrapper if present 

184 if getattr(header_th, "children", None): 

185 inner = header_th.children[0] 

186 if hasattr(inner, "attrs"): 

187 inner.attrs["class"] = ( 

188 inner.attrs.get("class", "") + " w-full min-w-0" 

189 ).strip() 

190 

191 # Add Reordering support 

192 if getattr(self.config, "reorderable_columns", False): 

193 # Add drag handle before the header content 

194 drag_handle = el( 

195 "span", 

196 el( 

197 "i", 

198 class_="fas fa-grip-vertical text-muted-foreground opacity-0 group-hover:opacity-100 cursor-move mr-1", 

199 ), 

200 class_="drag-handle inline-flex items-center", 

201 **{ 

202 "draggable": "true", 

203 "@dragstart": f"event.dataTransfer.setData('text/plain', '{col.name}')", 

204 "@dragover.prevent": "", 

205 "@drop": f"reorderColumn(event.dataTransfer.getData('text/plain'), '{col.name}')", 

206 }, 

207 ) 

208 header_th.children.insert(0, drag_handle) 

209 

210 if getattr(col, "_pinned", None) == "left": 

211 if hasattr(header_th, "attrs"): 

212 header_th.attrs["class_"] = ( 

213 header_th.attrs.get("class_", "") 

214 + " sticky left-0 z-30 border-r border-border" 

215 ) 

216 header_th.attrs["style"] = ( 

217 header_th.attrs.get("style", "") + f"; left: {left_offset}px" 

218 ).strip("; ") 

219 

220 col_width = getattr(col, "_width", None) or 150 

221 left_offset += col_width 

222 

223 header_cells.append(header_th) 

224 

225 if self.config.resource_prefix: 

226 header_cells.append( 

227 el( 

228 "th", 

229 "Actions", 

230 scope="col", 

231 class_="px-6 py-3 text-right text-xs uppercase tracking-wider text-muted-foreground font-medium sticky top-0 z-20 bg-muted dark:bg-background", 

232 ), 

233 ) 

234 

235 # Ensure header row has fixed height to match body rows and remains sticky 

236 return el( 

237 "thead", 

238 el( 

239 "tr", 

240 *header_cells, 

241 class_="bg-muted dark:bg-card/50 border-b border-border", 

242 style="height: 60px;", 

243 ), 

244 ) 

245 

246 def render_rows(self) -> list[Any]: 

247 body_rows = [] 

248 

249 # Determine grouping 

250 group_col = self.config.group_by 

251 

252 # Sort data for grouping if needed (groupby requires sorted data) 

253 # We assume data might be paginated, so this grouping applies to the current page. 

254 data_to_render = self.data 

255 if group_col: 

256 from itertools import groupby 

257 

258 # Sort stable to keep existing order within groups if possible, 

259 # though usually data comes sorted from DB. 

260 # We strictly sort by group key to ensure groupby works correctly. 

261 def get_group_key(x) -> Any: 

262 val = ( 

263 x.get(group_col) 

264 if isinstance(x, dict) 

265 else getattr(x, group_col, None) 

266 ) 

267 return str(val if val is not None else "Unknown") 

268 

269 data_to_render = sorted(self.data, key=get_group_key) 

270 

271 # Create groups 

272 grouped_data = groupby(data_to_render, key=get_group_key) 

273 

274 for group_name, items in grouped_data: 

275 group_items = list(items) 

276 

277 # Render Group Header 

278 colspan = ( 

279 len(self.config.columns) 

280 + ( 

281 1 

282 if self.config.resource_prefix and self.config.bulk_actions 

283 else 0 

284 ) 

285 + (1 if self.config.expandable_relationship else 0) 

286 + (1 if self.config.resource_prefix else 0) 

287 ) # Actions column 

288 

289 group_header = el( 

290 "tr", 

291 el( 

292 "td", 

293 el( 

294 "button", 

295 el( 

296 "i", 

297 class_="fas fa-chevron-down mr-2 transition-transform duration-200", 

298 **{ 

299 ":class": f"{{ '-rotate-90': collapsedGroups.includes('{group_name}') }}", 

300 }, 

301 ), 

302 el( 

303 "span", 

304 group_name, 

305 class_="font-semibold text-foreground", 

306 ), 

307 el( 

308 "span", 

309 f"({len(group_items)})", 

310 class_="ml-2 text-sm text-muted-foreground font-normal", 

311 ), 

312 type="button", 

313 class_="flex items-center w-full text-left focus:outline-none", 

314 **{"@click": f"toggleGroup('{group_name}')"}, 

315 ), 

316 colspan=colspan, 

317 class_="px-6 py-3 bg-muted/80 dark:bg-card/80 border-b border-border backdrop-blur-sm sticky left-0 z-10", 

318 ), 

319 class_="group-header", 

320 ) 

321 body_rows.append(group_header) 

322 

323 # Render Items in Group 

324 for i, item in enumerate(group_items): 

325 self._render_single_row(item, i, body_rows, group_name) 

326 

327 else: 

328 # Standard non-grouped rendering 

329 for i, item in enumerate(self.data): 

330 self._render_single_row(item, i, body_rows, None) 

331 

332 return body_rows 

333 

334 def _render_single_row( 

335 self, 

336 item: dict | Any, 

337 index: int, 

338 output_list: list, 

339 group_key: str | None, 

340 ) -> Any: 

341 cells = [] 

342 row_left_offset = 0 

343 rid = "" 

344 if isinstance(item, dict): 

345 rid = str(item.get("id", item.get("user_id", item.get("pk", "")))) 

346 elif hasattr(item, "id"): 

347 rid = str(item.id) 

348 elif hasattr(item, "user_id"): 

349 rid = str(item.user_id) 

350 elif hasattr(item, "pk"): 

351 rid = str(item.pk) 

352 elif hasattr(item, "__getitem__"): 

353 try: 

354 rid = str(item[0]) 

355 except (IndexError, TypeError): 

356 rid = "" 

357 

358 # Checkbox cell 

359 if self.config.resource_prefix and self.config.bulk_actions: 

360 is_pinned = any( 

361 getattr(col, "_pinned", None) == "left" for col in self.config.columns 

362 ) 

363 cls = "px-6 py-4 whitespace-nowrap w-12 z-20 bg-inherit" 

364 style = "" 

365 if is_pinned: 

366 cls += " sticky left-0 border-r border-border" 

367 style = f"left: {row_left_offset}px" 

368 row_left_offset += 48 

369 

370 td_attrs: dict[str, Any] = { 

371 "@click": f"handleSelect('{rid}', $event)", 

372 } 

373 cell_attrs: dict[str, Any] = {"@click.stop": ""} 

374 cells.append( 

375 el( 

376 "td", 

377 Checkbox( 

378 name="ids", 

379 value=rid, 

380 x_model="selectedIds", 

381 aria_label=f"Select row {rid}", 

382 **cell_attrs, 

383 ), 

384 class_=cls, 

385 style=style, 

386 **td_attrs, 

387 ), 

388 ) 

389 

390 # Expandable Toggle 

391 if self.config.expandable_relationship: 

392 toggle_btn = el( 

393 "button", 

394 el( 

395 "svg", 

396 el( 

397 "path", 

398 **{ 

399 "d": "M9 5l7 7-7 7", 

400 "stroke-linecap": "round", 

401 "stroke-linejoin": "round", 

402 "stroke-width": "2", 

403 }, 

404 ), 

405 class_="w-4 h-4 transition-transform duration-200", 

406 viewBox="0 0 24 24", 

407 stroke="currentColor", 

408 fill="none", 

409 aria_hidden="true", 

410 **{":class": f"{{ 'rotate-90': expandedIds.includes('{rid}') }}"}, 

411 ), 

412 type="button", 

413 aria_label=f"Toggle details for row {rid}", 

414 class_="p-1 rounded hover:bg-muted text-muted-foreground", 

415 **{ 

416 ":aria-expanded": f"expandedIds.includes('{rid}')", 

417 "@click": f"toggleExpand('{rid}')", 

418 }, 

419 ) 

420 cells.append( 

421 el("td", toggle_btn, class_="px-6 py-4 whitespace-nowrap w-12"), 

422 ) 

423 

424 # Data cells 

425 for col in self.config.columns: 

426 if not col.is_visible( 

427 user=self.user, 

428 resource_name=self.resource_name, 

429 record=item, 

430 ): 

431 continue 

432 cell_td = col.render_cell( 

433 item, 

434 user=self.user, 

435 resource_name=self.resource_name, 

436 ) 

437 

438 # Style behavior for cells: if column has an explicit width, apply it 

439 # (numeric -> rem units; string -> passthrough). Otherwise, if grow is enabled 

440 # mark the cell as fluid with Tailwind classes so the browser distributes space. 

441 col_width = getattr(col, "_width", None) 

442 col_grow = getattr(col, "_grow", True) 

443 

444 if col_width is not None: 

445 if isinstance(col_width, (int, float)): 

446 style_val = f"{float(col_width)}rem" 

447 else: 

448 style_val = str(col_width) 

449 if hasattr(cell_td, "attrs"): 

450 existing_style = cell_td.attrs.get("style", "") 

451 cell_td.attrs["style"] = ( 

452 existing_style + f"; width: {style_val}; min-width: {style_val}" 

453 ).strip("; ") 

454 elif col_grow: 

455 if hasattr(cell_td, "attrs"): 

456 cell_td.attrs["class_"] = ( 

457 cell_td.attrs.get("class_", "") + " w-full min-w-0" 

458 ).strip() 

459 

460 if getattr(col, "_pinned", None) == "left": 

461 if hasattr(cell_td, "attrs"): 

462 cell_td.attrs["class_"] = ( 

463 cell_td.attrs.get("class_", "") 

464 + " sticky left-0 z-20 border-r border-border bg-inherit" 

465 ) 

466 cell_td.attrs["style"] = ( 

467 cell_td.attrs.get("style", "") + f"; left: {row_left_offset}px" 

468 ).strip("; ") 

469 

470 col_width_pinned = getattr(col, "_width", None) or 150 

471 row_left_offset += col_width_pinned 

472 

473 cells.append(cell_td) 

474 

475 # Actions cell 

476 if self.config.resource_prefix: 

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

478 render_action_button, 

479 ) 

480 

481 action_nodes = [] 

482 for action in self.config.actions: 

483 node = render_action_button( 

484 action, 

485 record=item, 

486 user=self.user, 

487 resource_name=self.resource_name, 

488 resource_prefix=self.config.resource_prefix, 

489 ) 

490 if node: 

491 action_nodes.append(node) 

492 

493 # Allow configurable action layout: 'horizontal' (default) or 'stack' 

494 layout = getattr(self.config, "_action_layout", None) or getattr( 

495 self.config, 

496 "action_layout", 

497 "horizontal", 

498 ) 

499 if layout in ("stack", "vertical"): 

500 action_container_cls = "flex flex-col items-end gap-2 relative z-10" 

501 else: 

502 action_container_cls = ( 

503 "flex items-center gap-2 justify-end relative z-10" 

504 ) 

505 

506 cells.append( 

507 el( 

508 "td", 

509 el("div", *action_nodes, class_=action_container_cls), 

510 class_="px-6 py-4 whitespace-nowrap text-right text-sm", 

511 ), 

512 ) 

513 

514 row_height = getattr(self.config, "density_row_height", "48px") 

515 row_attrs = { 

516 "class_": "hover:bg-muted dark:hover:bg-card/80 transition-shadow duration-150 border-b border-border last:border-0 group", 

517 ":class": f"{{ 'bg-primary-50/50 dark:bg-primary-900/30 ring-inset ring-2 ring-primary-500/50 z-10 relative': $data.focusedId === '{rid}', 'bg-muted/30': {index} % 2 === 1 }}", 

518 "style": f"height: {row_height};", 

519 } 

520 

521 if group_key: 

522 row_attrs["x-show"] = f"!collapsedGroups.includes('{group_key}')" 

523 row_attrs["x-transition"] = "" 

524 

525 output_list.append(el("tr", *cells, **{**row_attrs, "data-row-id": rid})) 

526 

527 # Expandable Row (Detail) 

528 if self.config.expandable_relationship: 

529 colspan = ( 

530 len(self.config.columns) 

531 + (1 if self.config.resource_prefix else 0) 

532 + 1 

533 + (1 if self.config.bulk_actions else 0) 

534 ) 

535 detail_url = f"{self.config.resource_prefix}/{rid}/relations/{self.config.expandable_relationship}" 

536 

537 detail_attrs = { 

538 "x-show": f"expandedIds.includes('{rid}')", 

539 "x-transition": "", 

540 } 

541 # Also collapse detail if group is collapsed 

542 if group_key: 

543 detail_attrs["x-show"] = ( 

544 f"expandedIds.includes('{rid}') && !collapsedGroups.includes('{group_key}')" 

545 ) 

546 

547 detail_row = el( 

548 "tr", 

549 el( 

550 "td", 

551 el( 

552 "div", 

553 el( 

554 "div", 

555 "Loading relationship...", 

556 class_="animate-pulse text-muted-foreground text-sm p-4", 

557 ), 

558 **{"hx-get": detail_url, "hx-trigger": "intersect once"}, 

559 ), 

560 colspan=colspan, 

561 class_="px-0 py-0 border-b border-border bg-muted/50 dark:bg-background/50", 

562 ), 

563 **detail_attrs, 

564 ) 

565 output_list.append(detail_row) 

566 

567 def render_summary(self) -> Any: 

568 summary_cells = [] 

569 left_offset = 0 

570 

571 # Checkbox column 

572 if self.config.resource_prefix and self.config.bulk_actions: 

573 is_pinned = any( 

574 getattr(col, "_pinned", None) == "left" for col in self.config.columns 

575 ) 

576 cls = "px-6 py-3 sticky bottom-0 z-30 bg-muted dark:bg-background border-t border-border" 

577 style = "" 

578 if is_pinned: 

579 style = f"left: {left_offset}px" 

580 cls += " border-r" 

581 left_offset += 48 

582 summary_cells.append(el("td", "", class_=cls, style=style)) 

583 

584 # Expandable spacer 

585 if self.config.expandable_relationship: 

586 summary_cells.append( 

587 el( 

588 "td", 

589 "", 

590 class_="px-6 py-3 sticky bottom-0 z-20 bg-muted dark:bg-background border-t border-border", 

591 ), 

592 ) 

593 

594 # Data columns 

595 for col in self.config.columns: 

596 val = self.summary.get(col.name, "") if self.summary else "" 

597 cls = "px-6 py-3 text-sm font-bold text-foreground sticky bottom-0 z-20 bg-muted dark:bg-background border-t border-border" 

598 style = "" 

599 

600 if getattr(col, "_pinned", None) == "left": 

601 cls += " sticky left-0 z-30 border-r" 

602 style = f"left: {left_offset}px" 

603 left_offset += getattr(col, "_width", None) or 150 

604 

605 summary_cells.append(el("td", str(val), class_=cls, style=style)) 

606 

607 # Actions column 

608 if self.config.resource_prefix: 

609 summary_cells.append( 

610 el( 

611 "td", 

612 "", 

613 class_="px-6 py-3 sticky bottom-0 z-20 bg-muted dark:bg-background border-t border-border", 

614 ), 

615 ) 

616 

617 return el("tfoot", el("tr", *summary_cells, style="height: 50px;"))