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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +25 -2
  3. data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
  4. data/.claude/skills/plutonium-nested-resources/SKILL.md +79 -19
  5. data/.claude/skills/plutonium-policy/SKILL.md +93 -6
  6. data/CHANGELOG.md +36 -0
  7. data/CLAUDE.md +8 -10
  8. data/CONTRIBUTING.md +6 -8
  9. data/Rakefile +16 -1
  10. data/app/assets/plutonium.css +1 -1
  11. data/app/assets/plutonium.js +9371 -11492
  12. data/app/assets/plutonium.js.map +4 -4
  13. data/app/assets/plutonium.min.js +55 -55
  14. data/app/assets/plutonium.min.js.map +4 -4
  15. data/docs/guides/index.md +5 -0
  16. data/docs/guides/nested-resources.md +132 -29
  17. data/docs/guides/troubleshooting.md +82 -0
  18. data/docs/reference/controller/index.md +1 -1
  19. data/docs/reference/definition/fields.md +33 -0
  20. data/docs/reference/model/index.md +1 -1
  21. data/docs/reference/policy/index.md +77 -6
  22. data/gemfiles/rails_7.gemfile.lock +3 -3
  23. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  24. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  25. data/lib/plutonium/core/controller.rb +144 -19
  26. data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
  27. data/lib/plutonium/helpers/display_helper.rb +12 -0
  28. data/lib/plutonium/query/filters/association.rb +25 -3
  29. data/lib/plutonium/resource/controller.rb +90 -9
  30. data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
  31. data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
  32. data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
  33. data/lib/plutonium/resource/controllers/presentable.rb +13 -11
  34. data/lib/plutonium/resource/policy.rb +85 -2
  35. data/lib/plutonium/resource/record/routes.rb +31 -1
  36. data/lib/plutonium/routing/mapper_extensions.rb +40 -4
  37. data/lib/plutonium/routing/route_set_extensions.rb +3 -0
  38. data/lib/plutonium/ui/breadcrumbs.rb +1 -1
  39. data/lib/plutonium/ui/display/resource.rb +5 -2
  40. data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
  41. data/lib/plutonium/ui/page/index.rb +1 -1
  42. data/lib/plutonium/version.rb +1 -1
  43. data/lib/tasks/release.rake +1 -1
  44. data/package.json +6 -5
  45. data/plutonium.gemspec +1 -1
  46. data/src/js/controllers/key_value_store_controller.js +6 -0
  47. data/src/js/controllers/resource_drop_down_controller.js +3 -3
  48. data/yarn.lock +1465 -693
  49. metadata +6 -5
  50. 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
- # @param [Class, ApplicationRecord] *args arguments you would normally pass to `url_for`
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 = [current_package&.to_s].compact
195
+ controller_chain = [package&.to_s].compact
74
196
  [*args].compact.each_with_index do |element, index|
75
- if element.is_a?(Class)
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
- resource_route_config = current_engine.routes.resource_route_config_for(model_class.model_name.plural)[0]
89
- url_args[:id] = element.to_param unless resource_route_config[:route_type] == :resource
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
- @association_class = class_name
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 || key.to_s.classify.constantize
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
- name.to_s.gsub(/^#{current_package}::/, "").gsub(/Controller$/, "").singularize.camelize.constantize
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
- self.class.resource_class
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 = current_engine.routes.resource_route_config_for(resource_class.model_name.plural)[0]
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
- @submitted_resource_params ||= build_form(resource_class.new).extract_input(params, view_context:)[resource_param_key.to_sym].compact
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.singular_route_key
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
- resource_class.reflect_on_all_associations(:belongs_to).find { |assoc| assoc.klass.name == current_parent.class.name }&.name&.to_sym
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
- # Sets the policy context scope value to the current parent if available
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] default context for the current resource's policy
114
+ # @return [Hash] context containing parent, parent_association, and entity_scope
114
115
  def current_policy_context
115
- {entity_scope: current_parent || entity_scope_for_authorize}
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 ||= current_policy.send_with_report(:"permitted_attributes_for_#{action_name}").freeze
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.model_name.human.pluralize.titleize
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.any(:html, :turbo_stream) { render :new, formats: [:html], status: :unprocessable_content }
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.any(:html, :turbo_stream) { render :edit, formats: [:html], status: :unprocessable_content }
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