Coverage for src / lexigram / admin / ui / actions / base.py: 25%

154 statements  

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

1""" 

2Base Action classes for DataTable interactions. 

3 

4Provides a fluent API for defining row actions, header actions, and bulk actions. 

5Consistent with the Column system's builder pattern. 

6""" 

7 

8from __future__ import annotations 

9 

10from enum import Enum 

11from typing import TYPE_CHECKING, Any, Self 

12 

13from lexigram.ui import Zones 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Callable 

17 

18 

19class ActionTarget(str, Enum): 

20 """Where the action result should be rendered.""" 

21 

22 DATA = "data" # Refresh data zone 

23 MODAL = "modal" # Open in modal 

24 SLIDE_OVER = "slide_over" # Open in side panel 

25 ROW = "row" # Target the row itself 

26 FULL_TABLE = "full_table" # Full table refresh 

27 EXTERNAL = "external" # New tab/window 

28 

29 

30class Action: 

31 """Base class for all actions (row, header, etc.). 

32 

33 Implements a fluent API for configuration. 

34 

35 Example: 

36 >>> Action("approve") 

37 ... .icon("check") 

38 ... .color("success") 

39 ... .requires_confirmation() 

40 """ 

41 

42 def __init__(self, name: str, label: str | None = None): 

43 """ 

44 Initialize an action. 

45 

46 Args: 

47 name: Unique identifier for the action 

48 label: Display label (defaults to title-cased name) 

49 """ 

50 self.name = name 

51 self.label = label or name.replace("_", " ").title() 

52 self._icon: str | None = None 

53 self._icon_position: str = "left" 

54 self._color: str = "primary" # primary, success, warning, danger, info, gray 

55 self._url: str | Callable | None = None 

56 self._action: Callable | None = None # HTMX or callback 

57 self._visible: bool = True 

58 self._visible_callback: Callable | None = None 

59 self._disabled: bool = False 

60 self._disabled_callback: Callable | None = None 

61 self._requires_confirmation: bool = False 

62 self._confirmation_title: str = "Are you sure?" 

63 self._confirmation_message: str | None = None 

64 self._open_modal: bool = False 

65 self._open_slide_over: bool = False 

66 self._modal_component: str | None = None 

67 self._target: str = "_self" # _self, _blank 

68 

69 # HTMX specifics 

70 # HTMX specifics 

71 self._hx_get: str | None = None 

72 self._hx_post: str | None = None 

73 self._hx_delete: str | None = None 

74 self._hx_target: str | None = None 

75 self._hx_swap: str = "outerHTML" 

76 self._hx_push_url: str | None = None 

77 

78 def icon(self, icon: str, position: str = "left") -> Self: 

79 """Set action icon.""" 

80 self._icon = icon 

81 self._icon_position = position 

82 return self 

83 

84 def color(self, color: str) -> Self: 

85 """Set button color variant (primary, success, danger, etc.).""" 

86 self._color = color 

87 return self 

88 

89 def danger(self) -> Self: 

90 """Set action color to danger.""" 

91 return self.color("danger") 

92 

93 def success(self) -> Self: 

94 """Set action color to success.""" 

95 return self.color("success") 

96 

97 def warning(self) -> Self: 

98 """Set action color to warning.""" 

99 return self.color("warning") 

100 

101 def info(self) -> Self: 

102 """Set action color to info.""" 

103 return self.color("info") 

104 

105 def gray(self) -> Self: 

106 """Set action color to gray.""" 

107 return self.color("gray") 

108 

109 def url(self, url: str | Callable, target: str = "_self") -> Self: 

110 """Set URL for navigation actions.""" 

111 self._url = url 

112 self._target = target 

113 return self 

114 

115 def action(self, callback: Callable) -> Self: 

116 """Set backend action handler.""" 

117 self._action = callback 

118 return self 

119 

120 def open_modal(self, component: str | None = None) -> Self: 

121 """Open a modal when clicked.""" 

122 self._open_modal = True 

123 self._modal_component = component 

124 self._hx_push_url = "false" 

125 return self 

126 

127 def slide_over(self) -> Self: 

128 """Open in a side panel (SlideOver) when clicked.""" 

129 self._open_slide_over = True 

130 self._hx_target = Zones.SLIDE_OVER.selector 

131 self._hx_swap = Zones.SLIDE_OVER.swap_mode.value 

132 self._hx_push_url = "false" 

133 return self 

134 

135 def requires_confirmation( 

136 self, 

137 title: str = "Are you sure?", 

138 message: str | None = None, 

139 ) -> Self: 

140 """Require confirmation before execution.""" 

141 self._requires_confirmation = True 

142 self._confirmation_title = title 

143 self._confirmation_message = message 

144 return self 

145 

146 def visible(self, visible: bool | Callable = True) -> Self: 

147 """Control visibility.""" 

148 if callable(visible): 

149 self._visible_callback = visible 

150 else: 

151 self._visible = visible 

152 return self 

153 

154 def disabled(self, disabled: bool | Callable = True) -> Self: 

155 """Control enabled/disabled state.""" 

156 if callable(disabled): 

157 self._disabled_callback = disabled 

