nquery 0.1.0 → 0.1.2

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +263 -24
  4. data/app/assets/builds/nquery/application.css +61 -4
  5. data/app/assets/builds/nquery/application.js +107 -12
  6. data/app/controllers/concerns/nquery/breadcrumbs.rb +5 -3
  7. data/app/controllers/concerns/nquery/chart_actions.rb +1 -0
  8. data/app/controllers/concerns/nquery/chart_results.rb +17 -3
  9. data/app/controllers/nquery/admin/data_sources_controller.rb +49 -2
  10. data/app/controllers/nquery/admin/groups_controller.rb +8 -2
  11. data/app/controllers/nquery/admin/users_controller.rb +6 -1
  12. data/app/controllers/nquery/application_controller.rb +45 -28
  13. data/app/controllers/nquery/dashboards_controller.rb +30 -0
  14. data/app/controllers/nquery/embed/charts_controller.rb +1 -0
  15. data/app/controllers/nquery/embed/dashboards_controller.rb +1 -0
  16. data/app/controllers/nquery/errors_controller.rb +11 -0
  17. data/app/controllers/nquery/home_controller.rb +1 -0
  18. data/app/controllers/nquery/onboarding/admins_controller.rb +42 -0
  19. data/app/controllers/nquery/onboarding/base_controller.rb +27 -0
  20. data/app/controllers/nquery/onboarding/companies_controller.rb +30 -0
  21. data/app/controllers/nquery/onboarding/confirmations_controller.rb +48 -0
  22. data/app/controllers/nquery/onboarding/congrats_controller.rb +15 -0
  23. data/app/controllers/nquery/queries_controller.rb +0 -1
  24. data/app/controllers/nquery/sessions_controller.rb +14 -17
  25. data/app/helpers/nquery/data_sources_helper.rb +13 -0
  26. data/app/helpers/nquery/groups_helper.rb +19 -0
  27. data/app/javascript/nquery/controllers/data_source_form_controller.js +25 -0
  28. data/app/mailers/nquery/application_mailer.rb +7 -0
  29. data/app/mailers/nquery/devise_mailer.rb +9 -0
  30. data/app/models/nquery/audit.rb +2 -2
  31. data/app/models/nquery/collection.rb +1 -1
  32. data/app/models/nquery/dashboard.rb +1 -1
  33. data/app/models/nquery/data_source.rb +139 -4
  34. data/app/models/nquery/organization.rb +38 -0
  35. data/app/models/nquery/query.rb +8 -0
  36. data/app/models/nquery/user.rb +45 -10
  37. data/app/views/nquery/admin/data_sources/_form.html.erb +121 -0
  38. data/app/views/nquery/admin/data_sources/edit.html.erb +6 -8
  39. data/app/views/nquery/admin/data_sources/new.html.erb +6 -7
  40. data/app/views/nquery/admin/groups/_form.html.erb +47 -0
  41. data/app/views/nquery/admin/groups/edit.html.erb +15 -6
  42. data/app/views/nquery/admin/groups/index.html.erb +25 -20
  43. data/app/views/nquery/admin/groups/new.html.erb +6 -6
  44. data/app/views/nquery/admin/groups/show.html.erb +98 -19
  45. data/app/views/nquery/admin/logs/index.html.erb +26 -30
  46. data/app/views/nquery/charts/_builder_form.html.erb +6 -4
  47. data/app/views/nquery/charts/edit.html.erb +2 -1
  48. data/app/views/nquery/dashboards/index.html.erb +6 -0
  49. data/app/views/nquery/dashboards/new.html.erb +34 -0
  50. data/app/views/nquery/devise_mailer/confirmation_instructions.html.erb +5 -0
  51. data/app/views/nquery/devise_mailer/confirmation_instructions.text.erb +5 -0
  52. data/app/views/nquery/errors/internal_server_error.html.erb +10 -0
  53. data/app/views/nquery/errors/not_found.html.erb +10 -0
  54. data/app/views/nquery/home/index.html.erb +7 -15
  55. data/app/views/nquery/onboarding/admins/new.html.erb +21 -0
  56. data/app/views/nquery/onboarding/companies/new.html.erb +25 -0
  57. data/app/views/nquery/onboarding/confirmations/show.html.erb +17 -0
  58. data/app/views/nquery/onboarding/congrats/show.html.erb +11 -0
  59. data/app/views/nquery/sessions/new.html.erb +8 -9
  60. data/app/views/nquery/shared/_auth_shell.html.erb +1 -1
  61. data/app/views/nquery/shared/_topbar.html.erb +0 -4
  62. data/config/routes.rb +26 -7
  63. data/db/migrate/20260721000001_create_nquery_tables.rb +1 -1
  64. data/db/migrate/20260722000001_add_key_to_nquery_data_sources.rb +8 -0
  65. data/db/migrate/20260722000002_create_nquery_organizations.rb +11 -0
  66. data/db/migrate/20260722000003_add_devise_to_nquery_users.rb +35 -0
  67. data/db/migrate/20260722000004_add_onboarding_completed_at_to_nquery_organizations.rb +7 -0
  68. data/lib/generators/nquery/install_generator.rb +9 -17
  69. data/lib/nquery/configuration.rb +7 -23
  70. data/lib/nquery/csv_importer.rb +3 -3
  71. data/lib/nquery/data_sources/adapter.rb +4 -0
  72. data/lib/nquery/data_sources/postgresql_adapter.rb +15 -1
  73. data/lib/nquery/data_sources/rails_adapter.rb +5 -0
  74. data/lib/nquery/data_sources/syncer.rb +50 -0
  75. data/lib/nquery/encryption.rb +50 -0
  76. data/lib/nquery/engine.rb +42 -3
  77. data/lib/nquery/onboarding/admin_provisioner.rb +40 -0
  78. data/lib/nquery/onboarding/password_confirmation.rb +46 -0
  79. data/lib/nquery/onboarding.rb +40 -0
  80. data/lib/nquery/permissions/resolver.rb +14 -4
  81. data/lib/nquery/query_runner.rb +5 -9
  82. data/lib/nquery/readonly_sql.rb +25 -0
  83. data/lib/nquery/seeder.rb +25 -34
  84. data/lib/nquery/setup.rb +75 -0
  85. data/lib/nquery/version.rb +1 -1
  86. data/lib/nquery.rb +9 -0
  87. data/lib/tasks/nquery.rake +6 -0
  88. data/nquery.gemspec +11 -5
  89. metadata +135 -26
  90. data/app/controllers/nquery/registrations_controller.rb +0 -31
  91. data/app/views/nquery/registrations/new.html.erb +0 -34
