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

116 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 15:04 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

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

6from lexigram.ui import ( 

7 ActionButton, 

8 Component, 

9 GroupBySwitcher, 

10 LayoutSwitcher, 

11 SearchBar, 

12 ViewSwitcher, 

13 Zones, 

14 el, 

15) 

16 

17 

18class TableToolbar(Component): 

19 """ 

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

21 """ 

22 

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

24 self.config = config 

25 self.state = state 

26 

27 def render(self) -> Any: 

28 return el( 

29 "div", 

30 self.render_header(), 

31 self.render_search(), 

32 self.render_filters(), 

33 ) 

34 

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

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

37 header_buttons = [] 

38 for action in self.config.header_actions: 

39 if not action.is_visible(None): 

40 continue 

41 

42 # Old-style (lexigram.ui) header action: inject default create URL 

43 # and render via the shared old/new action bridge. 

44 from lexigram.ui import Action as OldActionBase 

45 

46 if isinstance(action, OldActionBase) and not hasattr(action, "_get_url"): 

47 if ( 

48 not action.get_url() 

49 and not action.get_hx_get() 

50 and not action.get_hx_post() 

51 and not action.get_hx_delete() 

52 ): 

53 prefix = self.config.resource_prefix or "" 

54 action.hx(get=f"{prefix.rstrip('/')}/create") 

55 

56 from lexigram.admin.ui.organisms.data_table.actions import ( 

57 render_action_button, 

58 ) 

59 

60 node = render_action_button( 

61 action, 

62 resource_name=self.config.resource_name, 

63 resource_prefix=self.config.resource_prefix, 

64 ) 

65 if node: 

66 header_buttons.append(node) 

67 continue 

68 

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

70 

71 ctx = _ActionContext( 

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

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

74 ) 

75 url = action._get_url(None, ctx) 

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

77 

78 btn = ActionButton( 

79 label=action.label, 

80 icon=action.icon, 

81 variant=action._color_to_variant(), 

82 **htmx_attrs, 

83 ) 

84 header_buttons.append(btn.render()) 

85 

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

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

88 bulk_action_items = [] 

89 if bulk_actions: 

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

91 bulk_action_items.append( 

92 el( 

93 "span", 

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

95 " selected on this page", 

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

97 ), 

98 ) 

99 

100 for action in bulk_actions: 

101 if not action.is_visible(None): 

102 continue 

103 

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

105 from lexigram.ui import HTMXAttrs 

106 

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

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

109 

110 if _hx_delete or _hx_post: 

111 # Old-style action — use HTMXAttrs helper 

112 _confirmation_message = getattr( 

113 action, "_confirmation_message", None 

114 ) 

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

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

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

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

119 

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

121 _url = _hx_delete or _hx_post or "" 

122 

123 bulk_attrs = HTMXAttrs.for_bulk_action( 

124 url=_url, 

125 method=_method, 

126 confirm_message=_confirmation_message or _confirmation_title, 

127 action_name=_action_name, 

128 ) 

129 

130 _variant = ( 

131 "secondary" 

132 if _color == "primary" 

133 else ( 

134 _color 

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

136 else "secondary" 

137 ) 

138 ) 

139 btn = ActionButton( 

140 label=action.label, 

141 color=_variant, 

142 icon=_icon, 

143 size="sm", 

144 type="button", 

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

146 ) 

147 else: 

148 # New-style action — use _get_url + _get_htmx_attrs 

149 ctx = _ActionContext( 

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

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

152 ) 

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

154 if hasattr(action, "_get_htmx_attrs"): 

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

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

157 else: 

158 from lexigram.ui import HTMXAttrs 

159 

160 htmx_attrs = HTMXAttrs.for_bulk_action( 

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

162 method="POST", 

163 action_name=action.name, 

164 ) 

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

166 _icon_value = getattr(action, "icon", None) 

167 _icon = ( 

168 _icon_value 

169 if isinstance(_icon_value, str) 

170 else getattr(action, "_icon", None) 

171 ) 

172 if hasattr(action, "_color_to_variant"): 

173 _color = action._color_to_variant() 

174 else: 

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

176 _color = ( 

177 "secondary" 

178 if _raw_color == "primary" 

179 else _raw_color 

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

181 else "secondary" 

182 ) 

183 btn = ActionButton( 

184 label=_label, 

185 icon=_icon, 

186 color=_color, 

187 size="sm", 

188 type="button", 

189 **htmx_attrs, 

190 ) 

191 bulk_action_items.append(btn.render()) 

192 

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

194 bulk_buttons = [] 

195 if bulk_action_items: 

196 bulk_buttons.append( 

197 el( 

198 "div", 

199 *bulk_action_items, 

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

201 x_cloak=True, 

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

203 ), 

204 ) 

205 

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

207 clear_buttons = [] 

208 

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

210 has_search_enabled = self.config.enable_search 

211 has_filters_available = bool(self.config.filters) 

212 

213 if self.config.resource_prefix and ( 

214 has_search_enabled or has_filters_available 

215 ): 

