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,92 @@
|
|
|
1
|
+
require_relative 'admin/configuration'
|
|
2
|
+
require_relative 'admin/dependents'
|
|
3
|
+
require_relative 'admin/entry'
|
|
4
|
+
require_relative 'admin/gate'
|
|
5
|
+
require_relative 'admin/registry'
|
|
6
|
+
require_relative 'admin/view_helpers'
|
|
7
|
+
|
|
8
|
+
module CrudComponents
|
|
9
|
+
# The optional, mountable admin UI:
|
|
10
|
+
#
|
|
11
|
+
# mount CrudComponents::Admin::Engine => '/admin'
|
|
12
|
+
#
|
|
13
|
+
# See docs/admin.md.
|
|
14
|
+
module Admin
|
|
15
|
+
class Error < CrudComponents::Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when the admin has no way to tell who may in: `auth_with :cancan`
|
|
18
|
+
# and nothing that answers `can?`.
|
|
19
|
+
class UnauthorizedError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when the ability denies the action behind the request. Rendered as
|
|
22
|
+
# 403 by the engine's controllers.
|
|
23
|
+
class ForbiddenError < Error; end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def config
|
|
27
|
+
@config ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield config
|
|
32
|
+
registry.reload!
|
|
33
|
+
config
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def registry
|
|
37
|
+
@registry ||= Registry.new(config)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Drops both the configuration and the resolved registry.
|
|
41
|
+
def reset!
|
|
42
|
+
@config = nil
|
|
43
|
+
@registry = nil
|
|
44
|
+
@mount_path = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The admin layout's own stylesheet, read once from the packaged file.
|
|
48
|
+
def bundled_css
|
|
49
|
+
@bundled_css ||= File.read(
|
|
50
|
+
File.expand_path('../../app/assets/stylesheets/crud_components_admin.css', __dir__)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Where the host mounted the engine, or nil when it did not.
|
|
55
|
+
def mount_path
|
|
56
|
+
return @mount_path if @mount_path
|
|
57
|
+
return nil unless defined?(Engine) && defined?(Rails) && Rails.application
|
|
58
|
+
|
|
59
|
+
route = Rails.application.routes.routes.find do |candidate|
|
|
60
|
+
candidate.app.respond_to?(:app) && candidate.app.app == Engine
|
|
61
|
+
end
|
|
62
|
+
path = route&.path&.spec.to_s
|
|
63
|
+
@mount_path = path == '/' ? '' : path.presence
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The admin's path for a record (or a model class, for its index), or nil
|
|
67
|
+
# when the admin isn't mounted, the model isn't registered, or the action
|
|
68
|
+
# is not enabled for it.
|
|
69
|
+
def path_for(subject, action = :show)
|
|
70
|
+
model = subject.is_a?(Class) ? subject : subject.class
|
|
71
|
+
entry = registry[model]
|
|
72
|
+
return nil unless entry&.allows?(action) && mount_path
|
|
73
|
+
|
|
74
|
+
helper, args = helper_for(entry, action, subject)
|
|
75
|
+
return nil unless Engine.routes.url_helpers.respond_to?(helper)
|
|
76
|
+
|
|
77
|
+
Engine.routes.url_helpers.public_send(helper, *args, script_name: mount_path)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def helper_for(entry, action, subject)
|
|
83
|
+
case action.to_sym
|
|
84
|
+
when :index then ["#{entry.route_key}_path", []]
|
|
85
|
+
when :new then ["new_#{entry.singular_route_key}_path", []]
|
|
86
|
+
when :edit then ["edit_#{entry.singular_route_key}_path", [subject]]
|
|
87
|
+
else ["#{entry.singular_route_key}_path", [subject]]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -18,7 +18,8 @@ module CrudComponents
|
|
|
18
18
|
class Builder
|
|
19
19
|
attr_reader :model, :declarations, :actions, :fieldsets,
|
|
20
20
|
:label_decl, :identify_by_decl, :search_decl,
|
|
21
|
-
:label_preload_decl, :preload_decl, :icon_decl
|
|
21
|
+
:label_preload_decl, :preload_decl, :icon_decl, :admin_decl,
|
|
22
|
+
:app_path_decl
|
|
22
23
|
|
|
23
24
|
# @param model [Class] the ActiveRecord model being described.
|
|
24
25
|
# @yield the `crud_structure` block, evaluated against this Builder.
|
|
@@ -34,7 +35,7 @@ module CrudComponents
|
|
|
34
35
|
# How a record is titled (links, headings). Give a method name or a block.
|
|
35
36
|
# @param method [Symbol, nil] a method on the record returning its label.
|
|
36
37
|
# @param preload [Array<Symbol>, Symbol, nil] associations the label reaches
|
|
37
|
-
# into (`label :
|
|
38
|
+
# into (`label :display_title, preload: %i[publisher authors]`). They're
|
|
38
39
|
# eager-loaded automatically whenever this model is shown as another
|
|
39
40
|
# model's association column — declare once, no N+1 anywhere.
|
|
40
41
|
# @yield [record] computes the label; receives the record.
|
|
@@ -51,7 +52,7 @@ module CrudComponents
|
|
|
51
52
|
# Associations to eager-load whenever this model is rendered (as a row or as
|
|
52
53
|
# another model's association cell) — for label/render dependencies the gem
|
|
53
54
|
# can't infer. Additive with `label …, preload:`; declare more than once to
|
|
54
|
-
# accumulate. e.g. `preload :
|
|
55
|
+
# accumulate. e.g. `preload :publisher, :authors`.
|
|
55
56
|
# @param names [Array<Symbol>] association names (nested hashes allowed).
|
|
56
57
|
# @return [void]
|
|
57
58
|
def preload(*names)
|
|
@@ -151,10 +152,64 @@ module CrudComponents
|
|
|
151
152
|
@fieldsets[name] = Fieldset.new(name, fields, actions: actions, filters: filters)
|
|
152
153
|
end
|
|
153
154
|
|
|
155
|
+
# The host application's own page for a record, when it is not the
|
|
156
|
+
# conventional route (`main_app.book_path(book)`). Runs in the view
|
|
157
|
+
# context; return nil to suppress the link for a record.
|
|
158
|
+
# app_path { |book| main_app.publisher_book_path(book.publisher, book) }
|
|
159
|
+
# @yield [record] the record to link to.
|
|
160
|
+
# @return [void]
|
|
161
|
+
def app_path(&block)
|
|
162
|
+
raise DefinitionError, "#{model}: app_path requires a block" unless block
|
|
163
|
+
raise DefinitionError, "#{model}: app_path declared twice" if defined?(@app_path_decl) && @app_path_decl
|
|
164
|
+
|
|
165
|
+
@app_path_decl = block
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# How this model appears in the mounted admin UI (inert without it).
|
|
169
|
+
# @param enabled [Boolean] `false` keeps the model out of the admin entirely.
|
|
170
|
+
# @param options [Hash] `actions:` (a subset of
|
|
171
|
+
# `%i[index show new create edit update destroy]`), `group:`, `label:`,
|
|
172
|
+
# `fieldset:`, `scope:` (a callable narrowing the base relation).
|
|
173
|
+
# @return [void]
|
|
174
|
+
def admin(enabled = true, **options)
|
|
175
|
+
raise DefinitionError, "#{model}: admin declared twice" if defined?(@admin_decl) && !@admin_decl.nil?
|
|
176
|
+
|
|
177
|
+
unless [true, false].include?(enabled)
|
|
178
|
+
raise DefinitionError, "#{model}: admin takes true or false, got #{enabled.inspect}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if enabled == false && options.any?
|
|
182
|
+
raise DefinitionError, "#{model}: `admin false` takes no options — #{options.keys.map(&:inspect).join(', ')} " \
|
|
183
|
+
'would never apply'
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
validate_admin_options!(options)
|
|
187
|
+
@admin_decl = enabled ? options : false
|
|
188
|
+
end
|
|
189
|
+
|
|
154
190
|
private
|
|
155
191
|
|
|
192
|
+
ADMIN_OPTIONS = %i[actions group label fieldset scope].freeze
|
|
193
|
+
|
|
194
|
+
def validate_admin_options!(options)
|
|
195
|
+
unknown = options.keys - ADMIN_OPTIONS
|
|
196
|
+
unless unknown.empty?
|
|
197
|
+
raise DefinitionError, "#{model}: admin got unknown option(s) #{unknown.map(&:inspect).join(', ')} — " \
|
|
198
|
+
"known: #{ADMIN_OPTIONS.map(&:inspect).join(', ')}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
actions = options[:actions]
|
|
202
|
+
return if actions.nil?
|
|
203
|
+
|
|
204
|
+
bad = Array(actions).map(&:to_sym) - Admin::Entry::ALL_ACTIONS
|
|
205
|
+
return if bad.empty?
|
|
206
|
+
|
|
207
|
+
raise DefinitionError, "#{model}: admin actions #{bad.map(&:inspect).join(', ')} are not RESTful actions — " \
|
|
208
|
+
"known: #{Admin::Entry::ALL_ACTIONS.map(&:inspect).join(', ')}"
|
|
209
|
+
end
|
|
210
|
+
|
|
156
211
|
# Normalize a preload value to an array of includes-specs, leaving a nested
|
|
157
|
-
# hash (`{
|
|
212
|
+
# hash (`{ publisher: :books }`) intact (Array() would split it).
|
|
158
213
|
def preload_list(value)
|
|
159
214
|
case value
|
|
160
215
|
when nil then []
|
|
@@ -27,7 +27,7 @@ module CrudComponents
|
|
|
27
27
|
# Default ?q= reaches the target's label (the name shown in the cell).
|
|
28
28
|
# Skipped for polymorphic (no single target) or a block/columnless label.
|
|
29
29
|
def search_spec_entry
|
|
30
|
-
name if !reflection.polymorphic? && target_structure.
|
|
30
|
+
name if !reflection.polymorphic? && target_structure.label_column_name
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def derived_filterable?
|
|
@@ -70,7 +70,7 @@ module CrudComponents
|
|
|
70
70
|
|
|
71
71
|
# Load the association, nesting the target's identity_preloads (its label's
|
|
72
72
|
# own association deps) plus any per-column `preload:` so the target's
|
|
73
|
-
# label never N+1s. e.g. {
|
|
73
|
+
# label never N+1s. e.g. { book: %i[publisher authors] }.
|
|
74
74
|
# A polymorphic belongs_to has no single target class, so we can't nest its
|
|
75
75
|
# label's preloads — just preload the association itself (Rails groups it by
|
|
76
76
|
# type); the cell still renders each record's label and links it at runtime.
|
|
@@ -102,14 +102,13 @@ module CrudComponents
|
|
|
102
102
|
def sort_column
|
|
103
103
|
return nil if reflection.polymorphic?
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
col if col && target.column_names.include?(col.to_s)
|
|
105
|
+
target_structure.label_column_name
|
|
107
106
|
end
|
|
108
107
|
|
|
109
108
|
# Free text matches the target's label only — the name shown in the cell.
|
|
110
109
|
# A block/computed label has no column to match, so there's no text filter.
|
|
111
110
|
def like_subquery(scope, value)
|
|
112
|
-
label = target_structure.
|
|
111
|
+
label = target_structure.label_column_name
|
|
113
112
|
return nil unless label
|
|
114
113
|
|
|
115
114
|
scope.where(name => LikeSpec.apply(target.all, [label], value))
|
|
@@ -49,7 +49,7 @@ module CrudComponents
|
|
|
49
49
|
# Default ?q= reaches the children's label (the names shown in the list).
|
|
50
50
|
# Skipped when the target's label is a block/columnless.
|
|
51
51
|
def search_spec_entry
|
|
52
|
-
name if target_structure.
|
|
52
|
+
name if target_structure.label_column_name
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# ── filter ─────────────────────────────────────────────────────────────
|
|
@@ -58,7 +58,7 @@ module CrudComponents
|
|
|
58
58
|
# one of its children does. Skipped when the target's label is a
|
|
59
59
|
# block/columnless (no single column to match). Mirrors belongs_to, which
|
|
60
60
|
# filters by its target's label the same way.
|
|
61
|
-
def derived_filterable? = target_structure.
|
|
61
|
+
def derived_filterable? = target_structure.label_column_name.present?
|
|
62
62
|
|
|
63
63
|
def apply_derived_filter(scope, value: nil, **)
|
|
64
64
|
return scope unless value
|
|
@@ -40,15 +40,22 @@ module CrudComponents
|
|
|
40
40
|
# it (from a persisted preference, or from the param via
|
|
41
41
|
# {CrudComponents.selected_columns}). A forged/stale name can only hide or
|
|
42
42
|
# reorder, never reveal a column the `if:` gate forbids.
|
|
43
|
+
# @param extra_actions [Array<CrudComponents::Action>, nil] row actions to append
|
|
44
|
+
# beyond the model's own — for a button that belongs to this surface rather
|
|
45
|
+
# than to the model. Subject to the same permission and route resolution.
|
|
46
|
+
# @param except_actions [Array<Symbol>, nil] action names this render drops,
|
|
47
|
+
# whatever the model declares (e.g. replacing `:destroy` with a button of
|
|
48
|
+
# your own). Applies to row, collection and selection actions.
|
|
43
49
|
# @return [ActiveSupport::SafeBuffer] the rendered HTML.
|
|
44
50
|
def crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
|
|
45
51
|
actions: true, search_bar: true, group_by: nil, extra_columns: nil,
|
|
46
|
-
picker: false, picked_columns: :auto)
|
|
52
|
+
picker: false, picked_columns: :auto, extra_actions: nil, except_actions: nil)
|
|
47
53
|
presenter = Presenters::Collection.new(view: self, records: records, fieldset: fieldset,
|
|
48
54
|
query: query, layout: layout, param_prefix: param_prefix,
|
|
49
55
|
actions: actions, search_bar: search_bar, group_by: group_by,
|
|
50
56
|
extra_columns: extra_columns,
|
|
51
|
-
picker: picker, picked_columns: picked_columns
|
|
57
|
+
picker: picker, picked_columns: picked_columns,
|
|
58
|
+
extra_actions: extra_actions, except_actions: except_actions)
|
|
52
59
|
render "crud_components/layouts/#{presenter.layout}", collection: presenter
|
|
53
60
|
end
|
|
54
61
|
|
|
@@ -71,12 +78,17 @@ module CrudComponents
|
|
|
71
78
|
# @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] user-defined
|
|
72
79
|
# columns whose data lives outside the model's table, shown as extra rows
|
|
73
80
|
# (same as {#crud_collection}'s `extra_columns:`, for a detail view).
|
|
81
|
+
# @param extra_actions [Array<CrudComponents::Action>, nil] actions to append
|
|
82
|
+
# beyond the model's own (same as {#crud_collection}'s `extra_actions:`).
|
|
83
|
+
# @param except_actions [Array<Symbol>, nil] action names this render drops
|
|
84
|
+
# (same as {#crud_collection}'s `except_actions:`).
|
|
74
85
|
# @return [ActiveSupport::SafeBuffer] the rendered HTML.
|
|
75
86
|
def crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto,
|
|
76
|
-
param_prefix: nil, extra_columns: nil)
|
|
87
|
+
param_prefix: nil, extra_columns: nil, extra_actions: nil, except_actions: nil)
|
|
77
88
|
presenter = Presenters::Record.new(view: self, record: record, fieldset: fieldset, actions: actions,
|
|
78
89
|
picked_columns: picked_columns, param_prefix: param_prefix,
|
|
79
|
-
extra_columns: extra_columns
|
|
90
|
+
extra_columns: extra_columns, extra_actions: extra_actions,
|
|
91
|
+
except_actions: except_actions)
|
|
80
92
|
render "crud_components/#{layout}", record_presenter: presenter
|
|
81
93
|
end
|
|
82
94
|
|
|
@@ -178,6 +190,25 @@ module CrudComponents
|
|
|
178
190
|
|
|
179
191
|
# ── utilities (used by the gem's partials; useful in apps too) ─────────
|
|
180
192
|
|
|
193
|
+
# The host application's own page for a record — its `app_path` block, else
|
|
194
|
+
# the conventional route resolved against `main_app`. nil when there is
|
|
195
|
+
# none, so guard the link rather than assuming it resolves.
|
|
196
|
+
# @param record [ActiveRecord::Base]
|
|
197
|
+
# @return [String, nil]
|
|
198
|
+
def crud_app_path(record)
|
|
199
|
+
RouteResolver.app_path(self, record)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The mounted admin's page for a record (or for a model class, its index).
|
|
203
|
+
# nil when the admin isn't mounted, the model isn't registered, or the
|
|
204
|
+
# action isn't enabled for it.
|
|
205
|
+
# @param subject [ActiveRecord::Base, Class]
|
|
206
|
+
# @param action [Symbol] `:show` (default), `:index`, `:edit` or `:new`.
|
|
207
|
+
# @return [String, nil]
|
|
208
|
+
def crud_admin_path(subject, action = :show)
|
|
209
|
+
Admin.path_for(subject, action)
|
|
210
|
+
end
|
|
211
|
+
|
|
181
212
|
# The display label for a record — its declared `label`, else a humanized guess.
|
|
182
213
|
# @param record [ActiveRecord::Base]
|
|
183
214
|
# @return [String]
|
|
@@ -186,7 +217,7 @@ module CrudComponents
|
|
|
186
217
|
end
|
|
187
218
|
|
|
188
219
|
# The label for an associated record in an association column: a per-column
|
|
189
|
-
# `label:` callable (`attribute :
|
|
220
|
+
# `label:` callable (`attribute :publisher, label: ->(p) { p.display_title(short: true) }`)
|
|
190
221
|
# when given, else the target's default {#crud_label}. Used by the
|
|
191
222
|
# association / association_list renderers so a column can re-title the
|
|
192
223
|
# associated record for its context while keeping the nil-safe link.
|
|
@@ -90,11 +90,11 @@ module CrudComponents
|
|
|
90
90
|
# no single column to match, so ask for the columns explicitly.
|
|
91
91
|
def delegate(model, reflection, path)
|
|
92
92
|
target = reflection.klass
|
|
93
|
-
label = Structure.for(target).
|
|
93
|
+
label = Structure.for(target).label_column_name
|
|
94
94
|
if label.nil?
|
|
95
95
|
raise DefinitionError, "cannot search #{model}##{reflection.name} by label: " \
|
|
96
|
-
"#{target}'s label is a custom block
|
|
97
|
-
"e.g. { #{reflection.name}: %i[...] }"
|
|
96
|
+
"#{target}'s label is not a column (custom block or computed method) — " \
|
|
97
|
+
"spell the columns out, e.g. { #{reflection.name}: %i[...] }"
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
[Entry.new(path + [reflection.name], target, label)]
|
|
@@ -16,7 +16,8 @@ module CrudComponents
|
|
|
16
16
|
|
|
17
17
|
def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
|
|
18
18
|
param_prefix: nil, actions: true, search_bar: true, group_by: nil,
|
|
19
|
-
extra_columns: nil, picker: false, picked_columns: :auto
|
|
19
|
+
extra_columns: nil, picker: false, picked_columns: :auto, extra_actions: nil,
|
|
20
|
+
except_actions: nil)
|
|
20
21
|
super(view: view)
|
|
21
22
|
unless records.respond_to?(:klass)
|
|
22
23
|
raise ArgumentError,
|
|
@@ -31,6 +32,8 @@ module CrudComponents
|
|
|
31
32
|
@layout = layout
|
|
32
33
|
@param_prefix = param_prefix
|
|
33
34
|
@actions_enabled = actions
|
|
35
|
+
@extra_actions = Array(extra_actions)
|
|
36
|
+
@except_actions = Array(except_actions).map(&:to_sym)
|
|
34
37
|
@search_bar_enabled = search_bar
|
|
35
38
|
# Two orthogonal column-picker knobs (see ColumnSelection): the gear is on
|
|
36
39
|
# iff `picker`; the selection comes from the param (`:auto`) or verbatim
|
|
@@ -385,7 +388,7 @@ module CrudComponents
|
|
|
385
388
|
return nil unless @actions_enabled
|
|
386
389
|
|
|
387
390
|
@collection_actions ||= Actions.new(view: view, subject: model, structure: structure,
|
|
388
|
-
actions: structure.fieldset_actions(fieldset, on: :collection),
|
|
391
|
+
actions: keep(structure.fieldset_actions(fieldset, on: :collection)),
|
|
389
392
|
owner: owner)
|
|
390
393
|
end
|
|
391
394
|
|
|
@@ -398,7 +401,8 @@ module CrudComponents
|
|
|
398
401
|
return nil unless @actions_enabled
|
|
399
402
|
|
|
400
403
|
@selection_actions ||= Actions.new(view: view, subject: model, structure: structure,
|
|
401
|
-
actions: structure.fieldset_actions(fieldset, on: :selection)
|
|
404
|
+
actions: keep(structure.fieldset_actions(fieldset, on: :selection)) +
|
|
405
|
+
@extra_actions.select { |action| action.on == :selection },
|
|
402
406
|
owner: owner)
|
|
403
407
|
end
|
|
404
408
|
|
|
@@ -489,7 +493,15 @@ module CrudComponents
|
|
|
489
493
|
end
|
|
490
494
|
|
|
491
495
|
def row_action_definitions
|
|
492
|
-
@row_action_definitions ||= structure.fieldset_actions(fieldset, on: :row)
|
|
496
|
+
@row_action_definitions ||= keep(structure.fieldset_actions(fieldset, on: :row)) +
|
|
497
|
+
@extra_actions.select { |action| action.on == :row }
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Actions this render drops, whatever the model declares.
|
|
501
|
+
def keep(actions)
|
|
502
|
+
return actions if @except_actions.empty?
|
|
503
|
+
|
|
504
|
+
actions.reject { |action| @except_actions.include?(action.name) }
|
|
493
505
|
end
|
|
494
506
|
|
|
495
507
|
def pn(key)
|
|
@@ -7,13 +7,15 @@ module CrudComponents
|
|
|
7
7
|
attr_reader :record, :model, :structure, :fieldset, :param_prefix
|
|
8
8
|
|
|
9
9
|
def initialize(view:, record:, fieldset: nil, actions: true, picked_columns: :auto,
|
|
10
|
-
param_prefix: nil, extra_columns: nil)
|
|
10
|
+
param_prefix: nil, extra_columns: nil, extra_actions: nil, except_actions: nil)
|
|
11
11
|
super(view: view)
|
|
12
12
|
@record = record
|
|
13
13
|
@model = record.class
|
|
14
14
|
@structure = Structure.for(@model)
|
|
15
15
|
@fieldset = @structure.fieldset(fieldset || :show)
|
|
16
16
|
@actions_enabled = actions
|
|
17
|
+
@extra_actions = Array(extra_actions).select { |action| action.on == :row }
|
|
18
|
+
@except_actions = Array(except_actions).map(&:to_sym)
|
|
17
19
|
@param_prefix = param_prefix
|
|
18
20
|
# Dynamic columns work on a detail view too — user-defined properties
|
|
19
21
|
# whose data lives outside the model's table, shown as extra rows.
|
|
@@ -49,9 +51,19 @@ module CrudComponents
|
|
|
49
51
|
return nil unless @actions_enabled
|
|
50
52
|
|
|
51
53
|
@actions ||= Actions.new(view: view, subject: record, structure: structure,
|
|
52
|
-
actions:
|
|
54
|
+
actions: declared_row_actions + @extra_actions,
|
|
53
55
|
suppress_show: true)
|
|
54
56
|
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Actions this render drops, whatever the model declares.
|
|
61
|
+
def declared_row_actions
|
|
62
|
+
actions = structure.fieldset_actions(fieldset, on: :row)
|
|
63
|
+
return actions if @except_actions.empty?
|
|
64
|
+
|
|
65
|
+
actions.reject { |action| @except_actions.include?(action.name) }
|
|
66
|
+
end
|
|
55
67
|
end
|
|
56
68
|
end
|
|
57
69
|
end
|
|
@@ -7,10 +7,7 @@ module CrudComponents
|
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
9
|
def action_path(view, action, record: nil, model: nil, owner: nil)
|
|
10
|
-
if action.path_block
|
|
11
|
-
subject = record || model
|
|
12
|
-
return view.instance_exec(subject, &action.path_block)
|
|
13
|
-
end
|
|
10
|
+
return safe_block(view, record || model, action.path_block) if action.path_block
|
|
14
11
|
|
|
15
12
|
if action.collection?
|
|
16
13
|
collection_path(view, action, model, owner)
|
|
@@ -19,6 +16,20 @@ module CrudComponents
|
|
|
19
16
|
end
|
|
20
17
|
end
|
|
21
18
|
|
|
19
|
+
# The host application's own page for a record — the same candidate logic,
|
|
20
|
+
# resolved against `main_app` instead of the current route set. A declared
|
|
21
|
+
# `app_path` block wins. nil when nothing resolves.
|
|
22
|
+
def app_path(view, record)
|
|
23
|
+
structure = Structure.for(record.class)
|
|
24
|
+
if (block = structure.app_path_block)
|
|
25
|
+
return safe_block(view, record, block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return nil unless view.respond_to?(:main_app)
|
|
29
|
+
|
|
30
|
+
try_helpers(view.main_app, member_candidates(nil, record, nil))
|
|
31
|
+
end
|
|
32
|
+
|
|
22
33
|
# The plain link to a record (label cells, association cells):
|
|
23
34
|
# show route, then edit. Returns [path, kind] or nil.
|
|
24
35
|
def record_path(view, record, owner: nil)
|
|
@@ -79,6 +90,24 @@ module CrudComponents
|
|
|
79
90
|
nil
|
|
80
91
|
end
|
|
81
92
|
|
|
93
|
+
# A path block naming a route helper that does not exist here — the same
|
|
94
|
+
# page rendered under a mount that lacks it — yields nil, so the button is
|
|
95
|
+
# omitted. Anything else the block gets wrong is the app's own error and
|
|
96
|
+
# stays loud.
|
|
97
|
+
def safe_block(view, subject, block)
|
|
98
|
+
view.instance_exec(subject, &block)
|
|
99
|
+
rescue ActionController::UrlGenerationError
|
|
100
|
+
nil
|
|
101
|
+
rescue NameError => e
|
|
102
|
+
raise unless missing_route_helper?(e)
|
|
103
|
+
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def missing_route_helper?(error)
|
|
108
|
+
error.name.to_s.end_with?('_path', '_url')
|
|
109
|
+
end
|
|
110
|
+
|
|
82
111
|
def safe_url(view, helper, *args, **kwargs)
|
|
83
112
|
kwargs.empty? ? view.public_send(helper, *args) : view.public_send(helper, *args, **kwargs)
|
|
84
113
|
rescue ActionController::UrlGenerationError, NoMethodError
|
|
@@ -107,12 +136,13 @@ module CrudComponents
|
|
|
107
136
|
candidates
|
|
108
137
|
end
|
|
109
138
|
|
|
110
|
-
|
|
139
|
+
# `helpers` is anything exposing route helpers — the view, or `main_app`.
|
|
140
|
+
def try_helpers(helpers, candidates)
|
|
111
141
|
candidates.each do |helper, args|
|
|
112
|
-
next unless
|
|
142
|
+
next unless helpers.respond_to?(helper)
|
|
113
143
|
|
|
114
144
|
begin
|
|
115
|
-
return
|
|
145
|
+
return helpers.public_send(helper, *args)
|
|
116
146
|
rescue ActionController::UrlGenerationError, NoMethodError
|
|
117
147
|
next
|
|
118
148
|
end
|
|
@@ -42,7 +42,9 @@ module CrudComponents
|
|
|
42
42
|
# identity_preloads: associations to eager-load whenever this model is shown
|
|
43
43
|
# as another model's association cell (its label/render dependencies), from
|
|
44
44
|
# `label …, preload:` and the standalone `preload` declaration.
|
|
45
|
-
|
|
45
|
+
# admin_options: the `admin` declaration — a Hash of options, `false` when
|
|
46
|
+
# the model opted out, nil when it said nothing.
|
|
47
|
+
attr_reader :model, :identify_by, :identity_preloads, :admin_options, :app_path_block
|
|
46
48
|
|
|
47
49
|
def initialize(model, builder = nil)
|
|
48
50
|
@model = model
|
|
@@ -50,6 +52,8 @@ module CrudComponents
|
|
|
50
52
|
@label_decl = builder&.label_decl
|
|
51
53
|
@identify_by = builder&.identify_by_decl || :id
|
|
52
54
|
@icon_decl = builder&.icon_decl
|
|
55
|
+
@admin_options = builder&.admin_decl
|
|
56
|
+
@app_path_block = builder&.app_path_decl
|
|
53
57
|
@search_decl = builder&.search_decl
|
|
54
58
|
@identity_preloads = ((builder&.label_preload_decl || []) + (builder&.preload_decl || [])).uniq
|
|
55
59
|
@declared_actions = builder&.actions || {}
|
|
@@ -117,6 +121,9 @@ module CrudComponents
|
|
|
117
121
|
@default_fieldset ||= Fieldset.new(:default, :all)
|
|
118
122
|
end
|
|
119
123
|
|
|
124
|
+
# The fieldsets this model actually declared (no derived defaults).
|
|
125
|
+
def declared_fieldset_names = @declared_fieldsets.keys
|
|
126
|
+
|
|
120
127
|
def fieldset_fields(fieldset)
|
|
121
128
|
names = fieldset.all_fields? ? default_field_names : fieldset.field_names
|
|
122
129
|
names.map { |name| field(name) }
|
|
@@ -172,6 +179,13 @@ module CrudComponents
|
|
|
172
179
|
label_source.is_a?(Symbol) ? label_source : nil
|
|
173
180
|
end
|
|
174
181
|
|
|
182
|
+
# The label as a real database column, else nil (block label, or a Symbol
|
|
183
|
+
# naming a computed method). Only a column can be sorted or LIKE-matched.
|
|
184
|
+
def label_column_name
|
|
185
|
+
name = label_field_name
|
|
186
|
+
name if name && model.column_names.include?(name.to_s)
|
|
187
|
+
end
|
|
188
|
+
|
|
175
189
|
# The icon name (no library prefix) badging this model: the declared `icon`,
|
|
176
190
|
# else the name-based guess in `config.model_icons` (keyed by the singular
|
|
177
191
|
# underscored model name), else `config.model_fallback_icon` (nil = none).
|
data/lib/crud_components.rb
CHANGED
|
@@ -54,6 +54,7 @@ require_relative 'crud_components/builder'
|
|
|
54
54
|
require_relative 'crud_components/structure'
|
|
55
55
|
require_relative 'crud_components/model'
|
|
56
56
|
require_relative 'crud_components/query'
|
|
57
|
+
require_relative 'crud_components/admin'
|
|
57
58
|
|
|
58
59
|
module CrudComponents
|
|
59
60
|
class << self
|
|
@@ -166,3 +167,4 @@ module CrudComponents
|
|
|
166
167
|
end
|
|
167
168
|
|
|
168
169
|
require_relative 'crud_components/engine' if defined?(Rails::Engine)
|
|
170
|
+
require_relative 'crud_components/admin/engine' if defined?(Rails::Engine)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: crud_components
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anatoly Zelenin
|
|
@@ -82,6 +82,10 @@ files:
|
|
|
82
82
|
- RELEASING.md
|
|
83
83
|
- Rakefile
|
|
84
84
|
- app/assets/stylesheets/crud_components.css
|
|
85
|
+
- app/assets/stylesheets/crud_components_admin.css
|
|
86
|
+
- app/controllers/crud_components/admin/application_controller.rb
|
|
87
|
+
- app/controllers/crud_components/admin/dashboard_controller.rb
|
|
88
|
+
- app/controllers/crud_components/admin/resources_controller.rb
|
|
85
89
|
- app/views/crud_components/_action_button.html.erb
|
|
86
90
|
- app/views/crud_components/_actions.html.erb
|
|
87
91
|
- app/views/crud_components/_column_header.html.erb
|
|
@@ -94,6 +98,14 @@ files:
|
|
|
94
98
|
- app/views/crud_components/_selection_action.html.erb
|
|
95
99
|
- app/views/crud_components/_sort_link.html.erb
|
|
96
100
|
- app/views/crud_components/_toolbar.html.erb
|
|
101
|
+
- app/views/crud_components/admin/_sidebar.html.erb
|
|
102
|
+
- app/views/crud_components/admin/dashboard/show.html.erb
|
|
103
|
+
- app/views/crud_components/admin/resources/_dependent_records.html.erb
|
|
104
|
+
- app/views/crud_components/admin/resources/delete.html.erb
|
|
105
|
+
- app/views/crud_components/admin/resources/edit.html.erb
|
|
106
|
+
- app/views/crud_components/admin/resources/index.html.erb
|
|
107
|
+
- app/views/crud_components/admin/resources/new.html.erb
|
|
108
|
+
- app/views/crud_components/admin/resources/show.html.erb
|
|
97
109
|
- app/views/crud_components/fields/_asciidoc.html.erb
|
|
98
110
|
- app/views/crud_components/fields/_association.html.erb
|
|
99
111
|
- app/views/crud_components/fields/_association_list.html.erb
|
|
@@ -129,9 +141,11 @@ files:
|
|
|
129
141
|
- app/views/crud_components/form_fields/_string.html.erb
|
|
130
142
|
- app/views/crud_components/form_fields/_text.html.erb
|
|
131
143
|
- app/views/crud_components/layouts/_table.html.erb
|
|
144
|
+
- app/views/layouts/crud_components/admin.html.erb
|
|
132
145
|
- config/locales/crud_components.de.yml
|
|
133
146
|
- config/locales/crud_components.en.yml
|
|
134
147
|
- crud_components.gemspec
|
|
148
|
+
- docs/admin.md
|
|
135
149
|
- docs/extending.md
|
|
136
150
|
- docs/fields.md
|
|
137
151
|
- docs/filtering.md
|
|
@@ -141,6 +155,15 @@ files:
|
|
|
141
155
|
- docs/views.md
|
|
142
156
|
- lib/crud_components.rb
|
|
143
157
|
- lib/crud_components/action.rb
|
|
158
|
+
- lib/crud_components/admin.rb
|
|
159
|
+
- lib/crud_components/admin/configuration.rb
|
|
160
|
+
- lib/crud_components/admin/dependents.rb
|
|
161
|
+
- lib/crud_components/admin/engine.rb
|
|
162
|
+
- lib/crud_components/admin/entry.rb
|
|
163
|
+
- lib/crud_components/admin/gate.rb
|
|
164
|
+
- lib/crud_components/admin/registry.rb
|
|
165
|
+
- lib/crud_components/admin/routes.rb
|
|
166
|
+
- lib/crud_components/admin/view_helpers.rb
|
|
144
167
|
- lib/crud_components/builder.rb
|
|
145
168
|
- lib/crud_components/config.rb
|
|
146
169
|
- lib/crud_components/dynamic_column.rb
|