nquery 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +52 -0
  4. data/Rakefile +9 -0
  5. data/app/assets/builds/nquery/application.css +339 -0
  6. data/app/assets/builds/nquery/application.js +989 -0
  7. data/app/assets/config/nquery_manifest.js +2 -0
  8. data/app/components/nquery/_card_component.html.erb +6 -0
  9. data/app/components/nquery/_card_table_component.html.erb +14 -0
  10. data/app/components/nquery/_flash_card_component.html.erb +32 -0
  11. data/app/components/nquery/_flash_cards_component.html.erb +3 -0
  12. data/app/components/nquery/_infinite_scroll_component.html.erb +11 -0
  13. data/app/components/nquery/card_component.rb +18 -0
  14. data/app/components/nquery/card_table_component.rb +19 -0
  15. data/app/components/nquery/component.rb +26 -0
  16. data/app/components/nquery/flash_card_component.rb +51 -0
  17. data/app/components/nquery/flash_cards_component.rb +20 -0
  18. data/app/components/nquery/infinite_scroll_component.rb +19 -0
  19. data/app/controllers/concerns/nquery/breadcrumbs.rb +238 -0
  20. data/app/controllers/concerns/nquery/browsable.rb +56 -0
  21. data/app/controllers/concerns/nquery/chart_actions.rb +104 -0
  22. data/app/controllers/concerns/nquery/chart_results.rb +30 -0
  23. data/app/controllers/nquery/admin/base_controller.rb +9 -0
  24. data/app/controllers/nquery/admin/data_sources_controller.rb +47 -0
  25. data/app/controllers/nquery/admin/groups_controller.rb +63 -0
  26. data/app/controllers/nquery/admin/logs_controller.rb +28 -0
  27. data/app/controllers/nquery/admin/permissions_controller.rb +53 -0
  28. data/app/controllers/nquery/admin/users_controller.rb +80 -0
  29. data/app/controllers/nquery/application_controller.rb +70 -0
  30. data/app/controllers/nquery/charts_controller.rb +51 -0
  31. data/app/controllers/nquery/collection/dashboards_controller.rb +38 -0
  32. data/app/controllers/nquery/collections_controller.rb +117 -0
  33. data/app/controllers/nquery/dashboard/charts_controller.rb +62 -0
  34. data/app/controllers/nquery/dashboards_controller.rb +90 -0
  35. data/app/controllers/nquery/embed/charts_controller.rb +24 -0
  36. data/app/controllers/nquery/embed/dashboards_controller.rb +17 -0
  37. data/app/controllers/nquery/home_controller.rb +16 -0
  38. data/app/controllers/nquery/imports_controller.rb +41 -0
  39. data/app/controllers/nquery/queries_controller.rb +105 -0
  40. data/app/controllers/nquery/registrations_controller.rb +31 -0
  41. data/app/controllers/nquery/sessions_controller.rb +28 -0
  42. data/app/helpers/nquery/charts_helper.rb +33 -0
  43. data/app/helpers/nquery/collections_helper.rb +13 -0
  44. data/app/helpers/nquery/components_helper.rb +29 -0
  45. data/app/helpers/nquery/home_helper.rb +23 -0
  46. data/app/helpers/nquery/icon_helper.rb +9 -0
  47. data/app/helpers/nquery/navigation_helper.rb +10 -0
  48. data/app/javascript/nquery/controllers/chart_preview_controller.js +125 -0
  49. data/app/javascript/nquery/controllers/csv_mapper_controller.js +23 -0
  50. data/app/javascript/nquery/controllers/dashboard_grid_controller.js +25 -0
  51. data/app/javascript/nquery/controllers/query_editor_controller.js +41 -0
  52. data/app/models/nquery/application_permission.rb +12 -0
  53. data/app/models/nquery/application_record.rb +7 -0
  54. data/app/models/nquery/audit.rb +38 -0
  55. data/app/models/nquery/chart.rb +37 -0
  56. data/app/models/nquery/collection.rb +35 -0
  57. data/app/models/nquery/collection_permission.rb +12 -0
  58. data/app/models/nquery/csv_upload.rb +11 -0
  59. data/app/models/nquery/dashboard.rb +27 -0
  60. data/app/models/nquery/dashboard_card.rb +8 -0
  61. data/app/models/nquery/data_permission.rb +13 -0
  62. data/app/models/nquery/data_source.rb +27 -0
  63. data/app/models/nquery/embed_token.rb +21 -0
  64. data/app/models/nquery/group.rb +27 -0
  65. data/app/models/nquery/group_membership.rb +10 -0
  66. data/app/models/nquery/query.rb +19 -0
  67. data/app/models/nquery/user.rb +58 -0
  68. data/app/views/layouts/nquery/application.html.erb +32 -0
  69. data/app/views/layouts/nquery/auth.html.erb +16 -0
  70. data/app/views/layouts/nquery/embed.html.erb +12 -0
  71. data/app/views/nquery/admin/data_sources/edit.html.erb +8 -0
  72. data/app/views/nquery/admin/data_sources/index.html.erb +21 -0
  73. data/app/views/nquery/admin/data_sources/new.html.erb +7 -0
  74. data/app/views/nquery/admin/groups/edit.html.erb +6 -0
  75. data/app/views/nquery/admin/groups/index.html.erb +21 -0
  76. data/app/views/nquery/admin/groups/new.html.erb +6 -0
  77. data/app/views/nquery/admin/groups/show.html.erb +24 -0
  78. data/app/views/nquery/admin/logs/index.html.erb +74 -0
  79. data/app/views/nquery/admin/permissions/_collection.html.erb +25 -0
  80. data/app/views/nquery/admin/permissions/_data_source.html.erb +27 -0
  81. data/app/views/nquery/admin/permissions/_group.html.erb +42 -0
  82. data/app/views/nquery/admin/permissions/by_collection.html.erb +10 -0
  83. data/app/views/nquery/admin/permissions/by_data_source.html.erb +7 -0
  84. data/app/views/nquery/admin/permissions/by_group.html.erb +12 -0
  85. data/app/views/nquery/admin/users/edit.html.erb +17 -0
  86. data/app/views/nquery/admin/users/index.html.erb +34 -0
  87. data/app/views/nquery/admin/users/new.html.erb +14 -0
  88. data/app/views/nquery/admin/users/show.html.erb +6 -0
  89. data/app/views/nquery/charts/_builder_form.html.erb +185 -0
  90. data/app/views/nquery/charts/_new_header.html.erb +31 -0
  91. data/app/views/nquery/charts/_schema_tree.html.erb +25 -0
  92. data/app/views/nquery/charts/edit.html.erb +6 -0
  93. data/app/views/nquery/charts/embed.html.erb +15 -0
  94. data/app/views/nquery/charts/new.html.erb +5 -0
  95. data/app/views/nquery/charts/show.html.erb +15 -0
  96. data/app/views/nquery/charts/update.turbo_stream.erb +1 -0
  97. data/app/views/nquery/collection/dashboards/new.html.erb +13 -0
  98. data/app/views/nquery/collections/_dashboard_card.html.erb +57 -0
  99. data/app/views/nquery/collections/edit.html.erb +15 -0
  100. data/app/views/nquery/collections/index.html.erb +41 -0
  101. data/app/views/nquery/collections/new.html.erb +15 -0
  102. data/app/views/nquery/collections/show.html.erb +100 -0
  103. data/app/views/nquery/dashboard/charts/new.html.erb +6 -0
  104. data/app/views/nquery/dashboards/_chart_card.html.erb +16 -0
  105. data/app/views/nquery/dashboards/edit.html.erb +34 -0
  106. data/app/views/nquery/dashboards/index.html.erb +35 -0
  107. data/app/views/nquery/dashboards/show.html.erb +46 -0
  108. data/app/views/nquery/embed/charts/show.html.erb +9 -0
  109. data/app/views/nquery/embed/dashboards/show.html.erb +7 -0
  110. data/app/views/nquery/home/index.html.erb +143 -0
  111. data/app/views/nquery/imports/new.html.erb +20 -0
  112. data/app/views/nquery/queries/edit.html.erb +40 -0
  113. data/app/views/nquery/queries/new.html.erb +37 -0
  114. data/app/views/nquery/registrations/new.html.erb +34 -0
  115. data/app/views/nquery/sessions/new.html.erb +29 -0
  116. data/app/views/nquery/shared/_auth_shell.html.erb +22 -0
  117. data/app/views/nquery/shared/_breadcrumbs.html.erb +15 -0
  118. data/app/views/nquery/shared/_chart_actions_menu.html.erb +27 -0
  119. data/app/views/nquery/shared/_chart_row_actions.html.erb +16 -0
  120. data/app/views/nquery/shared/_flash.html.erb +1 -0
  121. data/app/views/nquery/shared/_icon.html.erb +50 -0
  122. data/app/views/nquery/shared/_sidebar.html.erb +16 -0
  123. data/app/views/nquery/shared/_topbar.html.erb +12 -0
  124. data/config/importmap.rb +9 -0
  125. data/config/routes.rb +78 -0
  126. data/db/migrate/20260721000001_create_nquery_tables.rb +146 -0
  127. data/db/migrate/20260721180000_add_archived_at_to_nquery_dashboards.rb +8 -0
  128. data/db/migrate/20260721190000_add_archived_at_to_nquery_charts.rb +8 -0
  129. data/db/migrate/20260721200000_add_archived_at_to_nquery_collections.rb +8 -0
  130. data/lib/generators/nquery/install_generator.rb +38 -0
  131. data/lib/nquery/authorizes_collection.rb +50 -0
  132. data/lib/nquery/configuration.rb +44 -0
  133. data/lib/nquery/csv_importer.rb +68 -0
  134. data/lib/nquery/data_sources/adapter.rb +34 -0
  135. data/lib/nquery/data_sources/mysql_adapter.rb +24 -0
  136. data/lib/nquery/data_sources/postgresql_adapter.rb +46 -0
  137. data/lib/nquery/data_sources/rails_adapter.rb +54 -0
  138. data/lib/nquery/data_sources/sqlite_adapter.rb +8 -0
  139. data/lib/nquery/embed_token_service.rb +83 -0
  140. data/lib/nquery/engine.rb +55 -0
  141. data/lib/nquery/permissions/resolver.rb +83 -0
  142. data/lib/nquery/query_runner.rb +69 -0
  143. data/lib/nquery/sample_data/ecommerce.rb +157 -0
  144. data/lib/nquery/schema_explorer.rb +28 -0
  145. data/lib/nquery/seeder.rb +131 -0
  146. data/lib/nquery/version.rb +5 -0
  147. data/lib/nquery.rb +36 -0
  148. data/lib/tasks/nquery.rake +9 -0
  149. data/nquery.gemspec +40 -0
  150. metadata +395 -0
