Coverage for src / lexigram / admin / ui / organisms / table / toolbar.py: 87%

106 statements  

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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.admin.ui.molecules.filter_bar import FilterBar 

6from lexigram.admin.ui.molecules.layout_switcher import LayoutSwitcher 

7from lexigram.admin.ui.molecules.search_bar import SearchBar 

8from lexigram.admin.ui.molecules.view_switcher import ViewSwitcher 

9from lexigram.ui import ActionButton, Component, Zones, el 

10 

11 

12class TableToolbar(Component): 

13 """ 

14 Handles the top area of the DataTable: Actions, Switchers, Search, Filters. 

15 """ 

16 

17 def __init__(self, config: Any, state: Any): 

18 self.config = config 

19 self.state = state 

20 

21 def render(self) -> Any: 

22 return el( 

23 "div", 

24 self.render_header(), 

25 self.render_search(), 

26 self.render_filters(), 

27 ) 

28 

29 def render_header(self, bulk_actions: list[Any] | None = None) -> Any: 

30 # 1. Header Actions (Create New, etc.) - Always visible but grouped with Bulk 

31 header_buttons = [] 

32 for action in self.config.header_actions: 

33 if not action.is_visible(None): 

34 continue 

35 from lexigram.admin.actions.types import ActionContext as _ActionContext 

36 

37 ctx = _ActionContext( 

38 resource_name=self.config.resource_name or "", 

39 resource_prefix=self.config.resource_prefix or "", 

40 ) 

41 url = action._get_url(None, ctx) 

42 htmx_attrs = action._get_htmx_attrs(url, None, ctx) if url else {} 

43 

44 btn = ActionButton( 

45 label=action.label, 

46 icon=action.icon, 

47 variant=action._color_to_variant(), 

48 **htmx_attrs, 

49 ) 

50 header_buttons.append(btn.render()) 

51 

52 # 2. Bulk Actions (shown only when items selected) 

53 # Wrap all bulk actions in a single container with x-show/x-cloak for efficiency 

54 bulk_action_items = [] 

55 if bulk_actions: 

56 # Selection counter — page-scoped, not cross-page 

57 bulk_action_items.append( 

58 el( 

59 "span", 

60 el("strong", x_text="selectedIds.length"), 

61 " selected on this page", 

62 class_="text-sm font-medium text-primary-600 dark:text-primary-400 mr-2", 

63 ), 

64 ) 

65 

66 for action in bulk_actions: 

67 if not action.is_visible(None): 

68 continue 

69 

70 from lexigram.admin.actions.types import ActionContext as _ActionContext 

71 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

72 

73 _hx_delete = getattr(action, "_hx_delete", None) 

74 _hx_post = getattr(action, "_hx_post", None) 

75 

76 if _hx_delete or _hx_post: 

77 # Old-style action — use HTMXAttrs helper 

78 _confirmation_message = getattr( 

79 action, "_confirmation_message", None 

80 ) 

81 _confirmation_title = getattr(action, "_confirmation_title", None) 

82 _color = getattr(action, "_color", None) 

83 _icon = getattr(action, "_icon", None) 

84 _action_name = getattr(action, "name", "") 

85 

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

87 _url = _hx_delete or _hx_post or "" 

88 

89 bulk_attrs = HTMXAttrs.for_bulk_action( 

90 url=_url, 

91 method=_method, 

92 confirm_message=_confirmation_message or _confirmation_title, 

93 action_name=_action_name, 

94 ) 

95 

96 _variant = ( 

97 "secondary" 

98 if _color == "primary" 

99 else ( 

100 _color 

101 if _color in ("secondary", "danger", "ghost") 

102 else "secondary" 

103 ) 

104 ) 

105 btn = ActionButton( 

106 label=action.label, 

107 color=_variant, 

108 icon=_icon, 

109 size="sm", 

110 type="button", 

111 **bulk_attrs, # type: ignore[arg-type] 

112 ) 

113 else: 

114 # New-style action — use _get_url + _get_htmx_attrs 

115 ctx = _ActionContext( 

116 resource_name=self.config.resource_name or "", 

117 resource_prefix=self.config.resource_prefix or "", 

118 ) 

119 url = f"{ctx.resource_prefix}/bulk" 

120 if hasattr(action, "_get_htmx_attrs"): 

