crud_components 0.2.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/README.md +41 -4
  4. data/app/assets/stylesheets/crud_components_admin.css +13 -0
  5. data/app/controllers/crud_components/admin/application_controller.rb +100 -0
  6. data/app/controllers/crud_components/admin/dashboard_controller.rb +10 -0
  7. data/app/controllers/crud_components/admin/resources_controller.rb +184 -0
  8. data/app/views/crud_components/admin/_sidebar.html.erb +20 -0
  9. data/app/views/crud_components/admin/dashboard/show.html.erb +22 -0
  10. data/app/views/crud_components/admin/resources/_dependent_records.html.erb +16 -0
  11. data/app/views/crud_components/admin/resources/delete.html.erb +98 -0
  12. data/app/views/crud_components/admin/resources/edit.html.erb +14 -0
  13. data/app/views/crud_components/admin/resources/index.html.erb +26 -0
  14. data/app/views/crud_components/admin/resources/new.html.erb +14 -0
  15. data/app/views/crud_components/admin/resources/show.html.erb +16 -0
  16. data/app/views/layouts/crud_components/admin.html.erb +41 -0
  17. data/config/locales/crud_components.de.yml +31 -0
  18. data/config/locales/crud_components.en.yml +31 -0
  19. data/docs/admin.md +321 -0
  20. data/docs/fields.md +7 -7
  21. data/docs/filtering.md +6 -5
  22. data/docs/views.md +14 -0
  23. data/lib/crud_components/admin/configuration.rb +122 -0
  24. data/lib/crud_components/admin/dependents.rb +138 -0
  25. data/lib/crud_components/admin/engine.rb +22 -0
  26. data/lib/crud_components/admin/entry.rb +123 -0
  27. data/lib/crud_components/admin/gate.rb +40 -0
  28. data/lib/crud_components/admin/registry.rb +176 -0
  29. data/lib/crud_components/admin/routes.rb +37 -0
  30. data/lib/crud_components/admin/view_helpers.rb +156 -0
  31. data/lib/crud_components/admin.rb +92 -0
  32. data/lib/crud_components/builder.rb +59 -4
  33. data/lib/crud_components/fields/belongs_to_field.rb +4 -5
  34. data/lib/crud_components/fields/has_many_field.rb +2 -2
  35. data/lib/crud_components/helpers.rb +36 -5
  36. data/lib/crud_components/like_spec.rb +3 -3
  37. data/lib/crud_components/presenters/collection.rb +16 -4
  38. data/lib/crud_components/presenters/record.rb +14 -2
  39. data/lib/crud_components/route_resolver.rb +37 -7
  40. data/lib/crud_components/structure.rb +15 -1
  41. data/lib/crud_components/version.rb +1 -1
  42. data/lib/crud_components.rb +2 -0
  43. metadata +24 -1
