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

242 statements  

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

1from __future__ import annotations 

2 

3from abc import ABC, abstractmethod 

4import re 

5from typing import Any 

6 

7from lexigram.admin.ui.organisms.table.views.summarizers import compute_summaries 

8from lexigram.ui import Checkbox, el 

9 

10HEADER_HEIGHT = 50 

11 

12_ROW_HEIGHT_RE = re.compile(r"^\d+(px|rem|em|vh|%)$") 

13 

14 

15def _js_str(value: Any) -> str: 

16 """Escape a value for a single-quoted JavaScript string context. 

17 

18 Composes with ``el()``'s HTML attribute escaping: the browser decodes 

19 HTML entities before Alpine.js compiles the attribute as JavaScript, 

20 so ``el()`` alone cannot neutralize a ``'`` breakout — this helper 

21 backslash-escapes every character that could terminate or alter the 

22 JS string. 

23 """ 

24 s = str(value) 

25 return ( 

26 s.replace("\\", "\\\\") 

27 .replace("'", "\\'") 

28 .replace("\r", "\\r") 

29 .replace("\n", "\\n") 

30 .replace("\u2028", "\\u2028") 

31 .replace("\u2029", "\\u2029") 

32 ) 

33 

34 

35class AbstractDataView(ABC): 

36 """Abstract Strategy for Data Visualization.""" 

37 

38 def __init__( 

39 self, 

40 data: list[dict], 

41 config: Any, 

42 state: Any, 

43 total: int = 0, 

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

45 user: Any = None, 

46 resource_name: str | None = None, 

47 ): 

48 self.data = data 

49 self.config = config 

50 self.state = state 

51 self.total = total 

52 self.summary = summary 

53 self.user = user 

54 self.resource_name = resource_name 

55 

56 # Apply column ordering if present in state 

57 if self.state.column_order: 

58 ordered_cols = [] 

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

60 for name in self.state.column_order: 

61 if name in col_map: 

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

63 # Append any remaining columns not in the order list 

64 ordered_cols.extend(col_map.values()) 

65 self.config.columns = ordered_cols 

66 

67 @abstractmethod 

68 def render(self) -> Any: 

69 pass 

70 

71 

72class TabularView(AbstractDataView): 

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

74 

75 def render(self) -> Any: 

76 thead = self.render_header() 

77 tbody = el( 

78 "tbody", 

79 *self.render_rows(), 

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

81 ) 

82 tfoot = ( 

83 self.render_summary(self.effective_summary()) 

84 if self.effective_summary() 

85 else "" 

86 ) 

87 

88 density_class = getattr( 

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

90 ) 

91 

92 table_el = el( 

93 "table", 

94 thead, 

95 tbody, 

96 tfoot, 

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

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

99 ) 

100 

101 return el( 

102 "div", 

103 table_el, 

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

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

106 ) 

107 

108 def render_header(self) -> Any: 

109 current_sort = self.state.sort_by 

110 current_order = self.state.sort_order 

111 

112 # 1. Header Logic 

113 header_cells = [] 

114 left_offset = 0 

115 

116 # Checkbox header 

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

118 all_ids = [] 

119 for item in self.data: 

120 item_id = "" 

121 if isinstance(item, dict): 

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

123 elif hasattr(item, "id"): 

124 item_id = item.id 

125 elif hasattr(item, "user_id"): 

126 item_id = item.user_id 

127 elif hasattr(item, "pk"): 

128 item_id = item.pk 

129 elif hasattr(item, "__getitem__"): 

130 try: 

131 item_id = item[0] 

132 except (IndexError, TypeError): 

133 item_id = "" 

134 

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

136 

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

138 is_pinned = any( 

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

140 ) 

141 style = "" 

142 cls = ( 

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

144 ) 

145 if is_pinned: 

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

147 cls += " border-r border-border" 

148 left_offset += 48 # Approximate w-12 width 

149 

150 select_all_attrs: dict[str, Any] = { 

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

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

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

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

155 } 

156 header_cells.append( 

157 el( 

158 "th", 

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

160 class_=cls, 

161 style=style, 

162 ), 

163 ) 

164 elif self.config.resource_prefix: 

165 pass 

166 

167 # Spacer for expandable 

168 if self.config.expandable_relationship: 

169 header_cells.append( 

170 el( 

171 "th", 

172 "", 

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

174 ), 

175 ) 

176 

177 for col in self.config.columns: 

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

179 continue 

180 header_th = col.render_header( 

181 current_sort, 

182 current_order, 

183 state=self.state, 

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

185 ) 

186 

187 # Ensure standard headers are also sticky 

188 if hasattr(header_th, "attrs"): 

189 header_th.attrs["class_"] = ( 

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

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

192 ) 

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

