airview 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +65 -0
  6. data/app/assets/javascripts/airview/application.js +425 -0
  7. data/app/assets/stylesheets/airview/application.css +1239 -0
  8. data/app/controllers/airview/application_controller.rb +7 -0
  9. data/app/controllers/airview/resources_controller.rb +261 -0
  10. data/app/controllers/airview/setup_controller.rb +90 -0
  11. data/app/controllers/airview/views_controller.rb +76 -0
  12. data/app/helpers/airview/application_helper.rb +479 -0
  13. data/app/javascript/airview/controllers/grid_controller.js +7 -0
  14. data/app/models/airview/application_record.rb +7 -0
  15. data/app/models/airview/field_definition.rb +22 -0
  16. data/app/models/airview/resource_definition.rb +48 -0
  17. data/app/models/airview/view.rb +27 -0
  18. data/app/views/airview/resources/_delete_modal.html.erb +49 -0
  19. data/app/views/airview/resources/_record_modal.html.erb +62 -0
  20. data/app/views/airview/resources/empty.html.erb +5 -0
  21. data/app/views/airview/resources/show.html.erb +258 -0
  22. data/app/views/airview/setup/_form.html.erb +73 -0
  23. data/app/views/airview/setup/edit.html.erb +16 -0
  24. data/app/views/airview/setup/index.html.erb +52 -0
  25. data/app/views/airview/setup/new.html.erb +14 -0
  26. data/app/views/airview/shared/_sidebar.html.erb +76 -0
  27. data/app/views/layouts/airview/application.html.erb +21 -0
  28. data/config/routes.rb +23 -0
  29. data/db/migrate/20260806000000_create_airview_views.rb +57 -0
  30. data/lib/airview/configuration.rb +6 -0
  31. data/lib/airview/engine.rb +15 -0
  32. data/lib/airview/field.rb +81 -0
  33. data/lib/airview/model_discovery.rb +38 -0
  34. data/lib/airview/query.rb +194 -0
  35. data/lib/airview/resource.rb +51 -0
  36. data/lib/airview/resource_builder.rb +62 -0
  37. data/lib/airview/schema_inference.rb +196 -0
  38. data/lib/airview/version.rb +5 -0
  39. data/lib/airview.rb +93 -0
  40. data/lib/generators/airview/install/install_generator.rb +38 -0
  41. data/lib/generators/airview/install/templates/add_folders_to_airview_views.rb +8 -0
  42. data/lib/generators/airview/install/templates/airview.rb +12 -0
  43. data/lib/generators/airview/install/templates/create_airview_views.rb +57 -0
  44. data/sig/airview.rbs +54 -0
  45. metadata +110 -0
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class ApplicationController < ActionController::Base
5
+ layout "airview/application"
6
+ end
7
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class ResourcesController < ApplicationController
5
+ before_action :set_resource, except: :index
6
+
7
+ def index
8
+ @resources = Airview.resources.values
9
+ if @resources.any?
10
+ redirect_to resource_path(@resources.first.key)
11
+ else
12
+ redirect_to setup_path
13
+ end
14
+ end
15
+
16
+ def show
17
+ @resources = Airview.resources.values
18
+ @views = View.for_resource(@resource.key).ordered
19
+ @active_view = @views.find { |view| view.id.to_s == params[:view_id].to_s }
20
+ @sorted_fields = @resource.fields.values.sort_by { |field| field.label.downcase }
21
+ @visible_fields = visible_fields
22
+ @filter_conditions = filter_conditions
23
+ @query = Query.new(@resource, query_params.merge(filters: @filter_conditions))
24
+ @records = @query.records
25
+ @record = @resource.model_class.new
26
+ @expanded_record = expanded_record
27
+ @delete_record = delete_record
28
+ end
29
+
30
+ def create
31
+ record = @resource.model_class.new(record_attributes)
32
+
33
+ if record.save
34
+ redirect_to resource_path(@resource.key), notice: "Record created"
35
+ else
36
+ redirect_to resource_path(@resource.key), alert: record.errors.full_messages.to_sentence
37
+ end
38
+ end
39
+
40
+ def update
41
+ record = @resource.model_class.find(params[:id])
42
+
43
+ if record.update(record_attributes)
44
+ redirect_back fallback_location: resource_path(@resource.key), notice: "Record updated"
45
+ else
46
+ redirect_back fallback_location: resource_path(@resource.key), alert: record.errors.full_messages.to_sentence
47
+ end
48
+ end
49
+
50
+ def destroy
51
+ record = @resource.model_class.find(params[:id])
52
+ record.destroy!
53
+ redirect_to safe_return_path || resource_path(@resource.key), notice: "Record deleted"
54
+ end
55
+
56
+ def references
57
+ field = @resource.field!(params[:field])
58
+ raise ActionController::RoutingError, "Not Found" unless field.association?
59
+
60
+ scope = field.model_class.all
61
+ term = params[:q].to_s.strip
62
+ scope = filter_reference_scope(scope, field, term) if term.present?
63
+
64
+ render json: scope.limit(20).map { |record| reference_payload(record, field) }
65
+ end
66
+
67
+ private
68
+
69
+ def set_resource
70
+ @resource = Airview.resource!(params[:key])
71
+ end
72
+
73
+ def safe_return_path
74
+ return_to = params[:return_to].to_s
75
+ return nil unless return_to.start_with?("/") && !return_to.start_with?("//")
76
+
77
+ return_to
78
+ end
79
+
80
+ def query_params
81
+ permitted = params.permit(:q, :sort, :direction, :page, :limit, filters: %i[field operator value])
82
+ view_sorts = @active_view&.sorts || {}
83
+
84
+ permitted[:sort] ||= view_sorts["sort"]
85
+ permitted[:direction] ||= view_sorts["direction"]
86
+ permitted
87
+ end
88
+
89
+ def visible_fields
90
+ column_names = if params.key?(:columns_present)
91
+ Array(params[:columns]).flat_map { |value| value.to_s.split(",") }
92
+ elsif params[:columns].present?
93
+ Array(params[:columns]).flat_map { |value| value.to_s.split(",") }
94
+ elsif @active_view
95
+ @active_view.ordered_columns(@resource)
96
+ else
97
+ @resource.default_fields.map { |field| field.name.to_s }
98
+ end
99
+
100
+ column_names.filter_map { |name| @resource.fields[name.to_sym] }
101
+ end
102
+
103
+ def expanded_record
104
+ return nil if params[:record_id].blank?
105
+
106
+ @resource.model_class.find_by(id: params[:record_id])
107
+ end
108
+
109
+ def delete_record
110
+ return nil if params[:delete_record_id].blank?
111
+
112
+ @resource.model_class.find_by(id: params[:delete_record_id])
113
+ end
114
+
115
+ def reference_payload(record, field)
116
+ linked_resource = Airview.resources.values.find { |resource| resource.model == field.model.to_s }
117
+ {
118
+ id: record.id,
119
+ label: Airview.record_label(record, field.label_method),
120
+ resource_key: linked_resource&.key,
121
+ open_url: linked_resource ? reference_open_path(linked_resource, record) : nil,
122
+ preview: reference_preview(record, linked_resource)
123
+ }
124
+ end
125
+
126
+ def reference_open_path(resource, record)
127
+ resource_path(resource.key, record_id: record.id, return_to: current_table_path)
128
+ end
129
+
130
+ def current_table_path
131
+ query = request.query_parameters.except(:record_id, :delete_record_id, :return_to)
132
+ query_string = query.to_query
133
+
134
+ query_string.present? ? "#{request.path}?#{query_string}" : request.path
135
+ end
136
+
137
+ def reference_preview(record, linked_resource)
138
+ preview_fields = if linked_resource
139
+ linked_resource.fields.values.reject(&:association?).first(3)
140
+ else
141
+ reference_preview_columns(record.class).map do |column_name|
142
+ [column_name, column_name.to_s.humanize]
143
+ end
144
+ end
145
+
146
+ preview_fields.filter_map do |preview_field|
147
+ name, label = if preview_field.respond_to?(:name)
148
+ [preview_field.name, preview_field.label]
149
+ else
150
+ preview_field
151
+ end
152
+ value = record.public_send(name)
153
+ next if value.blank?
154
+
155
+ { label:, value: value.to_s }
156
+ end
157
+ end
158
+
159
+ def filter_reference_scope(scope, field, term)
160
+ columns = searchable_reference_columns(field.model_class, field.label_method)
161
+ id_scope = reference_id_scope(scope, term)
162
+ return id_scope || scope if columns.empty?
163
+
164
+ table = field.model_class.arel_table
165
+ escaped = "%#{ActiveRecord::Base.sanitize_sql_like(term)}%"
166
+ predicate = columns.map { |column_name| table[column_name].matches(escaped) }.reduce(&:or)
167
+ text_scope = scope.where(predicate)
168
+
169
+ id_scope ? text_scope.or(id_scope) : text_scope
170
+ end
171
+
172
+ def searchable_reference_columns(model_class, preferred_method)
173
+ candidates = [preferred_method, :full_name, :name, :title, :email, :username, :display_name, :company_name]
174
+ column_names = model_class.column_names
175
+
176
+ candidates.compact.map(&:to_s).uniq.select { |name| column_names.include?(name) }
177
+ end
178
+
179
+ def reference_id_scope(scope, term)
180
+ return nil unless term.match?(/\A\d+\z/) && scope.klass.column_names.include?("id")
181
+
182
+ scope.where(id: term)
183
+ end
184
+
185
+ def reference_preview_columns(model_class)
186
+ preferred = %w[email username company_name title name created_at]
187
+ columns = model_class.column_names - %w[id]
188
+
189
+ (preferred & columns).presence || columns.first(3)
190
+ end
191
+
192
+ def filter_conditions
193
+ request_filters = params[:filters]
194
+ return [] if params.key?(:filters_present)
195
+ return normalized_filters(request_filters) if request_filters.present?
196
+ return normalized_filters(@active_view.filters) if @active_view&.filters.present?
197
+
198
+ []
199
+ end
200
+
201
+ def normalized_filters(filters)
202
+ filter_list(filters).filter_map do |condition|
203
+ normalized_filter(condition)
204
+ end
205
+ end
206
+
207
+ def normalized_filter(condition)
208
+ return nil unless condition.respond_to?(:[])
209
+
210
+ field = condition["field"] || condition[:field]
211
+ field = field_name_from_label(condition) if @resource.fields[field.to_s.to_sym].nil?
212
+ operator = condition["operator"] || condition[:operator]
213
+ value = condition["value"] || condition[:value]
214
+ return nil if field.blank? || operator.blank?
215
+ return nil unless @resource.fields[field.to_s.to_sym]
216
+
217
+ { "field" => field.to_s, "operator" => operator.to_s, "value" => value }
218
+ end
219
+
220
+ def field_name_from_label(condition)
221
+ label = condition["field_label"] || condition[:field_label]
222
+ @resource.fields.values.find { |field| field.label == label }&.name
223
+ end
224
+
225
+ def filter_list(filters)
226
+ filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
227
+ filters = filters.values if filters.is_a?(Hash)
228
+ Array(filters)
229
+ end
230
+
231
+ def record_attributes
232
+ raw = params.fetch(:record, {}).permit!
233
+ @resource.editable_fields.each_with_object({}) do |field, attributes|
234
+ next unless raw.key?(field.name.to_s)
235
+
236
+ attributes[field.attribute_name] = cast_value(field, raw[field.name.to_s])
237
+ end
238
+ end
239
+
240
+ def cast_value(field, value)
241
+ return nil if value == ""
242
+
243
+ case field.type
244
+ when :boolean
245
+ ActiveModel::Type::Boolean.new.cast(value)
246
+ when :integer
247
+ value.to_i
248
+ when :float
249
+ value.to_f
250
+ when :decimal
251
+ BigDecimal(value.to_s)
252
+ when :json
253
+ JSON.parse(value)
254
+ else
255
+ value
256
+ end
257
+ rescue JSON::ParserError, ArgumentError
258
+ value
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class SetupController < ApplicationController
5
+ before_action :set_definition, only: %i[edit update destroy]
6
+
7
+ def index
8
+ @models = ModelDiscovery.models
9
+ @definitions_by_model = ResourceDefinition.all.index_by(&:record_class_name)
10
+ end
11
+
12
+ def new
13
+ @model = ModelDiscovery.model_named(params[:model_name])
14
+ raise ActionController::RoutingError, "Not Found" unless @model
15
+
16
+ @definition = build_definition(@model)
17
+ end
18
+
19
+ def create
20
+ @definition = ResourceDefinition.new(resource_definition_params)
21
+
22
+ if @definition.save
23
+ redirect_to setup_path, notice: "Resource saved"
24
+ else
25
+ @model = ModelDiscovery.model_named(@definition.record_class_name)
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit; end
31
+
32
+ def update
33
+ if @definition.update(resource_definition_params.except(:key, :record_class_name))
34
+ redirect_to setup_path, notice: "Resource updated"
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ @definition.destroy!
42
+ redirect_to setup_path, notice: "Resource removed"
43
+ end
44
+
45
+ private
46
+
47
+ def set_definition
48
+ @definition = ResourceDefinition.includes(:field_definitions).find(params[:id])
49
+ end
50
+
51
+ def build_definition(model)
52
+ inference = SchemaInference.new(model)
53
+ ResourceDefinition.new(normalized_resource_attributes(inference.resource_attributes)).tap do |definition|
54
+ inference.field_attributes.each do |attributes|
55
+ definition.field_definitions.build(attributes)
56
+ end
57
+ end
58
+ end
59
+
60
+ def normalized_resource_attributes(attributes)
61
+ attributes = attributes.dup
62
+ attributes[:record_class_name] ||= attributes.delete(:model_name)
63
+ attributes
64
+ end
65
+
66
+ def resource_definition_params
67
+ params.require(:resource_definition).permit(
68
+ :key,
69
+ :record_class_name,
70
+ :label,
71
+ :label_method,
72
+ :enabled,
73
+ :position,
74
+ field_definitions_attributes: %i[
75
+ id
76
+ name
77
+ label
78
+ field_type
79
+ visible
80
+ read_only
81
+ position
82
+ association_name
83
+ target_model_name
84
+ target_label_method
85
+ _destroy
86
+ ]
87
+ )
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class ViewsController < ApplicationController
5
+ before_action :set_resource
6
+
7
+ def create
8
+ view = View.create!(
9
+ name: params[:name].presence || "Untitled view",
10
+ resource_key: @resource.key,
11
+ folder: params[:folder],
12
+ filters: filter_params,
13
+ sorts: sort_params,
14
+ columns: column_params.presence || @resource.default_fields.map { |field| field.name.to_s },
15
+ preferences: {}
16
+ )
17
+
18
+ redirect_to resource_view_records_path(@resource.key, view), notice: "View saved"
19
+ end
20
+
21
+ def update
22
+ view = View.for_resource(@resource.key).find(params[:id])
23
+ view.update!(view_update_attributes)
24
+
25
+ redirect_to resource_view_records_path(@resource.key, view), notice: "View updated"
26
+ end
27
+
28
+ def destroy
29
+ View.for_resource(@resource.key).find(params[:id]).destroy!
30
+ redirect_to resource_path(@resource.key), notice: "View deleted"
31
+ end
32
+
33
+ private
34
+
35
+ def set_resource
36
+ @resource = Airview.resource!(params[:key])
37
+ end
38
+
39
+ def filter_params
40
+ filters = params[:filters]
41
+ return [] unless filters.respond_to?(:values)
42
+
43
+ filters.values.filter_map do |condition|
44
+ permitted = condition.permit(:field, :field_label, :operator, :value)
45
+ field = permitted[:field]
46
+ field = field_name_from_label(permitted) if @resource.fields[field.to_s.to_sym].nil?
47
+ next if field.blank? || permitted[:operator].blank?
48
+ next unless @resource.fields[field.to_s.to_sym]
49
+
50
+ { "field" => field.to_s, "operator" => permitted[:operator], "value" => permitted[:value] }
51
+ end
52
+ end
53
+
54
+ def field_name_from_label(condition)
55
+ @resource.fields.values.find { |field| field.label == condition[:field_label] }&.name
56
+ end
57
+
58
+ def sort_params
59
+ params.permit(:sort, :direction).to_h.compact
60
+ end
61
+
62
+ def column_params
63
+ Array(params[:columns]).select { |name| @resource.fields.key?(name.to_sym) }
64
+ end
65
+
66
+ def view_update_attributes
67
+ {}.tap do |attributes|
68
+ attributes[:name] = params[:name] if params.key?(:name)
69
+ attributes[:folder] = params[:folder] if params.key?(:folder)
70
+ attributes[:filters] = filter_params if params.key?(:filters) || params.key?(:filters_present)
71
+ attributes[:sorts] = sort_params if params.key?(:sort) || params.key?(:direction)
72
+ attributes[:columns] = column_params if params.key?(:columns) || params.key?(:columns_present)
73
+ end
74
+ end
75
+ end
76
+ end