plutonium 0.37.0 → 0.39.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -2
  3. data/.claude/skills/plutonium-definition-actions/SKILL.md +13 -0
  4. data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
  5. data/.claude/skills/plutonium-nested-resources/SKILL.md +85 -23
  6. data/.claude/skills/plutonium-policy/SKILL.md +93 -6
  7. data/CHANGELOG.md +42 -0
  8. data/CLAUDE.md +8 -10
  9. data/CONTRIBUTING.md +6 -8
  10. data/Rakefile +16 -1
  11. data/app/assets/plutonium.css +1 -1
  12. data/app/assets/plutonium.js +9371 -11492
  13. data/app/assets/plutonium.js.map +4 -4
  14. data/app/assets/plutonium.min.js +55 -55
  15. data/app/assets/plutonium.min.js.map +4 -4
  16. data/docs/guides/custom-actions.md +14 -0
  17. data/docs/guides/index.md +5 -0
  18. data/docs/guides/nested-resources.md +139 -32
  19. data/docs/guides/troubleshooting.md +82 -0
  20. data/docs/og-image.html +84 -0
  21. data/docs/public/og-image.png +0 -0
  22. data/docs/reference/controller/index.md +6 -2
  23. data/docs/reference/definition/actions.md +14 -0
  24. data/docs/reference/definition/fields.md +33 -0
  25. data/docs/reference/model/index.md +1 -1
  26. data/docs/reference/policy/index.md +77 -6
  27. data/gemfiles/rails_7.gemfile.lock +5 -5
  28. data/gemfiles/rails_8.0.gemfile.lock +5 -5
  29. data/gemfiles/rails_8.1.gemfile.lock +5 -5
  30. data/lib/generators/pu/rodauth/install_generator.rb +7 -11
  31. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +3 -5
  32. data/lib/plutonium/auth/sequel_adapter.rb +76 -0
  33. data/lib/plutonium/core/controller.rb +143 -19
  34. data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
  35. data/lib/plutonium/helpers/display_helper.rb +12 -0
  36. data/lib/plutonium/query/filters/association.rb +25 -3
  37. data/lib/plutonium/resource/controller.rb +91 -9
  38. data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
  39. data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
  40. data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
  41. data/lib/plutonium/resource/controllers/presentable.rb +15 -11
  42. data/lib/plutonium/resource/policy.rb +85 -2
  43. data/lib/plutonium/resource/record/routes.rb +31 -1
  44. data/lib/plutonium/routing/mapper_extensions.rb +49 -10
  45. data/lib/plutonium/routing/route_set_extensions.rb +3 -0
  46. data/lib/plutonium/ui/action_button.rb +72 -11
  47. data/lib/plutonium/ui/actions_dropdown.rb +3 -25
  48. data/lib/plutonium/ui/breadcrumbs.rb +2 -2
  49. data/lib/plutonium/ui/component/methods.rb +10 -3
  50. data/lib/plutonium/ui/display/resource.rb +5 -2
  51. data/lib/plutonium/ui/form/base.rb +1 -1
  52. data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
  53. data/lib/plutonium/ui/form/interaction.rb +5 -5
  54. data/lib/plutonium/ui/form/query.rb +1 -1
  55. data/lib/plutonium/ui/form/resource.rb +1 -1
  56. data/lib/plutonium/ui/layout/base.rb +1 -1
  57. data/lib/plutonium/ui/layout/basic_layout.rb +2 -2
  58. data/lib/plutonium/ui/layout/resource_layout.rb +2 -2
  59. data/lib/plutonium/ui/layout/rodauth_layout.rb +2 -2
  60. data/lib/plutonium/ui/page/index.rb +1 -1
  61. data/lib/plutonium/ui/page/interactive_action.rb +1 -1
  62. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +3 -25
  63. data/lib/plutonium/version.rb +1 -1
  64. data/lib/tasks/release.rake +1 -1
  65. data/package.json +6 -5
  66. data/plutonium.gemspec +2 -2
  67. data/src/js/controllers/key_value_store_controller.js +6 -0
  68. data/src/js/controllers/resource_drop_down_controller.js +3 -3
  69. data/yarn.lock +1465 -693
  70. metadata +10 -7
  71. data/app/javascript/controllers/key_value_store_controller.js +0 -119