@@ -2,26 +2,161 @@
2
2
 
3
3
  module Nquery
4
4
  class DataSource < ApplicationRecord
5
- ADAPTERS = %w[rails postgresql mysql sqlite].freeze
5
+ ADAPTER_OPTIONS = [
6
+ ["Application database", "rails"],
7
+ ["PostgreSQL", "postgresql"],
8
+ ["MySQL", "mysql"],
9
+ ["SQLite", "sqlite"]
10
+ ].freeze
11
+ ADAPTERS = ADAPTER_OPTIONS.map(&:last).freeze
12
+ REMOTE_ADAPTERS = %w[postgresql mysql].freeze
13
+ SSL_MODES = %w[disable require verify-ca verify-full].freeze
14
+
15
+ AR_ADAPTER_NAMES = {
16
+ "postgresql" => "postgresql",
17
+ "mysql" => "mysql2",
18
+ "sqlite" => "sqlite3"
19
+ }.freeze
20
+
21
+ DEFAULT_PORTS = {
22
+ "postgresql" => 5432,
23
+ "mysql" => 3306
24
+ }.freeze
25
+
26
+ # serialize must be declared before encrypts for structured attributes.
27
+ serialize :connection_config, coder: JSON
28
+ encrypts :connection_config
6
29
 
7
30
  has_many :data_permissions, class_name: "Nquery::DataPermission", dependent: :destroy
8
31
  has_many :queries, class_name: "Nquery::Query", dependent: :nullify
9
32
 
33
+ attr_accessor :host, :port, :database, :username, :password, :database_path, :sslmode, :connection_fields_submitted
34
+
10
35
  validates :name, presence: true