@@ -0,0 +1,989 @@
1
+ // Simplified Stimulus-style controllers without importmap
2
+ function escapeHtml(value) {
3
+ return String(value)
4
+ .replace(/&/g, "&")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;")
8
+ }
9
+
10
+ const schemaToggleIcon = `<svg class="nq-schema-toggle-icon" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`
11
+
12
+ function renderSchemaTable(table) {
13
+ const name = typeof table === "string" ? table : table.name
14
+ const columns = typeof table === "string" ? [] : (table.columns || [])
15
+ const columnsHtml = columns.map(column => `
16
+ <li class="nq-schema-column">
17
+ <span class="nq-schema-column-name">${escapeHtml(column.name)}</span>
18
+ <span class="nq-schema-column-type">${escapeHtml(column.type)}</span>
19
+ </li>
20
+ `).join("")
21
+
22
+ return `
23
+ <li class="nq-schema-table">
24
+ <div class="nq-schema-table-header">
25
+ <button type="button" class="nq-schema-toggle" aria-expanded="false" aria-label="Toggle ${escapeHtml(name)} columns">
26
+ ${schemaToggleIcon}
27
+ </button>
28
+ <span class="nq-schema-table-name">${escapeHtml(name)}</span>
29
+ </div>
30
+ <ul class="nq-schema-columns" hidden>${columnsHtml}</ul>
31
+ </li>
32
+ `
33
+ }
34
+
35
+ function bindSchemaTree(container) {
36
+ if (!container) return
37
+
38
+ container.querySelectorAll(".nq-schema-table").forEach(tableEl => {
39
+ const toggle = tableEl.querySelector(".nq-schema-toggle")
40
+ const columns = tableEl.querySelector(".nq-schema-columns")
41
+
42
+ toggle?.addEventListener("click", (event) => {
43
+ event.stopPropagation()
44
+ const expanded = tableEl.classList.toggle("is-expanded")
45
+ toggle.setAttribute("aria-expanded", expanded ? "true" : "false")
46
+ columns?.toggleAttribute("hidden", !expanded)
47
+ })
48
+ })
49
+ }
50
+
51
+ function renderSchemaTree(container, tables) {
52
+ if (!container) return
53
+ container.innerHTML = tables.map(renderSchemaTable).join("")
54
+ bindSchemaTree(container)
55
+ }
56
+
57
+ function formatSql(sql) {
58
+ if (!sql?.trim()) return sql || ""
59
+
60
+ if (typeof sqlFormatter !== "undefined") {
61
+ try {
62
+ return sqlFormatter.format(sql, {
63
+ language: "sql",
64
+ tabWidth: 2,
65
+ keywordCase: "upper",
66
+ linesBetweenQueries: 2
67
+ })
68
+ } catch {
69
+ // Fall back to the lightweight formatter below.
70
+ }
71
+ }
72
+
73
+ const breakBefore = [
74
+ "UNION ALL", "UNION", "EXCEPT", "INTERSECT",
75
+ "GROUP BY", "ORDER BY", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "FULL JOIN", "CROSS JOIN",
76
+ "INSERT INTO", "DELETE FROM",
77
+ "SELECT", "FROM", "WHERE", "HAVING", "LIMIT", "OFFSET", "JOIN", "SET", "VALUES", "WITH"
78
+ ]
79
+
80
+ let formatted = sql.replace(/([(),;])/g, " $1 ").replace(/\s+/g, " ").trim()
81
+
82
+ breakBefore.forEach(keyword => {
83
+ const pattern = new RegExp(`\\s*\\b${keyword.replace(/ /g, "\\s+")}\\b`, "gi")
84
+ formatted = formatted.replace(pattern, match => `\n${match.trim().toUpperCase()}`)
85
+ })
86
+
87
+ formatted = formatted.replace(/\s+\b(AND|OR)\b/gi, "\n $1")
88
+
89
+ return formatted
90
+ .split("\n")
91
+ .map(line => line.trim())
92
+ .filter(Boolean)
93
+ .join("\n")
94
+ }
95
+
96
+ function initSqlEditor(textarea, options = {}) {
97
+ if (!textarea || textarea.dataset.sqlEditorInitialized === "true") return textarea._sqlEditor || null
98
+ if (typeof CodeMirror === "undefined") return null
99
+
100
+ const editor = CodeMirror.fromTextArea(textarea, {
101
+ mode: "text/x-sql",
102
+ theme: "eclipse",
103
+ lineNumbers: true,
104
+ indentWithTabs: false,
105
+ indentUnit: 2,
106
+ tabSize: 2,
107
+ lineWrapping: true,
108
+ matchBrackets: true,
109
+ autoCloseBrackets: true,
110
+ styleActiveLine: true,
111
+ viewportMargin: Infinity,
112
+ extraKeys: {
113
+ Tab: (cm) => {
114
+ if (cm.somethingSelected()) cm.indentSelection("add")
115
+ else cm.replaceSelection(" ", "end")
116
+ },
117
+ "Shift-Tab": (cm) => cm.indentSelection("subtract"),
118
+ ...(options.extraKeys || {})
119
+ }
120
+ })
121
+
122
+ textarea.dataset.sqlEditorInitialized = "true"
123
+ textarea._sqlEditor = editor
124
+ editor.getWrapperElement().classList.add("nq-codemirror")
125
+
126
+ editor.on("change", () => {
127
+ editor.save()
128
+ })
129
+
130
+ return editor
131
+ }
132
+
133
+ function syncSqlEditorValue(editor) {
134
+ if (editor) editor.save()
135
+ }
136
+
137
+ function setButtonLoading(button, loading, { keepDisabled = false } = {}) {
138
+ if (!button) return
139
+ if (loading) {
140
+ button.classList.add("is-loading")
141
+ button.setAttribute("aria-busy", "true")
142
+ if ("disabled" in button) button.disabled = true
143
+ } else {
144
+ button.classList.remove("is-loading")
145
+ button.removeAttribute("aria-busy")
146
+ if ("disabled" in button) button.disabled = keepDisabled
147
+ }
148
+ }
149
+
150
+ const FLASH_CARD_ICONS = {
151
+ notice: '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/><path d="M6 10l2.5 2.5L14 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
152
+ alert: '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 6v5M10 14h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/></svg>',
153
+ warning: '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 7v4M10 14h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.5 16h13L10 4 3.5 16z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>',
154
+ info: '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.5"/><path d="M10 9v5M10 6h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
155
+ }
156
+
157
+ class FlashCardController {
158
+ constructor(element) {
159
+ this.element = element
160
+ this.autoDismiss = element.dataset.flashCardAutoDismissValue === "true"
161
+ this.delay = Number.parseInt(element.dataset.flashCardDelayValue || "5000", 10)
162
+ this.isToast = element.dataset.flashCardToastValue === "true"
163
+ this.timeout = null
164
+ }
165
+
166
+ connect() {
167
+ const dismissButton = this.element.querySelector("[data-action*='flash-card#dismiss']")
168
+ dismissButton?.addEventListener("click", () => this.dismiss())
169
+
170
+ if (this.isToast) {
171
+ this.element.hidden = false
172
+ requestAnimationFrame(() => {
173
+ requestAnimationFrame(() => this.element.classList.add("is-visible"))
174
+ })
175
+ }
176
+
177
+ if (this.autoDismiss) {
178
+ this.timeout = window.setTimeout(() => this.dismiss(), this.delay)
179
+ }
180
+ }
181
+
182
+ dismiss() {
183
+ window.clearTimeout(this.timeout)
184
+
185
+ if (!this.isToast) {
186
+ this.element.remove()
187
+ return
188
+ }
189
+
190
+ this.element.classList.remove("is-visible")
191
+ this.element.classList.add("is-leaving")
192
+
193
+ const remove = () => this.element.remove()
194
+ this.element.addEventListener("transitionend", remove, { once: true })
195
+ window.setTimeout(remove, 350)
196
+ }
197
+ }
198
+
199
+ function flashCardMarkup(message, type = "notice", { delay = 5000, autoDismiss = true } = {}) {
200
+ return `
201
+ <div class="nq-flash-card nq-flash-card-${type} nq-flash-card-toast nq-flash-client" role="status" data-controller="flash-card" data-flash-card-auto-dismiss-value="${autoDismiss}" data-flash-card-delay-value="${delay}" data-flash-card-toast-value="true" hidden>
202
+ <div class="nq-flash-card-icon" aria-hidden="true">${FLASH_CARD_ICONS[type] || FLASH_CARD_ICONS.notice}</div>
203
+ <div class="nq-flash-card-body"><p>${escapeHtml(message)}</p></div>
204
+ <button type="button" class="nq-flash-card-dismiss" aria-label="Dismiss" data-action="flash-card#dismiss">
205
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
206
+ </button>
207
+ </div>
208
+ `
209
+ }
210
+
211
+ function ensureToastStack() {
212
+ let stack = document.getElementById("flash")
213
+ if (!stack) {
214
+ stack = document.createElement("div")
215
+ stack.id = "flash"
216
+ stack.className = "nq-toast-stack"
217
+ stack.setAttribute("aria-live", "polite")
218
+ stack.setAttribute("aria-atomic", "false")
219
+ document.body.appendChild(stack)
220
+ }
221
+ return stack
222
+ }
223
+
224
+ function mountFlashCard(element) {
225
+ if (!element || element.dataset.flashCardInitialized === "true") return
226
+ element.dataset.flashCardInitialized = "true"
227
+ new FlashCardController(element).connect()
228
+ }
229
+
230
+ function initFlashCards(root = document) {
231
+ root.querySelectorAll("[data-controller='flash-card']").forEach(mountFlashCard)
232
+ }
233
+
234
+ function showClientFlash(message, type = "notice", { delay = 5000 } = {}) {
235
+ if (!message) return
236
+
237
+ const stack = ensureToastStack()
238
+ stack.insertAdjacentHTML("beforeend", flashCardMarkup(message, type, { delay }))
239
+ mountFlashCard(stack.lastElementChild)
240
+ }
241
+
242
+ function initToastEvents() {
243
+ window.addEventListener("nquery:toast", (event) => {
244
+ const { message, type = "notice", delay = 5000 } = event.detail || {}
245
+ showClientFlash(message, type, { delay })
246
+ })
247
+ }
248
+
249
+ function observeToastStack() {
250
+ const stack = ensureToastStack()
251
+ if (stack.dataset.toastObserver === "true") return
252
+ stack.dataset.toastObserver = "true"
253
+
254
+ new MutationObserver(() => initFlashCards(stack)).observe(stack, { childList: true })
255
+ }
256
+
257
+ function initButtonLoaders() {
258
+ document.addEventListener("submit", (event) => {
259
+ const submitter = event.submitter || event.target.querySelector("button[type=submit], input[type=submit]")
260
+ if (!submitter?.matches(".nq-btn, .nq-menu-item-button")) return
261
+ setButtonLoading(submitter, true)
262
+ })
263
+
264
+ document.addEventListener("click", (event) => {
265
+ const button = event.target.closest("a.nq-btn, button.nq-btn[type=button], input.nq-btn[type=button]")
266
+ if (!button || button.classList.contains("is-loading") || button.dataset.managesLoading === "true") return
267
+ setButtonLoading(button, true)
268
+ })
269
+
270
+ document.addEventListener("turbo:submit-end", (event) => {
271
+ const submitter = event.detail?.formSubmission?.submitter
272
+ if (submitter) setButtonLoading(submitter, false)
273
+ })
274
+
275
+ window.addEventListener("pageshow", (event) => {
276
+ if (!event.persisted) return
277
+ document.querySelectorAll(".is-loading").forEach((button) => setButtonLoading(button, false))
278
+ })
279
+ }
280
+
281
+ function columnIndex(columns, name) {
282
+ const index = columns.indexOf(name)
283
+ return index >= 0 ? index : 0
284
+ }
285
+
286
+ function chartJsType(type) {
287
+ if (type === "area") return "line"
288
+ if (type === "scatter") return "scatter"
289
+ if (type === "pie") return "pie"
290
+ return type === "line" ? "line" : "bar"
291
+ }
292
+
293
+ function buildPreviewChartConfig(type, data, xCol, yCol) {
294
+ const columns = data.columns || []
295
+ const rows = data.rows || []
296
+ const xIndex = columnIndex(columns, xCol || columns[0])
297
+ const yIndex = columnIndex(columns, yCol || columns[1] || columns[0])
298
+ const labels = rows.map(row => row[xIndex])
299
+ const values = rows.map(row => Number(row[yIndex]) || 0)
300
+ const color = "#509ee3"
301
+ const yLabel = yCol || columns[yIndex] || "Value"
302
+
303
+ if (type === "scatter") {
304
+ return {
305
+ type: "scatter",
306
+ data: {
307
+ datasets: [{
308
+ label: yLabel,
309
+ data: rows.map(row => ({ x: Number(row[xIndex]) || 0, y: Number(row[yIndex]) || 0 })),
310
+ backgroundColor: color
311
+ }]
312
+ },
313
+ options: { responsive: true, maintainAspectRatio: false }
314
+ }
315
+ }
316
+
317
+ if (type === "pie") {
318
+ return {
319
+ type: "pie",
320
+ data: {
321
+ labels,
322
+ datasets: [{ data: values, backgroundColor: [color, "#7db8ea", "#a8d0f0", "#d4e8f8", "#2d7ec1"] }]
323
+ },
324
+ options: { responsive: true, maintainAspectRatio: false }
325
+ }
326
+ }
327
+
328
+ const jsType = chartJsType(type)
329
+ return {
330
+ type: jsType,
331
+ data: {
332
+ labels,
333
+ datasets: [{
334
+ label: yLabel,
335
+ data: values,
336
+ backgroundColor: color,
337
+ borderColor: color,
338
+ fill: type === "area",
339
+ tension: type === "line" || type === "area" ? 0.3 : 0
340
+ }]
341
+ },
342
+ options: { responsive: true, maintainAspectRatio: false }
343
+ }
344
+ }
345
+
346
+ function initChartPreviews() {
347
+ if (typeof Chart === "undefined") {
348
+ requestAnimationFrame(initChartPreviews)
349
+ return
350
+ }
351
+
352
+ document.querySelectorAll("[data-controller='chart-preview']").forEach(el => {
353
+ if (el.dataset.chartPreviewInitialized === "true") return
354
+ el.dataset.chartPreviewInitialized = "true"
355
+
356
+ const type = el.dataset.chartPreviewTypeValue || "bar"
357
+ const xCol = el.dataset.chartPreviewXValue || ""
358
+ const yCol = el.dataset.chartPreviewYValue || ""
359
+ let data
360
+ try {
361
+ data = el.dataset.chartPreviewDataValue ? JSON.parse(el.dataset.chartPreviewDataValue) : null
362
+ } catch {
363
+ data = null
364
+ }
365
+
366
+ const demo = data?.rows ? data : {
367
+ columns: ["month", "revenue"],
368
+ rows: [["Jan", 1200], ["Feb", 1800], ["Mar", 2400], ["Apr", 2100]]
369
+ }
370
+
371
+ if (type === "number") {
372
+ const yIndex = columnIndex(demo.columns || [], yCol || demo.columns?.[1] || demo.columns?.[0])
373
+ const value = demo.rows?.[0]?.[yIndex] ?? "—"
374
+ el.classList.add("is-number")
375
+ el.innerHTML = `<div class="nq-number-display">${value}</div>`
376
+ return
377
+ }
378
+
379
+ if (type === "table") {
380
+ const columns = demo.columns || []
381
+ const rows = (demo.rows || []).slice(0, 8)
382
+ const table = document.createElement("table")
383
+ table.className = "nq-data-table"
384
+ const thead = document.createElement("thead")
385
+ const headRow = document.createElement("tr")
386
+ columns.forEach(col => {
387
+ const th = document.createElement("th")
388
+ th.textContent = col
389
+ headRow.appendChild(th)
390
+ })
391
+ thead.appendChild(headRow)
392
+ table.appendChild(thead)
393
+ const tbody = document.createElement("tbody")
394
+ rows.forEach(row => {
395
+ const tr = document.createElement("tr")
396
+ row.forEach(cell => {
397
+ const td = document.createElement("td")
398
+ td.textContent = cell ?? ""
399
+ tr.appendChild(td)
400
+ })
401
+ tbody.appendChild(tr)
402
+ })
403
+ table.appendChild(tbody)
404
+ el.innerHTML = ""
405
+ el.appendChild(table)
406
+ return
407
+ }
408
+
409
+ const canvas = el.querySelector("canvas")
410
+ if (!canvas || Chart.getChart(canvas)) return
411
+
412
+ new Chart(canvas, buildPreviewChartConfig(type, demo, xCol, yCol))
413
+ })
414
+ }
415
+
416
+ function initChartBuilders() {
417
+ document.querySelectorAll("[data-controller='chart-builder']:not([data-chart-builder-initialized])").forEach(root => {
418
+ root.dataset.chartBuilderInitialized = "true"
419
+ const statement = root.querySelector("[data-chart-builder-target='statement']")
420
+ const dataSource = root.querySelector("[data-chart-builder-target='dataSource']")
421
+ const schema = root.querySelector("[data-chart-builder-target='schema']")
422
+ const nameInput = root.querySelector("[data-chart-builder-target='name']")
423
+ const queryNameInput = root.querySelector("[data-chart-builder-target='queryName']")
424
+ const meta = root.querySelector("[data-chart-builder-target='meta']")
425
+ const workspaceTabs = root.querySelectorAll("[data-chart-builder-target='workspaceTab']")
426
+ const queryPanel = root.querySelector("[data-chart-builder-target='queryPanel']")
427
+ const workspaceOutputPanel = root.querySelector("[data-chart-builder-target='workspaceOutputPanel']")
428
+ const tabList = root.querySelector("[data-chart-builder-target='tabList']")
429
+ const outputTabs = root.querySelectorAll("[data-chart-builder-target='outputTab']")
430
+ const emptyState = root.querySelector("[data-chart-builder-target='emptyState']")
431
+ const errorBox = root.querySelector("[data-chart-builder-target='error']")
432
+ const tablePanel = root.querySelector("[data-chart-builder-target='tablePanel']")
433
+ const tableWrap = root.querySelector("[data-chart-builder-target='table']")
434
+ const chartPanel = root.querySelector("[data-chart-builder-target='chartPanel']")
435
+ const chartWrap = root.querySelector("[data-chart-builder-target='chartWrap']")
436
+ const canvas = root.querySelector("[data-chart-builder-target='canvas']")
437
+ const numberWrap = root.querySelector("[data-chart-builder-target='number']")
438
+ const mapping = root.querySelector("[data-chart-builder-target='mapping']")
439
+ const xMapping = root.querySelector("[data-chart-builder-target='xMapping']")
440
+ const xAxis = root.querySelector("[data-chart-builder-target='xAxis']")
441
+ const yAxis = root.querySelector("[data-chart-builder-target='yAxis']")
442
+ const typeField = root.querySelector("[data-chart-builder-target='typeField']")
443
+ const xField = root.querySelector("[data-chart-builder-target='xField']")
444
+ const yField = root.querySelector("[data-chart-builder-target='yField']")
445
+ const typeButtons = root.querySelectorAll("[data-chart-builder-target='typeButton']")
446
+ const runButton = root.querySelector("[data-action*='chart-builder#run']")
447
+ const formatButton = root.querySelector("[data-chart-builder-target='formatButton']") ||
448
+ root.querySelector("[data-action*='chart-builder#format']")
449
+ const saveStatus = root.querySelector("[data-chart-builder-target='saveStatus']")
450
+ const querySaveUrl = root.dataset.querySaveUrl
451
+
452
+ let currentResult = null
453
+ let chartInstance = null
454
+ let currentChartType = typeField?.value && typeField.value !== "table" ? typeField.value : "bar"
455
+ let currentOutputTab = typeField?.value && typeField.value !== "table" ? "chart" : "table"
456
+ let currentWorkspaceTab = "query"
457
+ let statementEditor = null
458
+ let lastFormattedSql = null
459
+ let lastSavedSql = statement?.value ?? ""
460
+ let ignoreEditorChanges = false
461
+ let autosaveTimer = null
462
+ let saveChain = Promise.resolve()
463
+
464
+ const syncStatementField = () => {
465
+ syncSqlEditorValue(statementEditor)
466
+ }
467
+
468
+ const getStatement = () => statementEditor ? statementEditor.getValue() : (statement?.value ?? "")
469
+
470
+ const setStatement = (value, { focus = true } = {}) => {
471
+ if (statementEditor) {
472
+ statementEditor.setValue(value)
473
+ statementEditor.save()
474
+ }
475
+
476
+ if (statement) statement.value = value
477
+
478
+ if (focus) {
479
+ if (statementEditor) statementEditor.focus()
480
+ else statement?.focus()
481
+ }
482
+ }
483
+
484
+ const setFormatEnabled = (enabled) => {
485
+ if (!formatButton) return
486
+ formatButton.disabled = !enabled
487
+ formatButton.setAttribute("aria-disabled", enabled ? "false" : "true")
488
+ }
489
+
490
+ const setSaveStatus = (message, { hidden = !message } = {}) => {
491
+ if (!saveStatus) return
492
+ saveStatus.textContent = message || ""
493
+ saveStatus.toggleAttribute("hidden", hidden)
494
+ }
495
+
496
+ const saveQueryStatement = ({ notice } = {}) => {
497
+ if (!querySaveUrl) return Promise.resolve({ saved: false })
498
+
499
+ const run = async () => {
500
+ const statementText = getStatement()
501
+ syncStatementField()
502
+
503
+ if (statementText === lastSavedSql) {
504
+ if (notice) showClientFlash(notice)
505
+ return { saved: true, skipped: true }
506
+ }
507
+
508
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
509
+ const res = await fetch(querySaveUrl, {
510
+ method: "PATCH",
511
+ headers: {
512
+ "Content-Type": "application/json",
513
+ Accept: "application/json",
514
+ "X-CSRF-Token": csrf
515
+ },
516
+ body: JSON.stringify({ query: { statement: statementText } })
517
+ })
518
+ const data = await res.json().catch(() => ({}))
519
+ if (!res.ok) throw new Error(data.error || "Failed to save query.")
520
+
521
+ lastSavedSql = statementText
522
+ if (notice) showClientFlash(notice)
523
+ return { saved: true }
524
+ }
525
+
526
+ const queued = saveChain.then(run, run)
527
+ saveChain = queued.then(() => undefined, () => undefined)
528
+ return queued
529
+ }
530
+
531
+ const scheduleAutosave = () => {
532
+ if (!querySaveUrl || ignoreEditorChanges) return
533
+
534
+ clearTimeout(autosaveTimer)
535
+ setSaveStatus("Saving…")
536
+ autosaveTimer = window.setTimeout(() => {
537
+ saveQueryStatement()
538
+ .then(() => setSaveStatus("Saved"))
539
+ .catch((error) => {
540
+ setSaveStatus("")
541
+ showClientFlash(error.message || "Failed to save query.", "alert")
542
+ })
543
+ }, 800)
544
+ }
545
+
546
+ const handleStatementChange = () => {
547
+ if (ignoreEditorChanges) return
548
+
549
+ const current = getStatement()
550
+ if (lastFormattedSql === null || current !== lastFormattedSql) setFormatEnabled(true)
551
+ scheduleAutosave()
552
+ }
553
+
554
+ const syncName = () => {
555
+ if (nameInput && queryNameInput) queryNameInput.value = nameInput.value
556
+ }
557
+
558
+ const updateOutputSubtabs = () => {
559
+ tabList?.toggleAttribute("hidden", !(currentWorkspaceTab === "output" && currentResult))
560
+ }
561
+
562
+ const selectWorkspaceTab = (tab) => {
563
+ currentWorkspaceTab = tab
564
+
565
+ workspaceTabs.forEach(btn => {
566
+ const active = btn.dataset.tab === tab
567
+ btn.classList.toggle("is-active", active)
568
+ btn.setAttribute("aria-selected", active ? "true" : "false")
569
+ })
570
+
571
+ queryPanel?.classList.toggle("is-active", tab === "query")
572
+ workspaceOutputPanel?.classList.toggle("is-active", tab === "output")
573
+ queryPanel?.toggleAttribute("hidden", tab !== "query")
574
+ workspaceOutputPanel?.toggleAttribute("hidden", tab !== "output")
575
+
576
+ if (tab === "query" && statementEditor) {
577
+ requestAnimationFrame(() => statementEditor.refresh())
578
+ }
579
+
580
+ updateOutputSubtabs()
581
+ }
582
+
583
+ const hideResults = () => {
584
+ emptyState?.removeAttribute("hidden")
585
+ errorBox?.setAttribute("hidden", "")
586
+ tabList?.setAttribute("hidden", "")
587
+ setActivePanel(null)
588
+ updateOutputSubtabs()
589
+ }
590
+
591
+ const setActivePanel = (panel) => {
592
+ const showTable = panel === "table"
593
+ const showChart = panel === "chart"
594
+
595
+ tablePanel?.classList.toggle("is-active", showTable)
596
+ chartPanel?.classList.toggle("is-active", showChart)
597
+ tablePanel?.toggleAttribute("hidden", !showTable)
598
+ chartPanel?.toggleAttribute("hidden", !showChart)
599
+ }
600
+
601
+ const showError = (message) => {
602
+ hideResults()
603
+ emptyState?.setAttribute("hidden", "")
604
+ if (errorBox) {
605
+ errorBox.textContent = message
606
+ errorBox.removeAttribute("hidden")
607
+ }
608
+ meta?.setAttribute("hidden", "")
609
+ selectWorkspaceTab("output")
610
+ }
611
+
612
+ const columnIndex = (columns, name) => columns.indexOf(name)
613
+
614
+ const guessAxes = (columns, rows) => {
615
+ const x = columns[0]
616
+ let y = columns[1] || columns[0]
617
+ for (let i = 0; i < columns.length; i++) {
618
+ if (columns[i] === x) continue
619
+ if (rows.some(row => !Number.isNaN(Number(row[i])))) {
620
+ y = columns[i]
621
+ break
622
+ }
623
+ }
624
+ return { x, y }
625
+ }
626
+
627
+ const populateAxisSelects = (columns, rows) => {
628
+ if (!xAxis || !yAxis) return
629
+ const guessed = guessAxes(columns, rows)
630
+ const x = xField?.value && columns.includes(xField.value) ? xField.value : guessed.x
631
+ const y = yField?.value && columns.includes(yField.value) ? yField.value : guessed.y
632
+ const options = columns.map(col => `<option value="${col}">${col}</option>`).join("")
633
+ xAxis.innerHTML = options
634
+ yAxis.innerHTML = options
635
+ xAxis.value = x
636
+ yAxis.value = y
637
+ if (xField) xField.value = x
638
+ if (yField) yField.value = y
639
+ }
640
+
641
+ const destroyChart = () => {
642
+ if (chartInstance) {
643
+ chartInstance.destroy()
644
+ chartInstance = null
645
+ }
646
+ }
647
+
648
+ const renderTable = (result) => {
649
+ const table = document.createElement("table")
650
+ table.className = "nq-data-table"
651
+ const thead = document.createElement("thead")
652
+ const headRow = document.createElement("tr")
653
+ result.columns.forEach(col => {
654
+ const th = document.createElement("th")
655
+ th.textContent = col
656
+ headRow.appendChild(th)
657
+ })
658
+ thead.appendChild(headRow)
659
+ table.appendChild(thead)
660
+
661
+ const tbody = document.createElement("tbody")
662
+ result.rows.forEach(row => {
663
+ const tr = document.createElement("tr")
664
+ row.forEach(cell => {
665
+ const td = document.createElement("td")
666
+ td.textContent = cell ?? ""
667
+ tr.appendChild(td)
668
+ })
669
+ tbody.appendChild(tr)
670
+ })
671
+ table.appendChild(tbody)
672
+
673
+ tableWrap.innerHTML = ""
674
+ tableWrap.appendChild(table)
675
+ }
676
+
677
+ const renderNumber = (result) => {
678
+ const yCol = yAxis?.value || result.columns[1] || result.columns[0]
679
+ const yIndex = columnIndex(result.columns, yCol)
680
+ const value = result.rows[0]?.[yIndex] ?? "—"
681
+ numberWrap.innerHTML = `<div class="nq-number-display">${value}</div>`
682
+ chartWrap?.setAttribute("hidden", "")
683
+ numberWrap?.removeAttribute("hidden")
684
+ }
685
+
686
+ const chartJsType = (type) => {
687
+ if (type === "area") return "line"
688
+ if (type === "scatter") return "scatter"
689
+ if (type === "pie") return "pie"
690
+ return type === "line" ? "line" : "bar"
691
+ }
692
+
693
+ const renderChart = (result, type) => {
694
+ if (typeof Chart === "undefined" || !canvas) return
695
+
696
+ destroyChart()
697
+ numberWrap?.setAttribute("hidden", "")
698
+ chartWrap?.removeAttribute("hidden")
699
+
700
+ const xCol = xAxis?.value || result.columns[0]
701
+ const yCol = yAxis?.value || result.columns[1] || result.columns[0]
702
+ const xIndex = columnIndex(result.columns, xCol)
703
+ const yIndex = columnIndex(result.columns, yCol)
704
+ const labels = result.rows.map(row => row[xIndex])
705
+ const values = result.rows.map(row => Number(row[yIndex]) || 0)
706
+ const color = "#509ee3"
707
+
708
+ let config
709
+ if (type === "scatter") {
710
+ config = {
711
+ type: "scatter",
712
+ data: {
713
+ datasets: [{
714
+ label: yCol,
715
+ data: result.rows.map(row => ({ x: Number(row[xIndex]) || 0, y: Number(row[yIndex]) || 0 })),
716
+ backgroundColor: color
717
+ }]
718
+ },
719
+ options: { responsive: true, maintainAspectRatio: false }
720
+ }
721
+ } else if (type === "pie") {
722
+ config = {
723
+ type: "pie",
724
+ data: {
725
+ labels,
726
+ datasets: [{ data: values, backgroundColor: [color, "#7db8ea", "#a8d0f0", "#d4e8f8", "#2d7ec1"] }]
727
+ },
728
+ options: { responsive: true, maintainAspectRatio: false }
729
+ }
730
+ } else {
731
+ const jsType = chartJsType(type)
732
+ config = {
733
+ type: jsType,
734
+ data: {
735
+ labels,
736
+ datasets: [{
737
+ label: yCol,
738
+ data: values,
739
+ backgroundColor: color,
740
+ borderColor: color,
741
+ fill: type === "area",
742
+ tension: type === "line" || type === "area" ? 0.3 : 0
743
+ }]
744
+ },
745
+ options: { responsive: true, maintainAspectRatio: false }
746
+ }
747
+ }
748
+
749
+ chartInstance = new Chart(canvas, config)
750
+ }
751
+
752
+ const updateChartPreview = () => {
753
+ if (!currentResult?.columns) return
754
+
755
+ destroyChart()
756
+ chartWrap?.setAttribute("hidden", "")
757
+ numberWrap?.setAttribute("hidden", "")
758
+
759
+ if (currentChartType === "number") {
760
+ xMapping?.setAttribute("hidden", "")
761
+ renderNumber(currentResult)
762
+ return
763
+ }
764
+
765
+ xMapping?.removeAttribute("hidden")
766
+ renderChart(currentResult, currentChartType)
767
+ }
768
+
769
+ const selectOutputTab = (tab) => {
770
+ currentOutputTab = tab
771
+ outputTabs.forEach(btn => {
772
+ const active = btn.dataset.tab === tab
773
+ btn.classList.toggle("is-active", active)
774
+ btn.setAttribute("aria-selected", active ? "true" : "false")
775
+ })
776
+
777
+ // Table/Chart tabs are preview modes only — never overwrite the saved visualization type.
778
+ if (tab === "table") {
779
+ setActivePanel("table")
780
+ } else {
781
+ setActivePanel("chart")
782
+ if (!currentChartType || currentChartType === "table") selectChartType("bar")
783
+ else {
784
+ if (typeField) typeField.value = currentChartType
785
+ updateChartPreview()
786
+ }
787
+ }
788
+ }
789
+
790
+ const selectChartType = (type) => {
791
+ currentChartType = type
792
+ if (typeField) typeField.value = type
793
+ typeButtons.forEach(btn => btn.classList.toggle("is-active", btn.dataset.type === type))
794
+ if (currentResult && currentOutputTab === "chart") updateChartPreview()
795
+ }
796
+
797
+ const updateMapping = () => {
798
+ if (xField && xAxis) xField.value = xAxis.value
799
+ if (yField && yAxis) yField.value = yAxis.value
800
+ if (currentResult && currentOutputTab === "chart") updateChartPreview()
801
+ }
802
+
803
+ const showResults = () => {
804
+ emptyState?.setAttribute("hidden", "")
805
+ errorBox?.setAttribute("hidden", "")
806
+ tabList?.removeAttribute("hidden")
807
+ selectOutputTab(currentOutputTab)
808
+ selectWorkspaceTab("output")
809
+ }
810
+
811
+ const runQuery = async (button) => {
812
+ syncStatementField()
813
+ setButtonLoading(button, true)
814
+ try {
815
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
816
+ const res = await fetch("/queries/run", {
817
+ method: "POST",
818
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
819
+ body: JSON.stringify({ statement: getStatement(), data_source_id: dataSource?.value })
820
+ })
821
+ const data = await res.json()
822
+ if (!res.ok || data.error) {
823
+ showError(data.error || "Query failed.")
824
+ return
825
+ }
826
+
827
+ currentResult = data
828
+ currentOutputTab = "table"
829
+ populateAxisSelects(data.columns, data.rows)
830
+ renderTable(data)
831
+ if (meta) {
832
+ const parts = []
833
+ if (data.row_count != null) parts.push(`${data.row_count} rows`)
834
+ if (data.duration_ms != null) parts.push(`${data.duration_ms} ms`)
835
+ meta.textContent = parts.join(" · ")
836
+ meta.removeAttribute("hidden")
837
+ }
838
+ showResults()
839
+ } catch (e) {
840
+ showError(e.message)
841
+ } finally {
842
+ setButtonLoading(button, false)
843
+ }
844
+ }
845
+
846
+ const loadSchema = async () => {
847
+ if (!schema || !dataSource) return
848
+ const response = await fetch(`/queries/schema?data_source_id=${dataSource.value}`)
849
+ const data = await response.json()
850
+ renderSchemaTree(schema, data.tables)
851
+ }
852
+
853
+ const formatStatement = async (button) => {
854
+ if (formatButton?.disabled) return
855
+
856
+ const sql = getStatement().trim()
857
+ if (!sql) return
858
+
859
+ if (button) setButtonLoading(button, true)
860
+ ignoreEditorChanges = true
861
+ clearTimeout(autosaveTimer)
862
+
863
+ try {
864
+ const formatted = formatSql(sql)
865
+ setStatement(formatted)
866
+ syncStatementField()
867
+ lastFormattedSql = formatted
868
+
869
+ if (querySaveUrl) {
870
+ setSaveStatus("Saving…")
871
+ await saveQueryStatement({ notice: "SQL formatted and saved." })
872
+ setSaveStatus("Saved")
873
+ } else {
874
+ showClientFlash("SQL formatted.")
875
+ }
876
+
877
+ setFormatEnabled(false)
878
+ } catch (error) {
879
+ showClientFlash(error.message || "Failed to format SQL.", "alert")
880
+ setFormatEnabled(true)
881
+ } finally {
882
+ ignoreEditorChanges = false
883
+ if (button) setButtonLoading(button, false, { keepDisabled: formatButton?.disabled })
884
+ }
885
+ }
886
+
887
+ statementEditor = initSqlEditor(statement, {
888
+ extraKeys: {
889
+ "Cmd-Enter": () => runQuery(runButton),
890
+ "Ctrl-Enter": () => runQuery(runButton),
891
+ "Cmd-Shift-F": () => formatStatement(formatButton),
892
+ "Ctrl-Shift-F": () => formatStatement(formatButton)
893
+ }
894
+ })
895
+
896
+ lastSavedSql = getStatement()
897
+ if (statementEditor) statementEditor.on("change", handleStatementChange)
898
+ else statement?.addEventListener("input", handleStatementChange)
899
+
900
+ const form = root.closest("form")
901
+ form?.addEventListener("submit", syncStatementField, true)
902
+ form?.addEventListener("turbo:submit-start", syncStatementField)
903
+
904
+ syncName()
905
+ nameInput?.addEventListener("input", syncName)
906
+ bindSchemaTree(schema)
907
+
908
+ formatButton?.addEventListener("click", (event) => {
909
+ formatStatement(event.currentTarget)
910
+ })
911
+
912
+ root.querySelector("[data-action*='chart-builder#run']")?.addEventListener("click", (event) => {
913
+ runQuery(event.currentTarget)
914
+ })
915
+
916
+ dataSource?.addEventListener("change", loadSchema)
917
+
918
+ workspaceTabs.forEach(btn => {
919
+ btn.addEventListener("click", (event) => {
920
+ event.preventDefault()
921
+ selectWorkspaceTab(btn.dataset.tab)
922
+ })
923
+ })
924
+
925
+ outputTabs.forEach(btn => {
926
+ btn.addEventListener("click", (event) => {
927
+ event.preventDefault()
928
+ selectOutputTab(btn.dataset.tab)
929
+ })
930
+ })
931
+
932
+ typeButtons.forEach(btn => {
933
+ btn.addEventListener("click", () => selectChartType(btn.dataset.type))
934
+ })
935
+
936
+ xAxis?.addEventListener("change", updateMapping)
937
+ yAxis?.addEventListener("change", updateMapping)
938
+ })
939
+ }
940
+
941
+ function initQueryEditors() {
942
+ document.querySelectorAll("[data-controller='query-editor']").forEach(el => {
943
+ const statement = el.querySelector("[data-query-editor-target='statement']")
944
+ const results = el.querySelector("[data-query-editor-target='results']")
945
+ const dataSource = el.querySelector("[data-query-editor-target='dataSource']")
946
+ el.querySelector("[data-action*='query-editor#run']")?.addEventListener("click", async (event) => {
947
+ const button = event.currentTarget
948
+ setButtonLoading(button, true)
949
+ try {
950
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
951
+ const res = await fetch("/queries/run", {
952
+ method: "POST",
953
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
954
+ body: JSON.stringify({ statement: statement?.value, data_source_id: dataSource?.value })
955
+ })
956
+ results.textContent = JSON.stringify(await res.json(), null, 2)
957
+ } finally {
958
+ setButtonLoading(button, false)
959
+ }
960
+ })
961
+ el.querySelectorAll("[data-action*='insertTable']").forEach(item => {
962
+ item.addEventListener("click", () => {
963
+ if (statement) statement.value = `SELECT * FROM ${item.dataset.table} LIMIT 100`
964
+ })
965
+ })
966
+ })
967
+ }
968
+
969
+ function initPage() {
970
+ initButtonLoaders()
971
+ initToastEvents()
972
+ observeToastStack()
973
+ initFlashCards()
974
+ initQueryEditors()
975
+ initChartBuilders()
976
+ initChartPreviews()
977
+ }
978
+
979
+ function bootPage() {
980
+ initPage()
981
+ }
982
+
983
+ if (document.readyState === "loading") {
984
+ document.addEventListener("DOMContentLoaded", bootPage)
985
+ } else {
986
+ bootPage()
987
+ }
988
+
989
+ document.addEventListener("turbo:load", bootPage)