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
@@ -36,12 +36,16 @@ Inside a policy, you have access to:
36
36
  | `user` | Current authenticated user (required) |
37
37
  | `record` | Resource being authorized |
38
38
  | `entity_scope` | Current scoped entity (for multi-tenancy) |
39
+ | `parent` | Parent record for nested resources (nil if not nested) |
40
+ | `parent_association` | Association name on parent (e.g., `:comments`) |
39
41
 
40
42
  ```ruby
41
43
  def update?
42
- user # => Current user
43
- record # => The Post instance
44
- entity_scope # => Organization for multi-tenant portals
44
+ user # => Current user
45
+ record # => The Post instance
46
+ entity_scope # => Organization for multi-tenant portals
47
+ parent # => Parent record (for nested routes)
48
+ parent_association # => :comments (association name)
45
49
  end
46
50
  ```
47
51
 
@@ -218,9 +222,29 @@ class PostPolicy < Plutonium::Resource::Policy
218
222
  end
219
223
  ```
220
224
 
221
- ### With Entity Scoping
225
+ ### With Parent Scoping (Nested Resources)
222
226
 
223
- Call `super` to preserve automatic entity scoping for multi-tenancy:
227
+ Call `super` to apply automatic parent scoping for nested resources:
228
+
229
+ ```ruby
230
+ relation_scope do |relation|
231
+ relation = super(relation) # Applies parent scoping automatically
232
+
233
+ if user.admin?
234
+ relation
235
+ else
236
+ relation.where(approved: true)
237
+ end
238
+ end
239
+ ```
240
+
241
+ **Parent scoping takes precedence over entity scoping.** When a parent is present:
242
+ - For `has_many` associations: scopes via `parent.association_name`
243
+ - For `has_one` associations: scopes via `where(foreign_key: parent.id)`
244
+
245
+ ### With Entity Scoping (Multi-tenancy)
246
+
247
+ When no parent is present, `super` applies entity scoping:
224
248
 
225
249
  ```ruby
226
250
  relation_scope do |relation|
@@ -234,7 +258,54 @@ relation_scope do |relation|
234
258
  end
