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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +52 -0
- data/Rakefile +9 -0
- data/app/assets/builds/nquery/application.css +339 -0
- data/app/assets/builds/nquery/application.js +989 -0
- data/app/assets/config/nquery_manifest.js +2 -0
- data/app/components/nquery/_card_component.html.erb +6 -0
- data/app/components/nquery/_card_table_component.html.erb +14 -0
- data/app/components/nquery/_flash_card_component.html.erb +32 -0
- data/app/components/nquery/_flash_cards_component.html.erb +3 -0
- data/app/components/nquery/_infinite_scroll_component.html.erb +11 -0
- data/app/components/nquery/card_component.rb +18 -0
- data/app/components/nquery/card_table_component.rb +19 -0
- data/app/components/nquery/component.rb +26 -0
- data/app/components/nquery/flash_card_component.rb +51 -0
- data/app/components/nquery/flash_cards_component.rb +20 -0
- data/app/components/nquery/infinite_scroll_component.rb +19 -0
- data/app/controllers/concerns/nquery/breadcrumbs.rb +238 -0
- data/app/controllers/concerns/nquery/browsable.rb +56 -0
- data/app/controllers/concerns/nquery/chart_actions.rb +104 -0
- data/app/controllers/concerns/nquery/chart_results.rb +30 -0
- data/app/controllers/nquery/admin/base_controller.rb +9 -0
- data/app/controllers/nquery/admin/data_sources_controller.rb +47 -0
- data/app/controllers/nquery/admin/groups_controller.rb +63 -0
- data/app/controllers/nquery/admin/logs_controller.rb +28 -0
- data/app/controllers/nquery/admin/permissions_controller.rb +53 -0
- data/app/controllers/nquery/admin/users_controller.rb +80 -0
- data/app/controllers/nquery/application_controller.rb +70 -0
- data/app/controllers/nquery/charts_controller.rb +51 -0
- data/app/controllers/nquery/collection/dashboards_controller.rb +38 -0
- data/app/controllers/nquery/collections_controller.rb +117 -0
- data/app/controllers/nquery/dashboard/charts_controller.rb +62 -0
- data/app/controllers/nquery/dashboards_controller.rb +90 -0
- data/app/controllers/nquery/embed/charts_controller.rb +24 -0
- data/app/controllers/nquery/embed/dashboards_controller.rb +17 -0
- data/app/controllers/nquery/home_controller.rb +16 -0
- data/app/controllers/nquery/imports_controller.rb +41 -0
- data/app/controllers/nquery/queries_controller.rb +105 -0
- data/app/controllers/nquery/registrations_controller.rb +31 -0
- data/app/controllers/nquery/sessions_controller.rb +28 -0
- data/app/helpers/nquery/charts_helper.rb +33 -0
- data/app/helpers/nquery/collections_helper.rb +13 -0
- data/app/helpers/nquery/components_helper.rb +29 -0
- data/app/helpers/nquery/home_helper.rb +23 -0
- data/app/helpers/nquery/icon_helper.rb +9 -0
- data/app/helpers/nquery/navigation_helper.rb +10 -0
- data/app/javascript/nquery/controllers/chart_preview_controller.js +125 -0
- data/app/javascript/nquery/controllers/csv_mapper_controller.js +23 -0
- data/app/javascript/nquery/controllers/dashboard_grid_controller.js +25 -0
- data/app/javascript/nquery/controllers/query_editor_controller.js +41 -0
- data/app/models/nquery/application_permission.rb +12 -0
- data/app/models/nquery/application_record.rb +7 -0
- data/app/models/nquery/audit.rb +38 -0
- data/app/models/nquery/chart.rb +37 -0
- data/app/models/nquery/collection.rb +35 -0
- data/app/models/nquery/collection_permission.rb +12 -0
- data/app/models/nquery/csv_upload.rb +11 -0
- data/app/models/nquery/dashboard.rb +27 -0
- data/app/models/nquery/dashboard_card.rb +8 -0
- data/app/models/nquery/data_permission.rb +13 -0
- data/app/models/nquery/data_source.rb +27 -0
- data/app/models/nquery/embed_token.rb +21 -0
- data/app/models/nquery/group.rb +27 -0
- data/app/models/nquery/group_membership.rb +10 -0
- data/app/models/nquery/query.rb +19 -0
- data/app/models/nquery/user.rb +58 -0
- data/app/views/layouts/nquery/application.html.erb +32 -0
- data/app/views/layouts/nquery/auth.html.erb +16 -0
- data/app/views/layouts/nquery/embed.html.erb +12 -0
- data/app/views/nquery/admin/data_sources/edit.html.erb +8 -0
- data/app/views/nquery/admin/data_sources/index.html.erb +21 -0
- data/app/views/nquery/admin/data_sources/new.html.erb +7 -0
- data/app/views/nquery/admin/groups/edit.html.erb +6 -0
- data/app/views/nquery/admin/groups/index.html.erb +21 -0
- data/app/views/nquery/admin/groups/new.html.erb +6 -0
- data/app/views/nquery/admin/groups/show.html.erb +24 -0
- data/app/views/nquery/admin/logs/index.html.erb +74 -0
- data/app/views/nquery/admin/permissions/_collection.html.erb +25 -0
- data/app/views/nquery/admin/permissions/_data_source.html.erb +27 -0
- data/app/views/nquery/admin/permissions/_group.html.erb +42 -0
- data/app/views/nquery/admin/permissions/by_collection.html.erb +10 -0
- data/app/views/nquery/admin/permissions/by_data_source.html.erb +7 -0
- data/app/views/nquery/admin/permissions/by_group.html.erb +12 -0
- data/app/views/nquery/admin/users/edit.html.erb +17 -0
- data/app/views/nquery/admin/users/index.html.erb +34 -0
- data/app/views/nquery/admin/users/new.html.erb +14 -0
- data/app/views/nquery/admin/users/show.html.erb +6 -0
- data/app/views/nquery/charts/_builder_form.html.erb +185 -0
- data/app/views/nquery/charts/_new_header.html.erb +31 -0
- data/app/views/nquery/charts/_schema_tree.html.erb +25 -0
- data/app/views/nquery/charts/edit.html.erb +6 -0
- data/app/views/nquery/charts/embed.html.erb +15 -0
- data/app/views/nquery/charts/new.html.erb +5 -0
- data/app/views/nquery/charts/show.html.erb +15 -0
- data/app/views/nquery/charts/update.turbo_stream.erb +1 -0
- data/app/views/nquery/collection/dashboards/new.html.erb +13 -0
- data/app/views/nquery/collections/_dashboard_card.html.erb +57 -0
- data/app/views/nquery/collections/edit.html.erb +15 -0
- data/app/views/nquery/collections/index.html.erb +41 -0
- data/app/views/nquery/collections/new.html.erb +15 -0
- data/app/views/nquery/collections/show.html.erb +100 -0
- data/app/views/nquery/dashboard/charts/new.html.erb +6 -0
- data/app/views/nquery/dashboards/_chart_card.html.erb +16 -0
- data/app/views/nquery/dashboards/edit.html.erb +34 -0
- data/app/views/nquery/dashboards/index.html.erb +35 -0
- data/app/views/nquery/dashboards/show.html.erb +46 -0
- data/app/views/nquery/embed/charts/show.html.erb +9 -0
- data/app/views/nquery/embed/dashboards/show.html.erb +7 -0
- data/app/views/nquery/home/index.html.erb +143 -0
- data/app/views/nquery/imports/new.html.erb +20 -0
- data/app/views/nquery/queries/edit.html.erb +40 -0
- data/app/views/nquery/queries/new.html.erb +37 -0
- data/app/views/nquery/registrations/new.html.erb +34 -0
- data/app/views/nquery/sessions/new.html.erb +29 -0
- data/app/views/nquery/shared/_auth_shell.html.erb +22 -0
- data/app/views/nquery/shared/_breadcrumbs.html.erb +15 -0
- data/app/views/nquery/shared/_chart_actions_menu.html.erb +27 -0
- data/app/views/nquery/shared/_chart_row_actions.html.erb +16 -0
- data/app/views/nquery/shared/_flash.html.erb +1 -0
- data/app/views/nquery/shared/_icon.html.erb +50 -0
- data/app/views/nquery/shared/_sidebar.html.erb +16 -0
- data/app/views/nquery/shared/_topbar.html.erb +12 -0
- data/config/importmap.rb +9 -0
- data/config/routes.rb +78 -0
- data/db/migrate/20260721000001_create_nquery_tables.rb +146 -0
- data/db/migrate/20260721180000_add_archived_at_to_nquery_dashboards.rb +8 -0
- data/db/migrate/20260721190000_add_archived_at_to_nquery_charts.rb +8 -0
- data/db/migrate/20260721200000_add_archived_at_to_nquery_collections.rb +8 -0
- data/lib/generators/nquery/install_generator.rb +38 -0
- data/lib/nquery/authorizes_collection.rb +50 -0
- data/lib/nquery/configuration.rb +44 -0
- data/lib/nquery/csv_importer.rb +68 -0
- data/lib/nquery/data_sources/adapter.rb +34 -0
- data/lib/nquery/data_sources/mysql_adapter.rb +24 -0
- data/lib/nquery/data_sources/postgresql_adapter.rb +46 -0
- data/lib/nquery/data_sources/rails_adapter.rb +54 -0
- data/lib/nquery/data_sources/sqlite_adapter.rb +8 -0
- data/lib/nquery/embed_token_service.rb +83 -0
- data/lib/nquery/engine.rb +55 -0
- data/lib/nquery/permissions/resolver.rb +83 -0
- data/lib/nquery/query_runner.rb +69 -0
- data/lib/nquery/sample_data/ecommerce.rb +157 -0
- data/lib/nquery/schema_explorer.rb +28 -0
- data/lib/nquery/seeder.rb +131 -0
- data/lib/nquery/version.rb +5 -0
- data/lib/nquery.rb +36 -0
- data/lib/tasks/nquery.rake +9 -0
- data/nquery.gemspec +40 -0
- 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,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,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
|