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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
1from __future__ import annotations
3from abc import ABC, abstractmethod
4from typing import Any
6from lexigram.ui import Checkbox, el
8HEADER_HEIGHT = 50
11class AbstractDataView(ABC):
12 """Abstract Strategy for Data Visualization."""
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
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
43 @abstractmethod
44 def render(self) -> Any:
45 pass
48class TabularView(AbstractDataView):
49 """Render data as a standard HTML Table."""
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 ""
60 density_class = getattr(
61 self.config, "density_css_class", "table-density-normal"
62 )
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 )
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 )
80 def render_header(self) -> Any:
81 current_sort = self.state.sort_by
82 current_order = self.state.sort_order
84 # 1. Header Logic
85 header_cells = []
86 left_offset = 0
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 = ""
107 all_ids.append(str(item_id) if item_id is not None else "")
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
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
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 )
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 )
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
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)
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()
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)
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("; ")
220 col_width = getattr(col, "_width", None) or 150
221 left_offset += col_width
223 header_cells.append(header_th)
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 )
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 )
246 def render_rows(self) -> list[Any]:
247 body_rows = []
249 # Determine grouping
250 group_col = self.config.group_by
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
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")
269 data_to_render = sorted(self.data, key=get_group_key)
271 # Create groups
272 grouped_data = groupby(data_to_render, key=get_group_key)
274 for group_name, items in grouped_data:
275 group_items = list(items)
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
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)
323 # Render Items in Group
324 for i, item in enumerate(group_items):
325 self._render_single_row(item, i, body_rows, group_name)
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)
332 return body_rows
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 = ""
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
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 )
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 )
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 )
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)
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()
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("; ")
470 col_width_pinned = getattr(col, "_width", None) or 150
471 row_left_offset += col_width_pinned
473 cells.append(cell_td)
475 # Actions cell
476 if self.config.resource_prefix:
477 from lexigram.admin.ui.organisms.data_table.actions import (
478 render_action_button,
479 )
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)
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 )
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 )
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 }
521 if group_key:
522 row_attrs["x-show"] = f"!collapsedGroups.includes('{group_key}')"
523 row_attrs["x-transition"] = ""
525 output_list.append(el("tr", *cells, **{**row_attrs, "data-row-id": rid}))
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}"
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 )
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)
567 def render_summary(self) -> Any:
568 summary_cells = []
569 left_offset = 0
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))
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 )
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 = ""
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
605 summary_cells.append(el("td", str(val), class_=cls, style=style))
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 )
617 return el("tfoot", el("tr", *summary_cells, style="height: 50px;"))