121 htmx_attrs = action._get_htmx_attrs(url, None, ctx) 

122 htmx_attrs["hx-vals"] = f'{{"action":"{action.name}"}}' 

123 else: 

124 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

125 

126 htmx_attrs = HTMXAttrs.for_bulk_action( 

127 url=f"{url}/{action.name}", 

128 method="POST", 

129 action_name=action.name, 

130 ) 

131 _label = getattr(action, "label", None) or action.name 

132 _icon = getattr(action, "icon", getattr(action, "_icon", None)) 

133 if hasattr(action, "_color_to_variant"): 

134 _color = action._color_to_variant() 

135 else: 

136 _raw_color = getattr(action, "_color", "secondary") 

137 _color = ( 

138 "secondary" 

139 if _raw_color == "primary" 

140 else _raw_color 

141 if _raw_color in ("secondary", "danger", "ghost") 

142 else "secondary" 

143 ) 

144 btn = ActionButton( 

145 label=_label, 

146 icon=_icon, 

147 color=_color, 

148 size="sm", 

149 type="button", 

150 **htmx_attrs, 

151 ) 

152 bulk_action_items.append(btn.render()) 

153 

154 # Wrap bulk actions in a single hidden container (x-cloak + x-show on container) 

155 bulk_buttons = [] 

156 if bulk_action_items: 

157 bulk_buttons.append( 

158 el( 

159 "div", 

160 *bulk_action_items, 

161 class_="flex items-center gap-2", 

162 x_cloak=True, 

163 **{"x-show": "selectedIds.length > 0"}, 

164 ), 

165 ) 

166 

167 # 3. Clear Filters Button (shown when filters/search are available) 

168 clear_buttons = [] 

169 

170 # Show clear button when search is enabled or filters are available 

171 has_search_enabled = self.config.enable_search 

172 has_filters_available = bool(self.config.filters) 

173 

174 if self.config.resource_prefix and ( 

175 has_search_enabled or has_filters_available 

176 ): 

177 # Use new HTMX API for clear button 

178 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

179 

180 clear_state = self.state.clear_filters() 

181 clear_attrs = HTMXAttrs.for_full_refresh( 

182 state=clear_state, 

183 resource_prefix=self.config.resource_prefix, 

184 push_url=True, 

185 ) 

186 

187 clear_btn = ActionButton( 

188 label="Clear", 

189 variant="ghost", 

190 icon="x", 

191 size="sm", 

192 **clear_attrs, # type: ignore[arg-type] 

193 **{ # type: ignore[arg-type] 

194 "x-bind:class": "{ 'opacity-50 cursor-not-allowed': !hasActiveFiltersState }", 

195 "x-bind:disabled": "!hasActiveFiltersState", 

196 "@click": "if (!hasActiveFiltersState) $event.preventDefault()", 

197 "x-ref": "clearFiltersButton", 

198 }, 

199 ) 

200 clear_buttons.append(clear_btn.render()) 

201 

202 # Global Switchers 

203 layout_switch = LayoutSwitcher( 

204 current=self.state.layout, 

205 resource_prefix=self.config.resource_prefix, 

206 state=self.state, 

207 ) 

208 view_switch = ViewSwitcher( 

209 current=self.state.view, 

210 resource_prefix=self.config.resource_prefix, 

211 state=self.state, 

212 ) 

213 

214 # Structure: [Left: Switchers] [Right: Bulk Actions | Header Buttons] 

215 # - Bulk actions hidden until selected (x-show/x-cloak) 

216 # - Header buttons (Create) ALWAYS visible, ALWAYS on the right 

217 return el( 

218 "div", 

219 # Left side: switchers 

220 el( 

221 "div", 

222 layout_switch.render(), 

223 view_switch.render(), 

224 *clear_buttons, 

225 class_="flex items-center gap-2", 

226 id=Zones.TOOLBAR.id + "-switchers", 

227 ), 

228 # Right side: bulk actions (hidden) + header buttons (always visible) 

229 el( 

230 "div", 

231 # Bulk actions - only these have x-cloak/x-show 

232 *bulk_buttons, 

233 # Header buttons (Create) - NO x-cloak, always visible 

234 *header_buttons, 

235 class_="flex items-center gap-2", 

236 ), 

237 class_="flex items-center justify-between mb-2 pb-2 border-b border-border", 

238 id=Zones.TOOLBAR.id, 

239 ) 

