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

1"""Action configuration and management for data table component.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from typing import Any 

7 

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 

18 

19OLD_ACTION_TYPES = (OldActionBase,) 

20 

21 

22@dataclass 

23class ActionDescriptor: 

24 """Normalized action descriptor used for rendering both old and new actions.""" 

25 

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

41 

42 

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" 

55 

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 ) 

61 

62 hx_swap = getattr(action, "_hx_swap", "innerHTML") 

63 hx_target = getattr(action, "_hx_target", None) 

64 

65 url = None 

66 if hasattr(action, "get_url"): 

67 url = action.get_url() 

68 

69 base_variant = { 

70 "primary": "primary", 

71 "danger": "danger", 

72 "gray": "ghost", 

73 "success": "secondary", 

74 }.get(getattr(action, "_color", "primary"), "ghost") 

75 

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 ) 

93 

94 

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

108 

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 

116 

117 is_bulk = hasattr(action, "task_runner") or "BulkAction" in type(action).__name__ 

118 

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 ) 

129 

130 

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) 

136 

137 

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. 

146 

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) 

151 

152 from lexigram.admin.actions.types import ActionContext 

153 

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) 

160 

161 

162def render_bulk_action_button(action: Any) -> Any: 

163 """Render a bulk action (old or new API) as a button element. 

164 

165 Handles the deprecated private-field access pattern for bulk actions. 

166 """ 

167 if not isinstance(action, OLD_ACTION_TYPES): 

168 return "" 

169 

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", "") 

179 

180 from lexigram.ui import HTMXAttrs 

181 

182 _method = "DELETE" if _hx_delete else "POST" 

183 _url = _hx_delete or _hx_post or "" 

184 

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) 

192 

193 variant = ( 

194 _color if _color in ("primary", "secondary", "danger", "ghost") else "primary" 

195 ) 

196 

197 from lexigram.ui import ActionButton 

198 

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() 

207 

208 

209class ActionManager: 

210 """Manages actions configuration for data table.""" 

211 

212 def __init__(self, config: TableConfiguration, permissions: dict[str, bool]): 

213 self.config = config 

214 self.permissions = permissions 

215 

216 def configure_actions(self) -> None: 

217 """Configure actions based on permissions and configuration.""" 

218 if not self.config.resource_prefix: 

219 return 

220 

221 # Fill in missing URLs for existing standard actions 

222 self._configure_existing_actions() 

223 

224 # Add default actions if none provided 

225 self._add_default_actions() 

226 

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. 

232 

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

238 

239 if self.permissions["can_update"]: 

240 self.config.actions.append(EditAction(label="")) 

241 

242 if self.permissions["can_delete"]: 

243 self.config.actions.append(DeleteAction(label="")) 

244 

245 if not self.config.header_actions and self.permissions["can_create"]: 

246 self.config.header_actions.append(CreateAction(label="Create New")) 

247 

248 if not self.config.bulk_actions and self.permissions["can_delete"]: 

249 self.config.bulk_actions.append(DeleteBulkAction(label="Delete Selected"))