Coverage for src / lexigram / admin / ui / organisms / table / views / stacked.py: 59%
66 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:23 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:23 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.admin.ui.organisms.data_table.actions import render_action_button
6from lexigram.admin.ui.organisms.table.views.tabular import AbstractDataView
7from lexigram.ui import Checkbox, el
10def _get_attr(item: Any, key: str, default: Any = None) -> Any:
11 """Safely get attribute from dict or Pydantic model."""
12 if isinstance(item, dict):
13 return item.get(key, default)
14 return getattr(item, key, default)
17class StackedView(AbstractDataView):
18 """Render data as a stacked list of field-value pairs (ideal for mobile)."""
20 def render(self) -> Any:
21 cards = []
22 for _, item in enumerate(self.data):
23 rid = ""
24 if isinstance(item, dict):
25 rid = str(item.get("id", item.get("user_id", item.get("pk", ""))))
26 elif hasattr(item, "id"):
27 rid = str(item.id)
28 elif hasattr(item, "user_id"):
29 rid = str(item.user_id)
30 elif hasattr(item, "pk"):
31 rid = str(item.pk)
32 elif hasattr(item, "__getitem__"):
33 try:
34 rid = str(item[0])
35 except (IndexError, TypeError):
36 rid = ""
38 # If rid is still empty, fallback to a safe string
39 if not rid:
40 rid = f"row-{_}"
41 # Row Header (Checkbox + Title + Actions)
42 header_parts = []
44 if self.config.resource_prefix and self.config.bulk_actions:
45 header_parts.append(
46 el(
47 "div",
48 Checkbox(
49 name="ids",
50 value=rid,
51 x_model="selectedIds",
52 aria_label=f"Select {rid}",
53 ),
54 class_="flex-shrink-0",
55 ),
56 )
58 # Detect Primary Field for the card header
59 primary_val = (
60 _get_attr(item, "name")
61 or _get_attr(item, "title")
62 or _get_attr(item, "label")
63 or f"Record {rid}"
64 )
65 header_parts.append(
66 el(
67 "div",
68 el(
69 "span",
70 primary_val,
71 class_="font-bold text-foreground truncate",
72 ),
73 class_="flex-1 min-w-0",
74 ),
75 )
77 # Row Actions
78 if self.config.resource_prefix:
79 action_nodes = []
80 for action in self.config.actions:
81 if not action.is_visible(
82 user=self.user,
83 resource_name=self.resource_name,
84 record=item,
85 ):
86 continue
87 node = render_action_button(
88 action,
89 record=item,
90 user=self.user,
91 resource_name=self.resource_name,
92 resource_prefix=self.config.resource_prefix,
93 )
94 if node:
95 action_nodes.append(node)
97 # Stack action buttons vertically inside the card header
98 header_parts.append(
99 el(
100 "div",
101 *action_nodes,
102 class_="flex flex-col items-start gap-1 flex-shrink-0",
103 ),
104 )
106 card_header = el(
107 "div",
108 *header_parts,
109 class_="flex items-center gap-3 p-4 border-b border-border bg-muted/50 dark:bg-card/50",
110 )
112 # Field List
113 fields = []
114 for col in self.config.columns:
115 if not col.is_visible(
116 user=self.user,
117 resource_name=self.resource_name,
118 record=item,
119 ):
120 continue
122 val = col.get_value(item)
123 rendered_val = col.render(col.format_value(val), item)
125 fields.append(
126 el(
127 "div",
128 el(
129 "dt",
130 col.label,
131 class_="text-xs font-medium text-muted-foreground uppercase tracking-wider w-1/3 flex-shrink-0",
132 ),
133 el(
134 "dd",
135 rendered_val,
136 class_="text-sm text-foreground flex-1 min-w-0",
137 ),
138 class_="flex items-start gap-4 py-2 px-4 border-b border-border last:border-0",
139 ),
140 )
142 card_body = el(
143 "dl",
144 *fields,
145 class_="divide-y divide-border",
146 )
148 card = el(
149 "div",
150 card_header,
151 card_body,
152 class_="bg-card rounded-xl border border-border shadow-sm overflow-hidden mb-4 last:mb-0",
153 )
154 cards.append(card)
156 stack = el("div", *cards, class_="block space-y-4")
158 # Virtual Scroll Logic
159 next_cursor = getattr(self.state, "next_cursor", None) or getattr(
160 self.config,
161 "next_cursor",
162 None,
163 )
164 if not next_cursor and hasattr(self, "next_cursor"):
165 next_cursor = self.next_cursor
167 if next_cursor and self.config.resource_prefix:
168 from urllib.parse import urlencode
170 from lexigram.ui import InfiniteScrollTrigger, Zones
172 params = {**self.state.to_query_params(), "cursor": next_cursor}
173 next_url = f"{self.config.resource_prefix}/?{urlencode(params)}"
175 trigger = InfiniteScrollTrigger(
176 url=next_url,
177 target=f"#{Zones.DATA.id}",
178 swap="beforeend",
179 )
180 return el("div", stack, trigger.render())
182 return stack