plutonium 0.42.0 → 0.43.1

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -1
  3. data/.claude/skills/plutonium-definition/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-forms/SKILL.md +16 -1
  5. data/.claude/skills/plutonium-profile/SKILL.md +276 -0
  6. data/.claude/skills/plutonium-views/SKILL.md +23 -1
  7. data/CHANGELOG.md +42 -0
  8. data/app/assets/plutonium.css +2 -2
  9. data/app/views/plutonium/_resource_header.html.erb +6 -27
  10. data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
  11. data/app/views/resource/_resource_details.rabl +3 -2
  12. data/app/views/resource/index.rabl +3 -2
  13. data/app/views/resource/show.rabl +3 -2
  14. data/docs/guides/user-profile.md +322 -0
  15. data/docs/reference/controller/index.md +38 -1
  16. data/docs/reference/definition/index.md +16 -0
  17. data/docs/reference/views/forms.md +15 -0
  18. data/docs/reference/views/index.md +23 -1
  19. data/gemfiles/rails_7.gemfile.lock +1 -1
  20. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
  23. data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
  24. data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
  25. data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
  26. data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
  27. data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
  28. data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
  29. data/lib/generators/pu/invites/USAGE +0 -1
  30. data/lib/generators/pu/invites/install_generator.rb +62 -15
  31. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
  32. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
  33. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
  34. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
  35. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
  36. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
  37. data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
  38. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
  39. data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
  40. data/lib/generators/pu/profile/USAGE +59 -0
  41. data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
  42. data/lib/generators/pu/profile/conn/USAGE +33 -0
  43. data/lib/generators/pu/profile/conn_generator.rb +167 -0
  44. data/lib/generators/pu/profile/install_generator.rb +119 -0
  45. data/lib/generators/pu/profile/setup/USAGE +42 -0
  46. data/lib/generators/pu/profile/setup_generator.rb +73 -0
  47. data/lib/generators/pu/rodauth/account_generator.rb +2 -4
  48. data/lib/generators/pu/rodauth/install_generator.rb +2 -2
  49. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  50. data/lib/generators/pu/saas/api_client_generator.rb +0 -2
  51. data/lib/generators/pu/saas/membership_generator.rb +68 -19
  52. data/lib/generators/pu/saas/setup_generator.rb +7 -2
  53. data/lib/generators/pu/saas/user_generator.rb +0 -2
  54. data/lib/plutonium/auth/rodauth.rb +8 -0
  55. data/lib/plutonium/core/controller.rb +7 -4
  56. data/lib/plutonium/core/controllers/authorizable.rb +5 -1
  57. data/lib/plutonium/definition/base.rb +7 -0
  58. data/lib/plutonium/helpers/display_helper.rb +6 -0
  59. data/lib/plutonium/profile/security_section.rb +118 -0
  60. data/lib/plutonium/resource/controller.rb +17 -7
  61. data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
  62. data/lib/plutonium/resource/controllers/presentable.rb +46 -3
  63. data/lib/plutonium/resource/record/associated_with.rb +7 -1
  64. data/lib/plutonium/routing/mapper_extensions.rb +18 -18
  65. data/lib/plutonium/routing/route_set_extensions.rb +23 -2
  66. data/lib/plutonium/ui/breadcrumbs.rb +111 -131
  67. data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
  68. data/lib/plutonium/ui/form/resource.rb +26 -19
  69. data/lib/plutonium/ui/page/base.rb +14 -14
  70. data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -74
  71. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  72. data/lib/plutonium/ui/table/resource.rb +3 -2
  73. data/lib/plutonium/version.rb +1 -1
  74. data/lib/tasks/release.rake +6 -6
  75. data/package.json +1 -1
  76. metadata +17 -3
  77. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -39,14 +39,14 @@ module Pu
39
39
 
40
40
  def validate_models_exist!
41
41
  user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
42
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
42
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
43
43
 
44
44
  unless File.exist?(Rails.root.join(user_model_path))
45
45
  raise "User model '#{normalized_user_name}' does not exist at #{user_model_path}. Please create it first with: rails g pu:saas:user #{options[:user]}"
