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.
- checksums.yaml +7 -0
- data/.claude/skills/layered-resource-rails/SKILL.md +449 -0
- data/AGENTS.md +36 -0
- data/CHANGELOG.md +45 -0
- data/CLA.md +10 -0
- data/LICENSE +201 -0
- data/NOTICE +7 -0
- data/README.md +912 -0
- data/Rakefile +23 -0
- data/TRADEMARK.md +31 -0
- data/app/controllers/layered/resource/controller.rb +284 -0
- data/app/controllers/layered/resource/internal/breadcrumbs.rb +80 -0
- data/app/controllers/layered/resource/internal/columns.rb +207 -0
- data/app/controllers/layered/resource/internal/routing.rb +60 -0
- data/app/controllers/layered/resource/resources_controller.rb +20 -0
- data/app/helpers/layered/resource/filters_helper.rb +321 -0
- data/app/views/layered/resource/columns/_badge.html.erb +2 -0
- data/app/views/layered/resource/columns/_boolean.html.erb +1 -0
- data/app/views/layered/resource/columns/_datetime.html.erb +1 -0
- data/app/views/layered/resource/columns/_text.html.erb +1 -0
- data/app/views/layered/resource/resources/_filter_control.html.erb +87 -0
- data/app/views/layered/resource/resources/_filters.html.erb +60 -0
- data/app/views/layered/resource/resources/edit.html.erb +16 -0
- data/app/views/layered/resource/resources/index.html.erb +106 -0
- data/app/views/layered/resource/resources/new.html.erb +16 -0
- data/app/views/layered/resource/resources/show.html.erb +34 -0
- data/config/locales/en.yml +9 -0
- data/lib/generators/layered/resource/column/column_generator.rb +63 -0
- data/lib/generators/layered/resource/controller/controller_generator.rb +54 -0
- data/lib/generators/layered/resource/controller/templates/controller.rb.tt +31 -0
- data/lib/generators/layered/resource/install_agent_skill_generator.rb +26 -0
- data/lib/generators/layered/resource/resource_generator.rb +63 -0
- data/lib/generators/layered/resource/scaffold/scaffold_generator.rb +94 -0
- data/lib/generators/layered/resource/templates/resource.rb.tt +19 -0
- data/lib/generators/layered/resource/views/views_generator.rb +49 -0
- data/lib/layered/resource/base.rb +625 -0
- data/lib/layered/resource/engine.rb +33 -0
- data/lib/layered/resource/routing.rb +366 -0
- data/lib/layered/resource/version.rb +5 -0
- data/lib/layered/resource.rb +61 -0
- data/lib/layered-resource-rails.rb +1 -0
- 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,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"
|