plutonium 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +25 -2
  3. data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
  4. data/.claude/skills/plutonium-nested-resources/SKILL.md +79 -19
  5. data/.claude/skills/plutonium-policy/SKILL.md +93 -6
  6. data/CHANGELOG.md +36 -0
  7. data/CLAUDE.md +8 -10
  8. data/CONTRIBUTING.md +6 -8
  9. data/Rakefile +16 -1
  10. data/app/assets/plutonium.css +1 -1
  11. data/app/assets/plutonium.js +9371 -11492
  12. data/app/assets/plutonium.js.map +4 -4
  13. data/app/assets/plutonium.min.js +55 -55
  14. data/app/assets/plutonium.min.js.map +4 -4
  15. data/docs/guides/index.md +5 -0
  16. data/docs/guides/nested-resources.md +132 -29
  17. data/docs/guides/troubleshooting.md +82 -0
  18. data/docs/reference/controller/index.md +1 -1
  19. data/docs/reference/definition/fields.md +33 -0
  20. data/docs/reference/model/index.md +1 -1
  21. data/docs/reference/policy/index.md +77 -6
  22. data/gemfiles/rails_7.gemfile.lock +3 -3
  23. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  24. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  25. data/lib/plutonium/core/controller.rb +144 -19
  26. data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
  27. data/lib/plutonium/helpers/display_helper.rb +12 -0
  28. data/lib/plutonium/query/filters/association.rb +25 -3
  29. data/lib/plutonium/resource/controller.rb +90 -9
  30. data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
  31. data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
  32. data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
  33. data/lib/plutonium/resource/controllers/presentable.rb +13 -11
  34. data/lib/plutonium/resource/policy.rb +85 -2
  35. data/lib/plutonium/resource/record/routes.rb +31 -1
  36. data/lib/plutonium/routing/mapper_extensions.rb +40 -4
  37. data/lib/plutonium/routing/route_set_extensions.rb +3 -0
  38. data/lib/plutonium/ui/breadcrumbs.rb +1 -1
  39. data/lib/plutonium/ui/display/resource.rb +5 -2
  40. data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
  41. data/lib/plutonium/ui/page/index.rb +1 -1
  42. data/lib/plutonium/version.rb +1 -1
  43. data/lib/tasks/release.rake +1 -1
  44. data/package.json +6 -5
  45. data/plutonium.gemspec +1 -1
  46. data/src/js/controllers/key_value_store_controller.js +6 -0
  47. data/src/js/controllers/resource_drop_down_controller.js +3 -3
  48. data/yarn.lock +1465 -693
  49. metadata +6 -5
  50. data/app/javascript/controllers/key_value_store_controller.js +0 -119
@@ -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,8 @@ 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)
50
+ current_definition.form_class.new(record, resource_fields: submittable_attributes_for(action), resource_definition: current_definition)
49
51
  end
50
52
 
51
53
  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
@@ -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,
@@ -101,7 +101,7 @@ module Plutonium
101
101
  d: "m1 9 4-4-4-4"
102
102
  )
103
103
  end
104
- link_to resource_name_plural(resource_class),
104
+ link_to helpers.nestable_resource_name_plural(resource_class),
105
105
  resource_url_for(resource_class),
106
106
  class: "ms-1 text-sm font-medium text-[var(--pu-text-muted)] hover:text-primary-600 md:ms-2 transition-colors"
107
107
  end
@@ -48,11 +48,14 @@ module Plutonium
48
48
 
49
49
  title = object.class.human_attribute_name(name)
50
50
  src = case reflection.macro
51
- when :belongs_to, :has_one
51
+ when :belongs_to
52
52
  associated = object.public_send name
53
53
  resource_url_for(associated, parent: nil) if associated
54
+ when :has_one
55
+ associated = object.public_send name
56
+ resource_url_for(associated, parent: object, association: name)
54
57
  when :has_many
55
- resource_url_for(reflection.klass, parent: object)
58
+ resource_url_for(reflection.klass, parent: object, association: name)
56
59
  end
57
60
 
58
61
  next unless src
@@ -60,6 +60,12 @@ module Plutonium
60
60
  end
61
61
 
62
62
  def render_key_value_pairs
63
+ # Hidden sentinel input ensures the field is always present in params when the
64
+ # component is rendered. Without this, removing all pairs would submit nothing,
65
+ # making it impossible to distinguish "field not in form" from "field cleared".
66
+ # This allows normalize_input to return nil (preserve existing) vs {} (clear field).
67
+ input(type: :hidden, name: "#{field_name}[_submitted]", value: "1", autocomplete: "off", hidden: true)
68
+
63
69
  div(class: "key-value-pairs space-y-2", data_key_value_store_target: "container") do
64
70
  pairs.each_with_index do |(key, value), index|
65
71
  render_key_value_pair(key, value, index)
@@ -196,19 +202,25 @@ module Plutonium
196
202
  attributes.fetch(:limit, DEFAULT_LIMIT)
197
203
  end
198
204
 
199
- # Override from ExtractsInput concern to normalize form parameters
205
+ # Override from ExtractsInput concern to normalize form parameters.
206
+ # Returns nil if field wasn't submitted (preserves existing value),
207
+ # or a Hash (possibly empty) if the field was in the form.
200
208
  def normalize_input(input_value)
201
209
  case input_value
202
210
  when Hash
203
- if input_value.keys.all? { |k| k.to_s.match?(/^\d+$/) }
211
+ # Remove the sentinel key before processing
212
+ params = input_value.except("_submitted", :_submitted)
213
+
214
+ if params.keys.all? { |k| k.to_s.match?(/^\d+$/) }
204
215
  # Handle indexed form params: {"0" => {"key" => "foo", "value" => "bar"}}