@@ -0,0 +1,138 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # What else goes when a record is destroyed: the associations that declare a
4
+ # `dependent:` behaviour, counted, plus the ones that would block it.
5
+ class Dependents
6
+ DESTROYING = %i[destroy destroy_async delete_all].freeze
7
+ BLOCKING = %i[restrict_with_error restrict_with_exception].freeze
8
+
9
+ # How many of an item's records the confirmation page names.
10
+ PREVIEW = 10
11
+
12
+ Item = Struct.new(:name, :model, :count, :behavior, :cascades, :records, keyword_init: true) do
13
+ def destroys? = DESTROYING.include?(behavior)
14
+
15
+ # What is left over once the named ones are shown.
16
+ def unnamed = count - Array(records).size
17
+
18
+ def blocks? = BLOCKING.include?(behavior) && count.positive?
19
+
20
+ def nullifies? = behavior == :nullify
21
+
22
+ def human_name
23
+ human = model ? model.model_name.human(count: count) : name.to_s.humanize
24
+ count == 1 ? human : human.pluralize(I18n.locale)
25
+ end
26
+ end
27
+
28
+ # The same overview for several records at once: one item per association,
29
+ # counts summed, names taken across the selection.
30
+ class Selection
31
+ def initialize(records) = @records = records
32
+
33
+ def items
34
+ @items ||= @records.flat_map { |record| Dependents.new(record).items }
35
+ .group_by(&:name).map { |name, items| merged(name, items) }
36
+ end
37
+
38
+ def blockers = items.select(&:blocks?)
39
+
40
+ private
41
+
42
+ def merged(name, items)
43
+ Item.new(name: name, model: items.first.model, behavior: items.first.behavior,
44
+ count: items.sum(&:count), cascades: items.any?(&:cascades),
45
+ records: items.flat_map(&:records).first(PREVIEW))
46
+ end
47
+ end
48
+
49
+ def self.for(record) = new(record).items
50
+
51
+ def initialize(record)
52
+ @record = record
53
+ end
54
+
55
+ def items
56
+ @items ||= (association_items + attachment_items).reject { |item| item.count.zero? }
57
+ end
58
+
59
+ # Anything that blocks the delete outright.
60
+ def blockers = items.select(&:blocks?)
61
+
62
+ private
63
+
64
+ attr_reader :record
65
+
66
+ def association_items
67
+ support = Structure.for(record.class).attachment_support_names
68
+
69
+ record.class.reflect_on_all_associations.filter_map do |reflection|
70
+ behavior = reflection.options[:dependent]&.to_sym
71
+ next unless behavior
72
+ next if support.include?(reflection.name)
73
+
74
+ Item.new(name: reflection.name, model: target_of(reflection), count: count_for(reflection),
75
+ behavior: behavior, cascades: cascades?(reflection), records: preview_of(reflection))
76
+ end
77
+ end
78
+
79
+ # Attached files go with the record; they are not an association the app
80
+ # declared, so they are counted separately.
81
+ def attachment_items
82
+ return [] unless record.class.respond_to?(:reflect_on_all_attachments)
83
+
84
+ record.class.reflect_on_all_attachments.map do |reflection|
85
+ attachments = attachments_of(record.public_send(reflection.name))
86
+ Item.new(name: reflection.name, model: nil, count: attachments.size, behavior: :destroy,
87
+ cascades: false, records: attachments.first(PREVIEW))
88
+ end
89
+ end
90
+
91
+ # The attachments behind `has_one_attached` / `has_many_attached`, each of
92
+ # which names a file the delete takes.
93
+ def attachments_of(attached)
94
+ return attached.attachments.to_a if attached.respond_to?(:attachments)
95
+
96
+ attached.attached? ? [attached.attachment] : []
97
+ rescue ActiveRecord::ActiveRecordError
98
+ []
99
+ end
100
+
101
+ def target_of(reflection)
102
+ reflection.klass
103
+ rescue NameError
104
+ nil
105
+ end
106
+
107
+ def count_for(reflection)
108
+ value = record.public_send(reflection.name)
109
+ return value.count if value.respond_to?(:count)
110
+
111
+ value.nil? ? 0 : 1
112
+ rescue ActiveRecord::ActiveRecordError, NameError
113
+ 0
114
+ end
115
+
116
+ # The first records the delete would reach, for naming them.
117
+ def preview_of(reflection)
118
+ value = record.public_send(reflection.name)
119
+ return Array(value) unless value.respond_to?(:limit)
120
+
121
+ value.limit(PREVIEW).to_a
122
+ rescue ActiveRecord::ActiveRecordError, NameError
123
+ []
124
+ end
125
+
126
+ # Whether the target itself destroys further records — the count shown is
127
+ # then the first level only.
128
+ def cascades?(reflection)
129
+ target = target_of(reflection)
130
+ return false unless target && DESTROYING.include?(reflection.options[:dependent]&.to_sym)
131
+
132
+ target.reflect_on_all_associations.any? do |nested|
133
+ DESTROYING.include?(nested.options[:dependent]&.to_sym)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,22 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # The mountable admin. Does nothing until an app mounts it:
4
+ #
5
+ # mount CrudComponents::Admin::Engine => '/admin'
6
+ #
7
+ # Its routes are drawn from {Registry}, one `resources` block per model, all
8
+ # pointing at ResourcesController.
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace CrudComponents::Admin
11
+
12
+ # The gem root already holds app/ and config/locales; only the route file
13
+ # is admin-specific, so point at it rather than at config/routes.rb (which
14
+ # the non-isolated main engine would draw into the host application).
15
+ paths['config/routes.rb'] = 'lib/crud_components/admin/routes.rb'
16
+
17
+ config.after_initialize do |app|
18
+ app.config.to_prepare { CrudComponents::Admin.registry.reload! }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,123 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # One registered model: everything the route drawer, the sidebar and the
4
+ # controller need, resolved once.
5
+ class Entry
6
+ # The seven RESTful actions, in the order routes are drawn.
7
+ ALL_ACTIONS = %i[index show new create edit update destroy].freeze
8
+
9
+ # Actions a member route needs a primary key for.
10
+ MEMBER_ACTIONS = %i[show edit update destroy].freeze
11
+
12
+ # `new` without `create` (or `edit` without `update`) is a form that can't
13
+ # submit, so each implies the other.
14
+ ACTION_PAIRS = { new: :create, create: :new, edit: :update, update: :edit }.freeze
15
+
16
+ attr_reader :model, :options
17
+
18
+ def initialize(model, options = {})
19
+ @model = model
20
+ @options = options || {}
21
+ end
22
+
23
+ def name = model.name
24
+
25
+ def route_key = model.model_name.route_key
26
+
27
+ def singular_route_key = model.model_name.singular_route_key
28
+
29
+ # The column a URL segment is matched against, from `identify_by`.
30
+ def param_key = structure.identify_by
31
+
32
+ def label = options[:label]&.to_s || model.model_name.human(count: 2)
33
+
34
+ def icon = structure.icon
35
+
36
+ # nil for a top-level model; the namespace for `Catalog::Book`.
37
+ def group
38
+ return options[:group].to_s if options[:group]
39
+
40
+ namespace = name.to_s.deconstantize
41
+ namespace.presence&.demodulize&.underscore&.humanize
42
+ end
43
+
44
+ # The declared group as a locale-independent key: what the heading is
45
+ # looked up under, and what `config.groups` orders by.
46
+ def self.group_key(value) = value.to_s.parameterize.underscore.presence
47
+
48
+ def group_key = self.class.group_key(group)
49
+
50
+ # The sidebar heading: translated when the app says so, else as declared.
51
+ def group_label
52
+ return nil unless group
53
+
54
+ I18n.t("crud_components.admin.groups.#{group_key}", default: group)
55
+ end
56
+
57
+ def actions
58
+ @actions ||= resolve_actions
59
+ end
60
+
61
+ def allows?(action) = actions.include?(action.to_sym)
62
+
63
+ # The base relation the admin renders, before filtering and sorting.
64
+ def scope
65
+ base = model.all
66
+ callable = options[:scope]
67
+ return base unless callable
68
+
69
+ callable.arity.zero? ? base.instance_exec(&callable) : callable.call(base)
70
+ end
71
+
72
+ # The fieldset the admin renders: `:admin` when declared, else every field.
73
+ def fieldset
74
+ return options[:fieldset].to_sym if options[:fieldset]
75
+
76
+ structure.declared_fieldset_names.include?(:admin) ? :admin : :default
77
+ end
78
+
79
+ # This model's to-many associations that point at another registered
80
+ # model, as [association name, entry] — one nested index each.
81
+ def nested_associations(registry)
82
+ model.reflect_on_all_associations.select(&:collection?).filter_map do |reflection|
83
+ target = safe_target(reflection)
84
+ nested = target && registry[target]
85
+ next unless nested&.allows?(:index) && nested.model != model
86
+
87
+ [reflection.name, nested]
88
+ end.uniq { |_, nested| nested.route_key }
89
+ end
90
+
91
+ private
92
+
93
+ def structure = Structure.for(model)
94
+
95
+ # Routes are drawn without a database in reach often enough (an asset
96
+ # build, a container ahead of its migrations), and asking for the primary
97
+ # key raises there; assume the ordinary case.
98
+ def primary_key?
99
+ !model.primary_key.nil?
100
+ rescue StandardError
101
+ true
102
+ end
103
+
104
+ # A polymorphic or otherwise unresolvable association has no single target.
105
+ def safe_target(reflection)
106
+ return nil if reflection.options[:polymorphic] || reflection.options[:through]
107
+
108
+ reflection.klass
109
+ rescue NameError
110
+ nil
111
+ end
112
+
113
+ def resolve_actions
114
+ declared = options[:actions]
115
+ list = declared ? Array(declared).map(&:to_sym) : ALL_ACTIONS.dup
116
+ list |= list.filter_map { |action| ACTION_PAIRS[action] }
117
+ list &= ALL_ACTIONS
118
+ list -= MEMBER_ACTIONS unless primary_key?
119
+ ALL_ACTIONS & list
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,40 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # Who may in at all, as one verdict the controller only has to carry out.
4
+ # It decides nothing beyond the door: what a visitor may see and do inside
5
+ # is the app's ordinary ability, asked per model and per action.
6
+ class Gate
7
+ ACTION = :access
8
+
9
+ VERDICTS = %i[open block unauthorized allowed forbidden].freeze
10
+
11
+ def initialize(config, ability)
12
+ @config = config
13
+ @ability = ability
14
+ end
15
+
16
+ def verdict
17
+ return :open if @config.open_gate?
18
+ return :block if @config.auth_block
19
+ return :unauthorized if @ability.nil?
20
+ return :allowed if @ability.can?(ACTION, subject)
21
+
22
+ :forbidden
23
+ end
24
+
25
+ def unauthorized_message
26
+ "The admin asks `can?(#{ACTION.inspect}, #{subject.inspect})`, and nothing here answers `can?`. " \
27
+ 'Add CanCanCan, or configure a gate of your own: ' \
28
+ 'CrudComponents::Admin.configure { |c| c.auth_with { … } } — ' \
29
+ "or say `c.auth_with :none` on purpose (see docs/admin.md).\n" \
30
+ "In an ability: `can #{ACTION.inspect}, #{subject.inspect}`."
31
+ end
32
+
33
+ def forbidden_message = "not allowed to #{ACTION} the admin"
34
+
35
+ private
36
+
37
+ def subject = @config.auth_subject
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,176 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # Which models the admin administers, resolved once and memoized. The route
4
+ # drawer, the sidebar and the controller all read from here.
5
+ class Registry
6
+ def initialize(config)
7
+ @config = config
8
+ end
9
+
10
+ # Registered models, in sidebar/route order.
11
+ def entries
12
+ @entries ||= build
13
+ end
14
+
15
+ # The entry for a model class or model name, or nil when unregistered.
16
+ def [](model)
17
+ key = model.is_a?(Class) ? model.name : model.to_s
18
+ index[key]
19
+ end
20
+
21
+ def registered?(model) = !self[model].nil?
22
+
23
+ # Entries grouped for the sidebar: [heading (nil = ungrouped), entries].
24
+ def groups(list = entries)
25
+ list.group_by(&:group_label)
26
+ .sort_by { |heading, grouped| [group_rank(grouped.first.group_key), heading.to_s] }
27
+ end
28
+
29
+ # Where a group sits in the configured order; unlisted groups come last.
30
+ # Ordered by the declared name, so a translated heading keeps its place.
31
+ def group_rank(key)
32
+ position = Array(config.groups).index { |name| Entry.group_key(name) == key }
33
+ position || Array(config.groups).size
34
+ end
35
+
36
+ def reload!
37
+ @entries = nil
38
+ @index = nil
39
+ @except_names = nil
40
+ @admin_options = nil
41
+ self
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :config
47
+
48
+ def index
49
+ @index ||= entries.to_h { |entry| [entry.name, entry] }
50
+ end
51
+
52
+ def build
53
+ return explicit_entries if config.only
54
+
55
+ discovered = candidates.reject { |model| excluded?(model) }
56
+ sort(discovered.map { |model| Entry.new(model, admin_options(model) || {}) })
57
+ end
58
+
59
+ # `only` is a directive: the listed models are registered in the listed
60
+ # order, whether or not discovery would have found them.
61
+ def explicit_entries
62
+ Array(config.only).filter_map do |name|
63
+ model = constantize(name)
64
+ next unless model && usable?(model) && admin_options(model) != false
65
+
66
+ Entry.new(model, admin_options(model) || {})
67
+ end
68
+ end
69
+
70
+ def candidates
71
+ load_model_constants
72
+ ActiveRecord::Base.descendants
73
+ end
74
+
75
+ # Resolves every constant under the model directories, and nothing else.
76
+ # Deliberately not `Rails.application.eager_load!`: routes are drawn in
77
+ # every process, including rake tasks and asset builds, which switch
78
+ # eager loading off on purpose — and where an app's own initializers may
79
+ # not have the credentials that eager loading would ask for.
80
+ def load_model_constants
81
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
82
+
83
+ model_dirs.each do |dir|
84
+ Dir.glob(File.join(dir, '**', '*.rb')).sort.each do |file|
85
+ file.delete_prefix("#{dir}/").delete_suffix('.rb').camelize.safe_constantize
86
+ end
87
+ end
88
+ end
89
+
90
+ # The application's own model directories, and only those. An engine's
91
+ # app/models holds the framework's own tables — loading Active Storage's
92
+ # Blob, for one, builds the configured storage service on the spot, which
93
+ # an asset build has no credentials for. Registering a model from an
94
+ # engine is what `config.only` is for.
95
+ def model_dirs
96
+ Rails.application.config.paths['app/models'].existent
97
+ end
98
+
99
+ def excluded?(model)
100
+ return true unless usable?(model)
101
+ return true if internal?(model)
102
+ return true if admin_options(model) == false
103
+ return true if except_names.include?(model.name)
104
+ return true if sti_subclass?(model) && !declares_admin?(model)
105
+
106
+ false
107
+ end
108
+
109
+ # Anonymous and throwaway classes are skipped: a route and a controller
110
+ # need a name that resolves back to the same class. Nothing here asks the
111
+ # database — routes are drawn before the schema exists often enough (a
112
+ # fresh checkout, a container that boots ahead of its migrations) that a
113
+ # registry which quietly comes up empty is the worse failure.
114
+ def usable?(model)
115
+ return false unless model.is_a?(Class) && model.name
116
+ return false if model.abstract_class?
117
+ return false if model == ActiveRecord::Base
118
+
119
+ model.name.safe_constantize.equal?(model)
120
+ end
121
+
122
+ def internal?(model)
123
+ return true if model.name.include?('HABTM_')
124
+
125
+ root = model.name.split('::').first
126
+ config.excluded_namespaces.include?(root)
127
+ end
128
+
129
+ def sti_subclass?(model) = model.base_class != model
130
+
131
+ # The `admin` declaration, read from the DSL block rather than from a
132
+ # resolved Structure: routes are drawn before a model's columns can be
133
+ # asked for (there may be no database, and resolving an attachment field
134
+ # would build the storage service). Inherited from an STI parent.
135
+ def admin_options(model)
136
+ @admin_options ||= {}
137
+ return @admin_options[model] if @admin_options.key?(model)
138
+
139
+ @admin_options[model] = declaration_of(model, inherited: true)
140
+ end
141
+
142
+ # Whether this very class declared `admin`, ignoring an STI parent's.
143
+ def declares_admin?(model)
144
+ !declaration_of(model, inherited: false).nil?
145
+ end
146
+
147
+ def declaration_of(model, inherited:)
148
+ klass = model
149
+ while klass.respond_to?(:instance_variable_defined?)
150
+ if klass.instance_variable_defined?(:@_crud_structure_block) &&
151
+ (block = klass.instance_variable_get(:@_crud_structure_block))
152
+ return Builder.new(model, &block).admin_decl
153
+ end
154
+ break unless inherited
155
+
156
+ klass = klass.superclass
157
+ break if klass.nil? || klass == ActiveRecord::Base
158
+ end
159
+ nil
160
+ end
161
+
162
+ def except_names
163
+ @except_names ||= Array(config.except).map { |name| name.is_a?(Class) ? name.name : name.to_s }
164
+ end
165
+
166
+ def constantize(name)
167
+ model = name.is_a?(Class) ? name : name.to_s.safe_constantize
168
+ model if model.respond_to?(:columns_hash)
169
+ end
170
+
171
+ def sort(list)
172
+ list.sort_by { |entry| [group_rank(entry.group_key), entry.group_label.to_s, entry.label.to_s] }
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,37 @@
1
+ CrudComponents::Admin::Engine.routes.draw do
2
+ root 'dashboard#show'
3
+
4
+ registry = CrudComponents::Admin.registry
5
+ drawn = {}
6
+
7
+ registry.entries.each do |entry|
8
+ next if entry.actions.empty?
9
+
10
+ if (clash = drawn[entry.route_key])
11
+ raise CrudComponents::Admin::Error,
12
+ "#{entry.name} and #{clash} both route to /#{entry.route_key} — " \
13
+ 'drop one with `admin false` or `config.except`'
14
+ end
15
+
16
+ drawn[entry.route_key] = entry.name
17
+ resources entry.route_key, controller: 'resources', only: entry.actions,
18
+ defaults: { crud_model: entry.name } do
19
+ if entry.allows?(:destroy)
20
+ collection do
21
+ # /books/delete confirms the selection, /books/<id>/delete one record.
22
+ get :delete, action: :delete_selected, controller: 'resources'
23
+ delete :destroy_selected, controller: 'resources'
24
+ end
25
+ member { get :delete, controller: 'resources' }
26
+ end
27
+
28
+ # An index per to-many association pointing here, so the owner's
29
+ # "+n more" links and its association cells resolve.
30
+ entry.nested_associations(registry).each do |association, nested|
31
+ resources nested.route_key, controller: 'resources', only: :index,
32
+ defaults: { crud_model: nested.name, crud_owner: entry.name,
33
+ crud_association: association.to_s }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,156 @@
1
+ module CrudComponents
2
+ module Admin
3
+ # View helpers for the engine's own templates. Included explicitly by the
4
+ # engine's controllers, so they never reach the host app's views.
5
+ module ViewHelpers
6
+ # The engine's index path for a registered model. Built from the engine's
7
+ # own route set rather than a bare helper name, which a host helper of the
8
+ # same name would shadow.
9
+ def admin_index_path(entry)
10
+ CrudComponents::Admin.path_for(entry.model, :index)
11
+ end
12
+
13
+ # Whether these could be deleted on their own. A cascade takes them either
14
+ # way; the confirmation page says so. No ability means no opinion.
15
+ def admin_may_destroy?(model)
16
+ return true unless model && respond_to?(:can?)
17
+
18
+ can?(:destroy, model)
19
+ end
20
+
21
+ # The ticked rows as one dependent item, so the confirmation page names
22
+ # them the same way it names everything else that goes.
23
+ def admin_selection_item(model, records)
24
+ CrudComponents::Admin::Dependents::Item.new(
25
+ name: nil, model: model, count: records.size, behavior: :destroy, cascades: false,
26
+ records: records.first(CrudComponents::Admin::Dependents::PREVIEW)
27
+ )
28
+ end
29
+
30
+ # What the ticked rows are identified by, for the form that deletes them.
31
+ def admin_selected_values(records)
32
+ records.map { |record| record.public_send(CrudComponents::Structure.for(record.class).identify_by).to_s }
33
+ end
34
+
35
+ # One record a delete would take, named: an attachment names its file and
36
+ # opens it, anything else links to its own admin page.
37
+ def admin_dependent_link(record, owner: nil)
38
+ return admin_attachment_link(record) if admin_attachment?(record)
39
+
40
+ path = crud_record_path(record, owner: owner)
41
+ path ? link_to(crud_label(record), path, data: { turbo_action: 'advance' }) : crud_label(record)
42
+ end
43
+
44
+ # The index holding the rest of a dependent item: nested under the owner,
45
+ # else the target's index filtered by it, else nil.
46
+ def admin_dependents_path(owner, item)
47
+ return nil unless item.model
48
+
49
+ CrudComponents::RouteResolver.collection_index_path(self, item.model, owner, item.name)
50
+ end
51
+
52
+ def admin_icon(name, css_class: nil)
53
+ return nil unless name
54
+
55
+ tag.i(nil, class: ["#{CrudComponents.config.css.icon_prefix}#{name}", css_class].compact.join(' '))
56
+ end
57
+
58
+ # Whether this entry is the one being looked at.
59
+ def admin_current_entry?(entry)
60
+ params[:crud_model].to_s == entry.name
61
+ end
62
+
63
+ def admin_title = CrudComponents::Admin.config.resolved_title
64
+
65
+ # The admin layout's stylesheet, inlined (CSP-nonce aware).
66
+ def admin_styles
67
+ nonce = content_security_policy_nonce if respond_to?(:content_security_policy_nonce)
68
+ tag.style(CrudComponents::Admin.bundled_css.html_safe, type: 'text/css', nonce: nonce)
69
+ end
70
+
71
+ # The bulk action, or nil for a model with no destroy route. Like the row's
72
+ # delete it leads to the confirmation page, not straight to a DELETE.
73
+ def admin_destroy_selected_action(entry)
74
+ return nil unless entry.allows?(:destroy)
75
+
76
+ CrudComponents::Action.new(
77
+ :destroy_selected, on: :selection, method: :get, confirm: false, icon: 'trash',
78
+ if: :destroy, title: t('crud_components.admin.destroy_selected', default: 'Delete selected')
79
+ ) { public_send("delete_#{entry.route_key}_path") }
80
+ end
81
+
82
+ # The delete button: a link to the confirmation page rather than a DELETE
83
+ # behind a browser dialog. Keeps the derived action's icon and label.
84
+ def admin_delete_action(entry)
85
+ return nil unless entry.allows?(:destroy)
86
+
87
+ CrudComponents::Action.new(:destroy, on: :row, method: :get, confirm: false) do |record|
88
+ public_send("delete_#{entry.singular_route_key}_path", record)
89
+ end
90
+ end
91
+
92
+ # The row action linking a record to the host app's own page for it.
93
+ # Resolves through crud_app_path, so it disappears when there is none.
94
+ def admin_show_in_app_action
95
+ CrudComponents::Action.new(
96
+ :show_in_app, on: :row, icon: 'box-arrow-up-right',
97
+ title: t('crud_components.admin.show_in_app', default: 'Show in app')
98
+ ) { |record| crud_app_path(record) }
99
+ end
100
+
101
+ # Active Storage routes live in the application's route set, not in an
102
+ # isolated engine's; attachment cells reach them through these two.
103
+ def polymorphic_url(record, options = {})
104
+ return main_app.polymorphic_url(record, options) if active_storage_object?(record)
105
+
106
+ super
107
+ end
108
+
109
+ def polymorphic_path(record, options = {})
110
+ return main_app.polymorphic_path(record, options) if active_storage_object?(record)
111
+
112
+ super
113
+ end
114
+
115
+ # A route helper the engine does not have falls through to the host
116
+ # application's routes — the admin renders the host's partials and render
117
+ # blocks, and those call the host's routes.
118
+ def method_missing(name, *args, **options, &block)
119
+ return super unless forwardable_route_helper?(name)
120
+
121
+ main_app.public_send(name, *args, **options, &block)
122
+ end
123
+
124
+ def respond_to_missing?(name, include_private = false)
125
+ forwardable_route_helper?(name) || super
126
+ end
127
+
128
+ private
129
+
130
+ def forwardable_route_helper?(name)
131
+ return false unless name.to_s.end_with?('_path', '_url')
132
+ return false unless respond_to?(:main_app)
133
+
134
+ main_app.respond_to?(name)
135
+ end
136
+
137
+ def admin_attachment?(record)
138
+ defined?(ActiveStorage) && record.is_a?(ActiveStorage::Attachment)
139
+ end
140
+
141
+ # The filename, linked to the file itself; a blob that is gone is named
142
+ # but not linked.
143
+ def admin_attachment_link(attachment)
144
+ blob = attachment.blob
145
+ return attachment.name.to_s.humanize unless blob
146
+
147
+ link_to(blob.filename.to_s, rails_blob_path(attachment, disposition: :inline),
148
+ target: '_blank', rel: 'noopener')
149
+ end
150
+
151
+ def active_storage_object?(object)
152
+ defined?(ActiveStorage) && object.class.name.to_s.start_with?('ActiveStorage::')
153
+ end
154
+ end
155
+ end
156
+ end