240 

241 def render_search(self) -> Any: 

242 if not self.config.enable_search: 

243 return "" 

244 

245 search_query = self.state.search 

246 search_fields = self.config.search_fields or [ 

247 c.name for c in self.config.columns if getattr(c, "_searchable", False) 

248 ] 

249 search_placeholder = ( 

250 f"Search by {', '.join(search_fields)}..." if search_fields else "Search..." 

251 ) 

252 

253 # Use canonical live-table-input attrs (hx-include for state + search), 

254 # appending filters zone so filter values are preserved on search. 

255 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

256 

257 base_attrs = HTMXAttrs.for_live_table_input( 

258 self.state, 

259 self.config.resource_prefix or "", 

260 ) 

261 search_attrs = { 

262 **base_attrs, 

263 "hx-trigger": "keyup changed delay:500ms, input, search", 

264 "hx-include": f"{base_attrs['hx-include']}, #{Zones.FILTERS.id}", 

265 } 

266 

267 search_bar = SearchBar( 

268 name="search", 

269 value=search_query or "", 

270 placeholder=search_placeholder, 

271 show_icon=True, 

272 show_clear=True, 

273 **search_attrs, 

274 ) 

275 return el("div", search_bar.render(), class_="flex-1 mb-2", id=Zones.SEARCH.id) 

276 

277 def render_filters(self) -> Any: 

278 active_filters = self.config.filters 

279 

280 if not (self.config.resource_prefix and active_filters): 

281 return "" # Return empty if no filters needed 

282 

283 # Determine display mode based on layout 

284 fb_display = "vertical" if self.state.layout == "sidebar" else "horizontal" 

285 

286 filter_bar = FilterBar( 

287 filters=active_filters or {}, 

288 current_values=self.state.filters, 

289 resource_prefix=self.config.resource_prefix, 

290 display=fb_display, 

291 state=self.state, 

292 id=Zones.FILTERS.id, 

293 ) 

294 return el("div", filter_bar.render(), class_="mb-4") 

295 

296 def render_switchers_oob(self) -> Any: 

297 """ 

298 Render just the switchers part of the toolbar with OOB swap. 

299 This allows updating the switcher links (state) without re-rendering 

300 the search bar (preserving focus). 

301 """ 

302 # Re-create global switchers logic (unfortunately duped, but cleaner than breaking render_header apart) 

303 

304 # Clear Filters Button 

305 clear_buttons = [] 

306 has_search_enabled = self.config.enable_search 

307 has_filters_available = bool(self.config.filters) 

308 

309 if self.config.resource_prefix and ( 

310 has_search_enabled or has_filters_available 

311 ): 

312 # Use new HTMX API for clear button 

313 from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

314 

315 clear_state = self.state.clear_filters() 

316 clear_attrs = HTMXAttrs.for_full_refresh( 

317 state=clear_state, 

318 resource_prefix=self.config.resource_prefix, 

319 push_url=True, 

320 ) 

321 

322 clear_btn = ActionButton( 

323 label="Clear", 

324 variant="ghost", 

325 icon="x", 

326 size="sm", 

327 **clear_attrs, # type: ignore[arg-type] 

328 **{ # type: ignore[arg-type] 

329 "x-bind:class": "{ 'opacity-50 cursor-not-allowed': !hasActiveFiltersState }", 

330 "x-bind:disabled": "!hasActiveFiltersState", 

331 "@click": "if (!hasActiveFiltersState) $event.preventDefault()", 

332 "x-ref": "clearFiltersButton", 

333 }, 

334 ) 

335 clear_buttons.append(clear_btn.render()) 

336 

337 layout_switch = LayoutSwitcher( 

338 current=self.state.layout, 

339 resource_prefix=self.config.resource_prefix, 

340 state=self.state, 

341 ) 

342 view_switch = ViewSwitcher( 

343 current=self.state.view, 

344 resource_prefix=self.config.resource_prefix, 

345 state=self.state, 

346 ) 

347 

348 return el( 

349 "div", 

350 layout_switch.render(), 

351 view_switch.render(), 

352 *clear_buttons, 

353 class_="flex items-center gap-2", 

354 id=Zones.TOOLBAR.id + "-switchers", 

355 hx_swap_oob="outerHTML", 

356 )