layered-resource-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
  3. data/AGENTS.md +36 -0
  4. data/CHANGELOG.md +45 -0
  5. data/CLA.md +10 -0
  6. data/LICENSE +201 -0
  7. data/NOTICE +7 -0
  8. data/README.md +912 -0
  9. data/Rakefile +23 -0
  10. data/TRADEMARK.md +31 -0
  11. data/app/controllers/layered/resource/controller.rb +284 -0
  12. data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
  13. data/app/controllers/layered/resource/internal/columns.rb +207 -0
  14. data/app/controllers/layered/resource/internal/routing.rb +60 -0
  15. data/app/controllers/layered/resource/resources_controller.rb +20 -0
  16. data/app/helpers/layered/resource/filters_helper.rb +321 -0
  17. data/app/views/layered/resource/columns/_badge.html.erb +2 -0
  18. data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
  19. data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
  20. data/app/views/layered/resource/columns/_text.html.erb +1 -0
  21. data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
  22. data/app/views/layered/resource/resources/_filters.html.erb +60 -0
  23. data/app/views/layered/resource/resources/edit.html.erb +16 -0
  24. data/app/views/layered/resource/resources/index.html.erb +106 -0
  25. data/app/views/layered/resource/resources/new.html.erb +16 -0
  26. data/app/views/layered/resource/resources/show.html.erb +34 -0
  27. data/config/locales/en.yml +9 -0
  28. data/lib/generators/layered/resource/column/column_generator.rb +63 -0
  29. data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
  30. data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
  31. data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
  32. data/lib/generators/layered/resource/resource_generator.rb +63 -0
  33. data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
  34. data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
  35. data/lib/generators/layered/resource/views/views_generator.rb +49 -0
  36. data/lib/layered/resource/base.rb +625 -0
  37. data/lib/layered/resource/engine.rb +33 -0
  38. data/lib/layered/resource/routing.rb +366 -0
  39. data/lib/layered/resource/version.rb +5 -0
  40. data/lib/layered/resource.rb +61 -0
  41. data/lib/layered-resource-rails.rb +1 -0
  42. metadata +299 -0