235
259
  ```
236
260
 
237
- The default `relation_scope` automatically applies `relation.associated_with(entity_scope)` when an entity scope is present.
261
+ The default `relation_scope` automatically applies `relation.associated_with(entity_scope)` when an entity scope is present and no parent is set.
262
+
263
+ ### default_relation_scope is Required
264
+
265
+ Plutonium verifies that `default_relation_scope` is called in every `relation_scope`. This prevents accidental multi-tenancy leaks when overriding scopes.
266
+
267
+ ```ruby
268
+ # ❌ This will raise an error
269
+ relation_scope do |relation|
270
+ relation.where(published: true) # Missing default_relation_scope!
271
+ end
272
+
273
+ # ✅ Correct - call default_relation_scope
274
+ relation_scope do |relation|
275
+ default_relation_scope(relation).where(published: true)
276
+ end
277
+
278
+ # ✅ Also correct - super calls default_relation_scope
279
+ relation_scope do |relation|
280
+ super(relation).where(published: true)
281
+ end
282
+ ```
283
+
284
+ When overriding an inherited scope:
285
+
286
+ ```ruby
287
+ class AdminPostPolicy < PostPolicy
288
+ relation_scope do |relation|
289
+ # Replace inherited scope but keep Plutonium's parent/entity scoping
290
+ default_relation_scope(relation)
291
+ end
292
+ end
293
+ ```
294
+
295
+ This method applies parent scoping (for nested resources) or entity scoping (for multi-tenancy) directly, bypassing any inherited scope customizations.
296
+
297
+ ### Skipping Default Scoping
298
+
299
+ If you intentionally need to bypass scoping, call `skip_default_relation_scope!`:
300
+
301
+ ```ruby
302
+ relation_scope do |relation|
303
+ skip_default_relation_scope!
304
+ relation # No parent/entity scoping applied
305
+ end
306
+ ```
307
+
308
+ This should be rare - consider using a separate portal with different scoping rules instead.
238
309
 
239
310
  ## Portal-Specific Policies
240
311
 
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.34.1)
4
+ plutonium (0.38.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -11,8 +11,8 @@ PATH
11
11
  phlex-tabler_icons
12
12
  phlexi-display (>= 0.2.0)
13
13
  phlexi-field (>= 0.2.0)
14
- phlexi-form (>= 0.10.0)
15
- phlexi-menu (>= 0.4.0)
14
+ phlexi-form (>= 0.14.1)
15
+ phlexi-menu (>= 0.4.1)
16
16
  phlexi-table (>= 0.2.0)
17
17
  rabl (~> 0.16.1)
18
18
  rails (>= 7.2)
@@ -230,12 +230,12 @@ GEM
230
230
  fiber-local
231
231
  phlex (~> 2.0)
232
232
  zeitwerk
233
- phlexi-form (0.11.0)
233
+ phlexi-form (0.14.1)
234
234
  activesupport
235
235
  phlex (~> 2.0)
236
236
  phlexi-field (~> 0.2.0)
237
237
  zeitwerk
238
- phlexi-menu (0.4.0)
238
+ phlexi-menu (0.4.1)
239
239
  phlex (~> 2.0)
240
240
  phlexi-field (~> 0.2.0)
241
241
  zeitwerk
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.34.1)
4
+ plutonium (0.38.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -11,8 +11,8 @@ PATH
11
11
  phlex-tabler_icons
12
12
  phlexi-display (>= 0.2.0)
13
13
  phlexi-field (>= 0.2.0)
14
- phlexi-form (>= 0.10.0)
15
- phlexi-menu (>= 0.4.0)
14
+ phlexi-form (>= 0.14.1)
15
+ phlexi-menu (>= 0.4.1)
16
16
  phlexi-table (>= 0.2.0)
17
17
  rabl (~> 0.16.1)
18
18
  rails (>= 7.2)
@@ -209,12 +209,12 @@ GEM
209
209
  fiber-local
210
210
  phlex (~> 2.0)
211
211
  zeitwerk
212
- phlexi-form (0.11.0)
212
+ phlexi-form (0.14.1)
213
213
  activesupport
214
214
  phlex (~> 2.0)
215
215
  phlexi-field (~> 0.2.0)
216
216
  zeitwerk
217
- phlexi-menu (0.4.0)
217
+ phlexi-menu (0.4.1)
218
218
  phlex (~> 2.0)
219
219
  phlexi-field (~> 0.2.0)
220
220
  zeitwerk
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.34.1)
4
+ plutonium (0.38.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -11,8 +11,8 @@ PATH
11
11
  phlex-tabler_icons
12
12
  phlexi-display (>= 0.2.0)
13
13
  phlexi-field (>= 0.2.0)
14
- phlexi-form (>= 0.10.0)
15
- phlexi-menu (>= 0.4.0)
14
+ phlexi-form (>= 0.14.1)
15
+ phlexi-menu (>= 0.4.1)
16
16
  phlexi-table (>= 0.2.0)
17
17
  rabl (~> 0.16.1)
18
18
  rails (>= 7.2)
@@ -211,12 +211,12 @@ GEM
211
211
  fiber-local
212
212
  phlex (~> 2.0)
213
213
  zeitwerk
214
- phlexi-form (0.11.0)
214
+ phlexi-form (0.14.1)
215
215
  activesupport
216
216
  phlex (~> 2.0)
217
217
  phlexi-field (~> 0.2.0)
218
218
  zeitwerk
219
- phlexi-menu (0.4.0)
219
+ phlexi-menu (0.4.1)
220
220
  phlex (~> 2.0)
221
221
  phlexi-field (~> 0.2.0)
222
222
  zeitwerk
@@ -1,20 +1,13 @@
1
1
  require "rails/generators/base"
2
2
  require "rails/generators/active_record/migration"
3
3
  require "securerandom"
4
+ require "plutonium/auth/sequel_adapter"
4
5
 
5
6
  module Pu
6
7
  module Rodauth
7
8
  class InstallGenerator < ::Rails::Generators::Base
8
9
  include ::ActiveRecord::Generators::Migration
9
10
 
10
- SEQUEL_ADAPTERS = {
11
- "postgresql" => (RUBY_ENGINE == "jruby") ? "postgresql" : "postgres",
12
- "mysql2" => (RUBY_ENGINE == "jruby") ? "mysql" : "mysql2",
13
- "sqlite3" => "sqlite",
14
- "oracle_enhanced" => "oracle",
15
- "sqlserver" => (RUBY_ENGINE == "jruby") ? "mssql" : "tinytds"
16
- }
17
-
18
11
  source_root "#{__dir__}/templates"
19
12
 
20
13
  desc "Install rodauth-rails"
@@ -61,15 +54,18 @@ module Pu
61
54
 
62
55
  private
63
56
 
57
+ # Delegates to the SequelAdapter module to avoid code duplication.
64
58
  def sequel_adapter
65
- SEQUEL_ADAPTERS[activerecord_adapter] || activerecord_adapter
59
+ Plutonium::Auth::SequelAdapter.sequel_adapter
66
60
  end
67
61
 
62
+ # Delegates to the SequelAdapter module's internal ActiveRecord adapter detection.
63
+ # We still provide this method for use in create_install_migration.
68
64
  def activerecord_adapter
69
65
  if ActiveRecord::Base.respond_to?(:connection_db_config)
70
- ActiveRecord::Base.connection_db_config.adapter
66
+ ActiveRecord::Base.connection_db_config&.adapter
71
67
  else
72
- ActiveRecord::Base.connection_config.fetch(:adapter)
68
+ ActiveRecord::Base.connection_config&.fetch(:adapter, nil)
73
69
  end
74
70
  end
75
71
  end
@@ -1,4 +1,5 @@
1
1
  require "sequel/core"
2
+ require "plutonium/auth/sequel_adapter"
2
3
 
3
4
  class RodauthPlugin < Rodauth::Rails::Auth
4
5
  attr_accessor :url_options
@@ -17,11 +18,8 @@ class RodauthPlugin < Rodauth::Rails::Auth
17
18
  # ==> General
18
19
 
19
20
  # Initialize Sequel and have it reuse Active Record's database connection.
20
- <% if RUBY_ENGINE == "jruby" -%>
21
- db Sequel.connect("jdbc:<%= sequel_adapter %>://", extensions: :activerecord_connection, keep_reference: false)
22
- <% else -%>
23
- db Sequel.<%= sequel_adapter %>(extensions: :activerecord_connection, keep_reference: false)
24
- <% end -%>
21
+ # The adapter is detected dynamically at runtime to support database changes.
22
+ db Plutonium::Auth::SequelAdapter.db
25
23
 
26
24
  # Change prefix of table and foreign key column names from default "account"
27
25
  # accounts_table :users
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sequel/core"
4
+
5
+ module Plutonium
6
+ module Auth
7
+ # Provides runtime detection of the database adapter for Sequel configuration.
8
+ # This module dynamically detects the ActiveRecord adapter and returns the
9
+ # corresponding Sequel adapter, allowing users to change their database
10
+ # without needing to regenerate rodauth files.
11
+ module SequelAdapter
12
+ # Maps ActiveRecord adapter names to their corresponding Sequel adapter names.
13
+ # JRuby uses JDBC adapters which have different naming conventions.
14
+ SEQUEL_ADAPTERS = {
15
+ "postgresql" => (RUBY_ENGINE == "jruby") ? "postgresql" : "postgres",
16
+ "mysql2" => (RUBY_ENGINE == "jruby") ? "mysql" : "mysql2",
17
+ "sqlite3" => "sqlite",
18
+ "oracle_enhanced" => "oracle",
19
+ "sqlserver" => (RUBY_ENGINE == "jruby") ? "mssql" : "tinytds"
20
+ }.freeze
21
+
22
+ class << self
23
+ # Returns a Sequel database connection that reuses ActiveRecord's connection.
24
+ # Automatically detects the correct adapter based on the current ActiveRecord config.
25
+ #
26
+ # @return [Sequel::Database] configured Sequel database connection
27
+ # @raise [RuntimeError] if the Sequel adapter initialization fails
28
+ def db
29
+ adapter = sequel_adapter
30
+ begin
31
+ if RUBY_ENGINE == "jruby"
32
+ Sequel.connect("jdbc:#{adapter}://", extensions: :activerecord_connection, keep_reference: false)
33
+ else
34
+ Sequel.public_send(adapter, extensions: :activerecord_connection, keep_reference: false)
35
+ end
36
+ rescue => e
37
+ raise "Failed to initialize Sequel with adapter '#{adapter}'. " \
38
+ "Please ensure your database configuration is correct and the required " \
39
+ "database gems are installed. Original error: #{e.message}"
40
+ end
41
+ end
42
+
43
+ # Returns the Sequel adapter name based on the current ActiveRecord adapter.
44
+ # If the ActiveRecord adapter is not in the SEQUEL_ADAPTERS mapping, the
45
+ # ActiveRecord adapter name is returned as-is, which may work for adapters
46
+ # where the names match between ActiveRecord and Sequel.
47
+ #
48
+ # @return [String] the Sequel adapter name
49
+ def sequel_adapter
50
+ SEQUEL_ADAPTERS[activerecord_adapter] || activerecord_adapter
51
+ end
52
+
53
+ private
54
+
55
+ # Returns the current ActiveRecord adapter name.
56
+ #
57
+ # @return [String] the ActiveRecord adapter name
58
+ # @raise [RuntimeError] if the ActiveRecord adapter cannot be determined
59
+ def activerecord_adapter
60
+ adapter = if ActiveRecord::Base.respond_to?(:connection_db_config)
61
+ ActiveRecord::Base.connection_db_config&.adapter
62
+ else
63
+ ActiveRecord::Base.connection_config&.fetch(:adapter, nil)
64
+ end
65
+
66
+ unless adapter
67
+ raise "Unable to determine the ActiveRecord database adapter. " \
68
+ "Please ensure ActiveRecord is properly configured with a database connection."
69
+ end
70
+
71
+ adapter
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -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,154 @@ 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 :show, nil then ""
170
+ else "#{action}_"
171
+ end
172
+
173
+ helper_name = :"#{helper_suffix}#{helper_base}_path"
174
+
175
+ # Build the arguments for the helper
176
+ helper_args = [parent.to_param]
177
+ # Include element ID for plural routes (has_many) when we have a record instance
178
+ unless is_singular || no_record
179
+ helper_args << element.to_param
180
+ end
181
+
182
+ # Build URL options
183
+ url_options = kwargs.dup
184
+ if !url_options.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
185
+ url_options[:format] = request.format.symbol
186
+ end
187
+
188
+ {_named_route: helper_name, _args: helper_args, _options: url_options}
189
+ end
190
+
191
+ def build_top_level_resource_url_args(*args, action: nil, parent: nil, association: nil, package: nil, **kwargs)
71
192
  url_args = {**kwargs, action: action}.compact
72
193
 
73
- controller_chain = [current_package&.to_s].compact
194
+ controller_chain = [package&.to_s].compact
74
195
  [*args].compact.each_with_index do |element, index|
75
- if element.is_a?(Class)
196
+ if element.is_a?(Symbol)
197
+ raise ArgumentError, "parent is required when using symbol association name" unless parent
198
+
199
+ assoc = parent.class.reflect_on_association(element)
200
+ raise ArgumentError, "Unknown association :#{element} on #{parent.class}" unless assoc
201
+
202
+ controller_chain << assoc.klass.to_s.pluralize
203
+ url_args[:action] ||= :index if index == args.length - 1
204
+ elsif element.is_a?(Class)
76
205
  controller_chain << element.to_s.pluralize
206
+ url_args[:action] ||= :index if index == args.length - 1 && parent.present?
77
207
  else
78
- # For STI models, use the base class for routing if the specific class isn't registered
79
208
  model_class = element.class
80
209
  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
210
  route_configs = current_engine.routes.resource_route_config_for(model_class.model_name.plural)
83
211
  model_class = model_class.base_class if route_configs.empty?
84
212
  end
85
213
 
86
214
  controller_chain << model_class.to_s.pluralize
87
215
  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
216
+ route_key = if parent.present?
217
+ assoc_name = association || resolve_association(element, parent)
218
+ "#{parent.class.model_name.plural}/#{assoc_name}"
219
+ else
220
+ model_class.model_name.plural
221
+ end
222
+ resource_route_config = current_engine.routes.resource_route_config_for(route_key)[0]
223
+ is_singular = resource_route_config&.dig(:route_type) == :resource
224
+ url_args[:id] = element.to_param unless is_singular
90
225
  url_args[:action] ||= :show
91
226
  else
92
227
  url_args[model_class.to_s.underscore.singularize.to_sym] = element.to_param
@@ -100,8 +235,6 @@ module Plutonium
100
235
  url_args[scoped_entity_param_key] = current_scoped_entity
101
236
  end
102
237
 
103
- # Preserve the request format unless explicitly specified
104
- # Don't preserve turbo_stream as it's for streaming updates, not page navigation
105
238
  if !url_args.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
106
239
  url_args[:format] = request.format.symbol
107
240
  end
@@ -109,15 +242,6 @@ module Plutonium
109
242
  url_args
110
243
  end
111
244
 
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
245
  def root_path(*)
122
246
  return send(:"#{scoped_entity_param_key}_root_path", *) if scoped_to_entity? && scoped_entity_strategy == :path
123
247
 
@@ -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