194 

195 # Style handling: apply explicit width styles when provided 

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

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

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

199 

200 if col_width is not None: 

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

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

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

204 else: 

205 style_val = str(col_width) 

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

207 header_th.attrs["style"] = ( 

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

209 ).strip("; ") 

210 elif col_grow: 

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

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

213 inner = header_th.children[0] 

214 if hasattr(inner, "attrs"): 

215 inner.attrs["class"] = ( 

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

217 ).strip() 

218 

219 # Add Reordering support 

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

221 # Add drag handle before the header content 

222 drag_handle = el( 

223 "span", 

224 el( 

225 "i", 

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

227 ), 

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

229 **{ 

230 "draggable": "true", 

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

232 "@dragover.prevent": "", 

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

234 }, 

235 ) 

236 header_th.children.insert(0, drag_handle) 

237 

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

239 if hasattr(header_th, "attrs"): 

240 header_th.attrs["class_"] = ( 

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

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

243 ) 

244 header_th.attrs["style"] = ( 

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

246 ).strip("; ") 

247 

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

249 left_offset += col_width 

250 

251 header_cells.append(header_th) 

252 

253 if self.config.resource_prefix: 

254 header_cells.append( 

255 el( 

256 "th", 

257 "Actions", 

258 scope="col", 

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

260 ), 

261 ) 

262 

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

264 return el( 

265 "thead", 

266 el( 

267 "tr", 

268 *header_cells, 

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

270 style="height: 60px;", 

271 ), 

272 ) 

273 

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

275 body_rows = [] 

276 

277 # Determine grouping 

278 group_col = self.config.group_by 

279 

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

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

282 data_to_render = self.data 

283 if group_col: 

284 from itertools import groupby 

285 

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

287 # though usually data comes sorted from DB. 

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

289 def get_group_key(x) -> Any: 

290 val = ( 

291 x.get(group_col) 

292 if isinstance(x, dict) 

293 else getattr(x, group_col, None) 

294 ) 

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

296 

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

298 

299 # Create groups 

300 grouped_data = groupby(data_to_render, key=get_group_key) 

301 

302 for group_name, items in grouped_data: 

303 group_items = list(items) 

304 

305 # Render Group Header 

306 colspan = ( 

307 len(self.config.columns) 

308 + ( 

309 1 

310 if self.config.resource_prefix and self.config.bulk_actions 

311 else 0 

312 ) 

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

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

315 ) # Actions column 

316 

317 group_header = el( 

318 "tr", 

319 el( 

320 "td", 

321 el( 

322 "button", 

323 el( 

324 "i", 

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

326 **{ 

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

328 }, 

329 ), 

330 el( 

331 "span", 

332 group_name, 

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

334 ), 

335 el( 

336 "span", 

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

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

339 ), 

340 type="button", 

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

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

343 ), 

344 colspan=colspan, 

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

346 ), 

347 class_="group-header", 

348 ) 

349 body_rows.append(group_header) 

350 

351 # Render Items in Group 

352 for i, item in enumerate(group_items): 

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

354 

355 else: 

356 # Standard non-grouped rendering 

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

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

359 

360 return body_rows 

361 

362 def _render_single_row( 

363 self, 

364 item: dict | Any, 

365 index: int, 

366 output_list: list, 

367 group_key: str | None, 

368 ) -> Any: 

369 cells = [] 

370 row_left_offset = 0 

371 rid = "" 

372 if isinstance(item, dict): 

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

374 elif hasattr(item, "id"): 

375 rid = str(item.id) 

376 elif hasattr(item, "user_id"): 

377 rid = str(item.user_id) 

378 elif hasattr(item, "pk"): 

379 rid = str(item.pk) 

380 elif hasattr(item, "__getitem__"): 

381 try: 

382 rid = str(item[0]) 

383 except (IndexError, TypeError): 

384 rid = "" 

385 

386 # Checkbox cell 

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

388 is_pinned = any( 

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

390 ) 

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

392 style = "" 

393 if is_pinned: 

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

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

396 row_left_offset += 48 

397 

398 td_attrs: dict[str, Any] = { 

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

400 } 

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

402 cells.append( 

403 el( 

404 "td", 

405 Checkbox( 

406 name="ids", 

407 value=rid, 

408 x_model="selectedIds", 

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

410 **cell_attrs, 

411 ), 

412 class_=cls, 

413 style=style, 

414 **td_attrs, 

415 ), 

416 ) 

417 

418 # Expandable Toggle 

419 if self.config.expandable_relationship: 