36
+ validates :key, uniqueness: true, allow_nil: true
11
37
  validates :adapter, inclusion: { in: ADAPTERS }
38
+ validate :validate_connection_fields, if: :connection_fields_submitted?
39
+
40
+ before_validation :compose_connection_config
12
41
 
13
42
  scope :active, -> { all }
14
43
 
44
+ def connection_fields_submitted?
45
+ ActiveModel::Type::Boolean.new.cast(connection_fields_submitted)
46
+ end
47
+
15
48
  def connection_config_hash
16
- return {} if connection_config.blank?
49
+ value = connection_config
50
+ return {} if value.blank?
17
51
 
18
- JSON.parse(connection_config)
52
+ hash = value.is_a?(String) ? JSON.parse(value) : value
53
+ normalize_config_hash(hash)
19
54
  rescue JSON::ParserError
20
55
  {}
21
56
  end
22
57
 
23
58
  def connection_config_hash=(hash)
24
- self.connection_config = hash.to_json
59
+ self.connection_config = hash.presence
60
+ end
61
+
62
+ def default_port
63
+ DEFAULT_PORTS.fetch(adapter, DEFAULT_PORTS.fetch("postgresql")).to_s
64
+ end
65
+
66
+ def test_connection
67
+ compose_connection_config
68
+ validate_connection_fields if connection_fields_submitted?
69
+ return false if errors.any?
70
+
71
+ DataSources::Adapter.for(self).test_connection
72
+ true
73
+ rescue StandardError => e
74
+ errors.add(:base, e.message)
75
+ false
76
+ end
77
+
78
+ def assign_connection_fields_from_config
79
+ hash = connection_config_hash
80
+ self.host = hash["host"]
81
+ self.port = hash["port"]&.to_s
82
+ self.database = hash["database"]
83
+ self.username = hash["username"]
84
+ self.database_path = hash["database_path"].presence || hash["database"]
85
+ self.sslmode = hash["sslmode"]
86
+ self.password = nil
87
+ end
88
+
89
+ private
90
+
91
+ def compose_connection_config
92
+ if adapter == "rails"
93
+ self.connection_config = {}
94
+ return
95
+ end
96
+
97
+ assign_connection_config_from_fields if connection_fields_submitted?
98
+ end
99
+
100
+ def validate_connection_fields
101
+ case adapter
102
+ when *REMOTE_ADAPTERS
103
+ errors.add(:host, "can't be blank") if host.blank?
104
+ errors.add(:database, "can't be blank") if database.blank?
105
+ errors.add(:username, "can't be blank") if username.blank?
106
+ errors.add(:password, "can't be blank") if password.blank? && (new_record? || existing_password.blank?)
107
+ if sslmode.present? && !SSL_MODES.include?(sslmode)
108
+ errors.add(:sslmode, "is not included in the list")
109
+ end
110
+ when "sqlite"
111
+ errors.add(:database_path, "can't be blank") if database_path.blank?
112
+ end
113
+ end
114
+
115
+ def assign_connection_config_from_fields
116
+ previous = persisted? ? load_persisted_connection_config : {}
117
+
118
+ self.connection_config = case adapter
119
+ when "sqlite"
120
+ {
121
+ "adapter" => AR_ADAPTER_NAMES.fetch(adapter),
122
+ "database" => database_path.to_s
123
+ }
124
+ when *REMOTE_ADAPTERS
125
+ config = {
126
+ "adapter" => AR_ADAPTER_NAMES.fetch(adapter),
127
+ "host" => host.to_s,
128
+ "port" => resolved_port,
129
+ "database" => database.to_s,
130
+ "username" => username.to_s
131
+ }
132
+ config["password"] = resolved_password(previous)
133
+ config["sslmode"] = sslmode if sslmode.present?
134
+ config
135
+ else
136
+ {}
137
+ end
138
+ end
139
+
140
+ def resolved_port
141
+ port.presence&.to_i || default_port.to_i
142
+ end
143
+
144
+ def resolved_password(previous)
145
+ return password if password.present?
146
+
147
+ previous["password"]
148
+ end
149
+
150
+ def existing_password
151
+ connection_config_hash["password"]
152
+ end
153
+
154
+ def load_persisted_connection_config
155
+ normalize_config_hash(self.class.find(id).connection_config.presence || {})
156
+ end
157
+
158
+ def normalize_config_hash(hash)
159
+ hash.stringify_keys
25
160
  end