46
46
  end
47
47
 
48
- unless File.exist?(Rails.root.join(entity_model_path))
49
- raise "Entity model '#{normalized_entity_name}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
48
+ unless File.exist?(entity_model_path)
49
+ raise "Entity model '#{full_entity_path}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
50
50
  end
51
51
  end
52
52
 
@@ -63,7 +63,7 @@ module Pu
63
63
  return unless migration_file
64
64
 
65
65
  insert_into_file migration_file,
66
- indent("add_index :#{membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_user_name}_id], unique: true\n", 4),
66
+ indent("add_index :#{full_membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_user_name}_id], unique: true\n", 4),
67
67
  before: /^ end\s*$/
68
68
  end
69
69
 
@@ -78,18 +78,18 @@ module Pu
78
78
  end
79
79
 
80
80
  def add_role_enum_to_model
81
- model_file = File.join("app", "models", "#{membership_model_name}.rb")
81
+ model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
82
82
 
83
- return unless File.exist?(Rails.root.join(model_file))
83
+ return unless File.exist?(model_file)
84
84
 
85
85
  enum_definition = "enum :role, #{roles_enum}\n"
86
86
  insert_into_file model_file, indent(enum_definition, 2), before: /^\s*# add enums above\./
87
87
  end
88
88
 
89
89
  def add_unique_validation_to_model
90
- model_file = File.join("app", "models", "#{membership_model_name}.rb")
90
+ model_file = File.join(dest_root, "app", "models", "#{full_membership_path}.rb")
91
91
 
92
- return unless File.exist?(Rails.root.join(model_file))
92
+ return unless File.exist?(model_file)
93
93
 
94
94
  validation = "validates :#{normalized_user_name}, uniqueness: {scope: :#{normalized_entity_name}_id, message: \"is already a member of this #{normalized_entity_name.humanize.downcase}\"}\n"
95
95
  insert_into_file model_file, indent(validation, 2), before: /^\s*# add validations above\./
@@ -101,9 +101,9 @@ module Pu
101
101
  end
102
102
 
103
103
  def add_association_to_entity_model
104
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
104
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
105
105
 
106
- return unless File.exist?(Rails.root.join(entity_model_path))
106
+ return unless File.exist?(entity_model_path)
107
107
 
108
108
  associations = <<~RUBY
109
109
  has_many :#{membership_table_name}, dependent: :destroy
@@ -117,17 +117,24 @@ module Pu
117
117
 
118
118
  return unless File.exist?(Rails.root.join(user_model_path))
119
119
 
120
- associations = <<~RUBY
121
- has_many :#{membership_table_name}, dependent: :destroy
122
- has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
123
- RUBY
120
+ associations = if dest_namespace
121
+ <<~RUBY
122
+ has_many :#{membership_table_name}, class_name: "#{full_membership_class_name}", dependent: :destroy
123
+ has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}, source: :#{normalized_entity_name}
124
+ RUBY
125
+ else
126
+ <<~RUBY
127
+ has_many :#{membership_table_name}, dependent: :destroy
128
+ has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
129
+ RUBY
130
+ end
124
131
  inject_into_file user_model_path, indent(associations, 2), before: /^\s*# add has_many associations above\.\n/
125
132
  end
126
133
 
127
134
  def add_associated_with_scope_to_entity
128
- entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
135
+ entity_model_path = File.join(dest_root, "app", "models", "#{full_entity_path}.rb")
129
136
 
130
- return unless File.exist?(Rails.root.join(entity_model_path))
137
+ return unless File.exist?(entity_model_path)
131
138
 
132
139
  scope_code = <<~RUBY
133
140
  scope :associated_with_#{normalized_user_name}, ->(#{normalized_user_name}) { joins(:#{membership_table_name}).where(#{membership_table_name}: {#{normalized_user_name}_id: #{normalized_user_name}.id}) }
@@ -136,8 +143,8 @@ module Pu
136
143
  end
137
144
 
138
145
  def find_migration_file