420 toggle_btn = el( 

421 "button", 

422 el( 

423 "svg", 

424 el( 

425 "path", 

426 **{ 

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

428 "stroke-linecap": "round", 

429 "stroke-linejoin": "round", 

430 "stroke-width": "2", 

431 }, 

432 ), 

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

434 viewBox="0 0 24 24", 

435 stroke="currentColor", 

436 fill="none", 

437 aria_hidden="true", 

438 **{ 

439 ":class": f"{{ 'rotate-90': expandedIds.includes('{_js_str(rid)}') }}" 

440 }, 

441 ), 

442 type="button", 

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

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

445 **{ 

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

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

448 }, 

449 ) 

450 cells.append( 

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

452 ) 

453 

454 # Data cells 

455 for col in self.config.columns: 

456 if not col.is_visible( 

457 user=self.user, 

458 resource_name=self.resource_name, 

459 record=item, 

460 ): 

461 continue 

462 cell_td = col.render_cell( 

463 item, 

464 user=self.user, 

465 resource_name=self.resource_name, 

466 ) 

467 

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

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

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

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

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

473 

474 if col_width is not None: 

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

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

477 else: 

478 style_val = str(col_width) 

479 if hasattr(cell_td, "attrs"): 

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

481 cell_td.attrs["style"] = ( 

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

483 ).strip("; ") 

484 elif col_grow: 

485 if hasattr(cell_td, "attrs"): 

486 cell_td.attrs["class_"] = ( 

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

488 ).strip() 

489 

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

491 if hasattr(cell_td, "attrs"): 

492 cell_td.attrs["class_"] = ( 

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

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

495 ) 

496 cell_td.attrs["style"] = ( 

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

498 ).strip("; ") 

499 

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

501 row_left_offset += col_width_pinned 

502 

503 cells.append(cell_td) 

504 

505 # Actions cell 

506 if self.config.resource_prefix: 

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

508 render_action_button, 

509 ) 

510 

511 action_nodes = [] 

512 for action in self.config.actions: 

513 node = render_action_button( 

514 action, 

515 record=item, 

516 user=self.user, 

517 resource_name=self.resource_name, 

518 resource_prefix=self.config.resource_prefix, 

519 ) 

520 if node: 

521 action_nodes.append(node) 

522 

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

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

525 self.config, 

526 "action_layout", 

527 "horizontal", 

528 ) 

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

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

531 else: 

532 action_container_cls = ( 

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

534 ) 

535 

536 cells.append( 

537 el( 

538 "td", 

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

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

541 ), 

542 ) 

543 

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

545 if not _ROW_HEIGHT_RE.fullmatch(row_height): 

546 row_height = "48px" 

547 row_attrs = { 

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

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

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

551 } 

552 

553 if group_key: 

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

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

556 

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

558 

559 # Expandable Row (Detail) 

560 if self.config.expandable_relationship: 

561 colspan = ( 

562 len(self.config.columns) 

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

564 + 1 

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

566 ) 

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

568 

569 detail_attrs = { 

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

571 "x-transition": "", 

572 } 

573 # Also collapse detail if group is collapsed 

574 if group_key: 

575 detail_attrs["x-show"] = ( 

576 f"expandedIds.includes('{_js_str(rid)}') && " 

577 f"!collapsedGroups.includes('{_js_str(group_key)}')" 

578 ) 

579 

580 detail_row = el( 

581 "tr", 

582 el( 

583 "td", 

584 el( 

585 "div", 

586 el( 

587 "div", 

588 "Loading relationship...", 

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

590 ), 

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

592 ), 

593 colspan=colspan, 

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

595 ), 

596 **detail_attrs, 

597 ) 

598 output_list.append(detail_row) 

599 

600 def effective_summary(self) -> dict[str, Any] | None: 

601 """Resolve footer summaries: explicit summary or per-column aggregates.""" 

602 if self.summary: 

603 return self.summary 

604 computed = compute_summaries(self.data, self.config.columns) 

605 return computed or None 

606 

607 def render_summary(self, summary: dict[str, Any] | None = None) -> Any: 

608 summary_cells = [] 

609 left_offset = 0 

610 

611 # Checkbox column 

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

613 is_pinned = any( 

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

615 ) 

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

617 style = "" 

618 if is_pinned: 

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

620 cls += " border-r" 

621 left_offset += 48 

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

623 

624 # Expandable spacer 

625 if self.config.expandable_relationship: 

626 summary_cells.append( 

627 el( 

628 "td", 

629 "", 

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

631 ), 

632 ) 

633 

634 # Data columns 

635 for col in self.config.columns: 

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

637 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" 

638 style = "" 

639 

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

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

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

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

644 

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

646 

647 # Actions column 

648 if self.config.resource_prefix: 

649 summary_cells.append( 

650 el( 

651 "td", 

652 "", 

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

654 ), 

655 ) 

656 

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