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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 15:04 +0800
1from __future__ import annotations
3from typing import Any
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)
18class TableToolbar(Component):
19 """
20 Handles the top area of the DataTable: Actions, Switchers, Search, Filters.
21 """
23 def __init__(self, config: Any, state: Any):
24 self.config = config
25 self.state = state
27 def render(self) -> Any:
28 return el(
29 "div",
30 self.render_header(),
31 self.render_search(),
32 self.render_filters(),
33 )
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
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
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")
56 from lexigram.admin.ui.organisms.data_table.actions import (
57 render_action_button,
58 )
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
69 from lexigram.admin.actions.types import ActionContext as _ActionContext
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 {}
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())
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 )
100 for action in bulk_actions:
101 if not action.is_visible(None):
102 continue
104 from lexigram.admin.actions.types import ActionContext as _ActionContext
105 from lexigram.ui import HTMXAttrs
107 _hx_delete = getattr(action, "_hx_delete", None)
108 _hx_post = getattr(action, "_hx_post", None)
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", "")
120 _method = "DELETE" if _hx_delete else "POST"
121 _url = _hx_delete or _hx_post or ""
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 )
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
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())
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 )
206 # 3. Clear Filters Button (shown when filters/search are available)
207 clear_buttons = []
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)
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
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 )
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())
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 )
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 )
287 def render_search(self) -> Any:
288 if not self.config.enable_search:
289 return ""
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 )
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
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 }
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)
323 def render_filters(self) -> Any:
324 active_filters = self.config.filters
326 if not (self.config.resource_prefix and active_filters):
327 return "" # Return empty if no filters needed
329 # Determine display mode based on layout
330 fb_display = "vertical" if self.state.layout == "sidebar" else "horizontal"
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")
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)
350 # Clear Filters Button
351 clear_buttons = []
352 has_search_enabled = self.config.enable_search
353 has_filters_available = bool(self.config.filters)
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
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 )
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())
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 )
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 )