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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""
2Base Action classes for DataTable interactions.
4Provides a fluent API for defining row actions, header actions, and bulk actions.
5Consistent with the Column system's builder pattern.
6"""
8from __future__ import annotations
10from enum import Enum
11from typing import TYPE_CHECKING, Any, Self
13from lexigram.ui import Zones
15if TYPE_CHECKING:
16 from collections.abc import Callable
19class ActionTarget(str, Enum):
20 """Where the action result should be rendered."""
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
30class Action:
31 """Base class for all actions (row, header, etc.).
33 Implements a fluent API for configuration.
35 Example:
36 >>> Action("approve")
37 ... .icon("check")
38 ... .color("success")
39 ... .requires_confirmation()
40 """
42 def __init__(self, name: str, label: str | None = None):
43 """
44 Initialize an action.
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
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
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
84 def color(self, color: str) -> Self:
85 """Set button color variant (primary, success, danger, etc.)."""
86 self._color = color
87 return self
89 def danger(self) -> Self:
90 """Set action color to danger."""
91 return self.color("danger")
93 def success(self) -> Self:
94 """Set action color to success."""
95 return self.color("success")
97 def warning(self) -> Self:
98 """Set action color to warning."""
99 return self.color("warning")
101 def info(self) -> Self:
102 """Set action color to info."""
103 return self.color("info")
105 def gray(self) -> Self:
106 """Set action color to gray."""
107 return self.color("gray")
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
115 def action(self, callback: Callable) -> Self:
116 """Set backend action handler."""
117 self._action = callback
118 return self
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
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
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
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
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
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
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 {})
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
212 return self._visible
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
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
226 def get_hx_get(self) -> str | None:
227 """Get the HTMX GET URL."""
228 return self._hx_get
230 def get_hx_post(self) -> str | None:
231 """Get the HTMX POST URL."""
232 return self._hx_post
234 def get_hx_delete(self) -> str | None:
235 """Get the HTMX DELETE URL."""
236 return self._hx_delete
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 ""
248 from lexigram.ui import ActionButton
250 url = self.get_url(record)
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)
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
265 return val.format(**data)
266 except (KeyError, ValueError, TypeError):
267 # Fallback if keys missing or format invalid
268 return val
269 return val
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)
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 )
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()
309class BulkAction(Action):
310 """Action that applies to multiple selected items."""
312 def __init__(self, name: str, label: str | None = None):
313 super().__init__(name, label)
314 self._deselect_after: bool = True
316 def deselect_after(self, deselect: bool = True) -> Self:
317 """Deselect all items after action completes."""
318 self._deselect_after = deselect
319 return self