26
161
  end
27
162
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nquery
4
+ class Organization < ApplicationRecord
5
+ IMAGE_CONTENT_TYPES = %w[image/png image/jpeg image/jpg image/webp].freeze
6
+ MAX_IMAGE_BYTE_SIZE = 5.megabytes
7
+
8
+ has_one_attached :logo
9
+ has_one_attached :cover_image
10
+
11
+ validates :name, presence: true
12
+ validate :acceptable_logo
13
+ validate :acceptable_cover_image
14
+
15
+ private
16
+
17
+ def acceptable_logo
18
+ validate_image_attachment(:logo)
19
+ end
20
+
21
+ def acceptable_cover_image
22
+ validate_image_attachment(:cover_image)
23
+ end
24
+
25
+ def validate_image_attachment(name)
26
+ attachment = public_send(name)
27
+ return unless attachment.attached?
28
+
29
+ unless attachment.blob.content_type.in?(IMAGE_CONTENT_TYPES)
30
+ errors.add(name, "must be a PNG, JPEG, or WebP")
31
+ end
32
+
33
+ return unless attachment.blob.byte_size > MAX_IMAGE_BYTE_SIZE
34
+
35
+ errors.add(name, "is too large (maximum is 5 MB)")
36
+ end
37
+ end
38
+ end
@@ -9,11 +9,19 @@ module Nquery
9
9
  has_many :audits, class_name: "Nquery::Audit", dependent: :nullify
10
10
 
11
11
  validates :name, presence: true, on: :update
12
+ validate :statement_must_be_readonly
12
13
 
13
14
  before_validation :set_default_name, on: :create
14
15
 
15
16
  def set_default_name
16
17
  self.name ||= "Untitled query"
17
18
  end
19
+
20
+ private
21
+
22
+ def statement_must_be_readonly
23
+ message = ReadonlySql.error_message(statement, allow_blank: true)
24
+ errors.add(:statement, message) if message
25
+ end
18
26
  end
19
27
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Nquery
4
4
  class User < ApplicationRecord
5
- has_secure_password validations: false
5
+ devise :database_authenticatable, :confirmable, :validatable,
6
+ confirm_within: 1.hour
6
7
 
7
8
  has_many :group_memberships, class_name: "Nquery::GroupMembership", dependent: :destroy
8
9
  has_many :groups, through: :group_memberships, class_name: "Nquery::Group"
@@ -16,18 +17,32 @@ module Nquery
16
17
  class_name: "Nquery::Collection", foreign_key: :owner_id, inverse_of: :owner, dependent: :destroy
17
18
 
18
19
  validates :email, presence: true, uniqueness: true
19
- validates :password, length: { minimum: 8 }, if: -> { password.present? }
20
20
 
21
21
  scope :active, -> { where(deactivated_at: nil) }
22
22
 
23
+ # Set by #deliver_devise_notification so controllers can flash delivery failures.
24
+ attr_accessor :last_devise_notification_delivered
25
+
23
26
  def name
24
27
  [first_name, last_name].compact_blank.join(" ").presence || email
25
28
  end
26
29
 
30
+ def name_with_email
31
+ "#{name} (#{email})"
32
+ end
33
+
27
34
  def active?
28
35
  deactivated_at.nil?
29
36
  end
30
37
 
38
+ def active_for_authentication?
39
+ super && active?
40
+ end
41
+
42
+ def inactive_message
43
+ active? ? super : :inactive
44
+ end
45
+
31
46
  def deactivate!
32
47
  update!(deactivated_at: Time.current)
33
48
  end
@@ -36,14 +51,6 @@ module Nquery
36
51
  Permissions::Resolver.new(self).admin?
37
52
  end
38
53
 
39
- def self.find_or_create_from_sso!(host_user)
40
- external_id = host_user.id.to_s
41
- find_or_create_by!(external_id: external_id) do |user|
42
- user.email = host_user.try(:email) || "#{external_id}@sso.local"
43
- user.first_name = host_user.try(:first_name) || host_user.try(:name)
44
- end.tap(&:ensure_all_users_membership!)
45
- end
46
-
47
54
  def ensure_all_users_membership!
