nquery 0.1.1 → 0.1.3

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +54 -0
  4. data/app/assets/builds/nquery/application.css +22 -2
  5. data/app/assets/builds/nquery/application.js +48 -34
  6. data/app/controllers/concerns/nquery/breadcrumbs.rb +2 -13
  7. data/app/controllers/concerns/nquery/chart_actions.rb +4 -4
  8. data/app/controllers/concerns/nquery/chart_results.rb +18 -4
  9. data/app/controllers/concerns/nquery/sharing_page.rb +38 -0
  10. data/app/controllers/nquery/application_controller.rb +2 -0
  11. data/app/controllers/nquery/dashboards_controller.rb +8 -3
  12. data/app/controllers/nquery/embed/charts_controller.rb +15 -13
  13. data/app/controllers/nquery/embed/dashboards_controller.rb +18 -8
  14. data/app/controllers/nquery/public/charts_controller.rb +19 -0
  15. data/app/controllers/nquery/public/dashboards_controller.rb +20 -0
  16. data/app/controllers/nquery/public_resources_controller.rb +79 -0
  17. data/app/controllers/nquery/queries_controller.rb +5 -49
  18. data/app/controllers/nquery/sharing/base_controller.rb +89 -0
  19. data/app/controllers/nquery/sharing/embed_tokens_controller.rb +41 -0
  20. data/app/controllers/nquery/sharing/embeddings_controller.rb +17 -0
  21. data/app/controllers/nquery/sharing/public_links_controller.rb +21 -0
  22. data/app/helpers/nquery/charts_helper.rb +13 -0
  23. data/app/helpers/nquery/sharing_helper.rb +67 -0
  24. data/app/javascript/nquery/controllers/chart_preview_controller.js +19 -1
  25. data/app/javascript/nquery/controllers/clipboard_controller.js +23 -0
  26. data/app/models/concerns/nquery/shareable.rb +43 -0
  27. data/app/models/nquery/chart.rb +14 -2
  28. data/app/models/nquery/dashboard.rb +2 -0
  29. data/app/models/nquery/data_source.rb +1 -1
  30. data/app/models/nquery/query.rb +4 -0
  31. data/app/views/layouts/nquery/embed.html.erb +3 -1
  32. data/app/views/nquery/charts/_builder_form.html.erb +1 -6
  33. data/app/views/nquery/charts/embed.html.erb +5 -11
  34. data/app/views/nquery/charts/new.html.erb +2 -1
  35. data/app/views/nquery/dashboard/charts/new.html.erb +2 -1
  36. data/app/views/nquery/dashboards/embed.html.erb +9 -0
  37. data/app/views/nquery/dashboards/show.html.erb +4 -0
  38. data/app/views/nquery/embed/charts/show.html.erb +7 -8
  39. data/app/views/nquery/embed/dashboards/show.html.erb +12 -6
  40. data/app/views/nquery/embed/unavailable.html.erb +1 -0
  41. data/app/views/nquery/shared/_icon.html.erb +6 -0
  42. data/app/views/nquery/shared/_shared_chart_card.html.erb +20 -0
  43. data/app/views/nquery/shared/_sharing_panel.html.erb +94 -0
  44. data/config/routes.rb +20 -1
  45. data/db/migrate/20260907120000_add_sharing_to_nquery_charts_and_dashboards.rb +19 -0
  46. data/db/migrate/20260907130000_remove_embedding_params_from_nquery_charts_and_dashboards.rb +8 -0
  47. data/lib/nquery/boolean.rb +9 -0
  48. data/lib/nquery/configuration.rb +6 -0
  49. data/lib/nquery/embed_token_service.rb +10 -5
  50. data/lib/nquery/encryption.rb +1 -1
  51. data/lib/nquery/seeder.rb +20 -6
  52. data/lib/nquery/version.rb +1 -1
  53. data/lib/nquery.rb +1 -0
  54. data/nquery.gemspec +2 -1
  55. metadata +25 -9
  56. data/app/javascript/nquery/controllers/query_editor_controller.js +0 -41
  57. data/app/views/nquery/queries/edit.html.erb +0 -40
  58. data/app/views/nquery/queries/new.html.erb +0 -37
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 475c8f1287dfc8b45a2ff64e75d57ededcef827fcc3e8ebaa3a5a599fb226e17
4
- data.tar.gz: 76c1a043316080c5010e64a55c7c3727e833b35053be4a9e08fe22dbedc740aa
3
+ metadata.gz: 16f10a8cbf379951ca3ba531d4692f3caa98ff41bedd49ebdb77fcc44244a6c7
4
+ data.tar.gz: 32a28cf89669ce5b093e090a22fe54cbd7c40b2f6ca9cd374498c601ad63d735
5
5
  SHA512:
6
- metadata.gz: 5148a02417bfdc0f21d775c13342bc9d02188880985c348ff1dc387d5215b8b82857185b222786e647f5d3cc06c9522138788a9e7fa55c7e1f812614884788e0
7
- data.tar.gz: 0cdb4fcaf1088266a3c76a9aabcd03a149977b51d0b85d60ad7cdfd2a830f25e9e05bd7b83aec6f9ba36a51c7689199a5e003b923209ee632cf847071da31d30
6
+ metadata.gz: e3a624fc4b6737a76b3a702a3d7a3c700f7ff6e416e5878b60c744ad2efc949b3002d4cf632201a91851ff6b9724fe23793f2ce3eea0c8147dad7529e71b352d
7
+ data.tar.gz: 4364b0dada29cb6324dba7c2e1649d47ea714f5a6edaf3900a05865066798dbe4852bd2f614863a9295f3fd3f7406b5ad0e0893572ad97983256b9d015daee54
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.3] - 2026-09-08
6
+
7
+ Host apps should `bundle update nquery` and run `rails db:migrate`. Re-run `rails generate nquery:install` (or add the sharing/embed keys to `config/initializers/nquery.rb`) if you want public links or static embeds.
8
+
9
+ ### Added
10
+ - Public sharing links and static signed embeds for charts and dashboards (Metabase-style)
11
+ - Sharing UI with expiry picker, revoke/regenerate, and copy-to-clipboard
12
+ - Real query execution on public and embed pages, including dashboard card grids
13
+
14
+ ### Changed
15
+ - Public embed endpoints require `static_embedding_enabled` and per-resource `enable_embedding`
16
+ - Embed layout loads application JS so chart previews initialize, and allows cross-origin iframes via CSP `frame-ancestors`
17
+ - Remove standalone query pages so SQL lives only on charts
18
+
19
+ ### Fixed
20
+ - Query runner and schema endpoints honor the engine mount path (#25)
21
+
22
+ ## [0.1.2] - 2026-09-07
23
+
24
+ ### Changed
25
+ - Publish homepage URL https://github.com/caiohenrique12/nquery (RubyGems does not allow repushing 0.1.1)
26
+
3
27
  ## [0.1.1] - 2026-09-07
4
28
 
5
29
  Host apps should `bundle update nquery` and run `rails db:migrate`. Re-run `rails generate nquery:install` if you installed 0.1.0 and need the updated initializer.
@@ -15,6 +39,7 @@ Host apps should `bundle update nquery` and run `rails db:migrate`. Re-run `rail
15
39
  - Hide the home New dashboard CTA when the user cannot curate
16
40
  - Tighten Devise (`>= 5.0.4`) and Puma (`>= 7.2.1`) minimums for known CVEs
17
41
  - Bump sqlite3 to `>= 2.9.6` (GHSA-mwm8-39rw-8826)
42
+ - Point gem homepage and source URLs to https://github.com/caiohenrique12/nquery
18
43
 
19
44
  ### Fixed
20
45
  - PostgreSQL adapter connections
data/README.md CHANGED
@@ -21,6 +21,7 @@ nquery lets a host Rails app expose a self-contained analytics UI. It:
21
21
  - [First admin onboarding](#first-admin-onboarding)
22
22
  - [Mail and SMTP](#mail-and-smtp)
23
23
  - [Data sources](#data-sources)
24
+ - [Sharing and embedding](#sharing-and-embedding)
24
25
  - [Active Storage](#active-storage)
25
26
  - [Development](#development)
26
27
  - [Contributing](#contributing)
@@ -135,6 +136,9 @@ end
135
136
  | `query_timeout` | `15` | Query timeout (seconds) |
136
137
  | `query_row_limit` | `10_000` | Max rows returned by a query |
137
138
  | `embed_secret` | `nil` | Override for embed token signing (falls back to `secret_key_base`) |
139
+ | `public_sharing_enabled` | `false` | Allow public UUID links for charts and dashboards |
140
+ | `static_embedding_enabled` | `false` | Allow signed iframe embeds for charts and dashboards |
141
+ | `embed_frame_ancestors` | `nil` | CSP `frame-ancestors` origins (`nil` allows any origin) |
138
142
 
139
143
  **Not supported in config:** storing database credentials for data sources, SSO/hybrid auth hooks, or public self-registration.
140
144
 
@@ -233,6 +237,56 @@ config.default_data_source = :main
233
237
 
234
238
  After setup, review sources under **Admin → Data sources**.
235
239
 
240
+ ## Sharing and embedding
241
+
242
+ nquery mirrors Metabase’s two sharing mechanisms for **charts** and **dashboards** (not collections). Both are **off by default** and must be enabled in the host initializer:
243
+
244
+ ```ruby
245
+ Nquery.configure do |config|
246
+ config.public_sharing_enabled = true
247
+ config.static_embedding_enabled = true
248
+ config.embed_secret = Rails.application.secret_key_base
249
+ # Optional: restrict which sites may iframe embeds
250
+ # config.embed_frame_ancestors = ["https://app.example.com"]
251
+ end
252
+ ```
253
+
254
+ Users with **view** access can open **Embed** on a chart or dashboard. Creating, revoking, or regenerating links and tokens requires **curate** access on the collection and `view_data` on the underlying data sources.
255
+
256
+ Public and embed endpoints rate-limit to 60 requests per minute per IP when the host uses Rails 7.2+ `rate_limit`. Older Rails versions log a warning and skip rate limiting.
257
+
258
+ ### Public links
259
+
260
+ An unguessable UUID stored on the resource. No expiry and no signature. Removing the link clears the UUID.
261
+
262
+ | Resource | Path |
263
+ |----------|------|
264
+ | Chart | `/public/charts/:uuid` |
265
+ | Dashboard | `/public/dashboards/:uuid` |
266
+
267
+ ### Static embeds
268
+
269
+ HMAC-signed tokens (`Nquery::EmbedToken` + `EmbedTokenService`) with optional expiry. The resource must also have **Enable embedding** turned on. Revoked or expired tokens return 403.
270
+
271
+ | Resource | Path |
272
+ |----------|------|
273
+ | Chart | `/embed/charts/show?token=…` |
274
+ | Dashboard | `/embed/dashboards/show?token=…` |
275
+
276
+ Tokens stay in the query string because the signed value contains a `.` that would collide with Rails format parsing in a path segment.
277
+
278
+ ### Hosting embeds (CSP / framing)
279
+
280
+ Public and embed responses:
281
+
282
+ * Delete Rails’ default `X-Frame-Options: SAMEORIGIN` so cross-origin iframes work
283
+ * Set `Content-Security-Policy: frame-ancestors *` (or the origins in `embed_frame_ancestors`)
284
+ * Send `X-Robots-Tag: noindex`
285
+
286
+ Host apps that set a global CSP should allow the nquery origin in `frame-src` (or `child-src`) on the page that contains the iframe.
287
+
288
+ Suggested iframe defaults from the Embed page: `width="800"` `height="600"` (charts) or `800` (dashboards), `loading="lazy"`. Optional query params: `titled=false`, `bordered=false`, `theme=night` or `theme=transparent`.
289
+
236
290
  ## Active Storage
237
291
 
238
292
  Required for organization `logo` and `cover_image` during company onboarding (and later branding):
@@ -9,9 +9,10 @@
9
9
  --nq-muted: #64748b;
10
10
  --nq-card: #ffffff;
11
11
  --nq-danger: #ef4444;
12
+ --nq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
12
13
  }
13
14
  * { box-sizing: border-box; }
14
- body.nq-body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: var(--nq-text); background: var(--nq-bg); }
15
+ body.nq-body { margin: 0; font-family: var(--nq-font); color: var(--nq-text); background: var(--nq-bg); }
15
16
  .nq-layout { display: flex; min-height: 100vh; }
16
17
  .nq-sidebar { width: 240px; background: var(--nq-sidebar); color: #fff; padding: 1rem; flex-shrink: 0; }
17
18
  .nq-logo { font-size: 1.5rem; font-weight: 700; margin-bottom: 2rem; color: var(--nq-primary); }
@@ -279,7 +280,8 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
279
280
  .nq-radio { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 1rem; }
280
281
  .nq-checkbox { display: block; margin-bottom: 0.25rem; }
281
282
  .nq-csv-preview { margin: 1rem 0; overflow: auto; }
282
- .nq-embed-body { margin: 0; padding: 1rem; background: #fff; }
283
+ .nq-embed-body { margin: 0; padding: 1rem; background: #fff; font-family: var(--nq-font); color: var(--nq-text); }
284
+ .nq-embed-body h1 { margin: 0 0 1rem; font-size: 1.5rem; font-weight: 600; color: inherit; }
283
285
  .nq-icon-lg { width: 2.5rem; height: 2.5rem; color: var(--nq-muted); }
284
286
  .nq-type-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.25rem 0.625rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; white-space: nowrap; }
285
287
  .nq-type-badge-folder { background: #fef3c7; color: #92400e; }
@@ -394,3 +396,21 @@ button.nq-menu-item-button.is-loading::before { content: ""; width: 1em; height:
394
396
  .nq-logs-empty td { display: block; }
395
397
  .nq-logs-empty td::before { content: none; }
396
398
  }
399
+ .nq-sharing-section { margin-bottom: 1.25rem; }
400
+ .nq-sharing-section h2 { margin-top: 0; }
401
+ .nq-sharing-section h3 { margin: 1rem 0 0.5rem; font-size: 0.9375rem; }
402
+ .nq-sharing-section > .nq-actions { margin-top: 1rem; }
403
+ .nq-sharing-token-form { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem; }
404
+ .nq-sharing-token-form .nq-form-row { margin-bottom: 0; }
405
+ .nq-copy-field { display: flex; align-items: flex-start; gap: 0.5rem; }
406
+ .nq-copy-field .nq-code { flex: 1; margin: 0; white-space: pre-wrap; word-break: break-all; }
407
+ .nq-copy-field .nq-btn { flex-shrink: 0; }
408
+ .nq-embed-grid { margin-top: 1rem; }
409
+ .nq-chart-error { margin: 0; padding: 1rem; border-radius: 6px; background: #fef2f2; color: #991b1b; }
410
+ .nq-chart-preview.is-error { min-height: 0; }
411
+ .nq-embed-body.nq-embed-untitled h1 { display: none; }
412
+ .nq-embed-body.nq-embed-borderless .nq-dashboard-card { border-color: transparent; box-shadow: none; }
413
+ .nq-embed-body.nq-embed-theme-night { background: #0f172a; color: #e2e8f0; }
414
+ .nq-embed-body.nq-embed-theme-night .nq-dashboard-card { background: #1e293b; border-color: #334155; }
415
+ .nq-embed-body.nq-embed-theme-night .nq-chart-error { background: #450a0a; color: #fecaca; }
416
+ .nq-embed-body.nq-embed-theme-transparent { background: transparent; }
@@ -263,7 +263,7 @@ function initButtonLoaders() {
263
263
 
264
264
  document.addEventListener("click", (event) => {
265
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
266
+ if (!button || button.classList.contains("is-loading") || button.dataset.managesLoading === "true" || button.hasAttribute("data-copy-button")) return
267
267
  setButtonLoading(button, true)
268
268
  })
269
269
 
@@ -343,12 +343,23 @@ function buildPreviewChartConfig(type, data, xCol, yCol) {
343
343
  }
344
344
  }
345
345
 
346
+ const NQ_CHART_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
347
+
348
+ function applyChartFontDefaults() {
349
+ if (typeof Chart === "undefined") return
350
+
351
+ Chart.defaults.font.family = NQ_CHART_FONT
352
+ Chart.defaults.color = document.body.classList.contains("nq-embed-theme-night") ? "#e2e8f0" : "#1e293b"
353
+ }
354
+
346
355
  function initChartPreviews() {
347
356
  if (typeof Chart === "undefined") {
348
357
  requestAnimationFrame(initChartPreviews)
349
358
  return
350
359
  }
351
360
 
361
+ applyChartFontDefaults()
362
+
352
363
  document.querySelectorAll("[data-controller='chart-preview']").forEach(el => {
353
364
  if (el.dataset.chartPreviewInitialized === "true") return
354
365
  el.dataset.chartPreviewInitialized = "true"
@@ -368,11 +379,17 @@ function initChartPreviews() {
368
379
  rows: [["Jan", 1200], ["Feb", 1800], ["Mar", 2400], ["Apr", 2100]]
369
380
  }
370
381
 
382
+ if (data?.error) {
383
+ el.classList.add("is-error")
384
+ el.innerHTML = `<div class="nq-chart-error">${escapeHtml(data.error)}</div>`
385
+ return
386
+ }
387
+
371
388
  if (type === "number") {
372
389
  const yIndex = columnIndex(demo.columns || [], yCol || demo.columns?.[1] || demo.columns?.[0])
373
390
  const value = demo.rows?.[0]?.[yIndex] ?? "—"
374
391
  el.classList.add("is-number")
375
- el.innerHTML = `<div class="nq-number-display">${value}</div>`
392
+ el.innerHTML = `<div class="nq-number-display">${escapeHtml(value)}</div>`
376
393
  return
377
394
  }
378
395
 
@@ -448,6 +465,8 @@ function initChartBuilders() {
448
465
  root.querySelector("[data-action*='chart-builder#format']")
449
466
  const saveStatus = root.querySelector("[data-chart-builder-target='saveStatus']")
450
467
  const querySaveUrl = root.dataset.querySaveUrl
468
+ const queryRunUrl = root.dataset.queryRunUrl
469
+ const querySchemaUrl = root.dataset.querySchemaUrl
451
470
 
452
471
  let currentResult = null
453
472
  let chartInstance = null
@@ -678,7 +697,7 @@ function initChartBuilders() {
678
697
  const yCol = yAxis?.value || result.columns[1] || result.columns[0]
679
698
  const yIndex = columnIndex(result.columns, yCol)
680
699
  const value = result.rows[0]?.[yIndex] ?? "—"
681
- numberWrap.innerHTML = `<div class="nq-number-display">${value}</div>`
700
+ numberWrap.innerHTML = `<div class="nq-number-display">${escapeHtml(value)}</div>`
682
701
  chartWrap?.setAttribute("hidden", "")
683
702
  numberWrap?.removeAttribute("hidden")
684
703
  }
@@ -830,7 +849,7 @@ function initChartBuilders() {
830
849
  setButtonLoading(button, true)
831
850
  try {
832
851
  const csrf = document.querySelector('meta[name="csrf-token"]')?.content
833
- const res = await fetch("/queries/run", {
852
+ const res = await fetch(queryRunUrl, {
834
853
  method: "POST",
835
854
  headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
836
855
  body: JSON.stringify({ statement: getStatement(), data_source_id: dataSource?.value })
@@ -851,7 +870,7 @@ function initChartBuilders() {
851
870
 
852
871
  const loadSchema = async () => {
853
872
  if (!schema || !dataSource) return
854
- const response = await fetch(`/queries/schema?data_source_id=${dataSource.value}`)
873
+ const response = await fetch(`${querySchemaUrl}?data_source_id=${dataSource.value}`)
855
874
  const data = await response.json()
856
875
  renderSchemaTree(schema, data.tables)
857
876
  }
@@ -959,34 +978,6 @@ function initChartBuilders() {
959
978
  })
960
979
  }
961
980
 
962
- function initQueryEditors() {
963
- document.querySelectorAll("[data-controller='query-editor']").forEach(el => {
964
- const statement = el.querySelector("[data-query-editor-target='statement']")
965
- const results = el.querySelector("[data-query-editor-target='results']")
966
- const dataSource = el.querySelector("[data-query-editor-target='dataSource']")
967
- el.querySelector("[data-action*='query-editor#run']")?.addEventListener("click", async (event) => {
968
- const button = event.currentTarget
969
- setButtonLoading(button, true)
970
- try {
971
- const csrf = document.querySelector('meta[name="csrf-token"]')?.content
972
- const res = await fetch("/queries/run", {
973
- method: "POST",
974
- headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
975
- body: JSON.stringify({ statement: statement?.value, data_source_id: dataSource?.value })
976
- })
977
- results.textContent = JSON.stringify(await res.json(), null, 2)
978
- } finally {
979
- setButtonLoading(button, false)
980
- }
981
- })
982
- el.querySelectorAll("[data-action*='insertTable']").forEach(item => {
983
- item.addEventListener("click", () => {
984
- if (statement) statement.value = `SELECT * FROM ${item.dataset.table} LIMIT 100`
985
- })
986
- })
987
- })
988
- }
989
-
990
981
  function initDataSourceForms() {
991
982
  document.querySelectorAll("[data-controller='data-source-form']").forEach(root => {
992
983
  if (root.dataset.dataSourceFormInitialized === "true") return
@@ -1060,15 +1051,38 @@ function initDataSourceForms() {
1060
1051
  })
1061
1052
  }
1062
1053
 
1054
+ function initCopyButtons() {
1055
+ document.querySelectorAll("[data-copy-button]").forEach(button => {
1056
+ if (button.dataset.copyInitialized === "true") return
1057
+ button.dataset.copyInitialized = "true"
1058
+
1059
+ button.addEventListener("click", async () => {
1060
+ const target = button.dataset.copyTarget ? document.querySelector(button.dataset.copyTarget) : null
1061
+ const text = target ? target.textContent : (button.dataset.copyText || "")
1062
+ const original = button.textContent
1063
+
1064
+ try {
1065
+ await navigator.clipboard.writeText(text.trim())
1066
+ button.textContent = "Copied"
1067
+ } catch {
1068
+ button.textContent = "Copy failed"
1069
+ }
1070
+
1071
+ setTimeout(() => { button.textContent = original }, 1500)
1072
+ })
1073
+ })
1074
+ }
1075
+
1063
1076
  function initPage() {
1077
+ applyChartFontDefaults()
1064
1078
  initButtonLoaders()
1065
1079
  initToastEvents()
1066
1080
  observeToastStack()
1067
1081
  initFlashCards()
1068
- initQueryEditors()
1069
1082
  initChartBuilders()
1070
1083
  initChartPreviews()
1071
1084
  initDataSourceForms()
1085
+ initCopyButtons()
1072
1086
  }
1073
1087
 
1074
1088
  function bootPage() {
@@ -53,9 +53,9 @@ module Nquery
53
53
  case action_name
54
54
  when "show"
55
55
  append_resource_breadcrumb(crumbs, breadcrumb_dashboard)
56
- when "edit"
56
+ when "edit", "embed"
57
57
  append_resource_breadcrumb(crumbs, breadcrumb_dashboard, path: dashboard_path(breadcrumb_dashboard), link: true)
58
- append_terminal_breadcrumb(crumbs, "Edit")
58
+ append_terminal_breadcrumb(crumbs, action_name.titleize)
59
59
  when "new"
60
60
  append_terminal_breadcrumb(crumbs, "New dashboard")
61
61
  end
@@ -86,9 +86,6 @@ module Nquery
86
86
  when "new"
87
87
  append_terminal_breadcrumb(crumbs, "New chart")
88
88
  end
89
- when "nquery/queries"
90
- append_section_breadcrumb(crumbs, "Queries", nil)
91
- append_terminal_breadcrumb(crumbs, query_breadcrumb_label)
92
89
  when "nquery/charts"
93
90
  append_section_breadcrumb(crumbs, "Collections", collections_path)
94
91
  if breadcrumb_chart&.collection
@@ -208,14 +205,6 @@ module Nquery
208
205
  @breadcrumb_data_source ||= @data_source || DataSource.find_by(id: params[:id])
209
206
  end
210
207
 
211
- def query_breadcrumb_label
212
- case action_name
213
- when "new" then "New SQL query"
214
- when "edit" then "Edit query: #{@query&.name.presence || 'Untitled'}"
215
- else @query&.name.presence || "Query"
216
- end
217
- end
218
-
219
208
  def append_section_breadcrumb(crumbs, label, path)
220
209
  crumbs << { label: label, path: path, section: true }
221
210
  end
@@ -6,6 +6,7 @@ module Nquery
6
6
 
7
7
  included do
8
8
  include ChartResults
9
+ include SharingPage
9
10
  before_action :set_chart, only: %i[show edit update embed destroy archive]
10
11
  before_action :authorize_chart_view!, only: %i[show embed]
11
12
  before_action :authorize_chart_curate!, only: %i[edit update destroy archive]
@@ -47,8 +48,7 @@ module Nquery
47
48
  end
48
49
 
49
50
  def embed
50
- @embed_token = EmbedToken.active.find_by(resource_type: "Nquery::Chart", resource_id: @chart.id)
51
- @embed_url = @embed_token ? embed_public_chart_url(token: EmbedTokenService.signed_token_for(@embed_token)) : nil
51
+ assign_sharing_page(@chart)
52
52
  render "nquery/charts/embed"
53
53
  end
54
54
 
@@ -94,8 +94,8 @@ module Nquery
94
94
 
95
95
  def load_chart_builder_assigns
96
96
  @data_sources = DataSource.active.order(:name)
97
- @schema = schema_for(@chart&.query&.data_source)
98
- @result = chart_builder_result(@chart) if @chart&.persisted? && @chart.query&.statement.present?
97
+ @schema = schema_for(@chart&.data_source)
98
+ @result = chart_builder_result(@chart) if @chart&.persisted? && @chart.statement?
99
99
  end
100
100
 
101
101
  def schema_for(data_source = nil)
@@ -7,7 +7,7 @@ module Nquery
7
7
  private
8
8
 
9
9
  def chart_result(chart)
10
- return demo_result unless chart.query&.statement.present?
10
+ return demo_result unless chart.statement?
11
11
 
12
12
  chart_query_result(chart)
13
13
  rescue StandardError
@@ -15,7 +15,7 @@ module Nquery
15
15
  end
16
16
 
17
17
  def chart_builder_result(chart)
18
- return nil unless chart.query&.statement.present?
18
+ return nil unless chart.statement?
19
19
 
20
20
  chart_query_result(chart, audit: false)
21
21
  rescue QueryRunner::PermissionError, QueryRunner::Error => e
@@ -24,10 +24,20 @@ module Nquery
24
24
  { error: e.message }
25
25
  end
26
26
 
27
+ def shared_chart_result(chart)
28
+ return { error: "This chart has no query." } unless chart.statement?
29
+ return { error: shared_chart_load_error } unless chart.data_source
30
+
31
+ chart_query_result(chart, audit: false)
32
+ rescue StandardError => e
33
+ Rails.logger.error("[nquery] shared chart #{chart.id} failed: #{e.class}: #{e.message}")
34
+ { error: shared_chart_load_error }
35
+ end
36
+
27
37
  def chart_query_result(chart, audit: true)
28
38
  QueryRunner.new(
29
- data_source: chart.query.data_source || DataSource.first,
30
- statement: chart.query.statement,
39
+ data_source: chart.data_source || DataSource.first,
40
+ statement: chart.statement,
31
41
  user: current_nquery_user,
32
42
  query: chart.query
33
43
  ).run(audit: audit)
@@ -40,5 +50,9 @@ module Nquery
40
50
  row_count: 4
41
51
  }
42
52
  end
53
+
54
+ def shared_chart_load_error
55
+ "This chart could not be loaded."
56
+ end
43
57
  end
44
58
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module SharingPage
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def assign_sharing_page(resource)
10
+ @shareable = resource
11
+ @embed_token = resource.active_embed_token
12
+ @embed_url = signed_embed_url_for(resource)
13
+ @public_url = public_share_url_for(resource)
14
+ end
15
+
16
+ def signed_embed_url_for(resource)
17
+ token = resource.active_embed_token
18
+ return unless token
19
+
20
+ signed = EmbedTokenService.signed_token_for(token)
21
+ if resource.is_a?(Dashboard)
22
+ embed_public_dashboard_url(token: signed)
23
+ else
24
+ embed_public_chart_url(token: signed)
25
+ end
26
+ end
27
+
28
+ def public_share_url_for(resource)
29
+ return unless resource.publicly_shared?
30
+
31
+ if resource.is_a?(Dashboard)
32
+ public_dashboard_url(resource.public_uuid)
33
+ else
34
+ public_chart_url(resource.public_uuid)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -40,6 +40,8 @@ module Nquery
40
40
  nquery/onboarding/confirmations
41
41
  nquery/embed/charts
42
42
  nquery/embed/dashboards
43
+ nquery/public/charts
44
+ nquery/public/dashboards
43
45
  nquery/errors
44
46
  ])
45
47
  end
@@ -4,9 +4,10 @@ module Nquery
4
4
  class DashboardsController < ApplicationController
5
5
  include ChartResults
6
6
  include Browsable
7
+ include SharingPage
7
8
 
8
- before_action :set_dashboard, only: %i[show edit update destroy archive unarchive update_layout]
9
- before_action :authorize_dashboard_view!, only: %i[show]
9
+ before_action :set_dashboard, only: %i[show edit update destroy archive unarchive update_layout embed]
10
+ before_action :authorize_dashboard_view!, only: %i[show embed]
10
11
  before_action :authorize_dashboard_curate!, only: %i[edit update destroy archive unarchive update_layout]
11
12
 
12
13
  def index
@@ -44,6 +45,10 @@ module Nquery
44
45
  @card_results = @dashboard_cards.index_with { |card| chart_result(card.chart) }
45
46
  end
46
47
 
48
+ def embed
49
+ assign_sharing_page(@dashboard)
50
+ end
51
+
47
52
  def edit
48
53
  @collections = assignable_collections
49
54
  end
@@ -85,7 +90,7 @@ module Nquery
85
90
  private
86
91
 
87
92
  def set_dashboard
88
- @dashboard = if action_name.in?(%w[show edit])
93
+ @dashboard = if action_name.in?(%w[show edit embed])
89
94
  Dashboard.includes(:creator, dashboard_cards: :chart).find(params[:id])
90
95
  else
91
96
  Dashboard.find(params[:id])
@@ -2,23 +2,25 @@
2
2
 
3
3
  module Nquery
4
4
  module Embed
5
- class ChartsController < ActionController::Base
6
- include Nquery::Engine.routes.url_helpers
7
- protect_from_forgery with: :exception
8
- layout "nquery/embed"
9
-
5
+ class ChartsController < PublicResourcesController
10
6
  def show
7
+ unless Nquery.configuration.static_embedding_enabled
8
+ return render_forbidden("Embedding is disabled")
9
+ end
10
+
11
11
  payload = EmbedTokenService.verify(params[:token])
12
- @chart = Chart.find(payload[:resource_id])
13
- @result = demo_result
14
- rescue EmbedTokenService::Error
15
- render plain: "Invalid or expired embed token", status: :forbidden
16
- end
12
+ unless payload[:resource_type] == "Nquery::Chart"
13
+ return render_forbidden
14
+ end
17
15
 
18
- private
16
+ @chart = Chart.active.find(payload[:resource_id])
17
+ unless @chart.enable_embedding?
18
+ return render_forbidden("Embedding is disabled")
19
+ end
19
20
 
20
- def demo_result
21
- { columns: %w[month revenue], rows: [%w[Jan 1200], %w[Feb 1800], %w[Mar 2400]] }
21
+ @result = shared_chart_result(@chart)
22
+ rescue EmbedTokenService::Error, ActiveRecord::RecordNotFound
23
+ render_forbidden
22
24
  end
23
25
  end
24
26
  end
@@ -2,16 +2,26 @@
2
2
 
3
3
  module Nquery
4
4
  module Embed
5
- class DashboardsController < ActionController::Base
6
- include Nquery::Engine.routes.url_helpers
7
- protect_from_forgery with: :exception
8
- layout "nquery/embed"
9
-
5
+ class DashboardsController < PublicResourcesController
10
6
  def show
7
+ unless Nquery.configuration.static_embedding_enabled
8
+ return render_forbidden("Embedding is disabled")
9
+ end
10
+
11
11
  payload = EmbedTokenService.verify(params[:token])
12
- @dashboard = Dashboard.includes(dashboard_cards: :chart).find(payload[:resource_id])
13
- rescue EmbedTokenService::Error
14
- render plain: "Invalid or expired embed token", status: :forbidden
12
+ unless payload[:resource_type] == "Nquery::Dashboard"
13
+ return render_forbidden
14
+ end
15
+
16
+ @dashboard = Dashboard.active.find(payload[:resource_id])
17
+ unless @dashboard.enable_embedding?
18
+ return render_forbidden("Embedding is disabled")
19
+ end
20
+
21
+ @dashboard_cards = load_dashboard_cards(@dashboard)
22
+ @card_results = @dashboard_cards.index_with { |card| shared_chart_result(card.chart) }
23
+ rescue EmbedTokenService::Error, ActiveRecord::RecordNotFound
24
+ render_forbidden
15
25
  end
16
26
  end
17
27
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module Public
5
+ class ChartsController < PublicResourcesController
6
+ def show
7
+ unless Nquery.configuration.public_sharing_enabled
8
+ return render_unavailable(status: :not_found)
9
+ end
10
+
11
+ @chart = Chart.active.find_by!(public_uuid: params[:uuid])
12
+ @result = shared_chart_result(@chart)
13
+ render template: "nquery/embed/charts/show"
14
+ rescue ActiveRecord::RecordNotFound
15
+ render_unavailable(status: :not_found)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module Public
5
+ class DashboardsController < PublicResourcesController
6
+ def show
7
+ unless Nquery.configuration.public_sharing_enabled
8
+ return render_unavailable(status: :not_found)
9
+ end
10
+
11
+ @dashboard = Dashboard.active.find_by!(public_uuid: params[:uuid])
12
+ @dashboard_cards = load_dashboard_cards(@dashboard)
13
+ @card_results = @dashboard_cards.index_with { |card| shared_chart_result(card.chart) }
14
+ render template: "nquery/embed/dashboards/show"
15
+ rescue ActiveRecord::RecordNotFound
16
+ render_unavailable(status: :not_found)
17
+ end
18
+ end
19
+ end
20
+ end