@@ -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,10 @@ 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
+ # Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
139
+ extraction_record = resource_record?&.dup || resource_class.new
140
+ @submitted_resource_params ||= build_form(extraction_record, form_action: false).extract_input(params, view_context:)[resource_param_key.to_sym].compact
81
141
  end
82
142
 
83
143
  # Returns the resource parameters, including scoped and parent parameters
@@ -93,9 +153,9 @@ module Plutonium
93
153
  end
94
154
 
95
155
  # Returns the resource parameter key
96
- # @return [Symbol] The resource parameter key
156
+ # @return [Symbol] The resource parameter key (for form params)
97
157
  def resource_param_key
98
- resource_class.model_name.singular_route_key
158
+ resource_class.model_name.param_key
99
159
  end
100
160
 
101
161
  # Creates a resource context
@@ -146,12 +206,30 @@ module Plutonium
146
206
  @parent_route_param ||= request.path_parameters.keys.reverse.find { |key| /_id$/.match? key }
147
207
  end
148
208
 
149
- # Returns the parent input parameter
209
+ # Returns the parent input parameter (the belongs_to association name on the child)
210
+ # Finds the belongs_to association on the child that matches the parent's foreign key
150
211
  # @return [Symbol, nil] The parent input parameter
151
212
  def parent_input_param
152
213
  return unless current_parent
153
214
 
154
- resource_class.reflect_on_all_associations(:belongs_to).find { |assoc| assoc.klass.name == current_parent.class.name }&.name&.to_sym
215
+ unless current_nested_association
216
+ raise "parent exists but current_nested_association is nil - routing misconfiguration"
217
+ end
218
+
219
+ parent_assoc = current_parent.class.reflect_on_association(current_nested_association)
220
+ unless parent_assoc
221
+ raise "#{current_parent.class} does not have association :#{current_nested_association}"
222
+ end
223
+
224
+ # Try inverse_of first (if explicitly set)
225
+ return parent_assoc.inverse_of.name.to_sym if parent_assoc.inverse_of
226
+
227
+ # Fall back to finding belongs_to by foreign key
228
+ foreign_key = parent_assoc.foreign_key.to_s
229
+ child_assoc = resource_class.reflect_on_all_associations(:belongs_to).find do |assoc|
230
+ assoc.foreign_key.to_s == foreign_key && assoc.klass == current_parent.class
231
+ end
232
+ child_assoc&.name&.to_sym
155
233
  end
156
234
 
157
235
  # Ensures the method is a GET request
@@ -195,6 +273,10 @@ module Plutonium
195
273
  # @return [Array] The URL arguments
196
274
  def resource_url_args_for(*, **kwargs)
197
275
  kwargs[:parent] = current_parent unless kwargs.key?(:parent)
276
+ # Pass the current association when in a nested context
277
+ if kwargs[:parent] && !kwargs.key?(:association) && current_nested_association
278
+ kwargs[:association] = current_nested_association
279
+ end
198
280
  super
199
281
  end
200
282
  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
@@ -24,16 +24,18 @@ module Plutonium
24
24
  end
25
25
 
26
26
  def submittable_attributes
27
- @submittable_attributes ||= begin
28
- submittable_attributes = permitted_attributes
29
- if current_parent && !submit_parent?
30
- submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
31
- end
32
- if scoped_to_entity? && !submit_scoped_entity?
33
- submittable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
34
- end
35
- submittable_attributes
27
+ @submittable_attributes ||= submittable_attributes_for(action_name)
28
+ end
29
+
30
+ def submittable_attributes_for(action)
31
+ submittable_attributes = permitted_attributes_for(action)
32
+ if current_parent && !submit_parent?
33
+ submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
34
+ end
35
+ if scoped_to_entity? && !submit_scoped_entity?
36
+ submittable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
36
37
  end
