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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +41 -4
- data/app/assets/stylesheets/crud_components_admin.css +13 -0
- data/app/controllers/crud_components/admin/application_controller.rb +100 -0
- data/app/controllers/crud_components/admin/dashboard_controller.rb +10 -0
- data/app/controllers/crud_components/admin/resources_controller.rb +184 -0
- data/app/views/crud_components/admin/_sidebar.html.erb +20 -0
- data/app/views/crud_components/admin/dashboard/show.html.erb +22 -0
- data/app/views/crud_components/admin/resources/_dependent_records.html.erb +16 -0
- data/app/views/crud_components/admin/resources/delete.html.erb +98 -0
- data/app/views/crud_components/admin/resources/edit.html.erb +14 -0
- data/app/views/crud_components/admin/resources/index.html.erb +26 -0
- data/app/views/crud_components/admin/resources/new.html.erb +14 -0
- data/app/views/crud_components/admin/resources/show.html.erb +16 -0
- data/app/views/layouts/crud_components/admin.html.erb +41 -0
- data/config/locales/crud_components.de.yml +31 -0
- data/config/locales/crud_components.en.yml +31 -0
- data/docs/admin.md +321 -0
- data/docs/fields.md +7 -7
- data/docs/filtering.md +6 -5
- data/docs/views.md +14 -0
- data/lib/crud_components/admin/configuration.rb +122 -0
- data/lib/crud_components/admin/dependents.rb +138 -0
- data/lib/crud_components/admin/engine.rb +22 -0
- data/lib/crud_components/admin/entry.rb +123 -0
- data/lib/crud_components/admin/gate.rb +40 -0
- data/lib/crud_components/admin/registry.rb +176 -0
- data/lib/crud_components/admin/routes.rb +37 -0
- data/lib/crud_components/admin/view_helpers.rb +156 -0
- data/lib/crud_components/admin.rb +92 -0
- data/lib/crud_components/builder.rb +59 -4
- data/lib/crud_components/fields/belongs_to_field.rb +4 -5
- data/lib/crud_components/fields/has_many_field.rb +2 -2
- data/lib/crud_components/helpers.rb +36 -5
- data/lib/crud_components/like_spec.rb +3 -3
- data/lib/crud_components/presenters/collection.rb +16 -4
- data/lib/crud_components/presenters/record.rb +14 -2
- data/lib/crud_components/route_resolver.rb +37 -7
- data/lib/crud_components/structure.rb +15 -1
- data/lib/crud_components/version.rb +1 -1
- data/lib/crud_components.rb +2 -0
- 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
|