139
- migration_dir = File.join("db", "migrate")
140
- Dir[Rails.root.join(migration_dir, "*_create_#{membership_table_name}.rb")].first
146
+ migration_dir = File.join(dest_root, "db", "migrate")
147
+ Dir[File.join(migration_dir, "*_create_#{full_membership_table_name}.rb")].first
141
148
  end
142
149
 
143
150
  def membership_model_name
@@ -150,7 +157,7 @@ module Pu
150
157
 
151
158
  def membership_attributes
152
159
  [
153
- "#{normalized_entity_name}:references",
160
+ "#{full_entity_name}:references",
154
161
  "#{normalized_user_name}:references",
155
162
  "role:integer",
156
163
  *Array(options[:extra_attributes])
@@ -161,6 +168,48 @@ module Pu
161
168
 
162
169
  def normalized_entity_name = options[:entity].underscore
163
170
 
171
+ def dest_namespace
172
+ return nil if main_app?
173
+
174
+ selected_destination_feature.underscore
175
+ end
176
+
177
+ def main_app?
178
+ selected_destination_feature == "main_app"
179
+ end
180
+
181
+ def dest_root
182
+ if main_app?
183
+ Rails.root
184
+ else
185
+ Rails.root.join("packages", dest_namespace)
186
+ end
187
+ end
188
+
189
+ def full_entity_path
190
+ [dest_namespace, normalized_entity_name].compact.join("/")
191
+ end
192
+
193
+ def full_membership_path
194
+ [dest_namespace, membership_model_name].compact.join("/")
195
+ end
196
+
197
+ def full_entity_name
198
+ full_entity_path
199
+ end
200
+
201
+ def full_membership_table_name
202
+ full_membership_path.tr("/", "_").pluralize
203
+ end
204
+
205
+ def full_membership_class_name
206
+ full_membership_path.camelize
207
+ end
208
+
209
+ def full_entity_class_name
210
+ full_entity_path.camelize
211
+ end
212
+
164
213
  def roles
165
214
  Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
166
215
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- return unless defined?(Rodauth::Rails)
4
-
5
3
  require "rails/generators/base"
6
4
  require_relative "../lib/plutonium_generators"
7
5
 
@@ -46,6 +44,7 @@ module Pu
46
44
  desc: "Available roles for API client memberships"
47
45
 
48
46
  def start
47
+ ensure_rodauth_installed
49
48
  generate_user
50
49
  generate_entity unless options[:skip_entity]
51
50
  generate_membership unless options[:skip_membership]
@@ -56,6 +55,12 @@ module Pu
56
55
 
57
56
  private
58
57
 
58
+ def ensure_rodauth_installed
59
+ return if File.exist?(Rails.root.join("app/rodauth/rodauth_app.rb"))
60
+
61
+ invoke "pu:rodauth:install"
62
+ end
63
+
59
64
  def generate_user
60
65
  # Use class-based invocation to avoid Thor's invoke caching
61
66
  klass = Rails::Generators.find_by_namespace("pu:saas:user")
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- return unless defined?(Rodauth::Rails)
4
-
5
3
  require "rails/generators/named_base"
6
4
  require_relative "../lib/plutonium_generators"
7
5
 
@@ -9,6 +9,7 @@ module Plutonium
9
9
  included do
10
10
  helper_method :current_user
11
11
  helper_method :logout_url
12
+ helper_method :profile_url
12
13
  end
13
14
 
14
15
  private
@@ -27,6 +28,13 @@ module Plutonium
27
28
  rodauth.logout_path
28
29
  end
29
30
 
31
+ # Override this method to return your profile page URL.
32
+ # When defined, a "Profile" link will appear in the user menu.
33
+ # Example: rodauth.change_password_path or your custom profile_path
34
+ def profile_url
35
+ nil
36
+ end
37
+
30
38
  define_singleton_method(:to_s) { "Plutonium::Auth::Rodauth(:#{name})" }
31
39
  define_singleton_method(:inspect) { "Plutonium::Auth::Rodauth(:#{name})" }
32
40
  RUBY
@@ -207,7 +207,11 @@ module Plutonium
207
207
  url_args[:action] ||= :index if index == args.length - 1
208
208
  elsif element.is_a?(Class)
209
209
  controller_chain << element.to_s.pluralize
210
- url_args[:action] ||= :index if index == args.length - 1 && parent.present?
210
+ if index == args.length - 1
211
+ # Singular resources have no index, default to show
212
+ is_singular = current_engine.routes.singular_resource_route?(element.model_name.plural)
213
+ url_args[:action] ||= is_singular ? :show : :index
214
+ end
211
215
  else
212
216
  model_class = element.class
213
217
  if model_class.respond_to?(:base_class) && model_class != model_class.base_class
@@ -223,8 +227,7 @@ module Plutonium
223
227
  else
224
228
  model_class.model_name.plural
225
229
  end
226
- resource_route_config = current_engine.routes.resource_route_config_for(route_key)[0]
227
- is_singular = resource_route_config&.dig(:route_type) == :resource
230
+ is_singular = current_engine.routes.singular_resource_route?(route_key)
228
231
  url_args[:id] = element.to_param unless is_singular
229
232
  url_args[:action] ||= :show
230
233
  else
@@ -236,7 +239,7 @@ module Plutonium
236
239
 
237
240
  url_args[:"#{parent.model_name.singular}_id"] = parent.to_param if parent.present?
238
241
  if scoped_to_entity? && scoped_entity_strategy == :path
239
- url_args[scoped_entity_param_key] = current_scoped_entity
242
+ url_args[scoped_entity_param_key] = current_scoped_entity.to_param
240
243
  end
241
244
 
242
245
  if !url_args.key?(:format) && request.present? && request.format.present? && !request.format.symbol.in?([:html, :turbo_stream])
@@ -30,7 +30,11 @@ module Plutonium
30
30
  end
31
31
 
32
32
  def entity_scope_for_authorize
33
- current_scoped_entity if scoped_to_entity?
33
+ # Use the instance variable directly to avoid circular dependency.
34
+ # When authorizing the scoped entity itself (in fetch_current_scoped_entity),
35
+ # @current_scoped_entity is not yet set, so this returns nil, which is correct
36
+ # since we can't use the entity as its own authorization scope.
37
+ @current_scoped_entity if scoped_to_entity?
34
38
  end
35
39
 
36
40
  def verify_authorized
@@ -75,6 +75,13 @@ module Plutonium
75
75
  # global default
76
76
  breadcrumbs true
77
77
 
78
+ # forms
79
+ # Controls the "Save and add another" / "Update and continue editing" buttons
80
+ # nil = auto-detect (hidden for singular resources, shown for plural)
81
+ # true = always show
82
+ # false = always hide
83
+ inheritable_config_attr :submit_and_continue
84
+
78
85
  def initialize
79
86
  super
80
87
  end
@@ -14,6 +14,12 @@ module Plutonium
14
14
  resource_name resource_class, 2
15
15
  end
16
16
 
17
+ # Returns the appropriate label for a resource (singular for singular resources, plural otherwise)
18
+ def resource_label(resource_class)
19
+ is_singular = current_engine.routes.singular_resource_route?(resource_class.model_name.plural)
20
+ resource_name(resource_class, is_singular ? 1 : 2)
21
+ end
22
+
17
23
  # Returns a human-readable name for a nested collection using the association name.
18
24
  # Falls back to resource_name_plural if not in a nested context.
19
25
  # Uses I18n via human_attribute_name for proper localization.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Profile
5
+ # Renders security settings links based on enabled Rodauth features.
6
+ class SecuritySection < Plutonium::UI::Component::Base
7
+ FEATURES = {
8
+ change_password: {
9
+ label: "Change Password",
10
+ description: "Update your account password",
11
+ icon: Phlex::TablerIcons::Key,
12
+ path_method: :change_password_path
13
+ },
14
+ change_login: {
15
+ label: "Change Email",
16
+ description: "Update your email address",
17
+ icon: Phlex::TablerIcons::Mail,
18
+ path_method: :change_login_path
19
+ },
20
+ otp: {
21
+ label: "Two-Factor Authentication",
22
+ description: "Add an extra layer of security",
23
+ icon: Phlex::TablerIcons::DeviceMobile,
24
+ path_method: :otp_setup_path
25
+ },
26
+ recovery_codes: {
27
+ label: "Recovery Codes",
28
+ description: "View or regenerate backup codes",
29
+ icon: Phlex::TablerIcons::FileCode,
30
+ path_method: :recovery_codes_path
31
+ },
32
+ webauthn: {
33
+ label: "Security Keys",
34
+ description: "Manage passkeys and security keys",
35
+ icon: Phlex::TablerIcons::Fingerprint,
36
+ path_method: :webauthn_setup_path
37
+ },
38
+ active_sessions: {
39
+ label: "Active Sessions",
40
+ description: "View and manage your sessions",
41
+ icon: Phlex::TablerIcons::DevicesCheck,
42
+ path_method: :active_sessions_path
43
+ },
44
+ close_account: {
45
+ label: "Close Account",
46
+ description: "Permanently delete your account",
47
+ icon: Phlex::TablerIcons::Trash,
48
+ path_method: :close_account_path,
49
+ danger: true
50
+ }
51
+ }.freeze
52
+
53
+ def view_template
54
+ div(class: "mt-8") do
55
+ render_section_header
56
+ render_feature_links
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def render_section_header
63
+ div(class: "mb-4") do
64
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Security Settings" }
65
+ p(class: "text-sm text-[var(--pu-text-muted)]") { "Manage your account security" }
66
+ end
67
+ end
68
+
69
+ def render_feature_links
70
+ div(
71
+ class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] divide-y divide-[var(--pu-border)]",
72
+ style: "box-shadow: var(--pu-shadow-sm)"
73
+ ) do
74
+ enabled_features.each do |feature, config|
75
+ render_feature_link(feature, config)
76
+ end
77
+ end
78
+ end
79
+
80
+ def render_feature_link(feature, config)
81
+ path = helpers.rodauth.send(config[:path_method])
82
+ danger = config[:danger]
83
+
84
+ a(
85
+ href: path,
86
+ class: tokens(
87
+ "flex items-center gap-4 p-4 hover:bg-[var(--pu-surface-alt)] transition-colors first:rounded-t-[var(--pu-radius-lg)] last:rounded-b-[var(--pu-radius-lg)]",
88
+ danger ? "text-[var(--pu-text-danger)]" : "text-[var(--pu-text)]"
89
+ )
90
+ ) do
91
+ # Icon
92
+ div(class: "flex-shrink-0") do
93
+ render config[:icon].new(class: "w-5 h-5")
94
+ end
95
+
96
+ # Content
97
+ div(class: "flex-grow") do
98
+ div(class: "font-medium") { config[:label] }
99
+ div(class: "text-sm text-[var(--pu-text-muted)]") { config[:description] }
100
+ end
101
+
102
+ # Arrow
103
+ div(class: "flex-shrink-0 text-[var(--pu-text-muted)]") do
104
+ render Phlex::TablerIcons::ChevronRight.new(class: "w-5 h-5")
105
+ end
106
+ end
107
+ end
108
+
109
+ def enabled_features
110
+ FEATURES.select { |feature, _config| feature_enabled?(feature) }
111
+ end
112
+
113
+ def feature_enabled?(feature)
114
+ helpers.rodauth.features.include?(feature)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -104,6 +104,13 @@ module Plutonium
104
104
  end