38
+ submittable_attributes
37
39
  end
38
40
 
39
41
  def build_collection
@@ -44,8 +46,10 @@ module Plutonium
44
46
  current_definition.detail_class.new(resource_record!, resource_fields: presentable_attributes, resource_associations: permitted_associations, resource_definition: current_definition)
45
47
  end
46
48
 
47
- def build_form(record = resource_record!)
48
- current_definition.form_class.new(record, resource_fields: submittable_attributes, resource_definition: current_definition)
49
+ def build_form(record = resource_record!, action: action_name, form_action: nil, **)
50
+ form_options = {resource_fields: submittable_attributes_for(action), resource_definition: current_definition, **}
51
+ form_options[:action] = form_action unless form_action.nil?
52
+ current_definition.form_class.new(record, **form_options)
49
53
  end
50
54
 
51
55
  def present_parent? = false
@@ -8,11 +8,82 @@ module Plutonium
8
8
  class Policy < ::ActionPolicy::Base
9
9
  authorize :user, allow_nil: false
10
10
  authorize :entity_scope, allow_nil: true
11
+ authorize :parent, optional: true
12
+ authorize :parent_association, optional: true
11
13
 
12
14
  relation_scope do |relation|
13
- next relation unless entity_scope
15
+ default_relation_scope(relation)
16
+ end
17
+
18
+ # Wraps apply_scope to verify default_relation_scope was called.
19
+ # This prevents accidental multi-tenancy leaks when overriding relation_scope.
20
+ def apply_scope(relation, type:, **options)
21
+ @_default_relation_scope_applied = false
22
+ result = super
23
+ verify_default_relation_scope_applied! if type == :active_record_relation
24
+ result
25
+ end
14
26
 
15
- relation.associated_with(entity_scope)
27
+ # Explicitly skip the default relation scope verification.
28
+ #
29
+ # Call this when you intentionally want to bypass parent/entity scoping.
30
+ # This should be rare - consider using a separate portal instead.
31
+ #
32
+ # @example Skipping default scoping (use sparingly)
33
+ # relation_scope do |relation|
34
+ # skip_default_relation_scope!
35
+ # relation.where(featured: true) # No parent/entity scoping
36
+ # end
37
+ def skip_default_relation_scope!
38
+ @_default_relation_scope_applied = true
39
+ end
40
+
41
+ # Applies Plutonium's default scoping (parent or entity) to a relation.
42
+ #
43
+ # This method MUST be called in any custom relation_scope to ensure proper
44
+ # parent/entity scoping. Failure to call it will raise an error.
45
+ #
46
+ # @example Overriding inherited scope while keeping default scoping
47
+ # # Parent policy has custom filtering you want to replace
48
+ # class AdminPostPolicy < PostPolicy
49
+ # relation_scope do |relation|
50
+ # # Replace inherited scope but keep Plutonium's parent/entity scoping
51
+ # default_relation_scope(relation)
52
+ # end
53
+ # end
54
+ #
55
+ # @example Adding filtering on top of default scoping
56
+ # relation_scope do |relation|
57
+ # default_relation_scope(relation).where(published: true)
58
+ # end
59
+ #
60
+ # @param relation [ActiveRecord::Relation] The relation to scope
61
+ # @return [ActiveRecord::Relation] The scoped relation
62
+ def default_relation_scope(relation)
63
+ @_default_relation_scope_applied = true
64
+
65
+ if parent || parent_association
66
+ unless parent && parent_association
67
+ raise ArgumentError, "parent and parent_association must both be provided together"
68
+ end
69
+
70
+ # Parent association scoping (nested routes)
71
+ # The parent was already entity-scoped during authorization, so children
72
+ # accessed through the parent don't need additional entity scoping
73
+ assoc_reflection = parent.class.reflect_on_association(parent_association)
74
+ if assoc_reflection.collection?
75
+ # has_many: merge with the association's scope
76
+ parent.public_send(parent_association).merge(relation)
77
+ else
78
+ # has_one: scope by foreign key
79
+ relation.where(assoc_reflection.foreign_key => parent.id)
80
+ end
81
+ elsif entity_scope
82
+ # Entity scoping (multi-tenancy)
83
+ relation.associated_with(entity_scope)
84
+ else
85
+ relation
86
+ end
16
87
  end
