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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +65 -0
- data/app/assets/javascripts/airview/application.js +425 -0
- data/app/assets/stylesheets/airview/application.css +1239 -0
- data/app/controllers/airview/application_controller.rb +7 -0
- data/app/controllers/airview/resources_controller.rb +261 -0
- data/app/controllers/airview/setup_controller.rb +90 -0
- data/app/controllers/airview/views_controller.rb +76 -0
- data/app/helpers/airview/application_helper.rb +479 -0
- data/app/javascript/airview/controllers/grid_controller.js +7 -0
- data/app/models/airview/application_record.rb +7 -0
- data/app/models/airview/field_definition.rb +22 -0
- data/app/models/airview/resource_definition.rb +48 -0
- data/app/models/airview/view.rb +27 -0
- data/app/views/airview/resources/_delete_modal.html.erb +49 -0
- data/app/views/airview/resources/_record_modal.html.erb +62 -0
- data/app/views/airview/resources/empty.html.erb +5 -0
- data/app/views/airview/resources/show.html.erb +258 -0
- data/app/views/airview/setup/_form.html.erb +73 -0
- data/app/views/airview/setup/edit.html.erb +16 -0
- data/app/views/airview/setup/index.html.erb +52 -0
- data/app/views/airview/setup/new.html.erb +14 -0
- data/app/views/airview/shared/_sidebar.html.erb +76 -0
- data/app/views/layouts/airview/application.html.erb +21 -0
- data/config/routes.rb +23 -0
- data/db/migrate/20260806000000_create_airview_views.rb +57 -0
- data/lib/airview/configuration.rb +6 -0
- data/lib/airview/engine.rb +15 -0
- data/lib/airview/field.rb +81 -0
- data/lib/airview/model_discovery.rb +38 -0
- data/lib/airview/query.rb +194 -0
- data/lib/airview/resource.rb +51 -0
- data/lib/airview/resource_builder.rb +62 -0
- data/lib/airview/schema_inference.rb +196 -0
- data/lib/airview/version.rb +5 -0
- data/lib/airview.rb +93 -0
- data/lib/generators/airview/install/install_generator.rb +38 -0
- data/lib/generators/airview/install/templates/add_folders_to_airview_views.rb +8 -0
- data/lib/generators/airview/install/templates/airview.rb +12 -0
- data/lib/generators/airview/install/templates/create_airview_views.rb +57 -0
- data/sig/airview.rbs +54 -0
- metadata +110 -0
|
@@ -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
|