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,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,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.element.dataset.airviewStimulusReady = "true"
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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>