158 else: 

159 self._disabled = disabled 

160 return self 

161 

162 # HTMX Shortcuts for advanced usage 

163 def hx( 

164 self, 

165 get: str | None = None, 

166 post: str | None = None, 

167 delete: str | None = None, 

168 target: str | None = None, 

169 swap: str | None = None, 

170 push_url: str | None = None, 

171 ) -> Self: 

172 """Configure HTMX attributes manually.""" 

173 if get: 

174 self._hx_get = get 

175 if post: 

176 self._hx_post = post 

177 if delete: 

178 self._hx_delete = delete 

179 if target: 

180 self._hx_target = target 

181 if swap: 

182 self._hx_swap = swap 

183 if push_url: 

184 self._hx_push_url = push_url 

185 return self 

186 

187 # Logic methods 

188 def is_visible( 

189 self, 

190 user: Any = None, 

191 resource_name: str | None = None, 

192 record: dict | Any | None = None, 

193 permission_service: Any = None, 

194 ) -> bool: 

195 """Check if action should be visible.""" 

196 # 1. Check callback if set 

197 if self._visible_callback: 

198 return self._visible_callback(record or {}) 

199 

200 # 2. Check PermissionService if user, resource_name, and service are provided 

201 if user and resource_name and permission_service is not None: 

202 # Ensure we are not accidentally treating record as user 

203 if ( 

204 hasattr(user, "roles") or hasattr(user, "user_id") 

205 ) and not permission_service.can_perform_action( 

206 user, 

207 resource_name, 

208 self.name, 

209 ): 

210 return False 

211 

212 return self._visible 

213 

214 def is_disabled(self, record: dict | None = None) -> bool: 

215 """Check if action should be disabled.""" 

216 if self._disabled_callback: 

217 return self._disabled_callback(record or {}) 

218 return self._disabled 

219 

220 def get_url(self, record: dict | None = None) -> str | None: 

221 """Resolve URL if it's a callable.""" 

222 if callable(self._url): 

223 return self._url(record or {}) 

224 return self._url 

225 

226 def get_hx_get(self) -> str | None: 

227 """Get the HTMX GET URL.""" 

228 return self._hx_get 

229 

230 def get_hx_post(self) -> str | None: 

231 """Get the HTMX POST URL.""" 

232 return self._hx_post 

233 

234 def get_hx_delete(self) -> str | None: 

235 """Get the HTMX DELETE URL.""" 

236 return self._hx_delete 

237 

238 def render( 

239 self, 

240 record: dict | Any | None = None, 

241 user: Any = None, 

242 resource_name: str | None = None, 

243 ) -> Any: 

244 """Render action as a button using ActionButton.""" 

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

246 return "" 

247 

248 from lexigram.ui import ActionButton 

249 

250 url = self.get_url(record) 

251 

252 # Helper for dynamic attribute formatting 

253 def fmt(val: str | None) -> str | None: 

254 if val and record: 

255 try: 

256 data = record if isinstance(record, dict) else vars(record) 

257 

258 # Use fallback for user_id/pk if id is missing in the record object 

259 if "{id}" in val and "id" not in data: 

260 for fallback in ["user_id", "pk"]: 

261 if hasattr(record, fallback): 

262 data = {**data, "id": getattr(record, fallback)} 

263 break 

264 

265 return val.format(**data) 

266 except (KeyError, ValueError, TypeError): 

267 # Fallback if keys missing or format invalid 

268 return val 

269 return val 

270 

271 # Determine HTMX params with formatting 

272 hx_delete = fmt(self._hx_delete) 

273 hx_get = fmt(self._hx_get) 

274 hx_post = fmt(self._hx_post) 

275 

276 # Build CSS classes based on color 

277 color_map = { 

278 "primary": "primary", 

279 "success": "green", 

280 "danger": "red", 

281 "warning": "yellow", 

282 "info": "blue", 

283 "gray": "gray", 

284 } 

285 color = color_map.get(self._color, self._color) 

286 text_color_class = ( 

287 f"text-{color}-600 hover:text-{color}-900 dark:text-{color}-400" 

288 ) 

289 

290 return ActionButton( 

291 label=self.label, 

292 variant="ghost", # Actions in table usually ghost 

293 icon=self._icon, 

294 size="sm", 

295 href=url, 

296 hx_delete=hx_delete, 

297 hx_get=hx_get, 

298 hx_post=hx_post, 

299 hx_target=self._hx_target, 

300 hx_swap=self._hx_swap, 

301 hx_push_url=self._hx_push_url, 

302 hx_confirm=self._confirmation_message or self._confirmation_title 

303 if self._requires_confirmation 

304 else None, 

305 class_=text_color_class, 

306 ).render() 

307 

308 

309class BulkAction(Action): 

310 """Action that applies to multiple selected items.""" 

311 

312 def __init__(self, name: str, label: str | None = None): 

313 super().__init__(name, label) 

314 self._deselect_after: bool = True 

315 

316 def deselect_after(self, deselect: bool = True) -> Self: 

317 """Deselect all items after action completes.""" 

318 self._deselect_after = deselect 

319 return self