Coverage for src / lexigram / admin / ui / organisms / data_table / rendering.py: 16%
164 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Main rendering logic for data table component with HTMX/Alpine integration."""
3from __future__ import annotations
5from typing import Any
6from urllib.parse import urlencode
8from lexigram.admin.config import TableConfiguration
9from lexigram.admin.ui.organisms.data_table.actions import ActionManager
10from lexigram.admin.ui.organisms.data_table.layout import LayoutComposer
11from lexigram.admin.ui.organisms.data_table.permissions import PermissionManager
12from lexigram.admin.ui.organisms.data_table.states import StateRenderer
13from lexigram.admin.ui.organisms.data_table.views import ViewFactory
14from lexigram.admin.ui.organisms.pagination import Pagination
15from lexigram.serialization import dumps_str
16from lexigram.ui import TableState, Zones, el, render_to_string
19class DataTableRenderer:
20 """Main renderer for data table component."""
22 def __init__(
23 self,
24 data: list[dict],
25 config: TableConfiguration,
26 state: TableState,
27 total: int | None = None,
28 user: Any = None,
29 loading: bool = False,
30 error: Any = None,
31 next_cursor: str | None = None,
32 summary: dict[str, Any] | None = None,
33 props: dict[str, Any] | None = None,
34 ):
35 self.data = data
36 self.config = config
37 self.state = state
38 self.total = total
39 self.user = user
40 self.loading = loading
41 self.error = error
42 self.next_cursor = next_cursor
43 self.summary = summary
44 self.props = props or {}
46 # Initialize managers
47 self.permission_manager = PermissionManager(user, config.resource_name)
48 self.permissions = self.permission_manager.check_permissions()
50 self.action_manager = ActionManager(config, self.permissions)
51 self.action_manager.configure_actions()
53 self.layout_composer = LayoutComposer(config, state)
54 self.state_renderer = StateRenderer(config, state)
56 # Pre-compute IDs
57 self._all_ids = self._extract_all_ids()
59 def _extract_all_ids(self) -> list[str]:
60 """Extract all record IDs from data."""
61 ids = []
62 for item in self.data:
63 if isinstance(item, dict):
64 item_id = item.get("id", item.get("user_id", item.get("pk")))
65 elif hasattr(item, "id"):
66 item_id = item.id
67 elif hasattr(item, "user_id"):
68 item_id = item.user_id
69 elif hasattr(item, "pk"):
70 item_id = item.pk
71 elif hasattr(item, "__getitem__"):
72 try:
73 item_id = item[0]
74 except (IndexError, TypeError):
75 item_id = None
76 else:
77 item_id = None
79 if item_id is not None:
80 ids.append(str(item_id))
81 else:
82 ids.append("")
84 return ids
86 @property
87 def all_ids_json(self) -> str:
88 """JSON serialization of all IDs for Alpine.js."""
89 return dumps_str(self._all_ids)
91 def render(self) -> str:
92 """Render the complete data table."""
93 # Toolbar sections
94 toolbar = self._render_toolbar()
95 header_section = toolbar.get("header", "")
96 search_section = toolbar.get("search", "")
97 filter_section = toolbar.get("filter", "")
99 is_htmx = self.props.get("htmx_request", False)
101 # Scope tabs — inline for full render, OOB for HTMX data-only
102 tabs_html = ""
103 if not (self.props.get("render_fragment") or is_htmx):
104 tabs_html = self._render_scope_tabs()
106 # View content
107 view_content = self._render_view_content()
109 # Pagination
110 pagination_el = self._render_pagination()
112 # Hidden state inputs
113 hidden_state_inputs = self._render_hidden_inputs()
115 # Table content
116 table_content = el(
117 "div",
118 *hidden_state_inputs,
119 view_content,
120 pagination_el if pagination_el else "",
121 id=Zones.DATA.id,
122 )
124 # HTMX wrapper
125 table_wrapper = self._render_htmx_wrapper(table_content)
127 # Fragment logic
128 if self.props.get("render_fragment", False):
129 return render_to_string(table_wrapper)
131 # HTMX data-only request: return data zone + OOB control fragments
132 if is_htmx:
133 oob_fragments = self._render_oob_fragments()
134 return render_to_string([table_wrapper, *oob_fragments])
136 if self.props.get("render_controls", False):
137 return render_to_string(header_section)
139 # Form wrapper
140 inner_form = el(
141 "form",
142 table_wrapper,
143 method="get",
144 x_ref="bulkForm",
145 class_="space-y-4",
146 **{"@submit.prevent": ""},
147 )
149 # Layout composition
150 container = self.layout_composer.compose(
151 search_section,
152 filter_section,
153 inner_form,
154 )
156 # Script and final markup
157 script = self._render_script()
159 return render_to_string(
160 [
161 script,
162 el(
163 "div",
164 header_section,
165 tabs_html,
166 container,
167 id=Zones.TABLE.id,
168 x_data=f"{{ selectedIds: [], expandedIds: [], collapsedGroups: [], lastSelected: null, focusedId: null, hasActiveFiltersState: false, allIds: {self.all_ids_json}, ...window.LexigramTableLogic }}",
169 **{
170 "@keydown.window": "handleKeydown($event)",
171 "@htmx:after-swap.window": "$nextTick(() => updateActiveFiltersState())",
172 "@input.window": f"if ($event.target.closest('{Zones.SEARCH.selector}') || $event.target.closest('{Zones.FILTERS.selector}')) $nextTick(() => updateActiveFiltersState())",
173 "@change.window": f"if ($event.target.closest('{Zones.SEARCH.selector}') || $event.target.closest('{Zones.FILTERS.selector}')) $nextTick(() => updateActiveFiltersState())",
174 },
175 ),
176 ],
177 )
179 def _render_toolbar(self) -> dict[str, Any]:
180 """Render toolbar sections."""
181 from lexigram.admin.ui.organisms.table.toolbar import TableToolbar
183 toolbar = TableToolbar(self.config, self.state)
184 return {
185 "header": toolbar.render_header(bulk_actions=self.config.bulk_actions)
186 if self.config.resource_prefix
187 else "",
188 "search": toolbar.render_search() if self.config.resource_prefix else "",
189 "filter": toolbar.render_filters(),
190 }
192 def _render_scope_tabs(self, oob: bool = False) -> str:
193 """Render Active/Trash scope tabs for soft-delete toggling."""
194 from lexigram.ui import HTMXAttrs
196 state = self.state
197 prefix = self.config.resource_prefix or ""
198 active = not state.include_deleted
200 active_state = state.with_include_deleted(False)
201 trash_state = state.with_include_deleted(True)
203 active_url = active_state.to_url(prefix)
204 trash_url = trash_state.to_url(prefix)
206 active_attrs = HTMXAttrs.for_full_refresh(active_state, prefix, push_url=True)
207 trash_attrs = HTMXAttrs.for_full_refresh(trash_state, prefix, push_url=True)
209 def _tab(label: str, is_active: bool, url: str, htmx_attrs: dict) -> Any:
210 """Render a single tab button or link."""
211 cls = (
212 "px-4 py-2 text-sm font-medium border-b-2 transition-colors "
213 "border-primary-500 text-primary-600"
214 if is_active
215 else (
216 "px-4 py-2 text-sm font-medium border-b-2 transition-colors "
217 "border-transparent text-muted-foreground hover:text-foreground "
218 "hover:border-border"
219 )
220 )
222 if is_active:
223 return el("span", label, class_=cls)
225 return el(
226 "a",
227 label,
228 href=url,
229 class_=cls,
230 **htmx_attrs,
231 )
233 outer_attrs: dict[str, Any] = {"class_": "mb-4", "id": "table-scope-tabs"}
234 if oob:
235 outer_attrs["hx_swap_oob"] = "outerHTML"
237 return render_to_string(
238 el(
239 "div",
240 el(
241 "div",
242 el(
243 "nav",
244 _tab("Active", active, active_url, active_attrs),
245 _tab("Trash", not active, trash_url, trash_attrs),
246 class_="flex space-x-8 border-b border-border",
247 ),
248 class_="px-6",
249 ),
250 **outer_attrs,
251 )
252 )
254 def _render_oob_fragments(self) -> list[Any]:
255 """Render OOB fragments for toolbar controls during data-only HTMX requests.
257 Returns a list of htpy elements with hx-swap-oob attributes.
258 """
259 from lexigram.admin.ui.organisms.table.toolbar import TableToolbar
261 fragments: list[Any] = []
263 # Toolbar switchers + clear button (existing OOB method)
264 toolbar = TableToolbar(self.config, self.state)
265 switchers = toolbar.render_switchers_oob()
266 if switchers is not None:
267 fragments.append(switchers)
269 # Scope tabs as OOB fragment
270 tabs = self._render_scope_tabs(oob=True)
271 if tabs:
272 fragments.append(tabs)
274 return fragments
276 def _render_view_content(self) -> Any:
277 """Render the appropriate view content based on state."""
278 if self.loading:
279 return self.state_renderer.render_skeleton()
280 if self.error:
281 return self.state_renderer.render_error(self.error)
282 if not self.data:
283 return self.state_renderer.render_empty()
284 view_strategy = ViewFactory.create_view(
285 self.state.view or "tabular",
286 self.data,
287 self.config,
288 self.state,
289 self.total,
290 self.summary,
291 self.user,
292 self.config.resource_name,
293 )
294 return view_strategy.render()
296 def _render_pagination(self) -> Any:
297 """Render pagination if needed."""
298 if self.total is None or self.total <= 0:
299 return None
301 params = self.state.to_query_params()
302 params.pop("page", None)
303 params.pop("per_page", None)
304 base_query = "&" + urlencode(params) if params else ""
305 base_url = (
306 f"{self.config.resource_prefix}/" if self.config.resource_prefix else ""
307 )
309 return Pagination(
310 page=self.state.page,
311 total=self.total,
312 per_page=self.state.per_page,
313 base_url=base_url,
314 extra_query=base_query,
315 hx_target=Zones.DATA.selector,
316 hx_swap="innerHTML",
317 show_size_selector=True,
318 next_cursor=self.next_cursor,
319 state=self.state,
320 )
322 def _render_hidden_inputs(self) -> list[Any]:
323 """Render hidden inputs for state preservation."""
324 exclude_keys = ["page", "per_page"]
325 if self.config.enable_search:
326 exclude_keys.append("search")
328 if self.config.filters:
329 if isinstance(self.config.filters, list):
330 exclude_keys.extend([f.name for f in self.config.filters])
331 elif isinstance(self.config.filters, dict):
332 exclude_keys.extend(self.config.filters.keys())
334 return self.state.render_hidden_inputs(exclude=exclude_keys)
336 def _render_htmx_wrapper(self, table_content: Any) -> Any:
337 """Render HTMX wrapper with appropriate attributes."""
338 from lexigram.ui import HTMXAttrs
340 htmx_attrs = {}
341 if self.state and self.config.resource_prefix:
342 htmx_attrs = HTMXAttrs.for_data_refresh(
343 self.state,
344 self.config.resource_prefix,
345 push_url=False,
346 )
348 return el(
349 "div",
350 table_content,
351 id=Zones.TABLE.id + "-inner",
352 hx_trigger="refreshTable from:body",
353 hx_disinherit="hx-select",
354 **htmx_attrs,
355 )
357 def _render_script(self) -> str:
358 """Render Alpine.js script."""
359 from lexigram.ui import DataTableScriptRenderer
361 return DataTableScriptRenderer.render(self._all_ids)
363 def render_bulk_actions(self) -> Any:
364 """Render bulk actions bar."""
365 if not self.config.bulk_actions:
366 return None
368 from lexigram.admin.ui.organisms.data_table.actions import (
369 render_bulk_action_button,
370 )
372 bulk_buttons = []
373 for action in self.config.bulk_actions:
374 if not action.is_visible(None):
375 continue
376 btn = render_bulk_action_button(action)
377 if btn:
378 bulk_buttons.append(btn)
380 return el(
381 "div",
382 el(
383 "div",
384 el(
385 "span",
386 el("strong", x_text="selectedIds.length"),
387 " items selected",
388 class_="text-sm font-medium text-primary-600 dark:text-primary-400 mr-2",
389 ),
390 *bulk_buttons,
391 class_="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 py-3 flex items-center justify-between",
392 ),
393 role="alert",
394 aria_live="polite",
395 class_="fixed bottom-0 left-0 right-0 bg-card dark:bg-background border-t border-border shadow-lg transition-transform duration-200 z-50",
396 x_show="selectedIds.length > 0",
397 style="display: none;",
398 **{
399 "x-transition:enter": "transform ease-out duration-200",
400 "x-transition:enter-start": "translate-y-full",
401 "x-transition:enter-end": "translate-y-0",
402 "x-transition:leave": "transform ease-in duration-200",
403 "x-transition:leave-start": "translate-y-0",
404 "x-transition:leave-end": "translate-y-full",
405 },
406 )