105
105
  end
106
106
 
107
+ # Returns true if current resource is registered as a singular route
108
+ # (e.g., `resource :profile` vs `resources :users`)
109
+ # @return [Boolean]
110
+ def singular_resource_context?
111
+ current_resource_route_config&.[](:route_type) == :resource
112
+ end
113
+
107
114
  # Extracts the association name from the current nested route
108
115
  # e.g., for route /posts/:post_id/nested_comments, returns :comments
109
116
  # @return [Symbol, nil] The association name
@@ -242,14 +249,17 @@ module Plutonium
242
249
  # Overrides entity scoping parameters
243
250
  # @param [Hash] input_params The input parameters
244
251
  def override_entity_scoping_params(input_params)
245
- if scoped_to_entity?
246
- if input_params.key?(scoped_entity_param_key) || resource_class.method_defined?(:"#{scoped_entity_param_key}=")
247
- input_params[scoped_entity_param_key] = current_scoped_entity
248
- end
252
+ return unless scoped_to_entity?
249
253
 
250
- if input_params.key?(:"#{scoped_entity_param_key}_id") || resource_class.method_defined?(:"#{scoped_entity_param_key}_id=")
251
- input_params[:"#{scoped_entity_param_key}_id"] = current_scoped_entity.id
252
- end
254
+ # Use the detected association if available, otherwise fall back to param_key
255
+ assoc_name = scoped_entity_association || scoped_entity_param_key
256
+
257
+ if input_params.key?(assoc_name) || resource_class.method_defined?(:"#{assoc_name}=")
258
+ input_params[assoc_name] = current_scoped_entity
259
+ end
260
+
261
+ if input_params.key?(:"#{assoc_name}_id") || resource_class.method_defined?(:"#{assoc_name}_id=")
262
+ input_params[:"#{assoc_name}_id"] = current_scoped_entity.id
253
263
  end
