plutonium 0.37.0 → 0.38.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/.claude/skills/plutonium-controller/SKILL.md +25 -2
- data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
- data/.claude/skills/plutonium-nested-resources/SKILL.md +79 -19
- data/.claude/skills/plutonium-policy/SKILL.md +93 -6
- data/CHANGELOG.md +36 -0
- data/CLAUDE.md +8 -10
- data/CONTRIBUTING.md +6 -8
- data/Rakefile +16 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +9371 -11492
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +55 -55
- data/app/assets/plutonium.min.js.map +4 -4
- data/docs/guides/index.md +5 -0
- data/docs/guides/nested-resources.md +132 -29
- data/docs/guides/troubleshooting.md +82 -0
- data/docs/reference/controller/index.md +1 -1
- data/docs/reference/definition/fields.md +33 -0
- data/docs/reference/model/index.md +1 -1
- data/docs/reference/policy/index.md +77 -6
- data/gemfiles/rails_7.gemfile.lock +3 -3
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/plutonium/core/controller.rb +144 -19
- data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
- data/lib/plutonium/helpers/display_helper.rb +12 -0
- data/lib/plutonium/query/filters/association.rb +25 -3
- data/lib/plutonium/resource/controller.rb +90 -9
- data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
- data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
- data/lib/plutonium/resource/controllers/presentable.rb +13 -11
- data/lib/plutonium/resource/policy.rb +85 -2
- data/lib/plutonium/resource/record/routes.rb +31 -1
- data/lib/plutonium/routing/mapper_extensions.rb +40 -4
- data/lib/plutonium/routing/route_set_extensions.rb +3 -0
- data/lib/plutonium/ui/breadcrumbs.rb +1 -1
- data/lib/plutonium/ui/display/resource.rb +5 -2
- data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
- data/lib/plutonium/ui/page/index.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +1 -1
- data/package.json +6 -5
- data/plutonium.gemspec +1 -1
- data/src/js/controllers/key_value_store_controller.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +3 -3
- data/yarn.lock +1465 -693
- metadata +6 -5
- data/app/javascript/controllers/key_value_store_controller.js +0 -119
|
@@ -5,12 +5,26 @@ module Plutonium
|
|
|
5
5
|
include Plutonium::Core::Controllers::Bootable
|
|
6
6
|
include Plutonium::Core::Controllers::EntityScoping
|
|
7
7
|
include Plutonium::Core::Controllers::Authorizable
|
|
8
|
+
include Plutonium::Core::Controllers::AssociationResolver
|
|
8
9
|
|
|
9
10
|
included do
|
|
10
11
|
add_flash_types :success, :warning, :error
|
|
11
12
|
|
|
12
13
|
protect_from_forgery with: :null_session, if: -> { request.headers["Authorization"].present? }
|
|
13
14
|
|
|
15
|
+
rescue_from ::ActionPolicy::Unauthorized do |exception|
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.any(:html, :turbo_stream) do
|
|
18
|
+
raise exception
|
|
19
|
+
end
|
|
20
|
+
format.any do
|
|
21
|
+
@errors = ActiveModel::Errors.new(exception.policy.record)
|
|
22
|
+
@errors.add(:base, :unauthorized, message: exception.result.message)
|
|
23
|
+
render "errors", status: :forbidden
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
14
28
|
before_action do
|
|
15
29
|
next unless defined?(ActiveStorage)
|
|
16
30
|
|
|
@@ -60,33 +74,155 @@ module Plutonium
|
|
|
60
74
|
# `resource_url_args_for @post` => `entity_user_post_*`
|
|
61
75
|
# `resource_url_args_for @post, action: :edit` => `edit_entity_user_post_*`
|
|
62
76
|
#
|
|
63
|
-
#
|
|
77
|
+
# - with explicit association (for multiple associations to same class)
|
|
78
|
+
#
|
|
79
|
+
# `resource_url_args_for :authored_posts, parent: @user` => `entity_user_authored_posts_*`
|
|
80
|
+
#
|
|
81
|
+
# @param [Class, ApplicationRecord, Symbol] *args arguments you would normally pass to `url_for`.
|
|
82
|
+
# When parent is specified, can be a Symbol to explicitly name the association.
|
|
64
83
|
# @param [Symbol] action optional action to invoke, e.g., :new, :edit
|
|
65
84
|
# @param [ApplicationRecord] parent the parent record for nested routes, if any
|
|
85
|
+
# @param [Symbol] association explicit association name (when multiple associations to same class)
|
|
66
86
|
# @param [Hash] kwargs additional keyword arguments to pass to `url_for`
|
|
67
87
|
#
|
|
68
88
|
# @return [Hash] args to pass to `url_for`
|
|
69
89
|
#
|
|
70
|
-
def resource_url_args_for(*args, action: nil, parent: nil, **kwargs)
|
|
90
|
+
def resource_url_args_for(*args, action: nil, parent: nil, association: nil, package: nil, **kwargs)
|
|
91
|
+
target_package = package || current_package
|
|
92
|
+
|
|
93
|
+
# For nested resources, use named route helpers to avoid Rails param recall ambiguity
|
|
94
|
+
if parent.present?
|
|
95
|
+
assoc_name = if args.first.is_a?(Symbol)
|
|
96
|
+
args.first
|
|
97
|
+
else
|
|
98
|
+
association || resolve_association(args.first, parent)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
nested_route_key = "#{parent.class.model_name.plural}/#{assoc_name}"
|
|
102
|
+
route_config = current_engine.routes.resource_route_config_for(nested_route_key)[0]
|
|
103
|
+
|
|
104
|
+
if route_config
|
|
105
|
+
return build_nested_resource_url_args(
|
|
106
|
+
args.first,
|
|
107
|
+
parent: parent,
|
|
108
|
+
association_name: assoc_name,
|
|
109
|
+
route_config: route_config,
|
|
110
|
+
action: action,
|
|
111
|
+
**kwargs
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Top-level resource: build controller/action hash for url_for
|
|
117
|
+
build_top_level_resource_url_args(*args, action: action, parent: parent, association: association, package: target_package, **kwargs)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def resource_url_for(*args, package: nil, **kwargs)
|
|
121
|
+
url_args = resource_url_args_for(*args, package: package, **kwargs)
|
|
122
|
+
url_helpers = route_url_helpers_for(package)
|
|
123
|
+
|
|
124
|
+
if url_args[:_named_route]
|
|
125
|
+
url_helpers.send(url_args[:_named_route], *url_args[:_args], **url_args[:_options])
|
|
126
|
+
else
|
|
127
|
+
url_helpers.url_for(url_args)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def route_url_helpers_for(package = nil)
|
|
132
|
+
pkg = package || current_package
|
|
133
|
+
pkg.present? ? send(pkg.name.underscore.to_sym) : current_engine.routes.url_helpers
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def build_nested_resource_url_args(element, parent:, association_name:, route_config:, action: nil, **kwargs)
|
|
139
|
+
prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
|
|
140
|
+
is_singular = route_config[:route_type] == :resource
|
|
141
|
+
|
|
142
|
+
# For singular resources (has_one), Class/Symbol/nil without action means "no record exists" -> default to :new
|
|
143
|
+
if is_singular && (element.is_a?(Class) || element.is_a?(Symbol) || element.nil?) && action.nil?
|
|
144
|
+
action = :new
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Build the named helper: e.g., "blogging_post_nested_post_metadata_path"
|
|
148
|
+
parent_singular = parent.model_name.singular
|
|
149
|
+
nested_resource_name = "#{prefix}#{association_name}"
|
|
150
|
+
|
|
151
|
+
# Determine if this is a collection action (no specific record)
|
|
152
|
+
no_record = element.is_a?(Class) || element.is_a?(Symbol) || element.nil?
|
|
153
|
+
|
|
154
|
+
# Determine the helper name based on action and route type
|
|
155
|
+
# For singular routes (has_one), always use the association name as-is (no singularize)
|
|
156
|
+
# For plural routes (has_many):
|
|
157
|
+
# - :index action uses plural (blogging_post_nested_comments)
|
|
158
|
+
# - :new action uses singular (new_blogging_post_nested_comment)
|
|
159
|
+
# - member actions (show/edit/destroy) use singular (blogging_post_nested_comment)
|
|
160
|
+
helper_base = if is_singular
|
|
161
|
+
"#{parent_singular}_#{nested_resource_name}"
|
|
162
|
+
elsif action == :index || (no_record && action != :new)
|
|
163
|
+
"#{parent_singular}_#{nested_resource_name}"
|
|
164
|
+
else
|
|
165
|
+
"#{parent_singular}_#{nested_resource_name.to_s.singularize}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
helper_suffix = case action
|
|
169
|
+
when :new then "new_"
|
|
170
|
+
when :edit then "edit_"
|
|
171
|
+
else ""
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
helper_name = :"#{helper_suffix}#{helper_base}_path"
|
|
175
|
+
|
|
176
|
+
# Build the arguments for the helper
|
|
177
|
+
helper_args = [parent.to_param]
|
|
178
|
+
# Include element ID for plural routes (has_many) when we have a record instance
|
|
179
|
+
unless is_singular || no_record
|
|
180
|
+
helper_args << element.to_param
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Build URL options
|
|
184
|
+
url_options = kwargs.dup
|
|
185
|
+
if !url_options.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
|
|
186
|
+
url_options[:format] = request.format.symbol
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
{_named_route: helper_name, _args: helper_args, _options: url_options}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_top_level_resource_url_args(*args, action: nil, parent: nil, association: nil, package: nil, **kwargs)
|
|
71
193
|
url_args = {**kwargs, action: action}.compact
|
|
72
194
|
|
|
73
|
-
controller_chain = [
|
|
195
|
+
controller_chain = [package&.to_s].compact
|
|
74
196
|
[*args].compact.each_with_index do |element, index|
|
|
75
|
-
if element.is_a?(
|
|
197
|
+
if element.is_a?(Symbol)
|
|
198
|
+
raise ArgumentError, "parent is required when using symbol association name" unless parent
|
|
199
|
+
|
|
200
|
+
assoc = parent.class.reflect_on_association(element)
|
|
201
|
+
raise ArgumentError, "Unknown association :#{element} on #{parent.class}" unless assoc
|
|
202
|
+
|
|
203
|
+
controller_chain << assoc.klass.to_s.pluralize
|
|
204
|
+
url_args[:action] ||= :index if index == args.length - 1
|
|
205
|
+
elsif element.is_a?(Class)
|
|
76
206
|
controller_chain << element.to_s.pluralize
|
|
207
|
+
url_args[:action] ||= :index if index == args.length - 1 && parent.present?
|
|
77
208
|
else
|
|
78
|
-
# For STI models, use the base class for routing if the specific class isn't registered
|
|
79
209
|
model_class = element.class
|
|
80
210
|
if model_class.respond_to?(:base_class) && model_class != model_class.base_class
|
|
81
|
-
# Check if the STI model is registered, if not use base class
|
|
82
211
|
route_configs = current_engine.routes.resource_route_config_for(model_class.model_name.plural)
|
|
83
212
|
model_class = model_class.base_class if route_configs.empty?
|
|
84
213
|
end
|
|
85
214
|
|
|
86
215
|
controller_chain << model_class.to_s.pluralize
|
|
87
216
|
if index == args.length - 1
|
|
88
|
-
|
|
89
|
-
|
|
217
|
+
route_key = if parent.present?
|
|
218
|
+
assoc_name = association || resolve_association(element, parent)
|
|
219
|
+
"#{parent.class.model_name.plural}/#{assoc_name}"
|
|
220
|
+
else
|
|
221
|
+
model_class.model_name.plural
|
|
222
|
+
end
|
|
223
|
+
resource_route_config = current_engine.routes.resource_route_config_for(route_key)[0]
|
|
224
|
+
is_singular = resource_route_config&.dig(:route_type) == :resource
|
|
225
|
+
url_args[:id] = element.to_param unless is_singular
|
|
90
226
|
url_args[:action] ||= :show
|
|
91
227
|
else
|
|
92
228
|
url_args[model_class.to_s.underscore.singularize.to_sym] = element.to_param
|
|
@@ -100,8 +236,6 @@ module Plutonium
|
|
|
100
236
|
url_args[scoped_entity_param_key] = current_scoped_entity
|
|
101
237
|
end
|
|
102
238
|
|
|
103
|
-
# Preserve the request format unless explicitly specified
|
|
104
|
-
# Don't preserve turbo_stream as it's for streaming updates, not page navigation
|
|
105
239
|
if !url_args.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
|
|
106
240
|
url_args[:format] = request.format.symbol
|
|
107
241
|
end
|
|
@@ -109,15 +243,6 @@ module Plutonium
|
|
|
109
243
|
url_args
|
|
110
244
|
end
|
|
111
245
|
|
|
112
|
-
def resource_url_for(...)
|
|
113
|
-
args = resource_url_args_for(...)
|
|
114
|
-
if current_package.present?
|
|
115
|
-
send(current_package.name.underscore.to_sym).url_for(args)
|
|
116
|
-
else
|
|
117
|
-
url_for(args)
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
246
|
def root_path(*)
|
|
122
247
|
return send(:"#{scoped_entity_param_key}_root_path", *) if scoped_to_entity? && scoped_entity_strategy == :path
|
|
123
248
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Core
|
|
5
|
+
module Controllers
|
|
6
|
+
# Resolves target classes/instances to association names on a parent model.
|
|
7
|
+
#
|
|
8
|
+
# This module handles the mapping between resource classes and their association
|
|
9
|
+
# names when generating nested resource URLs. It supports:
|
|
10
|
+
# - Explicit association names (symbols)
|
|
11
|
+
# - Class-based resolution with namespace fallback
|
|
12
|
+
# - Instance-based resolution
|
|
13
|
+
#
|
|
14
|
+
# @example Explicit association
|
|
15
|
+
# resolve_association(:comments, @post) # => :comments
|
|
16
|
+
#
|
|
17
|
+
# @example Class-based resolution
|
|
18
|
+
# resolve_association(Comment, @post) # => :comments
|
|
19
|
+
#
|
|
20
|
+
# @example Namespaced class resolution
|
|
21
|
+
# resolve_association(Blogging::Comment, @post) # => :comments (tries :blogging_comments first)
|
|
22
|
+
#
|
|
23
|
+
module AssociationResolver
|
|
24
|
+
class AmbiguousAssociationError < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Resolves a target to an association name on the parent
|
|
27
|
+
#
|
|
28
|
+
# @param target [Class, Object, Symbol] The target class, instance, or association name
|
|
29
|
+
# @param parent [Object] The parent instance
|
|
30
|
+
# @return [Symbol] The resolved association name
|
|
31
|
+
# @raise [ArgumentError] If no matching association is found
|
|
32
|
+
# @raise [AmbiguousAssociationError] If multiple associations match
|
|
33
|
+
def resolve_association(target, parent)
|
|
34
|
+
return target if target.is_a?(Symbol)
|
|
35
|
+
|
|
36
|
+
target_class = target.is_a?(Class) ? target : target.class
|
|
37
|
+
candidates = association_candidates_for(target_class)
|
|
38
|
+
|
|
39
|
+
matching = candidates.filter_map do |assoc_name|
|
|
40
|
+
assoc = parent.class.reflect_on_association(assoc_name)
|
|
41
|
+
assoc_name if assoc && assoc.klass >= target_class
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
case matching.size
|
|
45
|
+
when 0
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"No association found for #{target_class} on #{parent.class}. " \
|
|
48
|
+
"Tried: #{candidates.join(", ")}"
|
|
49
|
+
when 1
|
|
50
|
+
matching.first
|
|
51
|
+
else
|
|
52
|
+
raise AmbiguousAssociationError,
|
|
53
|
+
"Multiple associations to #{target_class} on #{parent.class}: #{matching.join(", ")}. " \
|
|
54
|
+
"Please specify explicitly using a symbol: resource_url_for(:association_name, parent: ...)"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Returns candidate association names for a class
|
|
61
|
+
#
|
|
62
|
+
# For Blogging::Comment, returns [:blogging_comments, :comments]
|
|
63
|
+
# For Comment, returns [:comments]
|
|
64
|
+
#
|
|
65
|
+
# @param klass [Class] The target class
|
|
66
|
+
# @return [Array<Symbol>] Candidate association names in priority order
|
|
67
|
+
def association_candidates_for(klass)
|
|
68
|
+
candidates = []
|
|
69
|
+
|
|
70
|
+
# Full namespaced name: Blogging::Comment => :blogging_comments
|
|
71
|
+
full_name = klass.model_name.plural.to_sym
|
|
72
|
+
candidates << full_name
|
|
73
|
+
|
|
74
|
+
# Demodulized name: Blogging::Comment => :comments
|
|
75
|
+
demodulized = klass.name.demodulize
|
|
76
|
+
if demodulized != klass.name
|
|
77
|
+
short_name = demodulized.underscore.pluralize.to_sym
|
|
78
|
+
candidates << short_name unless candidates.include?(short_name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
candidates
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -14,6 +14,18 @@ module Plutonium
|
|
|
14
14
|
resource_name resource_class, 2
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Returns a human-readable name for a nested collection using the association name.
|
|
18
|
+
# Falls back to resource_name_plural if not in a nested context.
|
|
19
|
+
# Uses I18n via human_attribute_name for proper localization.
|
|
20
|
+
# e.g., "Authored Comments" for has_many :authored_comments
|
|
21
|
+
def nestable_resource_name_plural(resource_class)
|
|
22
|
+
if current_parent && current_nested_association
|
|
23
|
+
current_parent.class.human_attribute_name(current_nested_association).titleize
|
|
24
|
+
else
|
|
25
|
+
resource_name_plural(resource_class)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
17
29
|
def display_field(value:, helper: nil, **options)
|
|
18
30
|
return "-" unless value.present?
|
|
19
31
|
|
|
@@ -12,10 +12,15 @@ module Plutonium
|
|
|
12
12
|
# @example With multiple selection
|
|
13
13
|
# filter :tags, with: :association, class_name: Tag, multiple: true
|
|
14
14
|
#
|
|
15
|
+
# @example With custom scope
|
|
16
|
+
# filter :user, with: :association, class_name: User, scope: ->(s) { s.active }
|
|
17
|
+
#
|
|
15
18
|
class Association < Filter
|
|
16
|
-
def initialize(class_name: nil, multiple: false, **)
|
|
19
|
+
def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: false, **)
|
|
17
20
|
super(**)
|
|
18
|
-
@
|
|
21
|
+
@class_name = class_name
|
|
22
|
+
@resource_class = resource_class
|
|
23
|
+
@scope_proc = scope
|
|
19
24
|
@multiple = multiple
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -41,7 +46,24 @@ module Plutonium
|
|
|
41
46
|
private
|
|
42
47
|
|
|
43
48
|
def association_class
|
|
44
|
-
@association_class ||
|
|
49
|
+
@association_class ||= resolve_class_name || detect_class_from_reflection || infer_class_from_key
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_class_name
|
|
53
|
+
return nil unless @class_name
|
|
54
|
+
|
|
55
|
+
@class_name.is_a?(String) ? @class_name.constantize : @class_name
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def detect_class_from_reflection
|
|
59
|
+
return nil unless @resource_class
|
|
60
|
+
|
|
61
|
+
reflection = @resource_class.reflect_on_association(key)
|
|
62
|
+
reflection&.klass
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def infer_class_from_key
|
|
66
|
+
key.to_s.classify.constantize
|
|
45
67
|
end
|
|
46
68
|
end
|
|
47
69
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "action_controller"
|
|
2
2
|
require "pagy"
|
|
3
|
+
require_relative "../routing/mapper_extensions"
|
|
3
4
|
|
|
4
5
|
module Plutonium
|
|
5
6
|
module Resource
|
|
@@ -19,7 +20,7 @@ module Plutonium
|
|
|
19
20
|
# https://github.com/ddnexus/pagy/blob/master/docs/extras/headers.md#headers
|
|
20
21
|
after_action { pagy_headers_merge(@pagy) if @pagy }
|
|
21
22
|
|
|
22
|
-
helper_method :current_parent, :resource_record!, :resource_record?, :resource_param_key, :resource_class
|
|
23
|
+
helper_method :current_parent, :current_nested_association, :resource_record!, :resource_record?, :resource_param_key, :resource_class
|
|
23
24
|
|
|
24
25
|
# Use class_attribute for proper inheritance
|
|
25
26
|
class_attribute :_resource_class, instance_accessor: false
|
|
@@ -39,9 +40,22 @@ module Plutonium
|
|
|
39
40
|
def resource_class
|
|
40
41
|
return _resource_class if _resource_class
|
|
41
42
|
|
|
43
|
+
base_name = name.to_s.gsub(/^#{current_package}::/, "").gsub(/Controller$/, "")
|
|
44
|
+
singularized_name = base_name.singularize.camelize
|
|
45
|
+
|
|
42
46
|
# Use singularize + camelize to respect custom inflections
|
|
43
|
-
|
|
47
|
+
singularized_name.constantize
|
|
44
48
|
rescue NameError
|
|
49
|
+
# Check if inflection is the issue (e.g., PostMetadata -> PostMetadatum)
|
|
50
|
+
if base_name != singularized_name && base_name.camelize.safe_constantize
|
|
51
|
+
raise NameError, <<~MSG.squish
|
|
52
|
+
Failed to determine the resource class for #{name}.
|
|
53
|
+
Rails singularized "#{base_name}" to "#{singularized_name}", but "#{base_name.camelize}" exists.
|
|
54
|
+
Add an inflection rule to config/initializers/inflections.rb.
|
|
55
|
+
See: https://radioactive-labs.github.io/plutonium-core/guides/troubleshooting
|
|
56
|
+
MSG
|
|
57
|
+
end
|
|
58
|
+
|
|
45
59
|
raise NameError, "Failed to determine the resource class. Please call `controller_for(MyResource)` in #{name}."
|
|
46
60
|
end
|
|
47
61
|
# memoize_unless_reloading :resource_class
|
|
@@ -49,13 +63,29 @@ module Plutonium
|
|
|
49
63
|
|
|
50
64
|
private
|
|
51
65
|
|
|
66
|
+
# Override to prepend parent label for nested resources in the browser tab title.
|
|
67
|
+
# e.g., "John Doe › Authored Comments"
|
|
68
|
+
def set_page_title(page_title)
|
|
69
|
+
@page_title = if current_parent
|
|
70
|
+
"#{current_parent.to_label} › #{page_title}"
|
|
71
|
+
else
|
|
72
|
+
page_title
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
52
76
|
def resource_class
|
|
53
|
-
|
|
77
|
+
if current_parent
|
|
78
|
+
# Nested route: resource_class must come from route config
|
|
79
|
+
current_resource_route_config&.dig(:resource_class) or
|
|
80
|
+
raise "No resource_class found in route config for nested route"
|
|
81
|
+
else
|
|
82
|
+
self.class.resource_class
|
|
83
|
+
end
|
|
54
84
|
end
|
|
55
85
|
|
|
56
86
|
def resource_record_relation
|
|
57
87
|
@resource_record_relation ||= begin
|
|
58
|
-
resource_route_config =
|
|
88
|
+
resource_route_config = current_resource_route_config
|
|
59
89
|
if resource_route_config[:route_type] == :resource
|
|
60
90
|
current_authorized_scope
|
|
61
91
|
elsif params[:id]
|
|
@@ -66,6 +96,33 @@ module Plutonium
|
|
|
66
96
|
end
|
|
67
97
|
end
|
|
68
98
|
|
|
99
|
+
def current_resource_route_config
|
|
100
|
+
@current_resource_route_config ||= if current_parent
|
|
101
|
+
current_engine.routes.resource_route_config_lookup["#{current_parent.class.model_name.plural}/#{current_nested_association}"]
|
|
102
|
+
else
|
|
103
|
+
current_engine.routes.resource_route_config_for(resource_class.model_name.plural)[0]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Extracts the association name from the current nested route
|
|
108
|
+
# e.g., for route /posts/:post_id/nested_comments, returns :comments
|
|
109
|
+
# @return [Symbol, nil] The association name
|
|
110
|
+
def current_nested_association
|
|
111
|
+
return unless parent_route_param
|
|
112
|
+
|
|
113
|
+
# Extract from request path: find the nested_* segment after the parent param
|
|
114
|
+
# e.g., /posts/123/nested_comments/456 => "comments"
|
|
115
|
+
# Note: Strip format extension (.json, .xml, etc.) from the segment
|
|
116
|
+
prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
|
|
117
|
+
path_segments = request.path.split("/")
|
|
118
|
+
nested_segment = path_segments.find { |seg| seg.start_with?(prefix) }
|
|
119
|
+
return unless nested_segment
|
|
120
|
+
|
|
121
|
+
# Remove prefix and any format extension (e.g., "nested_versions.json" -> "versions")
|
|
122
|
+
association_name = nested_segment.delete_prefix(prefix).sub(/\.\w+\z/, "")
|
|
123
|
+
association_name.to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
69
126
|
def resource_record!
|
|
70
127
|
@resource_record ||= resource_record_relation.first!
|
|
71
128
|
end
|
|
@@ -77,7 +134,9 @@ module Plutonium
|
|
|
77
134
|
# Returns the submitted resource parameters
|
|
78
135
|
# @return [Hash] The submitted resource parameters
|
|
79
136
|
def submitted_resource_params
|
|
80
|
-
|
|
137
|
+
# Use existing record (cloned) for context during param extraction, or new instance for create
|
|
138
|
+
extraction_record = resource_record?&.dup || resource_class.new
|
|
139
|
+
@submitted_resource_params ||= build_form(extraction_record).extract_input(params, view_context:)[resource_param_key.to_sym].compact
|
|
81
140
|
end
|
|
82
141
|
|
|
83
142
|
# Returns the resource parameters, including scoped and parent parameters
|
|
@@ -93,9 +152,9 @@ module Plutonium
|
|
|
93
152
|
end
|
|
94
153
|
|
|
95
154
|
# Returns the resource parameter key
|
|
96
|
-
# @return [Symbol] The resource parameter key
|
|
155
|
+
# @return [Symbol] The resource parameter key (for form params)
|
|
97
156
|
def resource_param_key
|
|
98
|
-
resource_class.model_name.
|
|
157
|
+
resource_class.model_name.param_key
|
|
99
158
|
end
|
|
100
159
|
|
|
101
160
|
# Creates a resource context
|
|
@@ -146,12 +205,30 @@ module Plutonium
|
|
|
146
205
|
@parent_route_param ||= request.path_parameters.keys.reverse.find { |key| /_id$/.match? key }
|
|
147
206
|
end
|
|
148
207
|
|
|
149
|
-
# Returns the parent input parameter
|
|
208
|
+
# Returns the parent input parameter (the belongs_to association name on the child)
|
|
209
|
+
# Finds the belongs_to association on the child that matches the parent's foreign key
|
|
150
210
|
# @return [Symbol, nil] The parent input parameter
|
|
151
211
|
def parent_input_param
|
|
152
212
|
return unless current_parent
|
|
153
213
|
|
|
154
|
-
|
|
214
|
+
unless current_nested_association
|
|
215
|
+
raise "parent exists but current_nested_association is nil - routing misconfiguration"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
parent_assoc = current_parent.class.reflect_on_association(current_nested_association)
|
|
219
|
+
unless parent_assoc
|
|
220
|
+
raise "#{current_parent.class} does not have association :#{current_nested_association}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Try inverse_of first (if explicitly set)
|
|
224
|
+
return parent_assoc.inverse_of.name.to_sym if parent_assoc.inverse_of
|
|
225
|
+
|
|
226
|
+
# Fall back to finding belongs_to by foreign key
|
|
227
|
+
foreign_key = parent_assoc.foreign_key.to_s
|
|
228
|
+
child_assoc = resource_class.reflect_on_all_associations(:belongs_to).find do |assoc|
|
|
229
|
+
assoc.foreign_key.to_s == foreign_key && assoc.klass == current_parent.class
|
|
230
|
+
end
|
|
231
|
+
child_assoc&.name&.to_sym
|
|
155
232
|
end
|
|
156
233
|
|
|
157
234
|
# Ensures the method is a GET request
|
|
@@ -195,6 +272,10 @@ module Plutonium
|
|
|
195
272
|
# @return [Array] The URL arguments
|
|
196
273
|
def resource_url_args_for(*, **kwargs)
|
|
197
274
|
kwargs[:parent] = current_parent unless kwargs.key?(:parent)
|
|
275
|
+
# Pass the current association when in a nested context
|
|
276
|
+
if current_parent && !kwargs.key?(:association) && current_nested_association
|
|
277
|
+
kwargs[:association] = current_nested_association
|
|
278
|
+
end
|
|
198
279
|
super
|
|
199
280
|
end
|
|
200
281
|
end
|
|
@@ -108,11 +108,16 @@ module Plutonium
|
|
|
108
108
|
authorized_scope(resource_class.all, context: current_policy_context)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
#
|
|
111
|
+
# Returns the policy context for the current resource
|
|
112
|
+
# Separates parent scoping (nested routes) from entity scoping (multi-tenancy)
|
|
112
113
|
#
|
|
113
|
-
# @return [Hash]
|
|
114
|
+
# @return [Hash] context containing parent, parent_association, and entity_scope
|
|
114
115
|
def current_policy_context
|
|
115
|
-
{
|
|
116
|
+
{
|
|
117
|
+
parent: current_parent,
|
|
118
|
+
parent_association: current_nested_association,
|
|
119
|
+
entity_scope: entity_scope_for_authorize
|
|
120
|
+
}
|
|
116
121
|
end
|
|
117
122
|
|
|
118
123
|
# Authorizes the current action for the given record of the current resource
|
|
@@ -130,7 +135,15 @@ module Plutonium
|
|
|
130
135
|
#
|
|
131
136
|
# @return [Array<Symbol>] the list of permitted attributes for the current action
|
|
132
137
|
def permitted_attributes
|
|
133
|
-
@permitted_attributes ||=
|
|
138
|
+
@permitted_attributes ||= permitted_attributes_for(action_name)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns the list of permitted attributes for a specific action on the current resource
|
|
142
|
+
#
|
|
143
|
+
# @param action [String, Symbol] the action to get permitted attributes for
|
|
144
|
+
# @return [Array<Symbol>] the list of permitted attributes for the action
|
|
145
|
+
def permitted_attributes_for(action)
|
|
146
|
+
current_policy.send_with_report(:"permitted_attributes_for_#{action}").freeze
|
|
134
147
|
end
|
|
135
148
|
|
|
136
149
|
# Returns the list of permitted associations for the current resource
|
|
@@ -12,7 +12,7 @@ module Plutonium
|
|
|
12
12
|
# GET /resources(.{format})
|
|
13
13
|
def index
|
|
14
14
|
authorize_current! resource_class
|
|
15
|
-
set_page_title resource_class
|
|
15
|
+
set_page_title helpers.nestable_resource_name_plural(resource_class)
|
|
16
16
|
|
|
17
17
|
setup_index_action!
|
|
18
18
|
|
|
@@ -53,7 +53,7 @@ module Plutonium
|
|
|
53
53
|
|
|
54
54
|
respond_to do |format|
|
|
55
55
|
if params[:pre_submit]
|
|
56
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
|
|
56
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
|
|
57
57
|
format.html { render :new, status: :unprocessable_content }
|
|
58
58
|
elsif resource_record!.save
|
|
59
59
|
format.turbo_stream do
|
|
@@ -71,7 +71,8 @@ module Plutonium
|
|
|
71
71
|
location: redirect_url_after_submit
|
|
72
72
|
end
|
|
73
73
|
else
|
|
74
|
-
format.
|
|
74
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
75
|
+
format.html { render :new, status: :unprocessable_content }
|
|
75
76
|
format.any do
|
|
76
77
|
@errors = resource_record!.errors
|
|
77
78
|
render "errors", status: :unprocessable_content
|
|
@@ -99,7 +100,7 @@ module Plutonium
|
|
|
99
100
|
|
|
100
101
|
respond_to do |format|
|
|
101
102
|
if params[:pre_submit]
|
|
102
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form)) }
|
|
103
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
|
|
103
104
|
format.html { render :edit, status: :unprocessable_content }
|
|
104
105
|
elsif resource_record!.save
|
|
105
106
|
format.turbo_stream do
|
|
@@ -115,7 +116,8 @@ module Plutonium
|
|
|
115
116
|
render :show, status: :ok, location: redirect_url_after_submit
|
|
116
117
|
end
|
|
117
118
|
else
|
|
118
|
-
format.
|
|
119
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
120
|
+
format.html { render :edit, status: :unprocessable_content }
|
|
119
121
|
format.any do
|
|
120
122
|
@errors = resource_record!.errors
|
|
121
123
|
render "errors", status: :unprocessable_content
|
|
@@ -62,6 +62,9 @@ module Plutonium
|
|
|
62
62
|
format.html do
|
|
63
63
|
redirect_to return_url, status: :see_other
|
|
64
64
|
end
|
|
65
|
+
format.any do
|
|
66
|
+
render :show, status: :ok, location: return_url
|
|
67
|
+
end
|
|
65
68
|
else
|
|
66
69
|
format.any(:html, :turbo_stream) do
|
|
67
70
|
render :interactive_record_action, formats: [:html], status: :unprocessable_content
|
|
@@ -117,6 +120,9 @@ module Plutonium
|
|
|
117
120
|
format.html do
|
|
118
121
|
redirect_to return_url, status: :see_other
|
|
119
122
|
end
|
|
123
|
+
format.any do
|
|
124
|
+
head :no_content, location: return_url
|
|
125
|
+
end
|
|
120
126
|
else
|
|
121
127
|
format.any(:html, :turbo_stream) do
|
|
122
128
|
render :interactive_resource_action, formats: [:html], status: :unprocessable_content
|
|
@@ -166,6 +172,9 @@ module Plutonium
|
|
|
166
172
|
format.html do
|
|
167
173
|
redirect_to return_url, status: :see_other
|
|
168
174
|
end
|
|
175
|
+
format.any do
|
|
176
|
+
head :no_content, location: return_url
|
|
177
|
+
end
|
|
169
178
|
else
|
|
170
179
|
format.any(:html, :turbo_stream) do
|
|
171
180
|
render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
|