airview 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28182d9bbb704849b5ede094038a3d6b031826c119b704f40691578639d592fd
4
- data.tar.gz: ae015890868c3cf07e46e46b947baa597998937f7a39f505c17944c7561789c7
3
+ metadata.gz: 4698348a323226676e3caba98313791047e11f6ca61d5481c4161e93b72df435
4
+ data.tar.gz: 991db64ebdb3d015d6a57b7f2cc008b9d1bdad64cca67f2b87727f6aa7533be1
5
5
  SHA512:
6
- metadata.gz: 44327f49faf4823b0439094b990d4ea007b76f39dc7c1d00f05550551cc662f96af4e5ce693e716ca51ade47c4a1058ea6970247153fca22549c92e5f82ebf8c
7
- data.tar.gz: 107620ed78ff8f7477d3b435ff98696b40b0925003446834f497866aa6d21753d29fa5077a9734a07e30dc0a19b302b6a8ad0fb096ba7e97a6dfe72fae8181f2
6
+ metadata.gz: 057bac0d19e8c39a2e1238eab49cf778d84dffb742ea7e67abb1e50622cbdbd8539b55b71b661f117598f60dd3efeec1141838aa9d2aff08801cdd9410d1fe8d
7
+ data.tar.gz: f276cd3b707856ee4b2b3b0e554d4dda4cead471cb6de46aacc64f0474af45d06cf538f16255ff86abd3a6cf3f62435444937ac9a917d29cf903e96aa3da1b26
@@ -474,6 +474,13 @@
474
474
  gap: 12px;
475
475
  }
476
476
 
477
+ .airview-toolbar-actions {
478
+ align-items: center;
479
+ display: flex;
480
+ flex-wrap: wrap;
481
+ gap: 8px;
482
+ }
483
+
477
484
  .airview-setup-table .airview-actions,
