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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module Embed
5
+ class ChartsController < ActionController::Base
6
+ include Nquery::Engine.routes.url_helpers
7
+ layout "nquery/embed"
8
+
9
+ def show
10
+ payload = EmbedTokenService.verify(params[:token])
11
+ @chart = Chart.find(payload[:resource_id])
12
+ @result = demo_result
13
+ rescue EmbedTokenService::Error
14
+ render plain: "Invalid or expired embed token", status: :forbidden
15
+ end
16
+
17
+ private
18
+
19
+ def demo_result
20
+ { columns: %w[month revenue], rows: [%w[Jan 1200], %w[Feb 1800], %w[Mar 2400]] }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module Embed
5
+ class DashboardsController < ActionController::Base
6
+ include Nquery::Engine.routes.url_helpers
7
+ layout "nquery/embed"
8
+
9
+ def show
10
+ payload = EmbedTokenService.verify(params[:token])
11
+ @dashboard = Dashboard.includes(dashboard_cards: :chart).find(payload[:resource_id])
12
+ rescue EmbedTokenService::Error
13
+ render plain: "Invalid or expired embed token", status: :forbidden
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class HomeController < ApplicationController
5
+ include Browsable
6
+
7
+ def index
8
+ charts = Chart.active.includes(:collection, :creator).order(updated_at: :desc).limit(12)
9
+ dashboards = Dashboard.active.includes(:collection, :creator, :dashboard_cards).order(updated_at: :desc).limit(12)
10
+
11
+ @recent_charts = filter_viewable_charts(charts).first(6)
12
+ @recent_dashboards = filter_viewable_dashboards(dashboards).first(6)
13
+ @root_collection = Collection.roots.first
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class ImportsController < ApplicationController
5
+ before_action :authorize_import_access!
6
+
7
+ def new
8
+ @csv_upload = CsvUpload.new
9
+ end
10
+
11
+ def create
12
+ @csv_upload = CsvImporter.new(
13
+ file: params[:file],
14
+ name: params[:name],
15
+ creator: current_nquery_user,
16
+ column_mapping: parse_mapping(params[:column_mapping])
17
+ ).import
18
+
19
+ redirect_to root_path, notice: "CSV import started: #{@csv_upload.name}"
20
+ rescue CsvImporter::Error => e
21
+ @csv_upload = CsvUpload.new(name: params[:name])
22
+ flash.now[:alert] = e.message
23
+ render :new, status: :unprocessable_content
24
+ end
25
+
26
+ private
27
+
28
+ def authorize_import_access!
29
+ root = Collection.roots.first || Collection.shared.first
30
+ authorize_collection_access!(root, required: :curate)
31
+ end
32
+
33
+ def parse_mapping(raw)
34
+ return {} if raw.blank?
35
+
36
+ JSON.parse(raw)
37
+ rescue JSON::ParserError
38
+ {}
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class QueriesController < ApplicationController
5
+ skip_before_action :_authenticate!, only: [] # always auth
6
+ before_action :set_query, only: %i[show edit update]
7
+ before_action :authorize_query_collection!, only: %i[show edit update]
8
+ before_action :set_data_source_for_run, only: %i[run schema]
9
+ before_action :authorize_run_data_source!, only: %i[run schema]
10
+
11
+ def new
12
+ @query = Query.new(statement: "SELECT 1 AS example")
13
+ @data_sources = DataSource.active.order(:name)
14
+ @schema_tables = schema_tables
15
+ end
16
+
17
+ def create
18
+ @query = Query.new(query_params.merge(creator: current_nquery_user))
19
+ if @query.save
20
+ redirect_to edit_query_path(@query), notice: "Query created."
21
+ else
22
+ @data_sources = DataSource.active.order(:name)
23
+ @schema_tables = schema_tables
24
+ render :new, status: :unprocessable_content
25
+ end
26
+ end
27
+
28
+ def edit
29
+ @data_sources = DataSource.active.order(:name)
30
+ @schema_tables = schema_tables
31
+ end
32
+
33
+ def update
34
+ if @query.update(query_params)
35
+ respond_to do |format|
36
+ format.html { redirect_to edit_query_path(@query), notice: "Query saved." }
37
+ format.json { render json: { ok: true, notice: "Query saved." } }
38
+ end
39
+ else
40
+ @data_sources = DataSource.active.order(:name)
41
+ @schema_tables = schema_tables
42
+ respond_to do |format|
43
+ format.html { render :edit, status: :unprocessable_content }
44
+ format.json {
45
+ render json: { error: @query.errors.full_messages.to_sentence.presence || "Query could not be saved." },
46
+ status: :unprocessable_content
47
+ }
48
+ end
49
+ end
50
+ end
51
+
52
+ def show
53
+ end
54
+
55
+ def run
56
+ result = QueryRunner.new(
57
+ data_source: @data_source,
58
+ statement: params[:statement],
59
+ user: current_nquery_user
60
+ ).run
61
+
62
+ render json: result
63
+ rescue QueryRunner::PermissionError => e
64
+ render json: { error: e.message }, status: :forbidden
65
+ rescue QueryRunner::Error => e
66
+ render json: { error: e.message }, status: :unprocessable_content
67
+ end
68
+
69
+ def schema
70
+ render json: { tables: Nquery::SchemaExplorer.tables_for(@data_source) }
71
+ end
72
+
73
+ private
74
+
75
+ def set_query
76
+ @query = Query.find(params[:id])
77
+ end
78
+
79
+ def authorize_query_collection!
80
+ authorize_collection_access!(@query.collection, required: :view)
81
+ end
82
+
83
+ def set_data_source_for_run
84
+ @data_source = DataSource.find(params[:data_source_id] || DataSource.first&.id)
85
+ end
86
+
87
+ def authorize_run_data_source!
88
+ authorize_data_source_access!(@data_source, permission_type: "view_data")
89
+ authorize_data_source_access!(@data_source, permission_type: "create_queries")
90
+ end
91
+
92
+ def query_params
93
+ params.require(:query).permit(:name, :statement, :data_source_id, :collection_id)
94
+ end
95
+
96
+ def schema_tables
97
+ data_source = DataSource.first
98
+ return [] unless data_source
99
+
100
+ DataSources::Adapter.for(data_source).tables
101
+ rescue StandardError
102
+ []
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class RegistrationsController < ApplicationController
5
+ skip_before_action :_authenticate!
6
+ layout "nquery/auth"
7
+
8
+ def new
9
+ redirect_to root_path if current_nquery_user
10
+ @user = User.new
11
+ end
12
+
13
+ def create
14
+ @user = User.new(registration_params)
15
+ if @user.save
16
+ @user.ensure_all_users_membership!
17
+ @user.ensure_personal_collection!
18
+ session[:nquery_user_id] = @user.id
19
+ redirect_to root_path, notice: "Welcome to nquery!"
20
+ else
21
+ render :new, status: :unprocessable_content
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def registration_params
28
+ params.require(:user).permit(:email, :first_name, :last_name, :password, :password_confirmation)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class SessionsController < ApplicationController
5
+ skip_before_action :_authenticate!
6
+ layout "nquery/auth"
7
+
8
+ def new
9
+ redirect_to root_path if current_nquery_user
10
+ end
11
+
12
+ def create
13
+ user = User.active.find_by(email: params[:email]&.downcase)
14
+ if user&.authenticate(params[:password])
15
+ session[:nquery_user_id] = user.id
16
+ redirect_to root_path, notice: "Signed in successfully."
17
+ else
18
+ flash.now[:alert] = "Invalid email or password."
19
+ render :new, status: :unprocessable_content
20
+ end
21
+ end
22
+
23
+ def destroy
24
+ session.delete(:nquery_user_id)
25
+ redirect_to login_path, notice: "Signed out."
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module ChartsHelper
5
+ def chart_dashboard
6
+ @dashboard
7
+ end
8
+
9
+ def chart_show_path(chart, dashboard: chart_dashboard)
10
+ dashboard ? dashboard_chart_path(dashboard, chart) : chart_path(chart)
11
+ end
12
+
13
+ def chart_edit_path(chart, dashboard: chart_dashboard)
14
+ dashboard ? edit_dashboard_chart_path(dashboard, chart) : edit_chart_path(chart)
15
+ end
16
+
17
+ def chart_embed_path(chart, dashboard: chart_dashboard)
18
+ dashboard ? embed_dashboard_chart_path(dashboard, chart) : embed_chart_path(chart)
19
+ end
20
+
21
+ def chart_archive_path(chart, dashboard: chart_dashboard)
22
+ dashboard ? archive_dashboard_chart_path(dashboard, chart) : archive_chart_path(chart)
23
+ end
24
+
25
+ def chart_form_path(chart, dashboard: chart_dashboard)
26
+ dashboard ? dashboard_chart_path(dashboard, chart) : chart_path(chart)
27
+ end
28
+
29
+ def chart_builder_chart_types
30
+ Chart::CHART_TYPES - %w[table]
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module CollectionsHelper
5
+ def collection_kind_label(collection)
6
+ case collection.kind
7
+ when "root" then "Top-level collection"
8
+ when "personal" then "Personal collection"
9
+ else "Collection"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module ComponentsHelper
5
+ def render_nquery_component(component, &block)
6
+ component.render_in(self, &block)
7
+ end
8
+
9
+ def nq_flash_cards(flash: self.flash)
10
+ render_nquery_component(FlashCardsComponent.new(flash: flash))
11
+ end
12
+
13
+ def nq_flash_card(message, type: :notice, **options)
14
+ render_nquery_component(FlashCardComponent.new(message: message, type: type, **options))
15
+ end
16
+
17
+ def nq_card(title: nil, **options, &block)
18
+ CardComponent.new(title: title, **options).render_in(self, &block)
19
+ end
20
+
21
+ def nq_card_table(columns:, **options, &block)
22
+ CardTableComponent.new(columns: columns, **options).render_in(self, &block)
23
+ end
24
+
25
+ def nq_infinite_scroll(url:, **options, &block)
26
+ InfiniteScrollComponent.new(url: url, **options).render_in(self, &block)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module HomeHelper
5
+ def home_greeting
6
+ hour = Time.zone.now.hour
7
+ salutation = if hour < 12
8
+ "Good morning"
9
+ elsif hour < 18
10
+ "Good afternoon"
11
+ else
12
+ "Good evening"
13
+ end
14
+
15
+ name = current_nquery_user&.first_name.presence || "there"
16
+ "#{salutation}, #{name}"
17
+ end
18
+
19
+ def chart_type_label(chart)
20
+ chart.chart_type.titleize
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module IconHelper
5
+ def nq_icon(name, **options)
6
+ render "nquery/shared/icon", name: name.to_s, html_options: options
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ module NavigationHelper
5
+ def nav_link_class(*controller_paths)
6
+ active = controller_paths.flatten.any? { |path| controller_path == path.to_s }
7
+ active ? "nq-nav-link active" : "nq-nav-link"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,125 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ function columnIndex(columns, name) {
4
+ const index = columns.indexOf(name)
5
+ return index >= 0 ? index : 0
6
+ }
7
+
8
+ function chartJsType(type) {
9
+ if (type === "area") return "line"
10
+ if (type === "scatter") return "scatter"
11
+ if (type === "pie") return "pie"
12
+ return type === "line" ? "line" : "bar"
13
+ }
14
+
15
+ function buildPreviewChartConfig(type, data, xCol, yCol) {
16
+ const columns = data.columns || []
17
+ const rows = data.rows || []
18
+ const xIndex = columnIndex(columns, xCol || columns[0])
19
+ const yIndex = columnIndex(columns, yCol || columns[1] || columns[0])
20
+ const labels = rows.map(row => row[xIndex])
21
+ const values = rows.map(row => Number(row[yIndex]) || 0)
22
+ const color = "#509ee3"
23
+ const yLabel = yCol || columns[yIndex] || "Value"
24
+
25
+ if (type === "scatter") {
26
+ return {
27
+ type: "scatter",
28
+ data: {
29
+ datasets: [{
30
+ label: yLabel,
31
+ data: rows.map(row => ({ x: Number(row[xIndex]) || 0, y: Number(row[yIndex]) || 0 })),
32
+ backgroundColor: color
33
+ }]
34
+ },
35
+ options: { responsive: true, maintainAspectRatio: false }
36
+ }
37
+ }
38
+
39
+ if (type === "pie") {
40
+ return {
41
+ type: "pie",
42
+ data: {
43
+ labels,
44
+ datasets: [{ data: values, backgroundColor: [color, "#7db8ea", "#a8d0f0", "#d4e8f8", "#2d7ec1"] }]
45
+ },
46
+ options: { responsive: true, maintainAspectRatio: false }
47
+ }
48
+ }
49
+
50
+ const jsType = chartJsType(type)
51
+ return {
52
+ type: jsType,
53
+ data: {
54
+ labels,
55
+ datasets: [{
56
+ label: yLabel,
57
+ data: values,
58
+ backgroundColor: color,
59
+ borderColor: color,
60
+ fill: type === "area",
61
+ tension: type === "line" || type === "area" ? 0.3 : 0
62
+ }]
63
+ },
64
+ options: { responsive: true, maintainAspectRatio: false }
65
+ }
66
+ }
67
+
68
+ export default class extends Controller {
69
+ static targets = ["canvas"]
70
+ static values = { type: String, data: Object, x: String, y: String }
71
+
72
+ connect() {
73
+ if (typeof Chart === "undefined") return
74
+
75
+ const demo = this.hasDataValue ? this.dataValue : {
76
+ columns: ["month", "revenue"],
77
+ rows: [["Jan", 1200], ["Feb", 1800], ["Mar", 2400]]
78
+ }
79
+ const type = this.typeValue || "bar"
80
+ const xCol = this.hasXValue ? this.xValue : ""
81
+ const yCol = this.hasYValue ? this.yValue : ""
82
+
83
+ if (type === "number") {
84
+ const yIndex = columnIndex(demo.columns || [], yCol || demo.columns?.[1] || demo.columns?.[0])
85
+ const value = demo.rows?.[0]?.[yIndex] ?? "—"
86
+ this.element.classList.add("is-number")
87
+ this.element.innerHTML = `<div class="nq-number-display">${value}</div>`
88
+ return
89
+ }
90
+
91
+ if (type === "table") {
92
+ const columns = demo.columns || []
93
+ const rows = (demo.rows || []).slice(0, 8)
94
+ const table = document.createElement("table")
95
+ table.className = "nq-data-table"
96
+ const thead = document.createElement("thead")
97
+ const headRow = document.createElement("tr")
98
+ columns.forEach(col => {
99
+ const th = document.createElement("th")
100
+ th.textContent = col
101
+ headRow.appendChild(th)
102
+ })
103
+ thead.appendChild(headRow)
104
+ table.appendChild(thead)
105
+ const tbody = document.createElement("tbody")
106
+ rows.forEach(row => {
107
+ const tr = document.createElement("tr")
108
+ row.forEach(cell => {
109
+ const td = document.createElement("td")
110
+ td.textContent = cell ?? ""
111
+ tr.appendChild(td)
112
+ })
113
+ tbody.appendChild(tr)
114
+ })
115
+ table.appendChild(tbody)
116
+ this.element.innerHTML = ""
117
+ this.element.appendChild(table)
118
+ return
119
+ }
120
+
121
+ if (!this.hasCanvasTarget) return
122
+
123
+ new Chart(this.canvasTarget, buildPreviewChartConfig(type, demo, xCol, yCol))
124
+ }
125
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["file", "preview", "mapping"]
5
+
6
+ preview() {
7
+ const file = this.fileTarget.files[0]
8
+ if (!file) return
9
+
10
+ const reader = new FileReader()
11
+ reader.onload = (e) => {
12
+ const lines = e.target.result.split("\n").slice(0, 6)
13
+ const headers = lines[0]?.split(",") || []
14
+ this.mappingTarget.value = JSON.stringify(
15
+ headers.map(h => ({ source: h.trim(), target: h.trim().toLowerCase().replace(/\s+/g, "_") }))
16
+ )
17
+ this.previewTarget.innerHTML = `<table class="nq-table"><tbody>${lines.map(l =>
18
+ `<tr>${l.split(",").map(c => `<td>${c.trim()}</td>`).join("")}</tr>`
19
+ ).join("")}</tbody></table>`
20
+ }
21
+ reader.readAsText(file)
22
+ }
23
+ }
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["card"]
5
+
6
+ connect() {
7
+ this.cardTargets.forEach(card => {
8
+ card.addEventListener("dragstart", this.dragStart.bind(this))
9
+ card.addEventListener("dragover", e => e.preventDefault())
10
+ card.addEventListener("drop", this.drop.bind(this))
11
+ })
12
+ }
13
+
14
+ dragStart(event) {
15
+ this.dragged = event.currentTarget
16
+ event.dataTransfer.effectAllowed = "move"
17
+ }
18
+
19
+ drop(event) {
20
+ event.preventDefault()
21
+ if (this.dragged && event.currentTarget !== this.dragged) {
22
+ event.currentTarget.parentNode.insertBefore(this.dragged, event.currentTarget)
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,41 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["statement", "results", "dataSource", "schema"]
5
+
6
+ insertTable(event) {
7
+ const table = event.currentTarget.dataset.table
8
+ const textarea = this.statementTarget
9
+ const insert = `SELECT * FROM ${table} LIMIT 100`
10
+ textarea.value = insert
11
+ }
12
+
13
+ async run() {
14
+ const statement = this.statementTarget.value
15
+ const dataSourceId = this.dataSourceTarget?.value
16
+ const csrf = document.querySelector('meta[name="csrf-token"]')?.content
17
+
18
+ try {
19
+ const response = await fetch("/queries/run", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
22
+ body: JSON.stringify({ statement, data_source_id: dataSourceId })
23
+ })
24
+ const data = await response.json()
25
+ this.resultsTarget.textContent = JSON.stringify(data, null, 2)
26
+ } catch (e) {
27
+ this.resultsTarget.textContent = e.message
28
+ }
29
+ }
30
+
31
+ async loadSchema() {
32
+ if (!this.hasSchemaTarget) return
33
+ const dataSourceId = this.dataSourceTarget.value
34
+ const response = await fetch(`/queries/schema?data_source_id=${dataSourceId}`)
35
+ const data = await response.json()
36
+ this.schemaTarget.innerHTML = data.tables.map(t => {
37
+ const name = typeof t === "string" ? t : t.name
38
+ return `<li class="nq-tree-item" data-action="click->query-editor#insertTable" data-table="${name}">${name}</li>`
39
+ }).join("")
40
+ }
41
+ }
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class ApplicationPermission < ApplicationRecord
5
+ FEATURES = %w[settings monitoring subscriptions].freeze
6
+
7
+ belongs_to :group, class_name: "Nquery::Group"
8
+
9
+ validates :feature, inclusion: { in: FEATURES }
10
+ validates :access_level, inclusion: { in: %w[yes no] }
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class Audit < ApplicationRecord
5
+ belongs_to :user, class_name: "Nquery::User", optional: true
6
+ belongs_to :query, class_name: "Nquery::Query", optional: true
7
+
8
+ scope :recent, -> { order(created_at: :desc) }
9
+
10
+ scope :for_user, lambda { |term|
11
+ pattern = "%#{sanitize_sql_like(term.downcase)}%"
12
+ full_name_sql = if connection.adapter_name.downcase.include?("mysql")
13
+ "CONCAT(nquery_users.first_name, ' ', nquery_users.last_name)"
14
+ else
15
+ "nquery_users.first_name || ' ' || nquery_users.last_name"
16
+ end
17
+
18
+ joins(:user).where(
19
+ "LOWER(nquery_users.email) LIKE :q OR LOWER(#{full_name_sql}) LIKE :q",
20
+ q: pattern
21
+ )
22
+ }
23
+
24
+ scope :for_collection, ->(collection_id) {
25
+ joins(query: :collection).where(nquery_collections: { id: collection_id })
26
+ }
27
+
28
+ scope :for_dashboard, lambda { |dashboard_id|
29
+ dashboard_audit_ids = joins(query: { chart: { dashboard_cards: :dashboard } })
30
+ .where(nquery_dashboards: { id: dashboard_id })
31
+ .select(:id)
32
+ where(id: dashboard_audit_ids)
33
+ }
34
+
35
+ scope :since, ->(date) { where(created_at: Date.parse(date).beginning_of_day..) }
36
+ scope :until_date, ->(date) { where(created_at: ..Date.parse(date).end_of_day) }
37
+ end
38
+ end