254
264
  end
255
265
 
@@ -29,12 +29,7 @@ module Plutonium
29
29
  # GET /resources/1/record_actions/:interactive_action
30
30
  def interactive_record_action
31
31
  build_interactive_record_action_interaction
32
-
33
- if helpers.current_turbo_frame == "remote_modal"
34
- render layout: false, formats: [:html]
35
- else
36
- render :interactive_record_action, formats: [:html]
37
- end
32
+ render :interactive_record_action, layout: modal_layout, formats: [:html]
38
33
  end
39
34
 
40
35
  # POST /resources/1/record_actions/:interactive_action
@@ -67,7 +62,7 @@ module Plutonium
67
62
  end
68
63
  else
69
64
  format.any(:html, :turbo_stream) do
70
- render :interactive_record_action, formats: [:html], status: :unprocessable_content
65
+ render :interactive_record_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
71
66
  end
72
67
  format.any do
73
68
  @errors = @interaction.errors
@@ -82,16 +77,7 @@ module Plutonium
82
77
  def interactive_resource_action
83
78
  skip_verify_current_authorized_scope!
84
79
  build_interactive_resource_action_interaction
85
-
86
- respond_to do |format|
87
- format.any(:html, :turbo_stream) do
88
- if helpers.current_turbo_frame == "remote_modal"
89
- render layout: false, formats: [:html]
90
- else
91
- render :interactive_resource_action, formats: [:html]
92
- end
93
- end
94
- end
80
+ render :interactive_resource_action, layout: modal_layout, formats: [:html]
95
81
  end