48
55
  all_users = Group.find_by!(system_group: "all_users")
49
56
  group_memberships.find_or_create_by!(group: all_users)
@@ -54,5 +61,33 @@ module Nquery
54
61
 
55
62
  create_personal_collection!(name: "#{name}'s Personal Collection", kind: "personal")
56
63
  end
64
+
65
+ def password_required?
66
+ return false if new_record? && password.blank?
67
+
68
+ super
69
+ end
70
+
71
+ def confirmation_expired?
72
+ confirmation_period_expired?
73
+ end
74
+
75
+ # Returns false when delivery fails so callers can flash a warning instead of 500ing.
76
+ def deliver_devise_notification(notification, *args)
77
+ Nquery::DeviseMailer.send(notification, self, *args).deliver_now
78
+ self.last_devise_notification_delivered = true
79
+ rescue StandardError => error
80
+ self.last_devise_notification_delivered = false
81
+ Rails.logger.error(
82
+ "[nquery] Failed to deliver Devise #{notification} to #{email}: #{error.class}: #{error.message}"
83
+ )
84
+ false
85
+ end
86
+
87
+ protected
88
+
89
+ def send_devise_notification(notification, *args)
90
+ deliver_devise_notification(notification, *args)
91
+ end
57
92
  end
58
93
  end
