nquery 0.1.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +54 -0
- data/app/assets/builds/nquery/application.css +22 -2
- data/app/assets/builds/nquery/application.js +48 -34
- data/app/controllers/concerns/nquery/breadcrumbs.rb +2 -13
- data/app/controllers/concerns/nquery/chart_actions.rb +4 -4
- data/app/controllers/concerns/nquery/chart_results.rb +18 -4
- data/app/controllers/concerns/nquery/sharing_page.rb +38 -0
- data/app/controllers/nquery/application_controller.rb +2 -0
- data/app/controllers/nquery/dashboards_controller.rb +8 -3
- data/app/controllers/nquery/embed/charts_controller.rb +15 -13
- data/app/controllers/nquery/embed/dashboards_controller.rb +18 -8
- data/app/controllers/nquery/public/charts_controller.rb +19 -0
- data/app/controllers/nquery/public/dashboards_controller.rb +20 -0
- data/app/controllers/nquery/public_resources_controller.rb +79 -0
- data/app/controllers/nquery/queries_controller.rb +5 -49
- data/app/controllers/nquery/sharing/base_controller.rb +89 -0
- data/app/controllers/nquery/sharing/embed_tokens_controller.rb +41 -0
- data/app/controllers/nquery/sharing/embeddings_controller.rb +17 -0
- data/app/controllers/nquery/sharing/public_links_controller.rb +21 -0
- data/app/helpers/nquery/charts_helper.rb +13 -0
- data/app/helpers/nquery/sharing_helper.rb +67 -0
- data/app/javascript/nquery/controllers/chart_preview_controller.js +19 -1
- data/app/javascript/nquery/controllers/clipboard_controller.js +23 -0
- data/app/models/concerns/nquery/shareable.rb +43 -0
- data/app/models/nquery/chart.rb +14 -2
- data/app/models/nquery/dashboard.rb +2 -0
- data/app/models/nquery/data_source.rb +1 -1
- data/app/models/nquery/query.rb +4 -0
- data/app/views/layouts/nquery/embed.html.erb +3 -1
- data/app/views/nquery/charts/_builder_form.html.erb +1 -6
- data/app/views/nquery/charts/embed.html.erb +5 -11
- data/app/views/nquery/charts/new.html.erb +2 -1
- data/app/views/nquery/dashboard/charts/new.html.erb +2 -1
- data/app/views/nquery/dashboards/embed.html.erb +9 -0
- data/app/views/nquery/dashboards/show.html.erb +4 -0
- data/app/views/nquery/embed/charts/show.html.erb +7 -8
- data/app/views/nquery/embed/dashboards/show.html.erb +12 -6
- data/app/views/nquery/embed/unavailable.html.erb +1 -0
- data/app/views/nquery/shared/_icon.html.erb +6 -0
- data/app/views/nquery/shared/_shared_chart_card.html.erb +20 -0
- data/app/views/nquery/shared/_sharing_panel.html.erb +94 -0
- data/config/routes.rb +20 -1
- data/db/migrate/20260907120000_add_sharing_to_nquery_charts_and_dashboards.rb +19 -0
- data/db/migrate/20260907130000_remove_embedding_params_from_nquery_charts_and_dashboards.rb +8 -0
- data/lib/nquery/boolean.rb +9 -0
- data/lib/nquery/configuration.rb +6 -0
- data/lib/nquery/embed_token_service.rb +10 -5
- data/lib/nquery/encryption.rb +1 -1
- data/lib/nquery/seeder.rb +20 -6
- data/lib/nquery/version.rb +1 -1
- data/lib/nquery.rb +1 -0
- metadata +20 -5
- data/app/javascript/nquery/controllers/query_editor_controller.js +0 -41
- data/app/views/nquery/queries/edit.html.erb +0 -40
- data/app/views/nquery/queries/new.html.erb +0 -37
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
class PublicResourcesController < ActionController::Base
|
|
5
|
+
include Nquery::Engine.routes.url_helpers
|
|
6
|
+
include ChartResults
|
|
7
|
+
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
layout "nquery/embed"
|
|
10
|
+
|
|
11
|
+
helper Nquery::Engine.helpers
|
|
12
|
+
helper_method :current_nquery_user, :embed_titled?, :embed_bordered?, :embed_theme, :embed_appearance_classes
|
|
13
|
+
|
|
14
|
+
after_action :set_embed_headers
|
|
15
|
+
|
|
16
|
+
def self.apply_embed_rate_limit(to: self)
|
|
17
|
+
if to.respond_to?(:rate_limit)
|
|
18
|
+
to.rate_limit to: 60, within: 1.minute, by: -> { request.remote_ip }
|
|
19
|
+
else
|
|
20
|
+
Rails.logger.warn("[nquery] public/embed endpoints are not rate limited; Rails 7.2+ rate_limit is unavailable")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
apply_embed_rate_limit
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def current_nquery_user
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def embed_titled?
|
|
32
|
+
params[:titled] != "false"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def embed_bordered?
|
|
36
|
+
params[:bordered] != "false"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def embed_theme
|
|
40
|
+
theme = params[:theme].to_s
|
|
41
|
+
theme.presence_in(%w[night transparent])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def embed_appearance_classes
|
|
45
|
+
classes = ["nq-embed-body"]
|
|
46
|
+
classes << "nq-embed-untitled" unless embed_titled?
|
|
47
|
+
classes << "nq-embed-borderless" unless embed_bordered?
|
|
48
|
+
classes << "nq-embed-theme-#{embed_theme}" if embed_theme
|
|
49
|
+
classes
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_embed_headers
|
|
53
|
+
response.headers.delete("X-Frame-Options")
|
|
54
|
+
response.headers["Content-Security-Policy"] = "frame-ancestors #{frame_ancestors_value}"
|
|
55
|
+
response.headers["X-Robots-Tag"] = "noindex"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def frame_ancestors_value
|
|
59
|
+
ancestors = Nquery.configuration.embed_frame_ancestors
|
|
60
|
+
ancestors.present? ? Array(ancestors).join(" ") : "*"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render_unavailable(status:, message: "This visualization is not available.")
|
|
64
|
+
@unavailable_message = message
|
|
65
|
+
render template: "nquery/embed/unavailable", status: status
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_forbidden(message = "Invalid or expired embed token")
|
|
69
|
+
render_unavailable(status: :forbidden, message: message)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def load_dashboard_cards(dashboard)
|
|
73
|
+
dashboard.dashboard_cards
|
|
74
|
+
.joins(:chart)
|
|
75
|
+
.merge(Chart.active)
|
|
76
|
+
.includes(chart: { query: :data_source })
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -2,55 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Nquery
|
|
4
4
|
class QueriesController < ApplicationController
|
|
5
|
-
before_action :set_query, only:
|
|
6
|
-
before_action :authorize_query_collection!, only:
|
|
5
|
+
before_action :set_query, only: :update
|
|
6
|
+
before_action :authorize_query_collection!, only: :update
|
|
7
7
|
before_action :set_data_source_for_run, only: %i[run schema]
|
|
8
8
|
before_action :authorize_run_data_source!, only: %i[run schema]
|
|
9
9
|
|
|
10
|
-
def new
|
|
11
|
-
@query = Query.new(statement: "SELECT 1 AS example")
|
|
12
|
-
@data_sources = DataSource.active.order(:name)
|
|
13
|
-
@schema_tables = schema_tables
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def create
|
|
17
|
-
@query = Query.new(query_params.merge(creator: current_nquery_user))
|
|
18
|
-
if @query.save
|
|
19
|
-
redirect_to edit_query_path(@query), notice: "Query created."
|
|
20
|
-
else
|
|
21
|
-
@data_sources = DataSource.active.order(:name)
|
|
22
|
-
@schema_tables = schema_tables
|
|
23
|
-
render :new, status: :unprocessable_content
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def edit
|
|
28
|
-
@data_sources = DataSource.active.order(:name)
|
|
29
|
-
@schema_tables = schema_tables
|
|
30
|
-
end
|
|
31
|
-
|
|
32
10
|
def update
|
|
33
11
|
if @query.update(query_params)
|
|
34
|
-
|
|
35
|
-
format.html { redirect_to edit_query_path(@query), notice: "Query saved." }
|
|
36
|
-
format.json { render json: { ok: true, notice: "Query saved." } }
|
|
37
|
-
end
|
|
12
|
+
render json: { ok: true, notice: "Query saved." }
|
|
38
13
|
else
|
|
39
|
-
@
|
|
40
|
-
|
|
41
|
-
respond_to do |format|
|
|
42
|
-
format.html { render :edit, status: :unprocessable_content }
|
|
43
|
-
format.json {
|
|
44
|
-
render json: { error: @query.errors.full_messages.to_sentence.presence || "Query could not be saved." },
|
|
45
|
-
status: :unprocessable_content
|
|
46
|
-
}
|
|
47
|
-
end
|
|
14
|
+
render json: { error: @query.errors.full_messages.to_sentence.presence || "Query could not be saved." },
|
|
15
|
+
status: :unprocessable_content
|
|
48
16
|
end
|
|
49
17
|
end
|
|
50
18
|
|
|
51
|
-
def show
|
|
52
|
-
end
|
|
53
|
-
|
|
54
19
|
def run
|
|
55
20
|
result = QueryRunner.new(
|
|
56
21
|
data_source: @data_source,
|
|
@@ -91,14 +56,5 @@ module Nquery
|
|
|
91
56
|
def query_params
|
|
92
57
|
params.require(:query).permit(:name, :statement, :data_source_id, :collection_id)
|
|
93
58
|
end
|
|
94
|
-
|
|
95
|
-
def schema_tables
|
|
96
|
-
data_source = DataSource.first
|
|
97
|
-
return [] unless data_source
|
|
98
|
-
|
|
99
|
-
DataSources::Adapter.for(data_source).tables
|
|
100
|
-
rescue StandardError
|
|
101
|
-
[]
|
|
102
|
-
end
|
|
103
59
|
end
|
|
104
60
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Sharing
|
|
5
|
+
class BaseController < ApplicationController
|
|
6
|
+
before_action :set_shareable
|
|
7
|
+
before_action :authorize_shareable_publish!, only: %i[create update]
|
|
8
|
+
before_action :authorize_shareable_revoke!, only: %i[destroy]
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def set_shareable
|
|
13
|
+
@shareable = if params[:chart_id]
|
|
14
|
+
Chart.find(params[:chart_id])
|
|
15
|
+
else
|
|
16
|
+
Dashboard.find(params[:dashboard_id])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def authorize_shareable_publish!
|
|
21
|
+
authorize_shareable_collection!
|
|
22
|
+
return if performed?
|
|
23
|
+
|
|
24
|
+
if shareable_missing_data_source?
|
|
25
|
+
deny_collection_access!(missing_data_source_message)
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
shareable_data_sources.each do |data_source|
|
|
30
|
+
authorize_data_source_access!(data_source)
|
|
31
|
+
return if performed?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def authorize_shareable_revoke!
|
|
36
|
+
authorize_shareable_collection!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def authorize_shareable_collection!
|
|
40
|
+
return if permission_resolver.admin?
|
|
41
|
+
|
|
42
|
+
if @shareable.collection.nil?
|
|
43
|
+
deny_collection_access!("You do not have permission to access this collection.")
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
authorize_collection_access!(@shareable.collection, required: :curate)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def shareable_charts
|
|
51
|
+
if @shareable.is_a?(Chart)
|
|
52
|
+
[@shareable]
|
|
53
|
+
else
|
|
54
|
+
@shareable.dashboard_cards
|
|
55
|
+
.joins(:chart)
|
|
56
|
+
.merge(Chart.active)
|
|
57
|
+
.includes(chart: { query: :data_source })
|
|
58
|
+
.map(&:chart)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def shareable_data_sources
|
|
63
|
+
shareable_charts.filter_map(&:data_source).uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def shareable_missing_data_source?
|
|
67
|
+
shareable_charts.any? { |chart| chart.query && chart.data_source.nil? }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def missing_data_source_message
|
|
71
|
+
if @shareable.is_a?(Chart)
|
|
72
|
+
"This chart has no data source."
|
|
73
|
+
else
|
|
74
|
+
"A chart on this dashboard has no data source."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def sharing_page_path
|
|
79
|
+
if params[:chart_id] && params[:dashboard_id]
|
|
80
|
+
embed_dashboard_chart_path(params[:dashboard_id], params[:chart_id])
|
|
81
|
+
elsif params[:chart_id]
|
|
82
|
+
embed_chart_path(params[:chart_id])
|
|
83
|
+
else
|
|
84
|
+
embed_dashboard_path(params[:dashboard_id])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Sharing
|
|
5
|
+
class EmbedTokensController < BaseController
|
|
6
|
+
def create
|
|
7
|
+
unless Nquery.configuration.static_embedding_enabled
|
|
8
|
+
return redirect_to sharing_page_path, alert: "Static embedding is disabled."
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@shareable.embed_tokens.active.find_each { |token| EmbedTokenService.revoke!(token) }
|
|
12
|
+
EmbedTokenService.sign(
|
|
13
|
+
resource_type: @shareable.class.name,
|
|
14
|
+
resource_id: @shareable.id,
|
|
15
|
+
creator: current_nquery_user,
|
|
16
|
+
expires_at: expires_at_from_param
|
|
17
|
+
)
|
|
18
|
+
@shareable.update!(enable_embedding: true) unless @shareable.enable_embedding?
|
|
19
|
+
|
|
20
|
+
redirect_to sharing_page_path, notice: "Embed token generated."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def destroy
|
|
24
|
+
token = @shareable.embed_tokens.find(params[:id])
|
|
25
|
+
EmbedTokenService.revoke!(token)
|
|
26
|
+
redirect_to sharing_page_path, notice: "Embed token revoked."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def expires_at_from_param
|
|
32
|
+
case params[:expires_in]
|
|
33
|
+
when "1.day" then 1.day.from_now
|
|
34
|
+
when "30.days" then 30.days.from_now
|
|
35
|
+
when "never" then nil
|
|
36
|
+
else 1.hour.from_now
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Sharing
|
|
5
|
+
class EmbeddingsController < BaseController
|
|
6
|
+
def update
|
|
7
|
+
unless Nquery.configuration.static_embedding_enabled
|
|
8
|
+
return redirect_to sharing_page_path, alert: "Static embedding is disabled."
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
enabled = Nquery::Boolean.cast(params[:enable_embedding])
|
|
12
|
+
@shareable.update!(enable_embedding: enabled)
|
|
13
|
+
redirect_to sharing_page_path, notice: enabled ? "Embedding enabled." : "Embedding disabled."
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Sharing
|
|
5
|
+
class PublicLinksController < BaseController
|
|
6
|
+
def create
|
|
7
|
+
unless Nquery.configuration.public_sharing_enabled
|
|
8
|
+
return redirect_to sharing_page_path, alert: "Public sharing is disabled."
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@shareable.share_publicly!(user: current_nquery_user)
|
|
12
|
+
redirect_to sharing_page_path, notice: "Public link created."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def destroy
|
|
16
|
+
@shareable.unshare_publicly!
|
|
17
|
+
redirect_to sharing_page_path, notice: "Public link removed."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -18,6 +18,10 @@ module Nquery
|
|
|
18
18
|
dashboard ? embed_dashboard_chart_path(dashboard, chart) : embed_chart_path(chart)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
def dashboard_embed_path(dashboard)
|
|
22
|
+
embed_dashboard_path(dashboard)
|
|
23
|
+
end
|
|
24
|
+
|
|
21
25
|
def chart_archive_path(chart, dashboard: chart_dashboard)
|
|
22
26
|
dashboard ? archive_dashboard_chart_path(dashboard, chart) : archive_chart_path(chart)
|
|
23
27
|
end
|
|
@@ -29,5 +33,14 @@ module Nquery
|
|
|
29
33
|
def chart_builder_chart_types
|
|
30
34
|
Chart::CHART_TYPES - %w[table]
|
|
31
35
|
end
|
|
36
|
+
|
|
37
|
+
def chart_builder_data(chart, result: nil)
|
|
38
|
+
{
|
|
39
|
+
controller: "chart-builder",
|
|
40
|
+
query_save_url: chart.query&.persisted? ? query_path(chart.query) : nil,
|
|
41
|
+
query_run_url: run_queries_path,
|
|
42
|
+
query_schema_url: schema_queries_path
|
|
43
|
+
}.tap { |data| data[:initial_result] = result if result.present? }
|
|
44
|
+
end
|
|
32
45
|
end
|
|
33
46
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module SharingHelper
|
|
5
|
+
EMBED_EXPIRY_OPTIONS = [
|
|
6
|
+
["1 hour", "1.hour"],
|
|
7
|
+
["1 day", "1.day"],
|
|
8
|
+
["30 days", "30.days"],
|
|
9
|
+
["Never", "never"]
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def shareable_public_link_path(resource)
|
|
13
|
+
case resource
|
|
14
|
+
when Chart
|
|
15
|
+
chart_dashboard ? dashboard_chart_public_link_path(chart_dashboard, resource) : chart_public_link_path(resource)
|
|
16
|
+
when Dashboard
|
|
17
|
+
dashboard_public_link_path(resource)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def shareable_embedding_path(resource)
|
|
22
|
+
case resource
|
|
23
|
+
when Chart
|
|
24
|
+
chart_dashboard ? dashboard_chart_embedding_path(chart_dashboard, resource) : chart_embedding_path(resource)
|
|
25
|
+
when Dashboard
|
|
26
|
+
dashboard_embedding_path(resource)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def shareable_embed_tokens_path(resource)
|
|
31
|
+
case resource
|
|
32
|
+
when Chart
|
|
33
|
+
chart_dashboard ? dashboard_chart_embed_tokens_path(chart_dashboard, resource) : chart_embed_tokens_path(resource)
|
|
34
|
+
when Dashboard
|
|
35
|
+
dashboard_embed_tokens_path(resource)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shareable_embed_token_path(resource, token)
|
|
40
|
+
case resource
|
|
41
|
+
when Chart
|
|
42
|
+
if chart_dashboard
|
|
43
|
+
dashboard_chart_embed_token_path(chart_dashboard, resource, token)
|
|
44
|
+
else
|
|
45
|
+
chart_embed_token_path(resource, token)
|
|
46
|
+
end
|
|
47
|
+
when Dashboard
|
|
48
|
+
dashboard_embed_token_path(resource, token)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def can_curate_shareable?(resource)
|
|
53
|
+
return true if permission_resolver.admin?
|
|
54
|
+
return false if resource.collection.nil?
|
|
55
|
+
|
|
56
|
+
permission_resolver.collection_access(resource.collection) == :curate
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def share_iframe_snippet(url, height: 600)
|
|
60
|
+
%(<iframe src="#{url}" width="800" height="#{height}" frameborder="0" loading="lazy"></iframe>)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def share_iframe_height(resource)
|
|
64
|
+
resource.is_a?(Dashboard) ? 800 : 600
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return String(value)
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """)
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
function columnIndex(columns, name) {
|
|
4
12
|
const index = columns.indexOf(name)
|
|
5
13
|
return index >= 0 ? index : 0
|
|
@@ -72,10 +80,20 @@ export default class extends Controller {
|
|
|
72
80
|
connect() {
|
|
73
81
|
if (typeof Chart === "undefined") return
|
|
74
82
|
|
|
83
|
+
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
|
84
|
+
Chart.defaults.color = document.body.classList.contains("nq-embed-theme-night") ? "#e2e8f0" : "#1e293b"
|
|
85
|
+
|
|
75
86
|
const demo = this.hasDataValue ? this.dataValue : {
|
|
76
87
|
columns: ["month", "revenue"],
|
|
77
88
|
rows: [["Jan", 1200], ["Feb", 1800], ["Mar", 2400]]
|
|
78
89
|
}
|
|
90
|
+
|
|
91
|
+
if (demo.error) {
|
|
92
|
+
this.element.classList.add("is-error")
|
|
93
|
+
this.element.innerHTML = `<div class="nq-chart-error">${escapeHtml(demo.error)}</div>`
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
79
97
|
const type = this.typeValue || "bar"
|
|
80
98
|
const xCol = this.hasXValue ? this.xValue : ""
|
|
81
99
|
const yCol = this.hasYValue ? this.yValue : ""
|
|
@@ -84,7 +102,7 @@ export default class extends Controller {
|
|
|
84
102
|
const yIndex = columnIndex(demo.columns || [], yCol || demo.columns?.[1] || demo.columns?.[0])
|
|
85
103
|
const value = demo.rows?.[0]?.[yIndex] ?? "—"
|
|
86
104
|
this.element.classList.add("is-number")
|
|
87
|
-
this.element.innerHTML = `<div class="nq-number-display">${value}</div>`
|
|
105
|
+
this.element.innerHTML = `<div class="nq-number-display">${escapeHtml(value)}</div>`
|
|
88
106
|
return
|
|
89
107
|
}
|
|
90
108
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static values = { text: String }
|
|
5
|
+
|
|
6
|
+
async copy(event) {
|
|
7
|
+
event.preventDefault()
|
|
8
|
+
const button = event.currentTarget
|
|
9
|
+
const targetSelector = button.dataset.copyTarget
|
|
10
|
+
const target = targetSelector ? document.querySelector(targetSelector) : null
|
|
11
|
+
const text = target ? target.textContent : this.textValue
|
|
12
|
+
const original = button.textContent
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await navigator.clipboard.writeText(text.trim())
|
|
16
|
+
button.textContent = "Copied"
|
|
17
|
+
} catch {
|
|
18
|
+
button.textContent = "Copy failed"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
setTimeout(() => { button.textContent = original }, 1500)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nquery
|
|
4
|
+
module Shareable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
resource_class_name = name
|
|
9
|
+
|
|
10
|
+
belongs_to :made_public_by, class_name: "Nquery::User", optional: true
|
|
11
|
+
has_many :embed_tokens, -> { where(resource_type: resource_class_name) },
|
|
12
|
+
class_name: "Nquery::EmbedToken", foreign_key: :resource_id, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
scope :publicly_shared, -> { where.not(public_uuid: nil) }
|
|
15
|
+
scope :embedding_enabled, -> { where(enable_embedding: true) }
|
|
16
|
+
|
|
17
|
+
validates :public_uuid, uniqueness: true, allow_nil: true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def publicly_shared?
|
|
21
|
+
public_uuid.present?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def share_publicly!(user:)
|
|
25
|
+
return self if publicly_shared?
|
|
26
|
+
|
|
27
|
+
update!(
|
|
28
|
+
public_uuid: SecureRandom.uuid,
|
|
29
|
+
made_public_by: user,
|
|
30
|
+
public_shared_at: Time.current
|
|
31
|
+
)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unshare_publicly!
|
|
36
|
+
update!(public_uuid: nil, made_public_by: nil, public_shared_at: nil)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active_embed_token
|
|
40
|
+
embed_tokens.active.order(created_at: :desc).first
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/app/models/nquery/chart.rb
CHANGED
|
@@ -4,12 +4,12 @@ module Nquery
|
|
|
4
4
|
class Chart < ApplicationRecord
|
|
5
5
|
CHART_TYPES = %w[table number bar line area pie scatter].freeze
|
|
6
6
|
|
|
7
|
+
include Shareable
|
|
8
|
+
|
|
7
9
|
belongs_to :query, class_name: "Nquery::Query", optional: true
|
|
8
10
|
belongs_to :collection, class_name: "Nquery::Collection", optional: true
|
|
9
11
|
belongs_to :creator, class_name: "Nquery::User", optional: true
|
|
10
12
|
has_many :dashboard_cards, class_name: "Nquery::DashboardCard", dependent: :destroy
|
|
11
|
-
has_many :embed_tokens, -> { where(resource_type: "Nquery::Chart") },
|
|
12
|
-
class_name: "Nquery::EmbedToken", foreign_key: :resource_id, dependent: :destroy
|
|
13
13
|
|
|
14
14
|
accepts_nested_attributes_for :query
|
|
15
15
|
|
|
@@ -33,5 +33,17 @@ module Nquery
|
|
|
33
33
|
def chart_type
|
|
34
34
|
visualization["type"] || "bar"
|
|
35
35
|
end
|
|
36
|
+
|
|
37
|
+
def statement
|
|
38
|
+
query&.statement
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def statement?
|
|
42
|
+
query&.statement? || false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def data_source
|
|
46
|
+
query&.data_source
|
|
47
|
+
end
|
|
36
48
|
end
|
|
37
49
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Nquery
|
|
4
4
|
class Dashboard < ApplicationRecord
|
|
5
|
+
include Shareable
|
|
6
|
+
|
|
5
7
|
belongs_to :collection, class_name: "Nquery::Collection"
|
|
6
8
|
belongs_to :creator, class_name: "Nquery::User", optional: true
|
|
7
9
|
has_many :dashboard_cards, class_name: "Nquery::DashboardCard", dependent: :destroy
|
data/app/models/nquery/query.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<title>nquery embed</title>
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="robots" content="noindex">
|
|
6
7
|
<%= stylesheet_link_tag "nquery/application" %>
|
|
7
8
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
|
9
|
+
<%= javascript_include_tag "nquery/application", defer: true %>
|
|
8
10
|
</head>
|
|
9
|
-
<body class="
|
|
11
|
+
<body class="<%= embed_appearance_classes.join(" ") %>">
|
|
10
12
|
<%= yield %>
|
|
11
13
|
</body>
|
|
12
14
|
</html>
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
<% viz = chart.visualization.is_a?(Hash) ? chart.visualization : {} %>
|
|
2
2
|
<% chart_type = viz["type"].presence || "table" %>
|
|
3
3
|
<% active_chart_type = chart_builder_chart_types.include?(chart_type) ? chart_type : "bar" %>
|
|
4
|
-
<% builder_data = {
|
|
5
|
-
controller: "chart-builder",
|
|
6
|
-
query_save_url: chart.query&.persisted? ? query_path(chart.query) : nil
|
|
7
|
-
} %>
|
|
8
|
-
<% builder_data[:initial_result] = result if local_assigns[:result].present? %>
|
|
9
4
|
<%= form_with model: chart, url: form_url, class: "nq-form nq-chart-builder-form", html: {
|
|
10
|
-
data:
|
|
5
|
+
data: chart_builder_data(chart, result: result)
|
|
11
6
|
} do |f| %>
|
|
12
7
|
<%= render "nquery/charts/new_header",
|
|
13
8
|
f: f,
|
|
@@ -2,14 +2,8 @@
|
|
|
2
2
|
<h1>Embed: <%= @chart.name %></h1>
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<h3>Iframe snippet</h3>
|
|
11
|
-
<pre class="nq-code"><iframe src="<%= @embed_url %>" width="800" height="600" frameborder="0"></iframe></pre>
|
|
12
|
-
<% else %>
|
|
13
|
-
<p class="nq-muted">No embed token yet. Generate one from the admin panel (phase 1 UI placeholder).</p>
|
|
14
|
-
<% end %>
|
|
15
|
-
</div>
|
|
5
|
+
<%= render "nquery/shared/sharing_panel",
|
|
6
|
+
shareable: @shareable,
|
|
7
|
+
public_url: @public_url,
|
|
8
|
+
embed_url: @embed_url,
|
|
9
|
+
embed_token: @embed_token %>
|