17
88
 
18
89
  # Sends a method and raises an error if the method is not implemented.
@@ -162,6 +233,18 @@ module Plutonium
162
233
  record.instance_of?(Class) ? record : record.class
163
234
  end
164
235
 
236
+ # Verifies that default_relation_scope was called during scope application.
237
+ # Raises an error if it wasn't, preventing accidental multi-tenancy leaks.
238
+ def verify_default_relation_scope_applied!
239
+ return if @_default_relation_scope_applied
240
+
241
+ raise <<~MSG.squish
242
+ #{self.class.name} did not call `default_relation_scope` in its relation_scope.
243
+ This can cause multi-tenancy leaks. Either call `default_relation_scope(relation)`
244
+ or `super(relation)` in your relation_scope block.
245
+ MSG
246
+ end
247
+
165
248
  # Autodetects the permitted fields for a given method.
166
249
  #
167
250
  # @param method_name [Symbol] The name of the method.
@@ -12,10 +12,40 @@ module Plutonium
12
12
  end
13
13
 
14
14
  class_methods do
15
+ # Returns metadata for has_many associations that can be routed
16
+ # @return [Array<Hash>] Array of hashes with :name, :klass, :plural keys
17
+ def routable_has_many_associations
18
+ return @routable_has_many_associations if defined?(@routable_has_many_associations) && !Rails.env.local?
19
+
20
+ @routable_has_many_associations = reflect_on_all_associations(:has_many).map do |assoc|
21
+ {name: assoc.name, klass: assoc.klass, plural: assoc.klass.model_name.plural}
22
+ end
23
+ end
24
+
25
+ # Returns metadata for has_one associations that can be routed
26
+ # @return [Array<Hash>] Array of hashes with :name, :klass, :plural keys
27
+ def routable_has_one_associations
28
+ return @routable_has_one_associations if defined?(@routable_has_one_associations) && !Rails.env.local?
29
+
30
+ @routable_has_one_associations = reflect_on_all_associations(:has_one)
31
+ .reject { |assoc| assoc.options[:through] }
32
+ .map do |assoc|
33
+ {name: assoc.name, klass: assoc.klass, plural: assoc.klass.model_name.plural}
34
+ end
35
+ end
36
+
37
+ # @deprecated Use routable_has_many_associations instead
15
38
  def has_many_association_routes
16
39
  return @has_many_association_routes if defined?(@has_many_association_routes) && !Rails.env.local?
17
40
 
18
- @has_many_association_routes = reflect_on_all_associations(:has_many).map { |assoc| assoc.klass.model_name.plural }
41
+ @has_many_association_routes = routable_has_many_associations.map { |info| info[:plural] }
42
+ end
43
+
44
+ # @deprecated Use routable_has_one_associations instead
45
+ def has_one_association_routes
46
+ return @has_one_association_routes if defined?(@has_one_association_routes) && !Rails.env.local?
47
+
48
+ @has_one_association_routes = routable_has_one_associations.map { |info| info[:plural] }
19
49
  end
20
50
 
21
51
  def all_nested_attributes_options
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Plutonium
4
4
  module Routing
5
+ # Prefix used for nested resource routes to disambiguate from user-defined routes
6
+ NESTED_ROUTE_PREFIX = "nested_"
7
+
5
8
  # MapperExtensions module provides additional functionality for route mapping in Plutonium applications.
6
9
  #
7
10
  # This module extends the functionality of Rails' routing mapper to support Plutonium-specific features,
@@ -77,10 +80,43 @@ module Plutonium
77
80
  # @param resource [Class] The parent resource class.
78
81
  # @return [void]
79
82
  def define_nested_resource_routes(resource)
