Coverage for src / lexigram / admin / ui / columns / column / rendering.py: 60%

75 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 17:07 +0800

1""" 

2Column rendering methods for HTML generation. 

3""" 

4 

5from __future__ import annotations 

6 

7from typing import TYPE_CHECKING, Any 

8 

9from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

10from lexigram.ui import Zones, el 

11 

12if TYPE_CHECKING: 

13 from lexigram.ui.state import TableState 

14 

15 

16class ColumnRenderingMixin: 

17 """Mixin class containing rendering methods.""" 

18 

19 # Attributes expected to be provided by Column base class 

20 name: str 

21 label: str 

22 _masker: Any 

23 _limit: int | None 

24 _alignment: str 

25 _visibility_classes: list[str] 

26 _wrap: bool 

27 _copyable: bool 

28 _sortable: bool 

29 

30 def is_visible(self, **kwargs: Any) -> bool: 

31 """Check if column is visible — implemented by ColumnVisibilityMixin.""" 

32 return True 

33 

34 def get_value(self, record: dict) -> Any: 

35 """Extract value from record — implemented by Column.""" 

36 

37 def format_value(self, value: Any) -> Any: 

38 """Format value — implemented by Column.""" 

39 return value 

40 

41 def render(self, value: Any, record: dict) -> Any: 

42 """Render cell value — implemented by Column.""" 

43 

44 def render_cell( 

45 self, 

46 record: dict, 

47 user: Any = None, 

48 resource_name: str | None = None, 

49 ) -> Any: 

50 """ 

51 Render complete table cell with wrapper. 

52 

53 Args: 

54 record: The full record dict 

55 user: Current user for permission checks 

56 resource_name: Current resource name for permission checks 

57 

58 Returns: 

59 htpy element for <td> 

60 """ 

61 if not self.is_visible(user=user, resource_name=resource_name, record=record): 

62 return "" 

63 

64 value = self.get_value(record) 

65 formatted_value = self.format_value(value) 

66 

67 # Apply masking if defined 

68 if self._masker and formatted_value is not None: 

69 formatted_value = self._masker(formatted_value) 

70 

71 # Apply limit if set 

72 if ( 

73 self._limit 

74 and isinstance(formatted_value, str) 

75 and len(formatted_value) > self._limit 

76 ): 

77 formatted_value = formatted_value[: self._limit] + "..." 

78 

79 # Render the value 

80 content = self.render(formatted_value, record) 

81 

82 # Build CSS classes 

83 classes = [f"text-{self._alignment}", "px-6", "py-4"] 

84 classes.extend(self._visibility_classes) 

85 if self._wrap: 

86 classes.append("whitespace-normal") 

87 else: 

88 classes.append("whitespace-nowrap") 

89 

90 # Add copyable functionality 

91 if self._copyable: 

92 classes.append("cursor-pointer hover:bg-muted dark:hover:bg-card") 

93 escaped_value = str(value).replace("'", "\\'") 

94 hx_on_click = f"navigator.clipboard.writeText('{escaped_value}')" 

95 return el( 

96 "td", 

97 content, 

98 class_="".join(classes), 

99 hx_on_click=hx_on_click, 

100 title="Click to copy", 

101 aria_label=f"Copy {value}", 

102 ) 

103 

104 return el("td", content, class_="".join(classes), **{"data-label": self.label}) 

105 

106 def render_header( 

107 self, 

108 current_sort: str | None = None, 

109 sort_order: str = "asc", 

110 state: TableState | None = None, 

111 resource_prefix: str = "", 

112 ) -> Any: 

113 """ 

114 Render table header cell. 

115 

116 Args: 

117 current_sort: Currently sorted column name 

118 sort_order: Current sort order ("asc" or "desc") 

119 state: Optional TableState for generating HTMX URLs 

120 resource_prefix: Base URL for the resource (e.g., "/admin/users") 

121 

122 Returns: 

123 htpy element for <th> 

124 """ 

125 from lexigram.ui import get_icon 

126 

