Coverage for src / lexigram / admin / ui / organisms / data_table / actions.py: 31%
112 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Action configuration and management for data table component."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from typing import Any
8from lexigram.admin.actions.standard import (
9 CreateAction,
10 DeleteAction,
11 DeleteBulkAction,
12 EditAction,
13 ViewAction,
14)
15from lexigram.admin.actions.types import ActionColor
16from lexigram.admin.config import TableConfiguration
17from lexigram.ui import Action as OldActionBase
19OLD_ACTION_TYPES = (OldActionBase,)
22@dataclass
23class ActionDescriptor:
24 """Normalized action descriptor used for rendering both old and new actions."""
26 label: str
27 name: str
28 icon: str | None = None
29 color: str = "primary"
30 visible: bool = True
31 url: str | None = None
32 hx_get: str | None = None
33 hx_post: str | None = None
34 hx_delete: str | None = None
35 hx_target: str | None = None
36 hx_swap: str | None = "innerHTML"
37 hx_confirm: str | None = None
38 hx_include: str | None = None
39 is_bulk: bool = False
40 variant: str = field(default="ghost")
43def _normalize_old_action(action: Any) -> ActionDescriptor:
44 """Normalize a deprecated ui.actions action into ActionDescriptor."""
45 hx_method = "GET"
46 hx_url = getattr(action, "_hx_get", None)
47 if hx_url:
48 hx_method = "GET"
49 elif getattr(action, "_hx_post", None):
50 hx_url = action._hx_post
51 hx_method = "POST"
52 elif getattr(action, "_hx_delete", None):
53 hx_url = action._hx_delete
54 hx_method = "DELETE"
56 confirm = None
57 if getattr(action, "_requires_confirmation", False):
58 confirm = getattr(action, "_confirmation_message", None) or getattr(
59 action, "_confirmation_title", None
60 )
62 hx_swap = getattr(action, "_hx_swap", "innerHTML")
63 hx_target = getattr(action, "_hx_target", None)
65 url = None
66 if hasattr(action, "get_url"):
67 url = action.get_url()
69 base_variant = {
70 "primary": "primary",
71 "danger": "danger",
72 "gray": "ghost",
73 "success": "secondary",
74 }.get(getattr(action, "_color", "primary"), "ghost")
76 return ActionDescriptor(
77 label=action.label,
78 name=action.name,
79 icon=getattr(action, "_icon", None),
80 color=getattr(action, "_color", "primary"),
81 visible=getattr(action, "_visible", True)
82 and (not hasattr(action, "is_visible") or action.is_visible()),
83 url=url,
84 hx_get=hx_url if hx_method == "GET" else None,
85 hx_post=hx_url if hx_method == "POST" else None,
86 hx_delete=hx_url if hx_method == "DELETE" else None,
87 hx_target=hx_target,
88 hx_swap=hx_swap,
89 hx_confirm=confirm,
90 is_bulk=hasattr(action, "_deselect_after"),
91 variant=base_variant if not hx_target else "ghost",
92 )
95def _normalize_new_action(action: Any) -> ActionDescriptor:
96 """Normalize a new lexigram.admin.actions action into ActionDescriptor."""
97 color_map = {
98 ActionColor.GRAY: "gray",
99 ActionColor.PRIMARY: "primary",
100 ActionColor.SECONDARY: "secondary",
101 ActionColor.SUCCESS: "success",
102 ActionColor.WARNING: "warning",
103 ActionColor.DANGER: "danger",
104 ActionColor.INFO: "info",
105 }
106 action_color = getattr(action, "color", ActionColor.GRAY)
107 color_str = color_map.get(action_color, "gray")
109 confirm = None
110 if hasattr(action, "confirm"):
111 cfg = action.confirm()
112 if cfg and cfg.message:
113 confirm = cfg.message
114 elif cfg:
115 confirm = cfg.title
117 is_bulk = hasattr(action, "task_runner") or "BulkAction" in type(action).__name__
119 return ActionDescriptor(
120 label=action.label or action.name,
121 name=action.name,
122 icon=action.icon,
123 color=color_str,
124 url=action._get_url(None, None) if hasattr(action, "_get_url") else None,
125 hx_confirm=confirm,
126 is_bulk=is_bulk,
127 variant="ghost",
128 )
131def normalize_action(action: Any) -> ActionDescriptor:
132 """Normalize any action (old or new API) into ActionDescriptor."""
133 if isinstance(action, OldActionBase):
134 return _normalize_old_action(action)
135 return _normalize_new_action(action)
138def render_action_button(
139 action: Any,
140 record: dict | Any | None = None,
141 user: Any = None,
142 resource_name: str | None = None,
143 resource_prefix: str | None = None,
144) -> Any:
145 """Render any action (old or new API) as a button element.
147 Handles both old ui.actions and new lexigram.admin.actions.
148 """
149 if isinstance(action, OldActionBase):
150 return action.render(record=record, user=user, resource_name=resource_name)
152 from lexigram.admin.actions.types import ActionContext
154 ctx = ActionContext(
155 user=user,
156 resource_name=resource_name or "",
157 resource_prefix=resource_prefix or f"/{resource_name}" if resource_name else "",
158 )
159 return action.render_button(record=record, ctx=ctx)
162def render_bulk_action_button(action: Any) -> Any:
163 """Render a bulk action (old or new API) as a button element.
165 Handles the deprecated private-field access pattern for bulk actions.
166 """
167 if not isinstance(action, OLD_ACTION_TYPES):
168 return ""
170 attrs = {}
171 _hx_delete = getattr(action, "_hx_delete", None)
172 _hx_post = getattr(action, "_hx_post", None)
173 _requires_confirmation = getattr(action, "_requires_confirmation", False)
174 _confirmation_message = getattr(action, "_confirmation_message", None)
175 _confirmation_title = getattr(action, "_confirmation_title", None)
176 _hx_target = getattr(action, "_hx_target", None)
177 _color = getattr(action, "_color", None)
178 _action_name = getattr(action, "name", "")
180 from lexigram.ui import HTMXAttrs
182 _method = "DELETE" if _hx_delete else "POST"
183 _url = _hx_delete or _hx_post or ""
185 bulk_attrs = HTMXAttrs.for_bulk_action(
186 url=_url,
187 method=_method,
188 confirm_message=_confirmation_message or _confirmation_title,
189 action_name=_action_name,
190 )
191 attrs.update(bulk_attrs)
193 variant = (
194 _color if _color in ("primary", "secondary", "danger", "ghost") else "primary"
195 )
197 from lexigram.ui import ActionButton
199 btn = ActionButton(
200 label=action.label,
201 color=variant,
202 size="md",
203 type="button",
204 **attrs, # type: ignore[arg-type]
205 )
206 return btn.render()
209class ActionManager:
210 """Manages actions configuration for data table."""
212 def __init__(self, config: TableConfiguration, permissions: dict[str, bool]):
213 self.config = config
214 self.permissions = permissions
216 def configure_actions(self) -> None:
217 """Configure actions based on permissions and configuration."""
218 if not self.config.resource_prefix:
219 return
221 # Fill in missing URLs for existing standard actions
222 self._configure_existing_actions()
224 # Add default actions if none provided
225 self._add_default_actions()
227 def _configure_existing_actions(self) -> None:
228 """Configure URLs for existing standard actions."""
229 # Note: New standard actions compute their URLs dynamically via ctx.resource_name,
230 # so we don't need to inject `_hx_get` or similar manually here unless they
231 # are explicitly OldActionBase.
233 def _add_default_actions(self) -> None:
234 """Add default actions if none are configured."""
235 if not self.config.actions:
236 if self.permissions.get("can_view", True):
237 self.config.actions.append(ViewAction(label=""))
239 if self.permissions["can_update"]:
240 self.config.actions.append(EditAction(label=""))
242 if self.permissions["can_delete"]:
243 self.config.actions.append(DeleteAction(label=""))
245 if not self.config.header_actions and self.permissions["can_create"]:
246 self.config.header_actions.append(CreateAction(label="Create New"))
248 if not self.config.bulk_actions and self.permissions["can_delete"]:
249 self.config.bulk_actions.append(DeleteBulkAction(label="Delete Selected"))