80
- nested_configs = route_set.resource_route_config_for(*resource.has_many_association_routes)
81
- nested_configs.each do |nested_config|
82
- resources "nested_#{nested_config[:route_name]}", **nested_config[:route_options] do
83
- instance_exec(&nested_config[:block]) if nested_config[:block]
83
+ # has_many associations use plural routes
84
+ resource.routable_has_many_associations.each do |assoc_info|
85
+ base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
86
+ next unless base_config
87
+
88
+ # Register with association-based key: "parent_plural/association_name"
89
+ nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
90
+ nested_config = base_config.merge(
91
+ association_name: assoc_info[:name],
92
+ resource_class: assoc_info[:klass]
93
+ )
94
+ route_set.resource_route_config_lookup[nested_key] = nested_config
95
+
96
+ resources "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
97
+ instance_exec(&base_config[:block]) if base_config[:block]
98
+ end
99
+ end
100
+
101
+ # has_one associations use singular routes
102
+ resource.routable_has_one_associations.each do |assoc_info|
103
+ base_config = route_set.resource_route_config_for(assoc_info[:plural])[0]
104
+ next unless base_config
105
+
106
+ # Register with association-based key and singular route type
107
+ nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
108
+ nested_config = base_config.merge(
109
+ route_type: :resource,
110
+ association_name: assoc_info[:name],
111
+ resource_class: assoc_info[:klass]
112
+ )
113
+ route_set.resource_route_config_lookup[nested_key] = nested_config
114
+
115
+ resource "#{NESTED_ROUTE_PREFIX}#{assoc_info[:name]}", **base_config[:route_options].except(:path) do
116
+ original_collection = method(:collection)
117
+ define_singleton_method(:collection) { |&_| } # no-op for singular resources
118
+ instance_exec(&base_config[:block]) if base_config[:block]
119
+ define_singleton_method(:collection, original_collection)
84
120
  end
85
121
  end
86
122
  end
@@ -91,8 +127,9 @@ module Plutonium
91
127
  def define_member_interactive_actions
92
128
  member do
93
129
  get "record_actions/:interactive_action", action: :interactive_record_action,
94
- as: :record_action
95
- post "record_actions/:interactive_action", action: :commit_interactive_record_action
130
+ as: :interactive_record_action
131
+ post "record_actions/:interactive_action", action: :commit_interactive_record_action,
132
+ as: :commit_interactive_record_action
96
133
  end
97
134
  end
98
135
 
@@ -102,12 +139,14 @@ module Plutonium
102
139
  def define_collection_interactive_actions
103
140
  collection do
104
141
  get "bulk_actions/:interactive_action", action: :interactive_bulk_action,
105
- as: :bulk_action
106
- post "bulk_actions/:interactive_action", action: :commit_interactive_bulk_action
142
+ as: :interactive_bulk_action
143
+ post "bulk_actions/:interactive_action", action: :commit_interactive_bulk_action,
144
+ as: :commit_interactive_bulk_action
107
145
 
108
146
  get "resource_actions/:interactive_action", action: :interactive_resource_action,
109
- as: :resource_action
110
- post "resource_actions/:interactive_action", action: :commit_interactive_resource_action
147
+ as: :interactive_resource_action
148
+ post "resource_actions/:interactive_action", action: :commit_interactive_resource_action,
149
+ as: :commit_interactive_resource_action
111
150
  end
112
151
  end
113
152
 
@@ -83,6 +83,8 @@ module Plutonium
83
83
  end
84
84
 
85
85
  # @return [Hash] A lookup table for resource route configurations.
86
+ # Keys are either plural names (e.g., "profiles") for top-level routes
87
+ # or "parent_plural/child_plural" (e.g., "users/profiles") for nested routes.
86
88
  def resource_route_config_lookup
87
89
  @resource_route_config_lookup ||= {}
88
90
  end
@@ -107,6 +109,7 @@ module Plutonium
107
109
  # @return [Hash] The complete resource configuration.
108
110
  def create_resource_config(resource, route_name, concern_name, options = {}, &block)
109
111
  {
112
+ resource_class: resource,
110
113
  route_type: options[:singular] ? :resource : :resources,
111
114
  route_name: route_name,
112
115
  concern_name: concern_name,