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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
1from __future__ import annotations
3from abc import ABC, abstractmethod
4import re
5from typing import Any
7from lexigram.admin.ui.organisms.table.views.summarizers import compute_summaries
8from lexigram.ui import Checkbox, el
10HEADER_HEIGHT = 50
12_ROW_HEIGHT_RE = re.compile(r"^\d+(px|rem|em|vh|%)$")
15def _js_str(value: Any) -> str:
16 """Escape a value for a single-quoted JavaScript string context.
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 )
35class AbstractDataView(ABC):
36 """Abstract Strategy for Data Visualization."""
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
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
67 @abstractmethod
68 def render(self) -> Any:
69 pass
72class TabularView(AbstractDataView):
73 """Render data as a standard HTML Table."""
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 )
88 density_class = getattr(
89 self.config, "density_css_class", "table-density-normal"
90 )
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 )
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 )
108 def render_header(self) -> Any:
109 current_sort = self.state.sort_by
110 current_order = self.state.sort_order
112 # 1. Header Logic
113 header_cells = []
114 left_offset = 0
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 = ""
135 all_ids.append(str(item_id) if item_id is not None else "")
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
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
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 )
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 )
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
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)
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()
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)
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("; ")
248 col_width = getattr(col, "_width", None) or 150
249 left_offset += col_width
251 header_cells.append(header_th)
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 )
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 )
274 def render_rows(self) -> list[Any]:
275 body_rows = []
277 # Determine grouping
278 group_col = self.config.group_by
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
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")
297 data_to_render = sorted(self.data, key=get_group_key)
299 # Create groups
300 grouped_data = groupby(data_to_render, key=get_group_key)
302 for group_name, items in grouped_data:
303 group_items = list(items)
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
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)
351 # Render Items in Group
352 for i, item in enumerate(group_items):
353 self._render_single_row(item, i, body_rows, group_name)
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)
360 return body_rows
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 = ""
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
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 )
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 )
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 )
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)
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()
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("; ")
500 col_width_pinned = getattr(col, "_width", None) or 150
501 row_left_offset += col_width_pinned
503 cells.append(cell_td)
505 # Actions cell
506 if self.config.resource_prefix:
507 from lexigram.admin.ui.organisms.data_table.actions import (
508 render_action_button,
509 )
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)
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 )
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 )
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 }
553 if group_key:
554 row_attrs["x-show"] = f"!collapsedGroups.includes('{_js_str(group_key)}')"
555 row_attrs["x-transition"] = ""
557 output_list.append(el("tr", *cells, **{**row_attrs, "data-row-id": rid}))
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}"
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 )
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)
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
607 def render_summary(self, summary: dict[str, Any] | None = None) -> Any:
608 summary_cells = []
609 left_offset = 0
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))
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 )
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 = ""
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
645 summary_cells.append(el("td", str(val), class_=cls, style=style))
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 )
657 return el("tfoot", el("tr", *summary_cells, style="height: 50px;"))