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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.serialization import dumps_str 

6from lexigram.ui import Zones, el 

7 

8 

9class DataTableScriptRenderer: 

10 """Renderer for the client-side Alpine.js logic of the DataTable.""" 

11 

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; 

19 

20 window.LexigramTableLogic = {{ 

21 allIds: {dumps_str(all_ids)}, 

22 hasActiveFiltersState: false, 

23 

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 }}, 

47 

48 

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 }}, 

58 

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 }}, 

78 

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 }}, 

87 

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 }}, 

94 

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 }}, 

101 

102 selectAll() {{ 

103 this.selectedIds = [...this.allIds]; 

104 }}, 

105 

106 handleSelectAll(event) {{ 

107 if (event.target.checked) {{ 

108 this.selectedIds = [...this.allIds]; 

109 }} else {{ 

110 this.selectedIds = []; 

111 }} 

112 }}, 

113 

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 }}, 

119 

120 reorderColumn(fromCol, toCol) {{ 

121 if (fromCol === toCol) return; 

122 

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')); 

126 

127 if (colNames.length === 0) {{ 

128 // Fallback if data-col-name is missing (should not happen with my update) 

129 return; 

130 }} 

131 

132 const fromIdx = colNames.indexOf(fromCol); 

133 const toIdx = colNames.indexOf(toCol); 

134 

135 if (fromIdx !== -1 && toIdx !== -1) {{ 

136 colNames.splice(toIdx, 0, colNames.splice(fromIdx, 1)[0]); 

137 

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 }}, 

147 

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 }} 

155 

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 }}, 

162 

163 handleKeydown(e) {{ 

164 if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) {{ 

165 if (e.key === 'Escape') e.target.blur(); 

166 return; 

167 }} 

168 

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 }}; 

197 

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 }}; 

224 

225 if (window.Alpine) {{ 

226 registerResizable(); 

227 }} else {{ 

228 document.addEventListener('alpine:init', registerResizable); 

229 }} 

230 

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 }}); 

253 

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 }}); 

270 

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 }} 

289 

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)