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,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 :full_title, preload: %i[customer training]`). They're
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 :customer, :training`.
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 (`{ customer: :company }`) intact (Array() would split it).
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.label_field_name
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. { order: %i[customer training] }.
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
- col = target_structure.label_field_name
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.label_field_name
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.label_field_name
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.label_field_name.present?
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 :order, label: ->(o) { o.full_title(short: true) }`)
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).label_field_name
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, not a columnspell the columns out, " \
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: structure.fieldset_actions(fieldset, on: :row),
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
- def try_helpers(view, candidates)
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 view.respond_to?(helper)
142
+ next unless helpers.respond_to?(helper)
113
143
 
114
144
  begin
115
- return view.public_send(helper, *args)
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
- attr_reader :model, :identify_by, :identity_preloads
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).
@@ -1,3 +1,3 @@
1
1
  module CrudComponents
2
- VERSION = '0.2.0'.freeze
2
+ VERSION = '0.3.0'.freeze
3
3
  end
@@ -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.2.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