Coverage for src / lexigram / admin / ui / organisms / table / client_logic.py: 100%
9 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.serialization import dumps_str
6from lexigram.ui import Zones, el
9class DataTableScriptRenderer:
10 """Renderer for the client-side Alpine.js logic of the DataTable."""
12 @staticmethod
13 def render(all_ids: list[str]) -> Any:
14 # We assume all_ids is already a list of strings
15 script_js = f"""
16 (function() {{
17 if (window.LexigramTableInitialized) return;
18 window.LexigramTableInitialized = true;
20 window.LexigramTableLogic = {{
21 allIds: {dumps_str(all_ids)},
22 hasActiveFiltersState: false,
24 updateActiveFiltersState() {{
25 const searchInput = document.getElementById('{Zones.SEARCH.id}-input');
26 const hasSearch = searchInput && searchInput.value && searchInput.value.trim() !== '';
27 const filterBar = document.getElementById('{Zones.FILTERS.id}');
28 let hasFilters = false;
29 if (filterBar) {{
30 // Consider filters active only if any control has a non-empty/checked value
31 const controls = Array.from(filterBar.querySelectorAll('select, input, textarea'));
32 for (const ctrl of controls) {{
33 if (!ctrl) continue;
34 const tag = (ctrl.tagName || '').toUpperCase();
35 const type = (ctrl.type || '').toLowerCase();
36 if (tag === 'SELECT') {{
37 if (ctrl.value !== '' && ctrl.value !== null) {{ hasFilters = true; break; }}
38 }} else if (type === 'checkbox' || type === 'radio') {{
39 if (ctrl.checked) {{ hasFilters = true; break; }}
40 }} else {{
41 if (ctrl.value && String(ctrl.value).trim() !== '') {{ hasFilters = true; break; }}
42 }}
43 }}
44 }}
45 this.hasActiveFiltersState = hasSearch || hasFilters;
46 }},
49 toggleSelect(id) {{
50 id = String(id);
51 if (this.selectedIds.includes(id)) {{
52 this.selectedIds = this.selectedIds.filter(i => i != id);
53 }} else {{
54 this.selectedIds.push(id);
55 }}
56 this.lastSelected = id;
57 }},
59 handleSelect(id, event) {{
60 id = String(id);
61 if (event.shiftKey && this.lastSelected) {{
62 const start = this.allIds.indexOf(this.lastSelected);
63 const end = this.allIds.indexOf(id);
64 if (start !== -1 && end !== -1) {{
65 const range = this.allIds.slice(Math.min(start, end), Math.max(start, end) + 1);
66 this.selectedIds = [...new Set([...this.selectedIds, ...range])];
67 }}
68 }} else {{
69 if (this.selectedIds.includes(id)) {{
70 this.selectedIds = this.selectedIds.filter(i => i != id);
71 }} else {{
72 this.selectedIds = [...this.selectedIds, id];
73 }}
74 }}
75 this.lastSelected = id;
76 this.focusedId = id;
77 }},
79 toggleExpand(id) {{
80 id = String(id);
81 if (this.expandedIds.includes(id)) {{
82 this.expandedIds = this.expandedIds.filter(i => i != id);
83 }} else {{
84 this.expandedIds.push(id);
85 }}
86 }},
88 nextRow() {{
89 if (!this.allIds.length) return;
90 const idx = this.focusedId ? this.allIds.indexOf(this.focusedId) : -1;
91 const next = idx + 1 < this.allIds.length ? this.allIds[idx + 1] : this.allIds[0];
92 this.focusedId = next;
93 }},
95 prevRow() {{
96 if (!this.allIds.length) return;
97 const idx = this.focusedId ? this.allIds.indexOf(this.focusedId) : -1;
98 const prev = idx - 1 >= 0 ? this.allIds[idx - 1] : this.allIds[this.allIds.length - 1];
99 this.focusedId = prev;
100 }},
102 selectAll() {{
103 this.selectedIds = [...this.allIds];
104 }},
106 handleSelectAll(event) {{
107 if (event.target.checked) {{
108 this.selectedIds = [...this.allIds];
109 }} else {{
110 this.selectedIds = [];
111 }}
112 }},
114 refreshAllIds(newIds) {{
115 this.allIds = newIds.map(id => String(id));
116 this.selectedIds = this.selectedIds.filter(id => this.allIds.includes(id));
117 this.lastSelected = null;
118 }},
120 reorderColumn(fromCol, toCol) {{
121 if (fromCol === toCol) return;
123 // Get current column names from the headers
124 const ths = Array.from(document.querySelectorAll('{Zones.TABLE.selector} thead th[data-col-name]'));
125 let colNames = ths.map(th => th.getAttribute('data-col-name'));
127 if (colNames.length === 0) {{
128 // Fallback if data-col-name is missing (should not happen with my update)
129 return;
130 }}
132 const fromIdx = colNames.indexOf(fromCol);
133 const toIdx = colNames.indexOf(toCol);
135 if (fromIdx !== -1 && toIdx !== -1) {{
136 colNames.splice(toIdx, 0, colNames.splice(fromIdx, 1)[0]);
138 // Update the hidden input
139 const input = document.querySelector('input[name="col_order"]');
140 if (input) {{
141 input.value = colNames.join(',');
142 // Trigger HTMX refresh by submitting the form or triggering a change
143 input.dispatchEvent(new Event('change', {{ bubbles: true }}));
144 }}
145 }}
146 }},
148 toggleGroup(groupName) {{
149 groupName = String(groupName);
150 if (this.collapsedGroups.includes(groupName)) {{
151 this.collapsedGroups = this.collapsedGroups.filter(g => g !== groupName);
152 }} else {{
153 this.collapsedGroups.push(groupName);
154 }}
156 // Keep in sync with hidden input for server state persistence on next load
157 const input = document.querySelector('input[name="collapsed_groups"]');
158 if (input) {{
159 input.value = this.collapsedGroups.join(',');
160 }}
161 }},
163 handleKeydown(e) {{
164 if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {{
165 if (e.key === 'Escape') e.target.blur();
166 return;
167 }}
169 switch(e.key) {{
170 case 'ArrowDown':
171 e.preventDefault();
172 this.nextRow();
173 break;
174 case 'ArrowUp':
175 e.preventDefault();
176 this.prevRow();
177 break;
178 case 'a':
179 if (e.metaKey || e.ctrlKey) {{
180 e.preventDefault();
181 this.selectAll();
182 }}
183 break;
184 case ' ':
185 if (e.target.tagName !== 'BUTTON') {{
186 e.preventDefault();
187 if (this.focusedId) this.toggleSelect(this.focusedId);
188 }}
189 break;
190 case '/':
191 e.preventDefault();
192 document.getElementById('{Zones.SEARCH.id}')?.focus();
193 break;
194 }}
195 }}
196 }};
198 // Register resizable columns robustly
199 const registerResizable = () => {{
200 if (window.Alpine && !window.LexigramResizableRegistered) {{
201 Alpine.data('resizableColumn', () => ({{
202 startResize(e) {{
203 const th = this.$el;
204 const startX = e.clientX;
205 const startWidth = th.offsetWidth;
206 const onMove = (moveEvent) => {{
207 const currentWidth = startWidth + (moveEvent.clientX - startX);
208 if (currentWidth > 50) {{
209 th.style.width = `${{currentWidth}}px`;
210 th.style.minWidth = `${{currentWidth}}px`;
211 }}
212 }};
213 const onUp = () => {{
214 window.removeEventListener('mousemove', onMove);
215 window.removeEventListener('mouseup', onUp);
216 }};
217 window.addEventListener('mousemove', onMove);
218 window.addEventListener('mouseup', onUp);
219 }}
220 }}));
221 window.LexigramResizableRegistered = true;
222 }}
223 }};
225 if (window.Alpine) {{
226 registerResizable();
227 }} else {{
228 document.addEventListener('alpine:init', registerResizable);
229 }}
231 document.addEventListener('htmx:afterSwap', (e) => {{
232 try {{
233 const target = e.detail.target;
234 if (window.Alpine && target) {{
235 try {{ Alpine.initTree(target); }} catch (err) {{ }}
236 }}
237 if (window.htmx && target) {{
238 try {{ htmx.process(target); }} catch (err) {{ }}
239 }}
240 if (window.LexigramTableLogic && typeof window.LexigramTableLogic.updateActiveFiltersState === 'function') {{
241 window.LexigramTableLogic.updateActiveFiltersState();
242 }}
243 if (target && target.id === '{Zones.DATA.id}') {{
244 const checkboxes = target.querySelectorAll('input[name="ids"]');
245 const newIds = Array.from(checkboxes).map(cb => cb.value);
246 const tableEl = document.getElementById('{Zones.TABLE.id}');
247 if (tableEl && window.Alpine) {{
248 Alpine.$data(tableEl).refreshAllIds(newIds);
249 }}
250 }}
251 }} catch (err) {{ }}
252 }});
254 document.addEventListener('htmx:beforeSwap', (e) => {{
255 try {{
256 const targetId = e.detail.target?.id;
257 if (targetId !== '{Zones.TABLE.id}' && targetId !== 'main-content') return;
258 const fragment = e.detail.serverResponse;
259 if (!fragment) return;
260 const doc = new DOMParser().parseFromString(fragment, 'text/html');
261 const newInput = doc.querySelector('#{Zones.SEARCH.id}-input');
262 const oldInput = document.getElementById('{Zones.SEARCH.id}-input');
263 if (!newInput || !oldInput) return;
264 ['hx-get','hx-trigger','hx-target','hx-swap','hx-include','hx-vals','hx-push-url','placeholder'].forEach(attr => {{
265 const val = newInput.getAttribute(attr);
266 if (val != null) oldInput.setAttribute(attr, val);
267 }});
268 }} catch (err) {{ }}
269 }});
271 function updateSidebarActive(url) {{
272 const sidebarLinks = document.querySelectorAll('#main-sidebar nav a[hx-get]');
273 sidebarLinks.forEach(link => {{
274 const href = link.getAttribute('hx-get');
275 const isActive = url === href || url.startsWith(href + '/');
276 const activeCls = 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400';
277 const inactiveCls = 'text-muted-foreground dark:text-muted-foreground hover:bg-muted dark:hover:bg-card/50 hover:text-primary-600 dark:hover:text-primary-400';
278 if (isActive) {{
279 link.classList.remove(...inactiveCls.split(' ').filter(c => c));
280 link.classList.add(...activeCls.split(' ').filter(c => c));
281 link.setAttribute('aria-current', 'page');
282 }} else {{
283 link.classList.remove(...activeCls.split(' ').filter(c => c));
284 link.classList.add(...inactiveCls.split(' ').filter(c => c));
285 link.setAttribute('aria-current', 'false');
286 }}
287 }});
288 }}
290 document.addEventListener('htmx:afterSettle', (e) => {{
291 try {{
292 const targetId = e.detail.target?.id;
293 if (targetId === 'main-content' || targetId === '{Zones.TABLE.id}') {{
294 updateSidebarActive(window.location.pathname);
295 }}
296 }} catch (err) {{ }}
297 }});
298 }})();
299 """
300 return el("script", script_js)