Coverage for src/lexigram/admin/ui/organisms/table/views/stacked.py: 59%

66 statements  

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

1from __future__ import annotations 

2 

3from typing import Any 

4 

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 

8 

9 

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) 

15 

16 

17class StackedView(AbstractDataView): 

18 """Render data as a stacked list of field-value pairs (ideal for mobile).""" 

19 

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

37 

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 = [] 

43 

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 ) 

57 

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 ) 

76 

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) 

96 

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 ) 

105 

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 ) 

111 

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 

121 

122 val = col.get_value(item) 

123 rendered_val = col.render(col.format_value(val), item) 

124 

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 ) 

141 

142 card_body = el( 

143 "dl", 

144 *fields, 

145 class_="divide-y divide-border", 

146 ) 

147 

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) 

155 

156 stack = el("div", *cards, class_="block space-y-4") 

157 

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 

166 

167 if next_cursor and self.config.resource_prefix: 

168 from urllib.parse import urlencode 

169 

170 from lexigram.ui import InfiniteScrollTrigger, Zones 

171 

172 params = {**self.state.to_query_params(), "cursor": next_cursor} 

173 next_url = f"{self.config.resource_prefix}/?{urlencode(params)}" 

174 

175 trigger = InfiniteScrollTrigger( 

176 url=next_url, 

177 target=f"#{Zones.DATA.id}", 

178 swap="beforeend", 

179 ) 

180 return el("div", stack, trigger.render()) 

181 

182 return stack