127 classes = [ 

128 f"text-{self._alignment}", 

129 "px-6", 

130 "py-3", 

131 "text-xs", 

132 "font-semibold", 

133 "text-foreground", 

134 "dark:text-foreground", 

135 "uppercase", 

136 "tracking-wider", 

137 ] 

138 classes.extend(self._visibility_classes) 

139 

140 # Base props for the th element 

141 th_props: dict[str, Any] = { 

142 "scope": "col", 

143 } 

144 

145 # Build header content 

146 is_current = current_sort == self.name 

147 

148 if self._sortable: 

149 classes.append("cursor-pointer") 

150 classes.append("hover:bg-muted") 

151 classes.append("dark:hover:bg-muted") 

152 classes.append("select-none") # Prevent text selection on click 

153 classes.append("transition-colors") 

154 

155 # Determine next sort order 

156 next_order = "desc" if (is_current and sort_order == "asc") else "asc" 

157 

158 # Build HTMX attrs directly on the th element (entire header clickable) 

159 if state and resource_prefix: 

160 # Use immutable mutation to get new state with sort 

161 new_state = state.with_sort(self.name) 

162 htmx_attrs = HTMXAttrs.for_data_refresh( 

163 new_state, 

164 resource_prefix, 

165 push_url=True, 

166 ) 

167 # Convert hx-* to hx_* for element builder 

168 for k, v in htmx_attrs.items(): 

169 th_props[k.replace("-", "_")] = v 

170 else: 

171 # Fallback: manual construction 

172 sort_params = f"sort_by={self.name}&sort_order={next_order}" 

173 th_props.update( 

174 { 

175 "hx_get": f"?{sort_params}", 

176 "hx_target": Zones.DATA.selector, 

177 "hx_swap": Zones.DATA.swap_mode.value, 

178 "hx_select": Zones.DATA.selector, 

179 "hx_params": "none", 

180 "hx_push_url": "true", 

181 }, 

182 ) 

183 

184 # Build sort icon 

185 if is_current: 

186 icon_name = "chevron-up" if sort_order == "asc" else "chevron-down" 

187 icon_classes = "h-4 w-4 text-primary-600 dark:text-primary-400" 

188 else: 

189 # Subtle indicator for sortable but not currently sorted 

190 icon_name = "chevrons-up-down" 

191 icon_classes = "h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" 

192 

193 sort_icon = get_icon(icon_name, size=icon_classes) 

194 

195 # Content with label + icon 

196 content = el( 

197 "span", 

198 self.label, 

199 sort_icon, 

200 class_="inline-flex items-center gap-1.5", 

201 ) 

202 else: 

203 # Non-sortable column - just the label 

204 content = el("span", self.label) 

205 

206 resize_handle = el( 

207 "div", 

208 role="separator", 

209 tabindex="0", 

210 aria_orientation="vertical", 

211 aria_label=f"Resize {self.label} column", 

212 class_="absolute right-0 top-0 bottom-0 w-2 cursor-col-resize hover:bg-primary-300 dark:hover:bg-primary-700 transition-colors opacity-0 hover:opacity-100 group-hover:opacity-100", 

213 **{ 

214 "@mousedown.stop.prevent": "startResize", 

215 "@keydown.left.prevent": "if ($el.parentElement) { let th = $el.parentElement; let w = Math.max(50, th.offsetWidth - 10); th.style.width = w + 'px'; th.style.minWidth = w + 'px' }", 

216 "@keydown.right.prevent": "if ($el.parentElement) { let th = $el.parentElement; let w = th.offsetWidth + 10; th.style.width = w + 'px'; th.style.minWidth = w + 'px' }", 

217 }, 

218 ) 

219 

220 # Content wrapper 

221 wrapper = el( 

222 "div", 

223 content, 

224 resize_handle, 

225 class_="flex items-center justify-between w-full h-full", 

226 ) 

227 

228 # Add relative for resize handle positioning, group for hover states 

229 # Sticky positioning for fixed headers 

230 classes.append("relative group sticky top-0 z-20 bg-muted dark:bg-background") 

231 th_props["class_"] = " ".join(classes) 

232 th_props["x-data"] = "resizableColumn" 

233 

234 return el("th", wrapper, **th_props)