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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:23 +0800
1from __future__ import annotations
3from typing import Any
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
12class TableToolbar(Component):
13 """
14 Handles the top area of the DataTable: Actions, Switchers, Search, Filters.
15 """
17 def __init__(self, config: Any, state: Any):
18 self.config = config
19 self.state = state
21 def render(self) -> Any:
22 return el(
23 "div",
24 self.render_header(),
25 self.render_search(),
26 self.render_filters(),
27 )
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
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 {}
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())
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 )
66 for action in bulk_actions:
67 if not action.is_visible(None):
68 continue
70 from lexigram.admin.actions.types import ActionContext as _ActionContext
71 from lexigram.admin.ui.htmx_attrs import HTMXAttrs
73 _hx_delete = getattr(action, "_hx_delete", None)
74 _hx_post = getattr(action, "_hx_post", None)
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", "")
86 _method = "DELETE" if _hx_delete else "POST"
87 _url = _hx_delete or _hx_post or ""
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 )
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
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())
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 )
167 # 3. Clear Filters Button (shown when filters/search are available)
168 clear_buttons = []
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)
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
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 )
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())
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 )
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 )
241 def render_search(self) -> Any:
242 if not self.config.enable_search:
243 return ""
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 )
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
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 }
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)
277 def render_filters(self) -> Any:
278 active_filters = self.config.filters
280 if not (self.config.resource_prefix and active_filters):
281 return "" # Return empty if no filters needed
283 # Determine display mode based on layout
284 fb_display = "vertical" if self.state.layout == "sidebar" else "horizontal"
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")
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)
304 # Clear Filters Button
305 clear_buttons = []
306 has_search_enabled = self.config.enable_search
307 has_filters_available = bool(self.config.filters)
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
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 )
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())
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 )
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 )