96
82
 
97
83
  # POST /resources/resource_actions/:interactive_action
@@ -125,7 +111,7 @@ module Plutonium
125
111
  end
126
112
  else
127
113
  format.any(:html, :turbo_stream) do
128
- render :interactive_resource_action, formats: [:html], status: :unprocessable_content
114
+ render :interactive_resource_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
129
115
  end
130
116
  format.any do
131
117
  @errors = @interaction.errors
@@ -139,12 +125,7 @@ module Plutonium
139
125
  # GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
140
126
  def interactive_bulk_action
141
127
  build_interactive_bulk_action_interaction
142
-
143
- if helpers.current_turbo_frame == "remote_modal"
144
- render layout: false, formats: [:html]
145
- else
146
- render :interactive_bulk_action, formats: [:html]
147
- end
128
+ render :interactive_bulk_action, layout: modal_layout, formats: [:html]
148
129
  end
149
130
 
150
131
  # POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
@@ -177,7 +158,7 @@ module Plutonium
177
158
  end
178
159
  else
179
160
  format.any(:html, :turbo_stream) do
180
- render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
161
+ render :interactive_bulk_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
181
162
  end
182
163
  format.any do
183
164
  @errors = @interaction.errors
@@ -190,6 +171,11 @@ module Plutonium
190
171
 