@@ -0,0 +1,121 @@
1
+ <% cancel_path = local_assigns.fetch(:cancel_path) %>
2
+ <% submit_label = local_assigns.fetch(:submit_label) %>
3
+
4
+ <%= form_with model: data_source, url: data_source_form_url(data_source), method: data_source_form_method(data_source), class: "nq-form nq-card",
5
+ data: {
6
+ controller: "data-source-form",
7
+ data_source_form_adapter_value: data_source.adapter,
8
+ data_source_form_test_url_value: test_connection_admin_data_sources_path,
9
+ data_source_form_id_value: data_source.persisted? ? data_source.id : nil
10
+ } do |f| %>
11
+ <% if data_source.errors.any? %>
12
+ <div class="nq-form-errors" role="alert">
13
+ <p><strong>Please fix the following:</strong></p>
14
+ <ul>
15
+ <% data_source.errors.full_messages.each do |message| %>
16
+ <li><%= message %></li>
17
+ <% end %>
18
+ </ul>
19
+ </div>
20
+ <% end %>
21
+
22
+ <section class="nq-form-section">
23
+ <h2 class="nq-form-section-title">Details</h2>
24
+
25
+ <div class="nq-grid nq-grid-2 nq-form-grid">
26
+ <div class="nq-form-row">
27
+ <%= f.label :name %>
28
+ <%= f.text_field :name, class: "nq-input", placeholder: "e.g. Warehouse, Analytics", autofocus: !data_source.persisted? %>
29
+ <p class="nq-field-hint">A short label shown when picking a data source.</p>
30
+ </div>
31
+
32
+ <div class="nq-form-row">
33
+ <%= f.label :adapter %>
34
+ <%= f.select :adapter,
35
+ options_for_select(Nquery::DataSource::ADAPTER_OPTIONS, data_source.adapter),
36
+ {},
37
+ class: "nq-input",
38
+ data: { action: "change->data-source-form#toggle", data_source_form_target: "adapter" } %>
39
+ <p class="nq-field-hint">Choose how nquery connects to the database.</p>
40
+ </div>
41
+ </div>
42
+
43
+ <p data-data-source-form-target="railsHint" class="nq-field-hint" hidden>
44
+ Uses the host Rails application database connection. No additional credentials are required.
45
+ </p>
46
+ </section>
47
+
48
+ <section class="nq-form-section" data-data-source-form-target="remoteFields" hidden>
49
+ <h2 class="nq-form-section-title">Connection</h2>
50
+
51
+ <div class="nq-grid nq-grid-2 nq-form-grid">
52
+ <div class="nq-form-row">
53
+ <%= f.label :host %>
54
+ <%= f.text_field :host, class: "nq-input", placeholder: "localhost", autocomplete: "off" %>
55
+ </div>
56
+
57
+ <div class="nq-form-row">
58
+ <%= f.label :port %>
59
+ <%= f.number_field :port, class: "nq-input", placeholder: data_source.default_port, min: 1, max: 65_535,
60
+ data: { data_source_form_target: "port" } %>
61
+ <p class="nq-field-hint">Leave blank to use the default port.</p>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="nq-grid nq-grid-2 nq-form-grid">
66
+ <div class="nq-form-row">
67
+ <%= f.label :database %>
68
+ <%= f.text_field :database, class: "nq-input", autocomplete: "off" %>
69
+ </div>
70
+
71
+ <div class="nq-form-row">
72
+ <%= f.label :sslmode, "SSL mode (optional)" %>
73
+ <%= f.select :sslmode,
74
+ options_for_select([["Default", ""]] + Nquery::DataSource::SSL_MODES.map { |mode| [mode, mode] }, data_source.sslmode),
75
+ {},
76
+ class: "nq-input" %>
77
+ </div>
78
+ </div>
79
+
80
+ <div class="nq-grid nq-grid-2 nq-form-grid">
81
+ <div class="nq-form-row">
82
+ <%= f.label :username %>
83
+ <%= f.text_field :username, class: "nq-input", autocomplete: "off" %>
84
+ </div>
85
+
86
+ <div class="nq-form-row">
87
+ <%= f.label :password %>
88
+ <%= f.password_field :password, class: "nq-input", autocomplete: "new-password",
89
+ placeholder: data_source.persisted? ? "Leave blank to keep current password" : nil %>
90
+ </div>
91
+ </div>
92
+ </section>
93
+
94
+ <section class="nq-form-section" data-data-source-form-target="sqliteFields" hidden>
95
+ <h2 class="nq-form-section-title">SQLite database</h2>
96
+
97
+ <div class="nq-form-row">
98
+ <%= f.label :database_path, "Database path" %>
99
+ <%= f.text_field :database_path, class: "nq-input", placeholder: "/path/to/database.sqlite3" %>
100
+ <p class="nq-field-hint">Absolute path to the SQLite file on the server.</p>
101
+ </div>
102
+ </section>
103
+
104
+ <% if data_source.persisted? %>
105
+ <section class="nq-form-section">
106
+ <div class="nq-form-row">
107
+ <label><%= f.check_box :active %> Active</label>
108
+ <p class="nq-field-hint">Inactive data sources are hidden from query and chart builders.</p>
109
+ </div>
110
+ </section>
111
+ <% end %>
112
+
113
+ <div class="nq-form-actions">
114
+ <button type="button" class="nq-btn nq-form-actions-start" data-data-source-form-target="testButton" data-manages-loading="true">
115
+ Test Connection
116
+ </button>
117
+ <%= link_to "Cancel", cancel_path, class: "nq-btn" %>
118
+ <%= f.submit submit_label, class: "nq-btn nq-btn-primary" %>
119
+ </div>
120
+ <p class="nq-connection-status" data-data-source-form-target="testStatus" role="status" hidden></p>
121
+ <% end %>
@@ -1,8 +1,6 @@
1
- <div class="nq-page-header"><h1>Edit data source</h1></div>
2
- <%= form_with model: @data_source, url: admin_data_source_path(@data_source), method: :patch, class: "nq-form nq-card" do |f| %>
3
- <div class="nq-form-row"><%= f.label :name %><%= f.text_field :name, class: "nq-input" %></div>
4
- <div class="nq-form-row"><%= f.label :adapter %><%= f.select :adapter, Nquery::DataSource::ADAPTERS, {}, class: "nq-input" %></div>
5
- <div class="nq-form-row"><%= f.label :connection_config %><%= f.text_area :connection_config, class: "nq-textarea", rows: 4 %></div>
6
- <div class="nq-form-row"><label><%= f.check_box :active %> Active</label></div>
7
- <%= f.submit "Update", class: "nq-btn nq-btn-primary" %>
8
- <% end %>
1
+ <div class="nq-page-header">
2
+ <h1>Edit data source</h1>
3
+ <p class="nq-muted nq-resource-meta">Update connection settings for <%= @data_source.name %>.</p>
4
+ </div>
5
+
6
+ <%= render "form", data_source: @data_source, cancel_path: admin_data_sources_path, submit_label: "Save changes" %>
@@ -1,7 +1,6 @@
1
- <div class="nq-page-header"><h1>New data source</h1></div>
2
- <%= form_with model: @data_source, url: admin_data_sources_path, class: "nq-form nq-card" do |f| %>
3
- <div class="nq-form-row"><%= f.label :name %><%= f.text_field :name, class: "nq-input" %></div>
4
- <div class="nq-form-row"><%= f.label :adapter %><%= f.select :adapter, Nquery::DataSource::ADAPTERS, {}, class: "nq-input" %></div>
5
- <div class="nq-form-row"><%= f.label :connection_config, "Connection (JSON)" %><%= f.text_area :connection_config, class: "nq-textarea", rows: 4, placeholder: '{"host":"localhost","database":"mydb"}' %></div>
6
- <%= f.submit "Create", class: "nq-btn nq-btn-primary" %>
7
- <% end %>
1
+ <div class="nq-page-header">
2
+ <h1>New data source</h1>
3
+ <p class="nq-muted nq-resource-meta">Connect a database so your team can query and chart data.</p>
4
+ </div>
5
+
6
+ <%= render "form", data_source: @data_source, cancel_path: admin_data_sources_path, submit_label: "Create data source" %>
@@ -0,0 +1,47 @@
1
+ <% cancel_path = local_assigns.fetch(:cancel_path) %>
2
+ <% submit_label = local_assigns.fetch(:submit_label) %>
3
+ <% form_url = group.persisted? ? admin_group_path(group) : admin_groups_path %>
4
+ <% form_method = group.persisted? ? :patch : :post %>
5
+
6
+ <%= form_with model: group, url: form_url, method: form_method, class: "nq-form nq-form-card" do |f| %>
7
+ <% if group.errors.any? %>
8
+ <div class="nq-form-errors" role="alert">
9
+ <p><strong>Please fix the following:</strong></p>
10
+ <ul>
11
+ <% group.errors.full_messages.each do |message| %>
12
+ <li><%= message %></li>
13
+ <% end %>
14
+ </ul>
15
+ </div>
16
+ <% end %>
17
+
18
+ <% if group.persisted? && group.system? %>
19
+ <%= nq_flash_card "This is a system group. The name is fixed, but you can update the description.", type: :info, toast: false %>
20
+ <% end %>
21
+
22
+ <section class="nq-form-section">
23
+ <h2 class="nq-form-section-title">Group details</h2>
24
+
25
+ <div class="nq-form-row">
26
+ <%= f.label :name %>
27
+ <% if group.system? %>
28
+ <%= f.text_field :name, class: "nq-input", disabled: true %>
29
+ <p class="nq-field-hint">System group names cannot be changed.</p>
30
+ <% else %>
31
+ <%= f.text_field :name, class: "nq-input", placeholder: "e.g. Finance, Engineering", autofocus: !group.persisted? %>
32
+ <p class="nq-field-hint">Choose a name members will recognize.</p>
33
+ <% end %>
34
+ </div>
35
+
36
+ <div class="nq-form-row">
37
+ <%= f.label :description, "Description (optional)" %>
38
+ <%= f.text_area :description, class: "nq-input nq-textarea-prose", rows: 3, placeholder: "What this group is for and who should belong to it." %>
39
+ <p class="nq-field-hint">Shown on the group page and helps others understand its purpose.</p>
40
+ </div>
41
+ </section>
42
+
43
+ <div class="nq-form-actions">
44
+ <%= link_to "Cancel", cancel_path, class: "nq-btn" %>
45
+ <%= f.submit submit_label, class: "nq-btn nq-btn-primary" %>
46
+ </div>
47
+ <% end %>
@@ -1,6 +1,15 @@
1
- <div class="nq-page-header"><h1>Edit group</h1></div>
2
- <%= form_with model: @group, url: admin_group_path(@group), method: :patch, class: "nq-form nq-card" do |f| %>
3
- <div class="nq-form-row"><%= f.label :name %><%= f.text_field :name, class: "nq-input", disabled: @group.system? %></div>
4
- <div class="nq-form-row"><%= f.label :description %><%= f.text_area :description, class: "nq-textarea" %></div>
5
- <%= f.submit "Update", class: "nq-btn nq-btn-primary" unless @group.system? && @group.system_group != "custom" %>
6
- <% end %>
1
+ <div class="nq-page-header">
2
+ <h1>Edit group</h1>
3
+ <p class="nq-muted nq-resource-meta">
4
+ <%= @group.name %>
5
+ · <%= group_type_label(@group) %>
6
+ · <%= pluralize(@group.users.size, "member") %>
7
+ </p>
8
+ <div class="nq-actions">
9
+ <%= link_to admin_group_path(@group), class: "nq-btn" do %>
10
+ <span>View members</span>
11
+ <% end %>
12
+ </div>
13
+ </div>
14
+
15
+ <%= render "form", group: @group, cancel_path: admin_group_path(@group), submit_label: "Save changes" %>
@@ -1,21 +1,26 @@
1
- <div class="nq-page-header"><h1>Groups</h1><%= link_to "New group", new_admin_group_path, class: "nq-btn nq-btn-primary" %></div>
2
-
3
- <div class="nq-card nq-card-table">
4
- <table class="nq-table">
5
- <thead><tr><th>Name</th><th>Type</th><th>Members</th><th></th></tr></thead>
6
- <tbody>
7
- <% @groups.each do |group| %>
8
- <tr>
9
- <td><%= link_to group.name, admin_group_path(group) %></td>
10
- <td><span class="nq-badge"><%= group.system_group %></span></td>
11
- <td><%= group.users.count %></td>
12
- <td>
13
- <div class="nq-actions">
14
- <%= link_to "Edit", edit_admin_group_path(group), class: "nq-btn" unless group.system? && group.system_group != "custom" %>
15
- </div>
16
- </td>
17
- </tr>
18
- <% end %>
19
- </tbody>
20
- </table>
1
+ <div class="nq-page-header">
2
+ <h1>Groups</h1>
3
+ <p class="nq-muted nq-resource-meta">Organize users into groups to manage access and permissions.</p>
4
+ <%= link_to new_admin_group_path, class: "nq-btn nq-btn-primary" do %>
5
+ <%= nq_icon(:plus) %>
6
+ <span>New group</span>
7
+ <% end %>
21
8
  </div>