216 # Use new HTMX API for clear button 

217 from lexigram.ui import HTMXAttrs 

218 

219 clear_state = self.state.clear_filters() 

220 clear_attrs = HTMXAttrs.for_full_refresh( 

221 state=clear_state, 

222 resource_prefix=self.config.resource_prefix, 

223 push_url=True, 

224 ) 

225 

226 clear_btn = ActionButton( 

227 label="Clear", 

228 variant="ghost", 

229 icon="x", 

230 size="sm", 

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

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

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

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

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

236 "x-ref": "clearFiltersButton", 

237 }, 

238 ) 

239 clear_buttons.append(clear_btn.render()) 

240 

241 # Global Switchers 

242 layout_switch = LayoutSwitcher( 

243 current=self.state.layout, 

244 resource_prefix=self.config.resource_prefix, 

245 state=self.state, 

246 ) 

247 view_switch = ViewSwitcher( 

248 current=self.state.view, 

249 resource_prefix=self.config.resource_prefix, 

250 state=self.state, 

251 ) 

252 group_by_switch = GroupBySwitcher( 

253 current=self.state.group_by or self.config.group_by, 

254 resource_prefix=self.config.resource_prefix, 

255 columns=self.config.columns, 

256 state=self.state, 

257 ) 

258 

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

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

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

262 return el( 

263 "div", 

264 # Left side: switchers 

265 el( 

266 "div", 

267 layout_switch.render(), 

268 view_switch.render(), 

269 group_by_switch.render(), 

270 *clear_buttons, 

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

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

273 ), 

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

275 el( 

276 "div", 

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

278 *bulk_buttons, 

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

280 *header_buttons, 

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

282 ), 

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

284 id=Zones.TOOLBAR.id, 

285 ) 

286 

287 def render_search(self) -> Any: 

288 if not self.config.enable_search: 

289 return "" 

290 

291 search_query = self.state.search 

292 search_fields = self.config.search_fields or [ 

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

294 ] 

295 search_placeholder = ( 

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

297 ) 

298 

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

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

301 from lexigram.ui import HTMXAttrs 

302 

303 base_attrs = HTMXAttrs.for_live_table_input( 

304 self.state, 

305 self.config.resource_prefix or "", 

306 ) 

307 search_attrs = { 

308 **base_attrs, 

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

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

311 } 

312 

313 search_bar = SearchBar( 

314 name="search", 

315 value=search_query or "", 

316 placeholder=search_placeholder, 

317 show_icon=True, 

318 show_clear=True, 

319 **search_attrs, 

320 ) 

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

322 

323 def render_filters(self) -> Any: 

324 active_filters = self.config.filters 

325 

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

327 return "" # Return empty if no filters needed 

328 

329 # Determine display mode based on layout 

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

331 

332 filter_bar = FilterBar( 

333 filters=active_filters or {}, 

334 current_values=self.state.filters, 

335 resource_prefix=self.config.resource_prefix, 

336 display=fb_display, 

337 state=self.state, 

338 id=Zones.FILTERS.id, 

339 ) 

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

341 

342 def render_switchers_oob(self) -> Any: 

343 """ 

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

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

346 the search bar (preserving focus). 

347 """ 

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

349 

350 # Clear Filters Button 

351 clear_buttons = [] 

352 has_search_enabled = self.config.enable_search 

353 has_filters_available = bool(self.config.filters) 

354 

355 if self.config.resource_prefix and ( 

356 has_search_enabled or has_filters_available 

357 ): 

358 # Use new HTMX API for clear button 

359 from lexigram.ui import HTMXAttrs 

360 

361 clear_state = self.state.clear_filters() 

362 clear_attrs = HTMXAttrs.for_full_refresh( 

363 state=clear_state, 

364 resource_prefix=self.config.resource_prefix, 

365 push_url=True, 

366 ) 

367 

368 clear_btn = ActionButton( 

369 label="Clear", 

370 variant="ghost", 

371 icon="x", 

372 size="sm", 

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

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

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

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

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

378 "x-ref": "clearFiltersButton", 

379 }, 

380 ) 

381 clear_buttons.append(clear_btn.render()) 

382 

383 layout_switch = LayoutSwitcher( 

384 current=self.state.layout, 

385 resource_prefix=self.config.resource_prefix, 

386 state=self.state, 

387 ) 

388 view_switch = ViewSwitcher( 

389 current=self.state.view, 

390 resource_prefix=self.config.resource_prefix, 

391 state=self.state, 

392 ) 

393 group_by_switch = GroupBySwitcher( 

394 current=self.state.group_by or self.config.group_by, 

395 resource_prefix=self.config.resource_prefix, 

396 columns=self.config.columns, 

397 state=self.state, 

398 ) 

399 

400 return el( 

401 "div", 

402 layout_switch.render(), 

403 view_switch.render(), 

404 group_by_switch.render(), 

405 *clear_buttons, 

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

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

408 hx_swap_oob="outerHTML", 

409 )