205
- process_indexed_params(input_value)
216
+ process_indexed_params(params)
206
217
  else
207
218
  # Handle direct hash params
208
- input_value.reject { |k, v| k.blank? || (v.blank? && v != false) }
219
+ params.reject { |k, v| k.blank? || (v.blank? && v != false) }
209
220
  end
210
221
  when nil
211
- {}
222
+ # Field was not submitted at all - preserve existing value
223
+ nil
212
224
  end
213
225
  end
214
226
 
@@ -7,7 +7,7 @@ module Plutonium
7
7
  private
8
8
 
9
9
  def page_title
10
- super || current_definition.index_page_title || resource_name_plural(resource_class)
10
+ super || current_definition.index_page_title || helpers.nestable_resource_name_plural(resource_class)
11
11
  end
12
12
 
13
13
  def page_description
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.37.0"
2
+ VERSION = "0.38.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
@@ -102,7 +102,7 @@ namespace :release do
102
102
  desc "Build front-end assets"
103
103
  task :build_frontend do
104
104
  puts "Building front-end assets..."
105
- system("npm run build") || abort("Front-end build failed")
105
+ system("yarn build") || abort("Front-end build failed")
106
106
  puts "✓ Built front-end assets"
107
107
  end
108
108
 
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -33,6 +33,7 @@
33
33
  "@tailwindcss/forms": "^0.5.10",
34
34
  "@tailwindcss/postcss": "^4.0.9",
35
35
  "@tailwindcss/typography": "^0.5.16",
36
+ "chokidar-cli": "^3.0.0",
36
37
  "concurrently": "^8.2.2",
37
38
  "cssnano": "^7.0.2",
38
39
  "esbuild": "^0.20.1",
@@ -48,10 +49,10 @@
48
49
  "vitepress-plugin-mermaid": "^2.0.17"
49
50
  },
50
51
  "scripts": {
51
- "dev": "concurrently \"npm run css:dev\" \"npm run js:dev\"",
52
- "build": "npm run js:prod && npm run css:prod",
53
- "prepare": "npm run build",
54
- "css:dev": "postcss src/css/plutonium.entry.css -o src/build/plutonium.css --watch --dev",
52
+ "dev": "concurrently \"yarn css:dev\" \"yarn js:dev\"",
53
+ "build": "yarn js:prod && yarn css:prod",
54
+ "prepare": "yarn build",
55
+ "css:dev": "chokidar \"src/css/**/*.css\" \"src/js/**/*.js\" \"app/views/**/*.{rb,erb,js}\" \"lib/plutonium/**/*.rb\" -c \"postcss src/css/plutonium.entry.css -o src/build/plutonium.css --dev\" --initial",
55
56
  "js:dev": "node esbuild.config.js --dev",
56
57
  "css:prod": "postcss src/css/plutonium.entry.css -o app/assets/plutonium.css && postcss src/css/plutonium.entry.css -o src/dist/css/plutonium.css",
57
58
  "js:prod": "node esbuild.config.js",
data/plutonium.gemspec CHANGED
@@ -44,7 +44,7 @@ Gem::Specification.new do |spec|
44
44
  spec.add_dependency "phlex-rails"
45
45
  spec.add_dependency "phlex-tabler_icons"
46
46
  spec.add_dependency "phlexi-field", ">= 0.2.0"
47
- spec.add_dependency "phlexi-form", ">= 0.13.0"
47
+ spec.add_dependency "phlexi-form", ">= 0.14.0"
48
48
  spec.add_dependency "phlexi-table", ">= 0.2.0"
49
49
  spec.add_dependency "phlexi-display", ">= 0.2.0"
50
50
  spec.add_dependency "phlexi-menu", ">= 0.4.0"
@@ -25,6 +25,7 @@ export default class extends Controller {
25
25
  this.updatePairIndices(newPair, index)
26
26
 
27
27
  this.containerTarget.appendChild(newPair)
28
+ this.updateIndices()
28
29
  this.updateAddButtonState()
29
30
 
30
31
  // Focus on the key input of the new pair
@@ -52,9 +53,11 @@ export default class extends Controller {
52
53
 
53
54
  if (keyInput) {
54
55
  keyInput.name = keyInput.name.replace(/\[\d+\]/, `[${index}]`)
56
+ keyInput.id = keyInput.id.replace(/_\d+_/, `_${index}_`)
55
57
  }
56
58
  if (valueInput) {
57
59
  valueInput.name = valueInput.name.replace(/\[\d+\]/, `[${index}]`)
60
+ valueInput.id = valueInput.id.replace(/_\d+_/, `_${index}_`)
58
61
  }
59
62
  })
60
63
  }
@@ -65,6 +68,9 @@ export default class extends Controller {
65
68
  if (input.name) {
66
69
  input.name = input.name.replace('__INDEX__', index)
67
70
  }
71
+ if (input.id) {
72
+ input.id = input.id.replace('___INDEX___', `_${index}_`)
73
+ }
68
74
  })
69
75
  }
70
76
 
@@ -42,14 +42,14 @@ export default class extends Controller {
42
42
  {
43
43
  name: 'flip',
44
44
  options: {
45
- fallbackPlacements: ['left-end', 'right-start', 'right-end', 'bottom-start', 'bottom-end', 'top-start', 'top-end'],
46
- boundary: 'clippingParents',
45
+ fallbackPlacements: ['bottom-end', 'bottom-start', 'top', 'top-end', 'top-start'],
46
+ boundary: 'viewport',
47
47
  },
48
48
  },
49
49
  {
50
50
  name: 'preventOverflow',
51
51
  options: {
52
- boundary: 'clippingParents',
52
+ boundary: 'viewport',
53
53
  altAxis: true,
54
54
  padding: 8,
55
55
  },