9
+
10
+ <%= nq_card_table columns: ["Name", "Description", "Type", "Members", ""] do %>
11
+ <% @groups.each do |group| %>
12
+ <tr>
13
+ <td><%= link_to group.name, admin_group_path(group) %></td>
14
+ <td class="nq-muted"><%= group.description.presence || "—" %></td>
15
+ <td>
16
+ <span class="nq-badge <%= group_type_badge_class(group) %>"><%= group_type_label(group) %></span>
17
+ </td>
18
+ <td><%= group.users.size %></td>
19
+ <td>
20
+ <div class="nq-actions">
21
+ <%= link_to "Edit", edit_admin_group_path(group), class: "nq-btn" %>
22
+ </div>
23
+ </td>
24
+ </tr>
25
+ <% end %>
26
+ <% end %>
@@ -1,6 +1,6 @@
1
- <div class="nq-page-header"><h1>New group</h1></div>
2
- <%= form_with model: @group, url: admin_groups_path, class: "nq-form nq-card" do |f| %>
3
- <div class="nq-form-row"><%= f.label :name %><%= f.text_field :name, class: "nq-input" %></div>
4
- <div class="nq-form-row"><%= f.label :description %><%= f.text_area :description, class: "nq-textarea" %></div>
5
- <%= f.submit "Create", class: "nq-btn nq-btn-primary" %>
6
- <% end %>
1
+ <div class="nq-page-header">
2
+ <h1>New group</h1>
3
+ <p class="nq-muted nq-resource-meta">Create a group to organize users and manage permissions together.</p>
4
+ </div>
5
+
6
+ <%= render "form", group: @group, cancel_path: admin_groups_path, submit_label: "Create group" %>