478
485
  .airview-setup-fields .airview-actions {
479
486
  white-space: nowrap;
@@ -15,6 +15,8 @@ module Airview
15
15
 
16
16
  def show
17
17
  @resources = Airview.resources.values
18
+ return render(:empty) if @resource.fields.empty?
19
+
18
20
  @views = View.for_resource(@resource.key).ordered
19
21
  @active_view = @views.find { |view| view.id.to_s == params[:view_id].to_s }
20
22
  @sorted_fields = @resource.fields.values.sort_by { |field| field.label.downcase }
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Airview
4
4
  class SetupController < ApplicationController
5
- before_action :set_definition, only: %i[edit update destroy]
5
+ before_action :set_definition, only: %i[edit update sync destroy]
6
6
 
7
7
  def index
8
8
  @models = ModelDiscovery.models
@@ -37,6 +37,12 @@ module Airview
37
37
  end
38
38
  end
39
39
 
40
+ def sync
41
+ added_count = ResourceSynchronizer.new(@definition).sync_missing_fields
42
+ field_label = "field".pluralize(added_count)
43
+ redirect_to edit_setup_resource_path(@definition), notice: "#{added_count} #{field_label} added"
44
+ end
45
+
40
46
  def destroy
41
47
  @definition.destroy!
42
48
  redirect_to setup_path, notice: "Resource removed"
@@ -3,6 +3,8 @@
3
3
  module Airview
4
4
  module ApplicationHelper
5
5
  def airview_field_value(record, field)
6
+ return nil unless airview_field_available?(record, field)
7
+
6
8
  value = record.public_send(field.name)
7
9
  return value if field.collection_association?
8
10
  return Airview.record_label(value, field.label_method) if field.association? && value
@@ -13,6 +15,7 @@ module Airview
13
15
 
14
16
  def airview_cell_value(record, field)
15
17
  value = airview_field_value(record, field)
18
+ return tag.span("", class: "airview-empty-value") unless airview_field_available?(record, field)
16
19
 
17
20
  case field.type
18
21
  when :boolean
@@ -37,6 +40,7 @@ module Airview
37
40
  def airview_input_for(record, field)
38
41
  value = airview_field_value(record, field)
39
42
  name = "record[#{field.name}]"
43
+ return text_field_tag(name, nil, disabled: true) unless airview_field_available?(record, field)
40
44
 
41
45
  case field.type
42
46
  when :boolean
@@ -189,6 +193,8 @@ module Airview
189
193
  end
190
194
 
191
195
  def airview_reference_pill(record, field)
196
+ return tag.span("", class: "airview-empty-value") unless airview_field_available?(record, field)
197
+
192
198
  related = record.public_send(field.name)
193
199
  return tag.span("+ Link record", class: "airview-reference-empty") unless related
194
200
 
@@ -204,6 +210,8 @@ module Airview
204
210
  end
205
211
 
206
212
  def airview_collection_pills(record, field)
213
+ return tag.span("0 records", class: "airview-reference-empty") unless airview_field_available?(record, field)
214
+
207
215
  records = record.public_send(field.name).limit(3).to_a
208
216
  count = airview_collection_count(record, field)
209
217
 
@@ -225,6 +233,8 @@ module Airview
225
233
  end
226
234
 
227
235
  def airview_attachment_cell(record, field)
236
+ return tag.span("No file", class: "airview-reference-empty") unless airview_field_available?(record, field)
237
+
228
238
  attachment = record.public_send(field.name)
229
239
  return tag.span("No file", class: "airview-reference-empty") unless attachment.attached?
230
240
 
@@ -241,6 +251,8 @@ module Airview
241
251
  end
242
252
 
243
253
  def airview_reference_input(record, field, name)
254
+ return text_field_tag(name, nil, disabled: true) unless airview_field_available?(record, field)
255
+
244
256
  related = record.public_send(field.name)
245
257
 
246
258
  safe_join(
@@ -427,11 +439,33 @@ module Airview
427
439
  return "##{record.id}" unless linked_resource
428
440
 
429
441
  linked_resource.fields.values.reject(&:association?).first(3).filter_map do |field|
442
+ next unless airview_field_available?(record, field)
443
+
430
444
  value = airview_field_value(record, field)
431
445
  "#{field.label}: #{value}" if value.present?
432
446
  end.join(" · ")
433
447
  end
434
448
 
449
+ def airview_field_available?(record, field)
450
+ model_class = record.class
451
+
452
+ case field.type
453
+ when :belongs_to
454
+ association = model_class.reflect_on_association(field.name) if model_class.respond_to?(:reflect_on_association)
455
+ association&.macro == :belongs_to && model_class.column_names.include?(association.foreign_key.to_s)
456
+ when :has_many
457
+ association = model_class.reflect_on_association(field.name) if model_class.respond_to?(:reflect_on_association)
458
+ association&.macro == :has_many
459
+ when :attachment
460
+ model_class.respond_to?(:attachment_reflections) &&
461
+ model_class.attachment_reflections.key?(field.name.to_s)
462
+ else
463
+ model_class.respond_to?(:column_names) && model_class.column_names.include?(field.name.to_s)
464
+ end
465
+ rescue ActiveRecord::StatementInvalid, NameError
466
+ false
467
+ end
468
+
435
469
  def airview_resource_for_model(model)
436
470
  Airview.resources.values.find { |resource| resource.model == model.to_s }
437
471
  end
@@ -18,5 +18,58 @@ module Airview
18
18
  def default_visible?
19
19
  metadata.fetch("default_visible", true)
20
20
  end
21
+
22
+ def stale?
23
+ !schema_backed?
24
+ end
25
+
26
+ def stale_reason
27
+ return nil if schema_backed?
28
+
29
+ case field_type.to_sym
30
+ when :belongs_to
31
+ "Missing belongs_to association or foreign key"
32
+ when :has_many
33
+ "Missing has_many association"
34
+ when :attachment
35
+ "Missing attachment"
36
+ else
37
+ "Missing column"
38
+ end
39
+ end
40
+
41
+ def schema_backed?
42
+ model_class = resource_definition.model_class
43
+
44
+ case field_type.to_sym
45
+ when :belongs_to
46
+ belongs_to_schema_backed?(model_class)
47
+ when :has_many
48
+ association = model_class.reflect_on_association(name.to_sym)
49
+ association&.macro == :has_many
50
+ when :attachment
51
+ attachment_names(model_class).include?(name.to_s)
52
+ else
53
+ model_class.column_names.include?(name.to_s)
54
+ end
55
+ rescue ActiveRecord::StatementInvalid, NameError
56
+ false
57
+ end
58
+
59
+ private
60
+
61
+ def belongs_to_schema_backed?(model_class)
62
+ association_key = association_name.presence || name
63
+ association = model_class.reflect_on_association(association_key.to_sym)
64
+ return false unless association&.macro == :belongs_to
65
+
66
+ model_class.column_names.include?(association.foreign_key.to_s)
67
+ end
68
+
69
+ def attachment_names(model_class)
70
+ return [] unless model_class.respond_to?(:attachment_reflections)
71
+
72
+ model_class.attachment_reflections.keys.map(&:to_s)
73
+ end
21
74
  end
22
75
  end
@@ -1,5 +1,11 @@
1
1
  <main class="airview-empty">
2
- <h1>Airview</h1>
3
- <p>No resources are enabled yet.</p>
2
+ <h1><%= @resource&.label || "Airview" %></h1>
3
+ <p>
4
+ <% if @resource %>
5
+ No available fields are backed by the current schema.
6
+ <% else %>
7
+ No resources are enabled yet.
8
+ <% end %>
9
+ </p>
4
10
  <%= link_to "Configure resources", setup_path %>
5
11
  </main>
@@ -37,6 +37,7 @@
37
37
  <th>Visible</th>
38
38
  <th>Readonly</th>
39
39
  <th>Name</th>
40
+ <th>Status</th>
40
41
  <th>Label</th>
41
42
  <th>Type</th>
42
43
  <th>Order</th>
@@ -46,8 +47,9 @@
46
47
  </thead>
47
48
  <tbody>
48
49
  <%= form.fields_for :field_definitions do |field_form| %>
50
+ <% stale = field_form.object.stale? %>
49
51
  <tr>
50
- <td><%= field_form.check_box :visible %></td>
52
+ <td><%= field_form.check_box :visible, disabled: stale %></td>
51
53
  <td><%= field_form.check_box :read_only %></td>
52
54
  <td>
53
55
  <%= field_form.hidden_field :id if field_form.object.persisted? %>
@@ -55,6 +57,7 @@
55
57
  <%= field_form.hidden_field :association_name %>
56
58
  <%= field_form.object.name %>
57
59
  </td>
60
+ <td><%= stale ? field_form.object.stale_reason : "Available" %></td>
58
61
  <td><%= field_form.text_field :label %></td>
59
62
  <td><%= field_form.select :field_type, Airview::Field::TYPES.map { |type| [type.to_s.humanize, type] } %></td>
60
63
  <td><%= field_form.number_field :position, min: 0 %></td>
@@ -8,7 +8,10 @@
8
8
  <p><%= @definition.record_class_name %></p>
9
9
  </div>
10
10
 
11
- <%= button_to "Remove resource", setup_resource_path(@definition), method: :delete, form: { data: { turbo_confirm: "Remove this Airview resource?" } } %>
11
+ <div class="airview-toolbar-actions">
12
+ <%= button_to "Sync fields", sync_setup_resource_path(@definition), method: :post %>
13
+ <%= button_to "Remove resource", setup_resource_path(@definition), method: :delete, form: { data: { turbo_confirm: "Remove this Airview resource?" } } %>
14
+ </div>
12
15
  </header>
13
16
 
14
17
  <%= render "form", definition: @definition, url: setup_resource_path(@definition), method: :patch %>
data/config/routes.rb CHANGED
@@ -8,6 +8,7 @@ Airview::Engine.routes.draw do
8
8
  post "/setup/resources", to: "setup#create", as: :setup_resources
9
9
  get "/setup/resources/:id/edit", to: "setup#edit", as: :edit_setup_resource
10
10
  patch "/setup/resources/:id", to: "setup#update", as: :setup_resource
11
+ post "/setup/resources/:id/sync", to: "setup#sync", as: :sync_setup_resource
11
12
  delete "/setup/resources/:id", to: "setup#destroy"
12
13
 
13
14
  get "/resources", to: "resources#index", as: :resources
data/lib/airview/query.rb CHANGED
@@ -43,7 +43,9 @@ module Airview
43
43
  term = params[:q].to_s.strip
44
44
  return scope if term.empty?
45
45
 
46
- searchable = resource.fields.values.select { |field| %i[string text select].include?(field.type) }
46
+ searchable = resource.fields.values.select do |field|
47
+ %i[string text select].include?(field.type) && queryable_field?(scope, field)
48
+ end
47
49
  return scope if searchable.empty?
48
50
 
49
51
  clauses = searchable.map do |field|
@@ -63,6 +65,7 @@ module Airview
63
65
 
64
66
  field = resource.fields[name.to_sym]
65
67
  next unless field
68
+ next unless queryable_field?(scope, field)
66
69
 
67
70
  scope = apply_filter(scope, field, value)
68
71
  end
@@ -82,6 +85,7 @@ module Airview
82
85
  field = resource.fields[condition["field"].to_s.to_sym]
83
86
  next unless field
84
87
  next unless field.filterable?
88
+ next unless queryable_field?(scope, field)
85
89
 
86
90
  scope = apply_structured_filter(scope, field, condition["operator"], condition["value"])
87
91
  end
@@ -183,10 +187,19 @@ module Airview
183
187
  field = resource.fields[sort.to_sym]
184
188
  return scope unless field
185
189
  return scope unless field.sortable?
190
+ return scope unless queryable_field?(scope, field)
186
191
 
187
192
  scope.order(field.attribute_name => direction)
188
193
  end
189
194
 
195
+ def queryable_field?(scope, field)
196
+ return false if field.display_only?
197
+
198
+ scope.klass.column_names.include?(field.attribute_name.to_s)
199
+ rescue NameError
200
+ false
201
+ end
202
+
190
203
  def sanitize_like(value)
191
204
  ActiveRecord::Base.sanitize_sql_like(value.to_s)
192
205
  end
@@ -11,6 +11,7 @@ module Airview
11
11
  @label = key.to_s.humanize
12
12
  @label_method = :to_s
13
13
  @fields = {}
14
+ @stale_fields = []
14
15
  end
15
16
 
16
17
  def field(name, type:, label: nil, **options)
@@ -38,6 +39,14 @@ module Airview
38
39
  fields.values.select(&:default_visible?)
39
40
  end
40
41
 
42
+ def stale_fields
43
+ @stale_fields.dup
44
+ end
45
+
46
+ def add_stale_field(field, reason:)
47
+ @stale_fields << { name: field.name.to_sym, label: field.label, reason: }
48
+ end
49
+
41
50
  def validate!
42
51
  raise ConfigurationError, "Airview resource #{key} must define at least one field" if fields.empty?
43
52
 
@@ -18,7 +18,12 @@ module Airview
18
18
  resource.label_method = definition.label_method.presence&.to_sym || :to_s
19
19
 
20
20
  definition.field_definitions.visible.order(:position, :name).each do |field|
21
- add_field(resource, field)
21
+ if field_schema_backed?(resource.model_class, field)
22
+ add_field(resource, field)
23
+ else
24
+ resource.add_stale_field(field, reason: stale_reason(resource.model_class, field))
25
+ log_stale_field(resource, field)
26
+ end
22
27
  end
23
28
 
24
29
  add_missing_virtual_fields(resource, definition)
@@ -57,6 +62,56 @@ module Airview
57
62
  )
58
63
  end
59
64
  end
65
+
66
+ def field_schema_backed?(model_class, field)
67
+ case field.field_type.to_sym
68
+ when :belongs_to
69
+ belongs_to_schema_backed?(model_class, field)
70
+ when :has_many
71
+ association = model_class.reflect_on_association(field.name.to_sym)
72
+ association&.macro == :has_many
73
+ when :attachment
74
+ attachment_names(model_class).include?(field.name.to_s)
75
+ else
76
+ model_class.column_names.include?(field.name.to_s)
77
+ end
78
+ rescue NameError
79
+ false
80
+ end
81
+
82
+ def belongs_to_schema_backed?(model_class, field)
83
+ association_name = field.association_name.presence || field.name
84
+ association = model_class.reflect_on_association(association_name.to_sym)
85
+ return false unless association&.macro == :belongs_to
86
+
87
+ model_class.column_names.include?(association.foreign_key.to_s)
88
+ end
89
+
90
+ def attachment_names(model_class)
91
+ return [] unless model_class.respond_to?(:attachment_reflections)
92
+
93
+ model_class.attachment_reflections.keys.map(&:to_s)
94
+ end
95
+
96
+ def stale_reason(model_class, field)
97
+ case field.field_type.to_sym
98
+ when :belongs_to
99
+ "missing belongs_to association or foreign key on #{model_class.name}"
100
+ when :has_many
101
+ "missing has_many association on #{model_class.name}"
102
+ when :attachment
103
+ "missing attachment on #{model_class.name}"
104
+ else
105
+ "missing column on #{model_class.name}"
106
+ end
107
+ end
108
+
109
+ def log_stale_field(resource, field)
110
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
111
+
112
+ reason = stale_reason(resource.model_class, field)
113
+ Rails.logger.warn("Airview skipped stale field #{field.name.inspect} for #{resource.model}: #{reason}")
114
+ end
60
115
  end
61
116
  end
62
117
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class ResourceSynchronizer
5
+ attr_reader :definition
6
+
7
+ def initialize(definition)
8
+ @definition = definition
9
+ end
10
+
11
+ def sync_missing_fields
12
+ existing_names = definition.field_definitions.map { |field| field.name.to_s }
13
+ position = definition.field_definitions.map(&:position).compact.max.to_i
14
+ added_count = 0
15
+
16
+ SchemaInference.new(definition.model_class).field_attributes.each do |attributes|
17
+ next if existing_names.include?(attributes[:name].to_s)
18
+
19
+ position += 1
20
+ definition.field_definitions.create!(attributes.merge(position:))
21
+ added_count += 1
22
+ end
23
+
24
+ added_count
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Airview
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/airview.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "airview/resource"
13
13
  require_relative "airview/query"
14
14
  require_relative "airview/model_discovery"
15
15
  require_relative "airview/resource_builder"
16
+ require_relative "airview/resource_synchronizer"
16
17
  require_relative "airview/schema_inference"
17
18
  require_relative "airview/engine" if defined?(Rails)
18
19
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: airview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jacksonriso
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-06 00:00:00.000000000 Z
11
+ date: 2026-08-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -74,6 +74,7 @@ files:
74
74
  - lib/airview/query.rb
75
75
  - lib/airview/resource.rb
76
76
  - lib/airview/resource_builder.rb
77
+ - lib/airview/resource_synchronizer.rb
77
78
  - lib/airview/schema_inference.rb
78
79
  - lib/airview/version.rb
79
80
  - lib/generators/airview/install/install_generator.rb