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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-controller/SKILL.md +38 -2
- data/.claude/skills/plutonium-definition-actions/SKILL.md +13 -0
- data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
- data/.claude/skills/plutonium-nested-resources/SKILL.md +85 -23
- data/.claude/skills/plutonium-policy/SKILL.md +93 -6
- data/CHANGELOG.md +42 -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/custom-actions.md +14 -0
- data/docs/guides/index.md +5 -0
- data/docs/guides/nested-resources.md +139 -32
- data/docs/guides/troubleshooting.md +82 -0
- data/docs/og-image.html +84 -0
- data/docs/public/og-image.png +0 -0
- data/docs/reference/controller/index.md +6 -2
- data/docs/reference/definition/actions.md +14 -0
- 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 +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +5 -5
- data/gemfiles/rails_8.1.gemfile.lock +5 -5
- data/lib/generators/pu/rodauth/install_generator.rb +7 -11
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +3 -5
- data/lib/plutonium/auth/sequel_adapter.rb +76 -0
- data/lib/plutonium/core/controller.rb +143 -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 +91 -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 +15 -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 +49 -10
- data/lib/plutonium/routing/route_set_extensions.rb +3 -0
- data/lib/plutonium/ui/action_button.rb +72 -11
- data/lib/plutonium/ui/actions_dropdown.rb +3 -25
- data/lib/plutonium/ui/breadcrumbs.rb +2 -2
- data/lib/plutonium/ui/component/methods.rb +10 -3
- data/lib/plutonium/ui/display/resource.rb +5 -2
- data/lib/plutonium/ui/form/base.rb +1 -1
- data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
- data/lib/plutonium/ui/form/interaction.rb +5 -5
- data/lib/plutonium/ui/form/query.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/basic_layout.rb +2 -2
- data/lib/plutonium/ui/layout/resource_layout.rb +2 -2
- data/lib/plutonium/ui/layout/rodauth_layout.rb +2 -2
- data/lib/plutonium/ui/page/index.rb +1 -1
- data/lib/plutonium/ui/page/interactive_action.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +3 -25
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +1 -1
- data/package.json +6 -5
- data/plutonium.gemspec +2 -2
- 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 +10 -7
- 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
|
|
43
|
-
record
|
|
44
|
-
entity_scope
|
|
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
|
|
225
|
+
### With Parent Scoping (Nested Resources)
|
|
222
226
|
|
|
223
|
-
Call `super` to
|
|
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.
|
|
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.
|
|
15
|
-
phlexi-menu (>= 0.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
-
phlexi-menu (>= 0.4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
15
|
-
phlexi-menu (>= 0.4.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
66
|
+
ActiveRecord::Base.connection_db_config&.adapter
|
|
71
67
|
else
|
|
72
|
-
ActiveRecord::Base.connection_config
|
|
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
|
-
|
|
21
|
-
db
|
|
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
|
-
#
|
|
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 = [
|
|
194
|
+
controller_chain = [package&.to_s].compact
|
|
74
195
|
[*args].compact.each_with_index do |element, index|
|
|
75
|
-
if element.is_a?(
|
|
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
|
-
|
|
89
|
-
|
|
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
|
|