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