@@ -0,0 +1,366 @@
1
+ require "concurrent/map"
2
+
3
+ module Layered
4
+ module Resource
5
+ module Routing
6
+ @registry = Concurrent::Map.new
7
+
8
+ class << self
9
+ def register(route_key, resource_class_name, actions: [], routes: nil, parent_params: [], parent_collection_keys: {}, resource_name: nil, member_actions: [], collection_actions: [])
10
+ @registry[route_key.to_s] = {
11
+ resource: resource_class_name.to_s,
12
+ actions: actions,
13
+ routes: routes,
14
+ parent_params: parent_params,
15
+ parent_collection_keys: parent_collection_keys,
16
+ resource_name: resource_name.to_s,
17
+ member_actions: member_actions,
18
+ collection_actions: collection_actions
19
+ }
20
+ end
21
+
22
+ def clear!
23
+ @registry = Concurrent::Map.new
24
+ end
25
+
26
+ def lookup(route_key)
27
+ @registry.fetch(route_key.to_s, nil)
28
+ end
29
+ end
30
+
31
+ RESOURCE_ACTIONS = %i[index show new create edit update destroy].freeze
32
+
33
+ # Collects custom member/collection routes declared inside a
34
+ # `layered_resources` block. Mirrors the small subset of Rails'
35
+ # `resources` block DSL we care about: nested `member do ... end` /
36
+ # `collection do ... end` containing HTTP-verb action declarations.
37
+ class CustomActionsBuilder
38
+ VERBS = %i[get post patch put delete].freeze
39
+
40
+ attr_reader :member_actions, :collection_actions
41
+
42
+ def initialize
43
+ @member_actions = []
44
+ @collection_actions = []
45
+ @scope = nil
46
+ end
47
+
48
+ def member(&block)
49
+ previous, @scope = @scope, :member
50
+ instance_eval(&block)
51
+ ensure
52
+ @scope = previous
53
+ end
54
+
55
+ def collection(&block)
56
+ previous, @scope = @scope, :collection
57
+ instance_eval(&block)
58
+ ensure
59
+ @scope = previous
60
+ end
61
+
62
+ VERBS.each do |verb|
63
+ define_method(verb) do |action_name|
64
+ unless @scope
65
+ raise ArgumentError,
66
+ "#{verb} :#{action_name} declared outside member/collection block in layered_resources"
67
+ end
68
+ target = @scope == :member ? @member_actions : @collection_actions
69
+ target << { verb: verb, action: action_name.to_sym }
70
+ end
71
+ end
72
+
73
+ def method_missing(name, *_args, &_block)
74
+ raise ArgumentError,
75
+ "`#{name}` is not supported inside a layered_resources block. " \
76
+ "Only `member`, `collection`, and HTTP verbs (#{VERBS.join(', ')}) are available; " \
77
+ "declare other routes outside the block."
78
+ end
79
+
80
+ def respond_to_missing?(_name, _include_private = false)
81
+ false
82
+ end
83
+ end
84
+
85
+ def layered_resources(resource_name, resource: nil, controller: nil, namespace: nil, only: RESOURCE_ACTIONS, except: nil, **options, &block)
86
+ # When called inside `resources :foo do ... end` (or `resource :foo do`),
87
+ # Rails has set up a resource_scope but hasn't pushed the parent's
88
+ # path into @scope. Push it ourselves via scope(path:) and recurse.
89
+ # We also null out the inherited @scope[:as] for the recursion so our
90
+ # full as_base (computed from path params, e.g. "user_posts") composes
91
+ # correctly — Rails' :nested scope_level orders `[name_prefix, prefix]`,
92
+ # which would otherwise turn `as: :new_user_post` into `:user_new_user_post`
93
+ # when name_prefix is already "user" from an outer `resources :users do`.
94
+ #
95
+ # Note: this branch leans on Rails-internal Mapper APIs
96
+ # (`with_scope_level`, `@scope[:scope_level_resource]`, `Resource#nested_scope`,
97
+ # direct `@scope.frame[:as]` mutation). Verified against Rails 8.x; if a
98
+ # future Rails release renames or removes any of these, the integration
99
+ # test "layered_resources inside resources :foo do block …" will fail at
100
+ # boot and this block needs revisiting.
101
+ if @scope.resource_scope?
102
+ parent = @scope[:scope_level_resource]
103
+ return send(:with_scope_level, :nested) do
104
+ scope(path: parent.nested_scope) do
105
+ saved_as = @scope.frame[:as]
106
+ @scope.frame[:as] = nil
107
+ begin
108
+ layered_resources(resource_name,
109
+ resource: resource, controller: controller, namespace: namespace,
110
+ only: only, except: except, **options, &block)
111
+ ensure
112
+ @scope.frame[:as] = saved_as
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # `namespace:` is explicit-only — passing "Layered::Assistant"
119
+ # derives the resource class as `Layered::Assistant::PostResource`
120
+ # and routes to `Layered::Assistant::ResourcesController` (when
121
+ # defined). We don't auto-infer from a surrounding `namespace :foo`
122
+ # block because Rails composes URL-helper names differently there
123
+ # than the gem-shipped views expect; for that pattern, use
124
+ # `scope path: "foo", module: "foo"` and pass `namespace:` here.
125
+ namespace = namespace.to_s.presence
126
+
127
+ resource_class_name = resource ||
128
+ (namespace ? "#{namespace}::#{resource_name.to_s.classify}Resource" : "#{resource_name.to_s.classify}Resource")
129
+ route_key = resource_name.to_s
130
+ singular_key = resource_name.to_s.singularize
131
+
132
+ raw_scope_path = @scope[:path].to_s
133
+ parent_params = raw_scope_path.scan(/:([a-zA-Z_]\w*)/).flatten.map(&:to_sym)
134
+
135
+ # For each parent param, compute the route key its collection would
136
+ # have been registered under (e.g. in scope "orgs/:org_id/users/:user_id",
137
+ # :user_id maps to the registry key "org_users"). Used by breadcrumbs
138
+ # and link: columns to find the parent's resource entry.
139
+ segments = raw_scope_path.split("/")
140
+ parent_collection_keys = {}
141
+ accumulated_param_prefix = []
142
+ segments.each_with_index do |seg, i|
143
+ next unless seg.start_with?(":")
144
+ param = seg.delete_prefix(":").to_sym
145
+ # Rails-standard prefix for a nested resource: each parent param
146
+ # contributes its singularised stem (`:user_id` → "user").
147
+ parent_collection_keys[param] = [*accumulated_param_prefix, segments[i - 1]].compact.join("_") if i > 0
148
+ accumulated_param_prefix << param.to_s.delete_suffix("_id")
149
+ end
150
+
151
+ # Build the route's :as following Rails' nested-resources convention
152
+ # so polymorphic_path([@user, :posts]) finds the route. Parents
153
+ # contribute their singularised stem (`:user_id` → "user"); extra
154
+ # static segments that aren't a parent's collection name (e.g.
155
+ # `admin` in `users/:user_id/admin`) prefix the result for
156
+ # disambiguation.
157
+ parent_collection_segments = segments.each_with_index
158
+ .select { |seg, i| i > 0 && seg.start_with?(":") }
159
+ .map { |_, i| segments[i - 1] }
160
+ extra_static = segments.reject { |s| s.empty? || s.start_with?(":") || parent_collection_segments.include?(s) }
161
+ parent_prefix = parent_params.map { |p| p.to_s.delete_suffix("_id") }.join("_")
162
+ as_prefix = [extra_static.join("_").presence, parent_prefix.presence].compact.join("_")
163
+ as_base = [as_prefix.presence, route_key].compact.join("_")
164
+ as_singular = [as_prefix.presence, singular_key].compact.join("_")
165
+
166
+ # We derive our own helper names from the path; a surrounding `as:`
167
+ # that disagrees would be silently ignored, so warn (it's nulled
168
+ # below before we declare routes).
169
+ user_as = @scope[:as].to_s
170
+ if user_as.present? && user_as != as_prefix
171
+ warn "[layered-resource-rails] layered_resources :#{resource_name} ignores the " \
172
+ "surrounding `as: #{user_as.inspect}` — it derives route-helper names from the " \
173
+ "path instead, so your helpers are named `#{as_base}_path`, `new_#{as_singular}_path`, " \
174
+ "etc. Drop the `as:` to silence this (the `path:`/scope segments already namespace " \
175
+ "the helpers)."
176
+ end
177
+ as_to_pass = as_base
178
+ singular_as_to_pass = as_singular
179
+
180
+ controller_override = controller
181
+ # Resolution order:
182
+ # 1. explicit `controller:` wins (legacy escape hatch).
183
+ # 2. if a namespace is in play AND the host has defined
184
+ # <Namespace>::ResourcesController (e.g.
185
+ # `Layered::Assistant::ResourcesController` including
186
+ # `Layered::Resource::Controller`), route to that — this is
187
+ # what lets engines wire the controller into their own
188
+ # ApplicationController for auth/authorize before_actions.
189
+ # 3. otherwise fall back to the default. Use a leading "/" when
190
+ # inside a module scope so Rails treats the path as absolute
191
+ # and doesn't prepend the engine's module to it.
192
+ controller = if controller
193
+ controller.to_s
194
+ elsif namespace && "#{namespace}::ResourcesController".safe_constantize
195
+ # Leading slash makes this absolute so a surrounding
196
+ # `module:` scope doesn't prepend its own namespace
197
+ # in front. Rails 8.1 rejects leading slashes in the
198
+ # `controller:` validation, so only add it when
199
+ # we're actually inside a module scope.
200
+ @scope[:module] ? "/#{namespace.underscore}/resources" : "#{namespace.underscore}/resources"
201
+ elsif @scope[:module]
202
+ "/layered/resource/resources"
203
+ else
204
+ "layered/resource/resources"
205
+ end
206
+ actions = Array(only).map(&:to_sym)
207
+ actions -= Array(except).map(&:to_sym) if except
208
+
209
+ if (actions & %i[new create]).any? && !actions.include?(:index)
210
+ raise ArgumentError,
211
+ "layered_resources :#{resource_name} includes :new or :create without :index. " \
212
+ "The form actions require a collection route; add :index to only:."
213
+ end
214
+
215
+ if actions.include?(:new) && !actions.include?(:create)
216
+ raise ArgumentError,
217
+ "layered_resources :#{resource_name} includes :new without :create. " \
218
+ "The new form posts to the collection route; add :create to only:."
219
+ end
220
+
221
+ if actions.include?(:edit) && !actions.include?(:update)
222
+ raise ArgumentError,
223
+ "layered_resources :#{resource_name} includes :edit without :update. " \
224
+ "The edit form patches the member route; add :update to only:."
225
+ end
226
+
227
+ if actions.include?(:update) && !actions.include?(:index)
228
+ raise ArgumentError,
229
+ "layered_resources :#{resource_name} includes :update without :index. " \
230
+ "Update redirects to the collection route; add :index to only:."
231
+ end
232
+
233
+ if actions.include?(:destroy) && !actions.include?(:index)
234
+ raise ArgumentError,
235
+ "layered_resources :#{resource_name} includes :destroy without :index. " \
236
+ "Destroy redirects to the collection route; add :index to only:."
237
+ end
238
+
239
+ custom_member = []
240
+ custom_collection = []
241
+ if block
242
+ unless controller_override
243
+ raise ArgumentError,
244
+ "layered_resources :#{resource_name} declared a block of custom actions " \
245
+ "but no controller: override. Generate one with " \
246
+ "`rails g layered:resource:controller #{resource_name}` and pass " \
247
+ "controller: \"#{resource_name}\"."
248
+ end
249
+
250
+ builder = CustomActionsBuilder.new
251
+ builder.instance_eval(&block)
252
+ custom_member = builder.member_actions
253
+ custom_collection = builder.collection_actions
254
+
255
+ # Path collisions with built-ins: collection :new shares
256
+ # /<route_key>/new, and member :edit shares /<route_key>/:id/edit.
257
+ # Other CRUD names live on different paths (:show is /:id, not
258
+ # /:id/show; :create is POST /<key>, not /<key>/create) so they
259
+ # don't collide. Built-in routes are declared first, so without
260
+ # these guards a custom :edit/:new would silently lose the
261
+ # dispatch race. Only flag when the colliding built-in is
262
+ # actually enabled (respect except:/only:).
263
+ if custom_collection.any? { |a| a[:action] == :new } && actions.include?(:new)
264
+ raise ArgumentError,
265
+ "layered_resources :#{resource_name} declares collection :new, " \
266
+ "which collides with the built-in /#{route_key}/new route. " \
267
+ "Rename it or pass `except: [:new]`."
268
+ end
269
+
270
+ if custom_member.any? { |a| a[:action] == :edit } && actions.include?(:edit)
271
+ raise ArgumentError,
272
+ "layered_resources :#{resource_name} declares member :edit, " \
273
+ "which collides with the built-in /#{route_key}/:id/edit route. " \
274
+ "Rename it or pass `except: [:edit]`."
275
+ end
276
+ end
277
+
278
+ Layered::Resource::Routing.register(as_base, resource_class_name,
279
+ actions: actions,
280
+ routes: @set,
281
+ parent_params: parent_params,
282
+ parent_collection_keys: parent_collection_keys,
283
+ resource_name: route_key,
284
+ member_actions: custom_member.map { |a| a[:action] },
285
+ collection_actions: custom_collection.map { |a| a[:action] })
286
+
287
+ route_defaults = (options[:defaults] || {}).merge(
288
+ _layered_resource_route_key: as_base
289
+ )
290
+ options = options.except(:defaults, :as)
291
+
292
+ # Null the scope `:as` so Rails doesn't prepend it to the full names
293
+ # we declare below (`new_manage_post`, not `manage_new_post`);
294
+ # restored in the ensure so sibling routes keep their prefix.
295
+ saved_scope_as = @scope.frame[:as]
296
+ @scope.frame[:as] = nil
297
+ begin
298
+ if actions.include?(:index)
299
+ get route_key, to: "#{controller}#index",
300
+ as: as_to_pass.to_sym,
301
+ defaults: route_defaults, **options
302
+ end
303
+
304
+ if actions.include?(:new)
305
+ get "#{route_key}/new", to: "#{controller}#new",
306
+ as: :"new_#{singular_as_to_pass}",
307
+ defaults: route_defaults, **options
308
+ end
309
+
310
+ if actions.include?(:create)
311
+ post route_key, to: "#{controller}#create",
312
+ as: nil,
313
+ defaults: route_defaults, **options
314
+ end
315
+
316
+ # Custom collection routes must be declared before member `:id` routes
317
+ # so that paths like `/posts/bulk_archive` don't get shadowed by
318
+ # `/posts/:id` (which would otherwise dispatch to #show with
319
+ # id: "bulk_archive").
320
+ custom_collection.each do |route|
321
+ public_send(route[:verb], "#{route_key}/#{route[:action]}",
322
+ to: "#{controller}##{route[:action]}",
323
+ as: :"#{route[:action]}_#{as_to_pass}",
324
+ defaults: route_defaults, **options)
325
+ end
326
+
327
+ if actions.include?(:edit)
328
+ get "#{route_key}/:id/edit", to: "#{controller}#edit",
329
+ as: :"edit_#{singular_as_to_pass}",
330
+ defaults: route_defaults, **options
331
+ end
332
+
333
+ member_named = false
334
+ if actions.include?(:show)
335
+ get "#{route_key}/:id", to: "#{controller}#show",
336
+ as: singular_as_to_pass.to_sym,
337
+ defaults: route_defaults, **options
338
+ member_named = true
339
+ end
340
+
341
+ if actions.include?(:update)
342
+ update_opts = { to: "#{controller}#update", defaults: route_defaults, **options }
343
+ update_opts[:as] = member_named ? nil : singular_as_to_pass.to_sym
344
+ patch "#{route_key}/:id", **update_opts
345
+ member_named = true
346
+ end
347
+
348
+ if actions.include?(:destroy)
349
+ destroy_opts = { to: "#{controller}#destroy", defaults: route_defaults, **options }
350
+ destroy_opts[:as] = member_named ? nil : singular_as_to_pass.to_sym
351
+ delete "#{route_key}/:id", **destroy_opts
352
+ end
353
+
354
+ custom_member.each do |route|
355
+ public_send(route[:verb], "#{route_key}/:id/#{route[:action]}",
356
+ to: "#{controller}##{route[:action]}",
357
+ as: :"#{route[:action]}_#{singular_as_to_pass}",
358
+ defaults: route_defaults, **options)
359
+ end
360
+ ensure
361
+ @scope.frame[:as] = saved_scope_as
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,5 @@
1
+ module Layered
2
+ module Resource
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,61 @@
1
+ require "layered-ui-rails"
2
+ require "ransack"
3
+ require "pagy"
4
+ require "layered/resource/version"
5
+ require "layered/resource/base"
6
+ require "layered/resource/routing"
7
+ require "layered/resource/engine"
8
+
9
+ module Layered
10
+ module Resource
11
+ # Raised when `owned_by` resolves a nil owner without `allow_nil: true`.
12
+ # Surfaces auth misconfiguration loudly instead of silently 404ing every
13
+ # request.
14
+ class MissingOwnerError < StandardError; end
15
+
16
+ # Attributes tried, in order, when labelling a record for which no
17
+ # labelling attribute is known - a `belongs_to` filter's options, say,
18
+ # where the associated model has no resource of its own to ask.
19
+ LABEL_CANDIDATES = %i[name title label email].freeze
20
+
21
+ class << self
22
+ # Renders a record as a human label: the given `attribute` when it has a
23
+ # value, else the first LABEL_CANDIDATES attribute that does, else the
24
+ # record's own `to_s` when its model defines one, else "Model #id".
25
+ #
26
+ # The single implementation behind `Resource::Base.record_label` (which
27
+ # supplies the resource's `label_attribute`), the controller's
28
+ # `layered_record_label` helper, and the labels a select-type filter
29
+ # gives the records in its collection.
30
+ def record_label(record, attribute: nil)
31
+ candidates = [attribute, *LABEL_CANDIDATES].compact.uniq
32
+
33
+ candidates.each do |candidate|
34
+ next unless record.respond_to?(candidate)
35
+
36
+ value = record.public_send(candidate)
37
+ return value.to_s if value.present?
38
+ end
39
+
40
+ # Kernel#to_s is the default `#<Post:0x...>`, which is no label at all;
41
+ # a model that has defined its own is saying what it should read as.
42
+ return record.to_s if record.method(:to_s).owner != Kernel
43
+
44
+ "#{record.model_name.human} ##{record.id}"
45
+ end
46
+ end
47
+
48
+ # When true (the default), the controller calls Resource.configure_ransack
49
+ # on the active resource's model the first time it's used. Set to false
50
+ # if your app already manages ransackable_attributes / ransackable_associations
51
+ # on the model and you don't want the gem to redefine them.
52
+ mattr_accessor :auto_configure_ransack, default: true
53
+
54
+ # How many options a select-type filter may have before its control
55
+ # switches from the plain list (checkboxes, or instant-apply links when
56
+ # single-choice) to a type-ahead combobox. Short lists are quicker to scan
57
+ # and click than to type into; long ones are unusable that way. A filter
58
+ # that names its own `as:` is unaffected.
59
+ mattr_accessor :filter_combobox_threshold, default: 10
60
+ end
61
+ end
@@ -0,0 +1 @@
1
+ require "layered/resource"