191
172
  private
192
173
 
174
+ # Returns false for modal requests (skip layout), nil otherwise (use default layout)
175
+ def modal_layout
176
+ helpers.current_turbo_frame.present? ? false : nil
177
+ end
178
+
193
179
  def current_interactive_action
194
180
  @current_interactive_action = interactive_resource_actions[params[:interactive_action].to_sym]
195
181
  end
@@ -17,7 +17,7 @@ module Plutonium
17
17
  presentable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
18
18
  end
19
19
  if scoped_to_entity? && !present_scoped_entity?
20
- presentable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
20
+ presentable_attributes -= scoped_entity_field_names
21
21
  end
22
22
  presentable_attributes
23
23
  end
@@ -33,11 +33,49 @@ module Plutonium
33
33
  submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
34
34
  end
35
35
  if scoped_to_entity? && !submit_scoped_entity?
36
- submittable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
36
+ submittable_attributes -= scoped_entity_field_names
37
37
  end
38
38
  submittable_attributes
39
39
  end
40
40
 
41
+ # Returns all field names related to the scoped entity.
42
+ # Finds associations by class to handle cases where param_key differs from association name.
43
+ def scoped_entity_field_names
44
+ field_names = [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
45
+
46
+ assoc_name = scoped_entity_association
47
+ if assoc_name
48
+ field_names << assoc_name
49
+ field_names << :"#{assoc_name}_id"
50
+ end
51
+
52
+ field_names.uniq
53
+ end
54
+
55
+ # Returns the name of the belongs_to association pointing to the scoped entity class.
56
+ # Raises if multiple associations exist (ambiguous - user must configure manually).
57
+ # @return [Symbol, nil] the association name or nil if not found
58
+ def scoped_entity_association
59
+ return @scoped_entity_association if defined?(@scoped_entity_association)
60
+
61
+ matching_assocs = resource_class.reflect_on_all_associations(:belongs_to).select do |assoc|
62
+ assoc.klass.name == scoped_entity_class.name
63
+ rescue NameError
64
+ false
65
+ end
66
+
67
+ if matching_assocs.size > 1
68
+ assoc_names = matching_assocs.map(&:name).join(", ")
69
+ raise <<~MSG.squish
70
+ #{resource_class} has multiple associations to #{scoped_entity_class}: #{assoc_names}.
71
+ Plutonium cannot auto-detect which one to use for entity scoping.
72
+ Override `scoped_entity_association` in your controller to specify the association.
73
+ MSG
74
+ end
75
+
76
+ @scoped_entity_association = matching_assocs.first&.name
77
+ end
78
+
41
79
  def build_collection
42
80
  current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
43
81
  end
@@ -47,7 +85,12 @@ module Plutonium
47
85
  end
48
86
 
49
87
  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, **}
88
+ form_options = {
89
+ resource_fields: submittable_attributes_for(action),
90
+ resource_definition: current_definition,
91
+ singular_resource: singular_resource_context?,
92
+ **
93
+ }
51
94
  form_options[:action] = form_action unless form_action.nil?
52
95
  current_definition.form_class.new(record, **form_options)
53
96
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/plutonium/resource/associations.rb
4
3
  module Plutonium
5
4
  module Resource
6
5
  module Record
@@ -9,6 +8,13 @@ module Plutonium
9
8
 
10
9
  included do
11
10
  scope :associated_with, ->(record) do
11
+ # If scoping to same class, just match by ID (e.g., Team scoped to Team)
12
+ # Compare by name to handle Rails class reloading (different object_id after reload)
13
+ if klass.name == record.class.name
14
+ pk = klass.primary_key
15
+ return where(pk => record.public_send(pk))
16
+ end
17
+
12
18
  named_scope = :"associated_with_#{record.model_name.singular}"
13
19
  return send(named_scope, record) if respond_to?(named_scope)
14
20