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,479 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
def airview_field_value(record, field)
|
|
6
|
+
value = record.public_send(field.name)
|
|
7
|
+
return value if field.collection_association?
|
|
8
|
+
return Airview.record_label(value, field.label_method) if field.association? && value
|
|
9
|
+
return value.to_json if field.type == :json && value.present?
|
|
10
|
+
|
|
11
|
+
value
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def airview_cell_value(record, field)
|
|
15
|
+
value = airview_field_value(record, field)
|
|
16
|
+
|
|
17
|
+
case field.type
|
|
18
|
+
when :boolean
|
|
19
|
+
tag.span(value ? "✓" : "", class: ["airview-check", ("is-on" if value)])
|
|
20
|
+
when :belongs_to
|
|
21
|
+
airview_reference_pill(record, field)
|
|
22
|
+
when :has_many
|
|
23
|
+
airview_collection_pills(record, field)
|
|
24
|
+
when :attachment
|
|
25
|
+
airview_attachment_cell(record, field)
|
|
26
|
+
when :select
|
|
27
|
+
value.present? ? tag.span(value, class: "airview-pill") : tag.span("", class: "airview-empty-value")
|
|
28
|
+
when :date
|
|
29
|
+
value.present? ? l(value.to_date, format: :default) : tag.span("", class: "airview-empty-value")
|
|
30
|
+
when :datetime
|
|
31
|
+
value.present? ? l(value, format: :short) : tag.span("", class: "airview-empty-value")
|
|
32
|
+
else
|
|
33
|
+
tag.span(value, class: "airview-cell-text")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def airview_input_for(record, field)
|
|
38
|
+
value = airview_field_value(record, field)
|
|
39
|
+
name = "record[#{field.name}]"
|
|
40
|
+
|
|
41
|
+
case field.type
|
|
42
|
+
when :boolean
|
|
43
|
+
check_box_tag(name, "1", ActiveModel::Type::Boolean.new.cast(value), disabled: field.readonly?)
|
|
44
|
+
when :text, :json
|
|
45
|
+
text_area_tag(name, value, rows: 2, disabled: field.readonly?)
|
|
46
|
+
when :select
|
|
47
|
+
select_tag(name, options_for_select(field.option_values, value), include_blank: true, disabled: field.readonly?)
|
|
48
|
+
when :belongs_to
|
|
49
|
+
airview_reference_input(record, field, name)
|
|
50
|
+
when :date
|
|
51
|
+
date_field_tag(name, value, disabled: field.readonly?)
|
|
52
|
+
when :datetime
|
|
53
|
+
datetime_local_field_tag(name, value&.strftime("%Y-%m-%dT%H:%M"), disabled: field.readonly?)
|
|
54
|
+
else
|
|
55
|
+
text_field_tag(name, value, disabled: field.readonly?)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def airview_sort_link(resource, field)
|
|
60
|
+
active = airview_active_sort_field == field.name.to_s
|
|
61
|
+
current_direction = airview_active_sort_direction
|
|
62
|
+
direction = active && current_direction != "desc" ? "desc" : "asc"
|
|
63
|
+
|
|
64
|
+
link_to(
|
|
65
|
+
resource_path(resource.key, request.query_parameters.merge(sort: field.name, direction:)),
|
|
66
|
+
class: ["airview-sort-link", ("is-active" if active)]
|
|
67
|
+
) do
|
|
68
|
+
safe_join(
|
|
69
|
+
[
|
|
70
|
+
tag.span(field.label, class: "airview-column-label"),
|
|
71
|
+
airview_sort_indicator(active, current_direction)
|
|
72
|
+
].compact
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def airview_sort_indicator(active, direction)
|
|
78
|
+
return nil unless active
|
|
79
|
+
|
|
80
|
+
tag.span(direction == "desc" ? "↓" : "↑", class: "airview-sort-indicator")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def airview_active_sort_field
|
|
84
|
+
params[:sort].presence || @active_view&.sorts&.dig("sort")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def airview_active_sort_direction
|
|
88
|
+
(params[:direction].presence || @active_view&.sorts&.dig("direction") || "asc").to_s
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def airview_column_action_path
|
|
92
|
+
@active_view ? resource_view_path(@resource.key, @active_view) : resource_path(@resource.key)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def airview_column_action_params(action, field)
|
|
96
|
+
columns = @visible_fields.map { |visible_field| visible_field.name.to_s }
|
|
97
|
+
columns = reorder_columns(columns, field.name.to_s, action)
|
|
98
|
+
columns -= [field.name.to_s] if action == :hide
|
|
99
|
+
|
|
100
|
+
if @active_view
|
|
101
|
+
{ columns_present: "1", columns: }
|
|
102
|
+
else
|
|
103
|
+
request.query_parameters.merge(columns_present: "1", columns:)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def airview_column_action_method
|
|
108
|
+
@active_view ? :patch : :get
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def airview_current_table_path
|
|
112
|
+
query = request.query_parameters.except(:record_id, :delete_record_id, :return_to)
|
|
113
|
+
query_string = query.to_query
|
|
114
|
+
|
|
115
|
+
query_string.present? ? "#{request.path}?#{query_string}" : request.path
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def airview_modal_close_path
|
|
119
|
+
return_to = params[:return_to].to_s
|
|
120
|
+
return return_to if return_to.start_with?("/") && !return_to.start_with?("//")
|
|
121
|
+
|
|
122
|
+
airview_current_table_path
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def airview_record_modal_path(resource, record)
|
|
126
|
+
resource_path(resource.key, record_id: record.id, return_to: airview_current_table_path)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def airview_filter_operator_options(field, selected = nil)
|
|
130
|
+
options = case field.type
|
|
131
|
+
when :boolean
|
|
132
|
+
[["is checked", "is_true"], ["is unchecked", "is_false"]]
|
|
133
|
+
when :integer, :float, :decimal
|
|
134
|
+
[["=", "equals"], [">", "gt"], ["<", "lt"], [">=", "gte"], ["<=", "lte"], ["is empty", "is_empty"]]
|
|
135
|
+
when :date, :datetime
|
|
136
|
+
[["is", "equals"], ["before", "before"], ["after", "after"], ["is empty", "is_empty"]]
|
|
137
|
+
else
|
|
138
|
+
[
|
|
139
|
+
["contains", "contains"],
|
|
140
|
+
["equals", "equals"],
|
|
141
|
+
["starts with", "starts_with"],
|
|
142
|
+
["is empty", "is_empty"]
|
|
143
|
+
]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
options_for_select(options, selected)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def airview_filter_value_input(index, field, value = nil)
|
|
150
|
+
name = "filters[#{index}][value]"
|
|
151
|
+
|
|
152
|
+
case field&.type
|
|
153
|
+
when :boolean
|
|
154
|
+
select_tag(
|
|
155
|
+
name,
|
|
156
|
+
options_for_select([%w[checked true], %w[unchecked false]], value.to_s),
|
|
157
|
+
data: { airview_filter_value: true }
|
|
158
|
+
)
|
|
159
|
+
when :date, :datetime
|
|
160
|
+
date_field_tag(name, value, data: { airview_filter_value: true })
|
|
161
|
+
when :integer, :float, :decimal
|
|
162
|
+
number_field_tag(name, value, step: "any", placeholder: "Value", data: { airview_filter_value: true })
|
|
163
|
+
else
|
|
164
|
+
text_field_tag(name, value, placeholder: "Enter a value", data: { airview_filter_value: true })
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def airview_filter_field_picker(index, field)
|
|
169
|
+
safe_join(
|
|
170
|
+
[
|
|
171
|
+
hidden_field_tag("filters[#{index}][field]", field&.name, data: { airview_field_target: true }),
|
|
172
|
+
airview_filter_field_picker_menu(index, field)
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def airview_delete_impacts(record)
|
|
178
|
+
record.class.reflect_on_all_associations.filter_map do |association|
|
|
179
|
+
dependent = association.options[:dependent]
|
|
180
|
+
next unless dependent
|
|
181
|
+
|
|
182
|
+
{
|
|
183
|
+
name: association.name.to_s.humanize,
|
|
184
|
+
dependent: dependent.to_s.humanize.downcase,
|
|
185
|
+
count: airview_association_count(record, association),
|
|
186
|
+
action: airview_dependent_action(dependent)
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def airview_reference_pill(record, field)
|
|
192
|
+
related = record.public_send(field.name)
|
|
193
|
+
return tag.span("+ Link record", class: "airview-reference-empty") unless related
|
|
194
|
+
|
|
195
|
+
label = Airview.record_label(related, field.label_method)
|
|
196
|
+
linked_resource = airview_resource_for_model(field.model)
|
|
197
|
+
path = linked_resource ? airview_record_modal_path(linked_resource, related) : nil
|
|
198
|
+
|
|
199
|
+
if path
|
|
200
|
+
link_to(label, path, class: "airview-reference-pill")
|
|
201
|
+
else
|
|
202
|
+
tag.span(label, class: "airview-reference-pill")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def airview_collection_pills(record, field)
|
|
207
|
+
records = record.public_send(field.name).limit(3).to_a
|
|
208
|
+
count = airview_collection_count(record, field)
|
|
209
|
+
|
|
210
|
+
return tag.span("0 records", class: "airview-reference-empty") if count.to_i.zero?
|
|
211
|
+
|
|
212
|
+
linked_resource = airview_resource_for_model(field.model)
|
|
213
|
+
collection_path = airview_collection_path(record, field, linked_resource)
|
|
214
|
+
cards = records.map do |related|
|
|
215
|
+
airview_collection_record_card(related, field, linked_resource)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
cards << airview_collection_count_pill(count - records.size, collection_path) if count > records.size
|
|
219
|
+
|
|
220
|
+
tag.div(
|
|
221
|
+
safe_join(cards),
|
|
222
|
+
class: "airview-reference-list airview-reference-list--cards",
|
|
223
|
+
title: collection_path ? "Open #{field.label}" : nil
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def airview_attachment_cell(record, field)
|
|
228
|
+
attachment = record.public_send(field.name)
|
|
229
|
+
return tag.span("No file", class: "airview-reference-empty") unless attachment.attached?
|
|
230
|
+
|
|
231
|
+
blob = attachment.blob
|
|
232
|
+
path = airview_attachment_path(attachment)
|
|
233
|
+
|
|
234
|
+
link_to(path, class: "airview-attachment", target: "_blank", rel: "noopener") do
|
|
235
|
+
if blob.image?
|
|
236
|
+
image_tag(path, alt: blob.filename.to_s, class: "airview-attachment-thumb")
|
|
237
|
+
else
|
|
238
|
+
tag.span(blob.filename.to_s, class: "airview-attachment-file")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def airview_reference_input(record, field, name)
|
|
244
|
+
related = record.public_send(field.name)
|
|
245
|
+
|
|
246
|
+
safe_join(
|
|
247
|
+
[
|
|
248
|
+
hidden_field_tag(name, record.public_send(field.attribute_name), data: { airview_reference_target: true }),
|
|
249
|
+
airview_reference_preview(related, field),
|
|
250
|
+
tag.div(
|
|
251
|
+
class: "airview-reference-picker",
|
|
252
|
+
data: { airview_reference_url: resource_references_path(@resource.key, field.name) }
|
|
253
|
+
) do
|
|
254
|
+
safe_join(
|
|
255
|
+
[
|
|
256
|
+
search_field_tag(
|
|
257
|
+
nil,
|
|
258
|
+
nil,
|
|
259
|
+
placeholder: "Find linked record",
|
|
260
|
+
autocomplete: "off",
|
|
261
|
+
data: { airview_reference_picker: true }
|
|
262
|
+
),
|
|
263
|
+
tag.div("", class: "airview-reference-results", data: { airview_reference_results: true }),
|
|
264
|
+
airview_reference_clear_button(record, field)
|
|
265
|
+
].compact
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
]
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
def airview_reference_preview(related, field)
|
|
275
|
+
unless related
|
|
276
|
+
return tag.div(
|
|
277
|
+
"+ Link record",
|
|
278
|
+
class: "airview-reference-preview is-empty",
|
|
279
|
+
data: { airview_reference_preview: true }
|
|
280
|
+
)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
linked_resource = airview_resource_for_model(field.model)
|
|
284
|
+
details = airview_reference_details(related, linked_resource)
|
|
285
|
+
|
|
286
|
+
tag.div(class: "airview-reference-preview", data: { airview_reference_preview: true }) do
|
|
287
|
+
open_link = if linked_resource
|
|
288
|
+
link_to(
|
|
289
|
+
"Open record",
|
|
290
|
+
airview_record_modal_path(linked_resource, related),
|
|
291
|
+
class: "airview-reference-open"
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
safe_join(
|
|
296
|
+
[
|
|
297
|
+
tag.div(Airview.record_label(related, field.label_method), class: "airview-reference-preview-title"),
|
|
298
|
+
(tag.div(details, class: "airview-reference-preview-details") if details.present?),
|
|
299
|
+
open_link
|
|
300
|
+
].compact
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def airview_filter_field_picker_menu(index, field)
|
|
306
|
+
tag.div(class: "airview-filter-field-picker", data: { airview_filter_field_picker: true }) do
|
|
307
|
+
safe_join(
|
|
308
|
+
[
|
|
309
|
+
search_field_tag(
|
|
310
|
+
"filters[#{index}][field_label]",
|
|
311
|
+
field&.label,
|
|
312
|
+
placeholder: "Find field",
|
|
313
|
+
autocomplete: "off",
|
|
314
|
+
data: { airview_filter_field_search: true }
|
|
315
|
+
),
|
|
316
|
+
airview_filter_field_options
|
|
317
|
+
]
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def airview_filter_field_options
|
|
323
|
+
tag.div(class: "airview-filter-field-results", data: { airview_filter_field_results: true }) do
|
|
324
|
+
safe_join(@sorted_fields.select(&:filterable?).map { |option| airview_filter_field_option(option) })
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def airview_filter_field_option(option)
|
|
329
|
+
button_tag(
|
|
330
|
+
option.label,
|
|
331
|
+
type: "button",
|
|
332
|
+
class: "airview-filter-field-option",
|
|
333
|
+
data: {
|
|
334
|
+
airview_filter_field_option: true,
|
|
335
|
+
field_name: option.name,
|
|
336
|
+
field_label: option.label,
|
|
337
|
+
field_type: option.type
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def airview_attachment_path(attachment)
|
|
343
|
+
main_app.rails_blob_path(attachment, only_path: true)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def airview_collection_count_pill(count, path)
|
|
347
|
+
label = "+#{count}"
|
|
348
|
+
return tag.span(label, class: "airview-reference-preview airview-reference-preview--compact") unless path
|
|
349
|
+
|
|
350
|
+
link_to(path, class: airview_reference_card_class) do
|
|
351
|
+
safe_join(
|
|
352
|
+
[
|
|
353
|
+
tag.div(label, class: "airview-reference-preview-title"),
|
|
354
|
+
tag.div("Open #{label} more", class: "airview-reference-preview-details")
|
|
355
|
+
]
|
|
356
|
+
)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def airview_collection_record_card(record, field, linked_resource)
|
|
361
|
+
path = linked_resource ? airview_record_modal_path(linked_resource, record) : nil
|
|
362
|
+
content = safe_join(
|
|
363
|
+
[
|
|
364
|
+
tag.div(Airview.record_label(record, field.label_method), class: "airview-reference-preview-title"),
|
|
365
|
+
tag.div(airview_reference_details(record, linked_resource), class: "airview-reference-preview-details"),
|
|
366
|
+
(tag.div("Open record", class: "airview-reference-open") if path)
|
|
367
|
+
].compact
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
if path
|
|
371
|
+
link_to(path, class: airview_reference_card_class) do
|
|
372
|
+
content
|
|
373
|
+
end
|
|
374
|
+
else
|
|
375
|
+
tag.div(content, class: "airview-reference-preview airview-reference-preview--compact")
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def airview_reference_card_class
|
|
380
|
+
"airview-reference-preview airview-reference-preview--compact airview-reference-card-link"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def airview_collection_path(record, field, linked_resource)
|
|
384
|
+
return nil unless linked_resource
|
|
385
|
+
|
|
386
|
+
inverse_field = airview_collection_inverse_field(record, field, linked_resource)
|
|
387
|
+
return nil unless inverse_field
|
|
388
|
+
|
|
389
|
+
resource_path(
|
|
390
|
+
linked_resource.key,
|
|
391
|
+
filters_present: "1",
|
|
392
|
+
filters: {
|
|
393
|
+
"0" => {
|
|
394
|
+
field: inverse_field.name,
|
|
395
|
+
operator: "equals",
|
|
396
|
+
value: record.id
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def airview_collection_inverse_field(record, field, linked_resource)
|
|
403
|
+
association = record.class.reflect_on_association(field.name)
|
|
404
|
+
foreign_key = association&.foreign_key&.to_s
|
|
405
|
+
|
|
406
|
+
linked_resource.fields.values.find do |candidate|
|
|
407
|
+
candidate.type == :belongs_to &&
|
|
408
|
+
(candidate.attribute_name.to_s == foreign_key || candidate.model.to_s == record.class.name)
|
|
409
|
+
end
|
|
410
|
+
rescue NoMethodError
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def airview_reference_clear_button(record, field)
|
|
415
|
+
column = record.class.columns_hash[field.attribute_name.to_s]
|
|
416
|
+
return nil if column && !column.null
|
|
417
|
+
|
|
418
|
+
button_tag(
|
|
419
|
+
"Clear link",
|
|
420
|
+
type: "button",
|
|
421
|
+
class: "airview-reference-clear",
|
|
422
|
+
data: { airview_reference_clear: true }
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def airview_reference_details(record, linked_resource)
|
|
427
|
+
return "##{record.id}" unless linked_resource
|
|
428
|
+
|
|
429
|
+
linked_resource.fields.values.reject(&:association?).first(3).filter_map do |field|
|
|
430
|
+
value = airview_field_value(record, field)
|
|
431
|
+
"#{field.label}: #{value}" if value.present?
|
|
432
|
+
end.join(" · ")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def airview_resource_for_model(model)
|
|
436
|
+
Airview.resources.values.find { |resource| resource.model == model.to_s }
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def airview_association_count(record, association)
|
|
440
|
+
related = record.public_send(association.name)
|
|
441
|
+
return related.count if related.respond_to?(:count) && association.collection?
|
|
442
|
+
|
|
443
|
+
related.present? ? 1 : 0
|
|
444
|
+
rescue ActiveRecord::StatementInvalid, NoMethodError
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def airview_collection_count(record, field)
|
|
449
|
+
record.public_send(field.name).count
|
|
450
|
+
rescue ActiveRecord::StatementInvalid, NoMethodError
|
|
451
|
+
0
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def airview_dependent_action(dependent)
|
|
455
|
+
case dependent
|
|
456
|
+
when :destroy, :delete, :delete_all, :destroy_async
|
|
457
|
+
"deleted"
|
|
458
|
+
when :nullify
|
|
459
|
+
"detached"
|
|
460
|
+
when :restrict_with_error, :restrict_with_exception
|
|
461
|
+
"may block deletion"
|
|
462
|
+
else
|
|
463
|
+
dependent.to_s.humanize.downcase
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def reorder_columns(columns, field_name, action)
|
|
468
|
+
index = columns.index(field_name)
|
|
469
|
+
return columns unless index
|
|
470
|
+
|
|
471
|
+
direction = action == :left ? -1 : 1
|
|
472
|
+
target = index + direction
|
|
473
|
+
return columns unless target.between?(0, columns.length - 1)
|
|
474
|
+
|
|
475
|
+
columns[index], columns[target] = columns[target], columns[index]
|
|
476
|
+
columns
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class FieldDefinition < ApplicationRecord
|
|
5
|
+
self.table_name = "airview_fields"
|
|
6
|
+
|
|
7
|
+
belongs_to :resource_definition,
|
|
8
|
+
class_name: "Airview::ResourceDefinition",
|
|
9
|
+
foreign_key: :airview_resource_id,
|
|
10
|
+
inverse_of: :field_definitions
|
|
11
|
+
|
|
12
|
+
validates :name, :label, :field_type, presence: true
|
|
13
|
+
validates :name, uniqueness: { scope: :airview_resource_id }
|
|
14
|
+
validates :field_type, inclusion: { in: Airview::Field::TYPES.map(&:to_s) }
|
|
15
|
+
|
|
16
|
+
scope :visible, -> { where(visible: true) }
|
|
17
|
+
|
|
18
|
+
def default_visible?
|
|
19
|
+
metadata.fetch("default_visible", true)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class ResourceDefinition < ApplicationRecord
|
|
5
|
+
self.table_name = "airview_resources"
|
|
6
|
+
|
|
7
|
+
has_many :field_definitions,
|
|
8
|
+
-> { order(:position, :name) },
|
|
9
|
+
class_name: "Airview::FieldDefinition",
|
|
10
|
+
foreign_key: :airview_resource_id,
|
|
11
|
+
inverse_of: :resource_definition,
|
|
12
|
+
dependent: :destroy
|
|
13
|
+
|
|
14
|
+
accepts_nested_attributes_for :field_definitions, allow_destroy: true
|
|
15
|
+
|
|
16
|
+
validates :key, :record_class_name, :label, presence: true
|
|
17
|
+
validates :key, uniqueness: true
|
|
18
|
+
validates :record_class_name, uniqueness: true
|
|
19
|
+
validate :key_is_immutable, on: :update
|
|
20
|
+
validate :model_is_discoverable
|
|
21
|
+
validate :enabled_resource_has_visible_fields
|
|
22
|
+
|
|
23
|
+
scope :enabled, -> { where(enabled: true) }
|
|
24
|
+
|
|
25
|
+
def model_class
|
|
26
|
+
record_class_name.constantize
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def key_is_immutable
|
|
32
|
+
errors.add(:key, "cannot be changed") if will_save_change_to_key?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def model_is_discoverable
|
|
36
|
+
return if Airview::ModelDiscovery.model_named(record_class_name)
|
|
37
|
+
|
|
38
|
+
errors.add(:record_class_name, "must be a concrete ActiveRecord model with a table")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enabled_resource_has_visible_fields
|
|
42
|
+
return unless enabled?
|
|
43
|
+
return if field_definitions.reject(&:marked_for_destruction?).any?(&:visible?)
|
|
44
|
+
|
|
45
|
+
errors.add(:base, "Enabled resources must have at least one visible field")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class View < ApplicationRecord
|
|
5
|
+
self.table_name = "airview_views"
|
|
6
|
+
|
|
7
|
+
validates :name, :resource_key, presence: true
|
|
8
|
+
|
|
9
|
+
scope :for_resource, ->(resource_key) { where(resource_key: resource_key.to_s) }
|
|
10
|
+
scope :ordered, -> { order(:folder, :name) }
|
|
11
|
+
|
|
12
|
+
before_validation :normalize_folder
|
|
13
|
+
|
|
14
|
+
def ordered_columns(resource)
|
|
15
|
+
saved = Array(columns).map(&:to_s)
|
|
16
|
+
available = resource.fields.keys.map(&:to_s)
|
|
17
|
+
|
|
18
|
+
saved & available
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def normalize_folder
|
|
24
|
+
self.folder = folder.to_s.strip.presence
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<div class="airview-modal-backdrop" data-airview-modal>
|
|
2
|
+
<section class="airview-record-modal airview-delete-modal" role="dialog" aria-modal="true" aria-labelledby="airview-delete-modal-title">
|
|
3
|
+
<header class="airview-record-modal-header">
|
|
4
|
+
<div>
|
|
5
|
+
<h2 id="airview-delete-modal-title">Delete <%= @resource.record_label(record).presence || "#{@resource.label} ##{record.id}" %></h2>
|
|
6
|
+
<p>This action cannot be undone.</p>
|
|
7
|
+
</div>
|
|
8
|
+
<%= link_to "×", airview_modal_close_path, class: "airview-modal-close", data: { airview_modal_close: true } %>
|
|
9
|
+
</header>
|
|
10
|
+
|
|
11
|
+
<div class="airview-delete-body">
|
|
12
|
+
<p>
|
|
13
|
+
Airview will call <code>destroy!</code> on this record, so Rails callbacks and configured dependent associations will run.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<% impacts = airview_delete_impacts(record) %>
|
|
17
|
+
<% if impacts.any? %>
|
|
18
|
+
<div class="airview-delete-impact-list">
|
|
19
|
+
<% impacts.each do |impact| %>
|
|
20
|
+
<div class="airview-delete-impact">
|
|
21
|
+
<strong><%= impact[:name] %></strong>
|
|
22
|
+
<span>
|
|
23
|
+
<% if impact[:count].nil? %>
|
|
24
|
+
Related records could not be counted and will be <%= impact[:action] %>.
|
|
25
|
+
<% else %>
|
|
26
|
+
<%= pluralize(impact[:count], "related record") %> will be <%= impact[:action] %>.
|
|
27
|
+
<% end %>
|
|
28
|
+
</span>
|
|
29
|
+
<small>dependent: <%= impact[:dependent] %></small>
|
|
30
|
+
</div>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
<% else %>
|
|
34
|
+
<div class="airview-delete-empty">
|
|
35
|
+
No dependent associations are configured on this model.
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<p class="airview-delete-note">
|
|
40
|
+
Database foreign keys, validations, callbacks, and custom model code may still prevent deletion or affect additional records.
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<footer class="airview-record-modal-actions airview-delete-actions">
|
|
45
|
+
<%= button_to "Delete record", resource_record_path(@resource.key, record, return_to: params[:return_to]), method: :delete, class: "airview-danger-button" %>
|
|
46
|
+
<%= link_to "Cancel", airview_modal_close_path %>
|
|
47
|
+
</footer>
|
|
48
|
+
</section>
|
|
49
|
+
</div>
|