plutonium 0.45.3 → 0.47.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +150 -0
  3. data/.claude/skills/plutonium-assets/SKILL.md +248 -157
  4. data/.claude/skills/{plutonium-rodauth → plutonium-auth}/SKILL.md +195 -229
  5. data/.claude/skills/plutonium-controller/SKILL.md +9 -2
  6. data/.claude/skills/plutonium-create-resource/SKILL.md +22 -1
  7. data/.claude/skills/plutonium-definition/SKILL.md +521 -7
  8. data/.claude/skills/plutonium-entity-scoping/SKILL.md +317 -0
  9. data/.claude/skills/plutonium-forms/SKILL.md +8 -1
  10. data/.claude/skills/plutonium-installation/SKILL.md +25 -2
  11. data/.claude/skills/plutonium-interaction/SKILL.md +32 -2
  12. data/.claude/skills/plutonium-invites/SKILL.md +11 -7
  13. data/.claude/skills/plutonium-model/SKILL.md +50 -50
  14. data/.claude/skills/plutonium-nested-resources/SKILL.md +18 -1
  15. data/.claude/skills/plutonium-package/SKILL.md +8 -1
  16. data/.claude/skills/plutonium-policy/SKILL.md +69 -78
  17. data/.claude/skills/plutonium-portal/SKILL.md +26 -70
  18. data/.claude/skills/plutonium-testing/SKILL.md +268 -0
  19. data/.claude/skills/plutonium-views/SKILL.md +9 -2
  20. data/.yarnrc.yml +1 -0
  21. data/CHANGELOG.md +38 -0
  22. data/app/assets/plutonium.css +1 -1
  23. data/app/views/rodauth/_login_form.html.erb +0 -3
  24. data/app/views/rodauth/confirm_password.html.erb +0 -4
  25. data/app/views/rodauth/create_account.html.erb +0 -3
  26. data/app/views/rodauth/logout.html.erb +0 -3
  27. data/docs/.vitepress/config.ts +6 -0
  28. data/docs/guides/nested-resources.md +10 -0
  29. data/docs/guides/testing.md +154 -0
  30. data/docs/reference/controller/index.md +9 -4
  31. data/docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md +481 -0
  32. data/docs/superpowers/plans/2026-04-14-plutonium-testing.md +2046 -0
  33. data/docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json +21 -0
  34. data/docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md +236 -0
  35. data/docs/superpowers/specs/2026-04-14-plutonium-testing-design.md +364 -0
  36. data/gemfiles/rails_7.gemfile.lock +1 -1
  37. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  38. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  39. data/lib/generators/pu/core/update/update_generator.rb +8 -0
  40. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +56 -0
  41. data/lib/generators/pu/invites/install_generator.rb +8 -1
  42. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +43 -0
  43. data/lib/generators/pu/profile/concerns/profile_arguments.rb +10 -4
  44. data/lib/generators/pu/profile/conn_generator.rb +9 -12
  45. data/lib/generators/pu/profile/install_generator.rb +5 -2
  46. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  47. data/lib/generators/pu/saas/portal_generator.rb +4 -9
  48. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +2 -2
  49. data/lib/generators/pu/test/install/install_generator.rb +34 -0
  50. data/lib/generators/pu/test/install/templates/plutonium_testing.rb.tt +14 -0
  51. data/lib/generators/pu/test/scaffold/scaffold_generator.rb +55 -0
  52. data/lib/generators/pu/test/scaffold/templates/integration_test.rb.tt +65 -0
  53. data/lib/plutonium/core/controller.rb +18 -1
  54. data/lib/plutonium/engine.rb +18 -5
  55. data/lib/plutonium/testing/auth_helpers.rb +62 -0
  56. data/lib/plutonium/testing/dsl.rb +73 -0
  57. data/lib/plutonium/testing/nested_resource.rb +58 -0
  58. data/lib/plutonium/testing/portal_access.rb +49 -0
  59. data/lib/plutonium/testing/resource_crud.rb +104 -0
  60. data/lib/plutonium/testing/resource_definition.rb +61 -0
  61. data/lib/plutonium/testing/resource_interaction.rb +51 -0
  62. data/lib/plutonium/testing/resource_model.rb +53 -0
  63. data/lib/plutonium/testing/resource_policy.rb +72 -0
  64. data/lib/plutonium/testing.rb +16 -0
  65. data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -1
  66. data/lib/plutonium/version.rb +1 -1
  67. data/lib/plutonium.rb +2 -0
  68. data/package.json +1 -1
  69. data/yarn.lock +6037 -3893
  70. metadata +27 -8
  71. data/.claude/skills/plutonium/skill.md +0 -130
  72. data/.claude/skills/plutonium-definition-actions/SKILL.md +0 -424
  73. data/.claude/skills/plutonium-definition-query/SKILL.md +0 -364
  74. data/.claude/skills/plutonium-profile/SKILL.md +0 -276
  75. data/.claude/skills/plutonium-theming/SKILL.md +0 -424
@@ -23,9 +23,65 @@ module Pu
23
23
 
24
24
  generate "active_shrine:install"
25
25
  template "config/initializers/shrine.rb", force: true
26
+
27
+ disable_active_storage_railtie
28
+ include_active_shrine_model_in_application_record
26
29
  rescue => e
27
30
  exception "#{self.class} failed:", e
28
31
  end
32
+
33
+ private
34
+
35
+ # Active Storage and active_shrine both define `has_one_attached`. Active
36
+ # Storage is loaded by `require "rails/all"`, so it wins by default and
37
+ # `has_one_attached :foo` quietly creates Active Storage attachments
38
+ # (which fail at runtime because the table doesn't exist). Replace
39
+ # `rails/all` with explicit framework requires that exclude
40
+ # active_storage/engine.
41
+ def disable_active_storage_railtie
42
+ return unless File.exist?("config/application.rb")
43
+ unless File.read("config/application.rb").include?(%(require "rails/all"))
44
+ say_status :warn,
45
+ "config/application.rb does not use `require \"rails/all\"`; skipping Active Storage railtie removal. " \
46
+ "Ensure active_storage/engine is NOT required, or `has_one_attached` will resolve to Active Storage instead of active_shrine.",
47
+ :yellow
48
+ return
49
+ end
50
+
51
+ gsub_file "config/application.rb",
52
+ /^require "rails\/all"$/,
53
+ <<~RUBY.strip
54
+ require "rails"
55
+ # Active Storage is intentionally excluded — file uploads use active_shrine.
56
+ %w[
57
+ active_record/railtie
58
+ active_model/railtie
59
+ active_job/railtie
60
+ action_controller/railtie
61
+ action_view/railtie
62
+ action_mailer/railtie
63
+ action_cable/engine
64
+ rails/test_unit/railtie
65
+ ].each { |railtie| require railtie }
66
+ RUBY
67
+
68
+ # Strip per-environment active_storage.service config since the railtie
69
+ # is gone.
70
+ Dir.glob("config/environments/*.rb").each do |env_file|
71
+ gsub_file env_file,
72
+ /^\s*config\.active_storage\.service\s*=.*\n/,
73
+ ""
74
+ end
75
+ end
76
+
77
+ # Include ActiveShrine::Model on ApplicationRecord so the gem's
78
+ # `has_one_attached` / `has_many_attached` macros are available everywhere.
79
+ def include_active_shrine_model_in_application_record
80
+ return unless File.exist?("app/models/application_record.rb")
81
+ return if File.read("app/models/application_record.rb").include?("ActiveShrine::Model")
82
+
83
+ inject_into_class "app/models/application_record.rb", "ApplicationRecord", " include ActiveShrine::Model\n"
84
+ end
29
85
  end
30
86
  end
31
87
  end
@@ -186,7 +186,7 @@ module Pu
186
186
  def current_membership
187
187
  return unless entity_scope && user
188
188
 
189
- @current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope, user: user)
189
+ @current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope, #{user_association_name}: user)
190
190
  end
191
191
  RUBY
192
192
 
@@ -382,6 +382,13 @@ module Pu
382
382
  PlutoniumGenerators::Generator.derive_association_name(membership_model, entity_model)
383
383
  end
384
384
 
385
+ # Returns the association name for user on the membership model.
386
+ # Same logic as entity_association_name but for the user side.
387
+ # e.g., RestaurantStaffUser -> StaffUser uses :staff_user (not :user)
388
+ def user_association_name
389
+ PlutoniumGenerators::Generator.derive_association_name(membership_model, user_model)
390
+ end
391
+
385
392
  def entity_in_package?
386
393
  options[:dest] != "main_app"
387
394
  end
@@ -472,6 +472,49 @@ module PlutoniumGenerators
472
472
  end
473
473
  end
474
474
 
475
+ #
476
+ # Injects helper methods into a Plutonium Concerns::Controller file,
477
+ # merging with any existing `included do` and `private` sections.
478
+ #
479
+ # ActiveSupport::Concern only permits ONE `included do` block per concern,
480
+ # so we cannot naively append a new block when multiple generators need to
481
+ # register helper_methods in the same file.
482
+ #
483
+ # @param file [String] path to the concerns/controller.rb file
484
+ # @param helper_methods [Array<Symbol>] names to expose via `helper_method`
485
+ # @param methods [String] method definitions (already indented to 6 spaces)
486
+ #
487
+ def inject_into_concerns_controller(file, helper_methods:, methods:)
488
+ helper_list = Array(helper_methods).map { |m| ":#{m}" }.join(", ")
489
+
490
+ in_root do
491
+ # 1. helper_method declaration: merge into existing `included do` block,
492
+ # otherwise create a new block right after the `# add concerns above.` marker.
493
+ content = File.read(file)
494
+ if (match = content.match(/^(?<indent>[ \t]*)included do\n/))
495
+ indent = match[:indent]
496
+ inject_into_file file,
497
+ "#{indent} helper_method #{helper_list}\n",
498
+ after: /^[ \t]*included do\n/
499
+ else
500
+ block = "\n included do\n helper_method #{helper_list}\n end\n"
501
+ inject_into_file file, block, after: /# add concerns above\.\n/
502
+ end
503
+
504
+ # 2. Method definitions: append into the existing `private` section if any,
505
+ # otherwise create one just before the closing ` end`s of the concern.
506
+ content = File.read(file)
507
+ trimmed_methods = methods.sub(/\A\n+/, "").chomp
508
+ if content.match?(/^[ \t]*private\n/)
509
+ inject_into_file file, "\n#{trimmed_methods}\n", after: /^[ \t]*private\n/
510
+ else
511
+ inject_into_file file,
512
+ "\n private\n\n#{trimmed_methods}\n",
513
+ before: /^ end\n end\nend\s*\z/
514
+ end
515
+ end
516
+ end
517
+
475
518
  def file_includes?(path, check)
476
519
  destination = File.expand_path(path, destination_root)
477
520
  return false unless File.exist?(destination)
@@ -7,15 +7,21 @@ module Pu
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- argument :name, type: :string, default: "Profile", required: false, banner: "NAME"
10
+ argument :name, type: :string, required: false, banner: "NAME"
11
11
  argument :attributes, type: :array, default: [], banner: "field[:type] field[:type]"
12
12
  end
13
13
 
14
- # Normalize arguments: if name contains ":", treat it as an attribute
14
+ # Normalize arguments: if name is omitted, default to "{UserModel}Profile";
15
+ # if name looks like an attribute (contains ":"), treat it as an attribute
16
+ # and still default the profile name to "{UserModel}Profile".
15
17
  def normalize_arguments
16
- if name.include?(":")
18
+ default_name = "#{options[:user_model] || "User"}Profile"
19
+ if name.nil?
20
+ @profile_name = default_name
21
+ @profile_attributes = attributes
22
+ elsif name.include?(":")
17
23
  @profile_attributes = [name, *attributes]
18
- @profile_name = "Profile"
24
+ @profile_name = default_name
19
25
  else
20
26
  @profile_name = name
21
27
  @profile_attributes = attributes
@@ -10,7 +10,7 @@ module Pu
10
10
 
11
11
  desc "Connect a Profile resource to a portal and configure the profile_url helper"
12
12
 
13
- argument :name, type: :string, default: "Profile", required: false, banner: "RESOURCE"
13
+ argument :name, type: :string, required: false, banner: "RESOURCE"
14
14
 
15
15
  class_option :dest, type: :string,
16
16
  desc: "Destination portal"
@@ -106,14 +106,7 @@ module Pu
106
106
  end
107
107
 
108
108
  def add_profile_url_helper
109
- content = <<-RUBY.chomp
110
-
111
- included do
112
- helper_method :profile_url
113
- end
114
-
115
- private
116
-
109
+ methods = <<-RUBY
117
110
  # Returns the URL to the user's profile page.
118
111
  def profile_url
119
112
  profile = current_user.#{profile_association}
@@ -124,15 +117,19 @@ module Pu
124
117
  end
125
118
  end
126
119
  RUBY
127
- inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
120
+ inject_into_concerns_controller concerns_controller_path,
121
+ helper_methods: [:profile_url],
122
+ methods: methods
128
123
  end
129
124
 
130
125
  def profile_association
131
- resource_class_name.demodulize.underscore
126
+ # The install generator always exposes the profile as `:profile` on the
127
+ # user model (via class_name:), regardless of the underlying class name.
128
+ "profile"
132
129
  end
133
130
 
134
131
  def resource_class_name
135
- name.camelize
132
+ (name.presence || "#{options[:user_model]}Profile").camelize
136
133
  end
137
134
 
138
135
  def user_table
@@ -37,10 +37,13 @@ module Pu
37
37
  end
38
38
 
39
39
  def add_user_association
40
+ # Always expose the association as `:profile` on the user model so that
41
+ # `current_user.profile` works regardless of the underlying class name
42
+ # (e.g. UserProfile, StaffUserProfile, AccountSettings).
40
43
  association = if dest_package?
41
- " has_one :#{file_name}, class_name: \"#{namespaced_class_name}\", dependent: :destroy\n"
44
+ " has_one :profile, class_name: \"#{namespaced_class_name}\", dependent: :destroy\n"
42
45
  else
43
- " has_one :#{file_name}, dependent: :destroy\n"
46
+ " has_one :profile, class_name: \"#{class_name}\", dependent: :destroy\n"
44
47
  end
45
48
  inject_into_file user_model_path, association,
46
49
  before: /^\s*# add has_one associations above\.\n/
@@ -285,6 +285,9 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
285
285
  <% end -%>
286
286
  <% if verify_account? -%>
287
287
 
288
+ # Redirect to login page after requesting account verification email.
289
+ verify_account_email_sent_redirect { login_path }
290
+
288
291
  # Redirect to wherever login redirects to after account verification.
289
292
  verify_account_redirect { login_redirect }
290
293
  <% end -%>
@@ -70,14 +70,7 @@ module Pu
70
70
  end
71
71
 
72
72
  def add_entity_url_helper
73
- content = <<-RUBY
74
-
75
- included do
76
- helper_method :entity_url, :user_entities
77
- end
78
-
79
- private
80
-
73
+ methods = <<-RUBY
81
74
  # Returns the URL to the current entity's show page.
82
75
  def entity_url
83
76
  resource_url_for(current_scoped_entity)
@@ -88,7 +81,9 @@ module Pu
88
81
  @user_entities ||= current_user.#{entity_table.pluralize}
89
82
  end
90
83
  RUBY
91
- inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
84
+ inject_into_concerns_controller concerns_controller_path,
85
+ helper_methods: [:entity_url, :user_entities],
86
+ methods: methods
92
87
  end
93
88
 
94
89
  def add_entity_link_to_header
@@ -23,7 +23,7 @@
23
23
  <%% end %>
24
24
  <% if profile? -%>
25
25
 
26
- <%%= f.fields_for :profile, @profile do |pf| %>
26
+ <%%= fields_for :profile, @profile do |pf| %>
27
27
  <div>
28
28
  <%%= pf.label :name, "Your Name", class: "block mb-2 text-sm font-medium text-[var(--pu-text)]" %>
29
29
  <%%= pf.text_field :name,
@@ -34,7 +34,7 @@
34
34
  <%% end %>
35
35
  <% end -%>
36
36
 
37
- <%%= f.fields_for :<%= entity_table %>, @<%= entity_table %> do |ef| %>
37
+ <%%= fields_for :<%= entity_table %>, @<%= entity_table %> do |ef| %>
38
38
  <div>
39
39
  <%%= ef.label :name, "Workspace Name", class: "block mb-2 text-sm font-medium text-[var(--pu-text)]" %>
40
40
  <%%= ef.text_field :name,
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Test
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Install Plutonium::Testing scaffolding"
13
+
14
+ def install
15
+ add_require_to_test_helper
16
+ copy_support_file
17
+ end
18
+
19
+ private
20
+
21
+ def add_require_to_test_helper
22
+ helper = "test/test_helper.rb"
23
+ return unless File.exist?(helper)
24
+ line = %(require "plutonium/testing")
25
+ return if File.read(helper).include?(line)
26
+ append_to_file helper, "\n#{line}\n"
27
+ end
28
+
29
+ def copy_support_file
30
+ copy_file "plutonium_testing.rb", "test/support/plutonium_testing.rb"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Plutonium::Testing project hooks.
4
+ #
5
+ # Override authentication for non-Rodauth setups by defining a top-level helper
6
+ # that gets included into integration tests:
7
+ #
8
+ # module PlutoniumTestingOverrides
9
+ # def sign_in_for_tests(account, portal:)
10
+ # # your custom auth flow here
11
+ # end
12
+ # end
13
+ #
14
+ # ActiveSupport::TestCase.include(PlutoniumTestingOverrides)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Test
7
+ class ScaffoldGenerator < Rails::Generators::NamedBase
8
+ include PlutoniumGenerators::Generator
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Scaffold Plutonium::Testing tests for a resource across one or more portals"
13
+
14
+ class_option :portals, type: :array, required: true,
15
+ desc: "Portals to scaffold tests for (e.g. admin,org)"
16
+ class_option :concerns, type: :array, default: %w[crud policy definition],
17
+ desc: "Concerns to include (crud,policy,definition,nested,model,interaction)"
18
+ class_option :parent, type: :string, desc: "Parent association for nested resources"
19
+ class_option :dest, type: :string, default: "main_app",
20
+ desc: "main_app or package name"
21
+
22
+ def scaffold
23
+ options[:portals].each { |portal| scaffold_for_portal(portal) }
24
+ end
25
+
26
+ private
27
+
28
+ CONCERN_MAP = {
29
+ "crud" => "ResourceCrud",
30
+ "policy" => "ResourcePolicy",
31
+ "definition" => "ResourceDefinition",
32
+ "model" => "ResourceModel",
33
+ "interaction" => "ResourceInteraction",
34
+ "nested" => "NestedResource",
35
+ "portal_access" => "PortalAccess"
36
+ }.freeze
37
+
38
+ def concern_module_name(concern)
39
+ CONCERN_MAP.fetch(concern) { concern.camelize }
40
+ end
41
+
42
+ def scaffold_for_portal(portal)
43
+ @portal = portal
44
+ @resource_class = name
45
+ @file_name = name.underscore.tr("/", "_")
46
+ @class_name = "#{portal.camelize}Portal::#{name.tr("::", "")}Test"
47
+ @concerns = options[:concerns]
48
+ @parent = options[:parent]
49
+ target_dir = (options[:dest] == "main_app") ? "test/integration" : "packages/#{options[:dest]}/test/integration"
50
+ target = "#{target_dir}/#{portal}_portal/#{@file_name}_test.rb"
51
+ template "integration_test.rb.tt", target
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class <%= @class_name %> < ActionDispatch::IntegrationTest
6
+ <% @concerns.each do |c| -%>
7
+ include Plutonium::Testing::<%= concern_module_name(c) %>
8
+ <% end -%>
9
+
10
+ resource_tests_for <%= @resource_class %>,
11
+ portal: :<%= @portal %><% if @parent %>,
12
+ parent: :<%= @parent %><% end %>
13
+
14
+ setup do
15
+ # TODO: replace with your factories.
16
+ @account = nil
17
+ login_as(@account)
18
+ end
19
+
20
+ <% if @concerns.include?("crud") -%>
21
+ def create_resource!
22
+ <%= @resource_class %>.create!(
23
+ # TODO: fill in valid attributes
24
+ )
25
+ end
26
+
27
+ def valid_create_params
28
+ {} # TODO
29
+ end
30
+
31
+ def valid_update_params
32
+ {} # TODO
33
+ end
34
+ <% end -%>
35
+ <% if @concerns.include?("policy") -%>
36
+
37
+ def policy_roles
38
+ {<%= @portal %>: -> { @account }}
39
+ end
40
+
41
+ def policy_record
42
+ create_resource!
43
+ end
44
+
45
+ def policy_matrix
46
+ {
47
+ index: %i[<%= @portal %>],
48
+ show: %i[<%= @portal %>],
49
+ create: %i[<%= @portal %>],
50
+ update: %i[<%= @portal %>],
51
+ destroy: %i[<%= @portal %>]
52
+ }
53
+ end
54
+ <% end -%>
55
+ <% if @concerns.include?("nested") && @parent -%>
56
+
57
+ def parent_record!
58
+ # TODO: return the current tenant for this test
59
+ end
60
+
61
+ def other_parent_record!
62
+ # TODO: return a sibling tenant
63
+ end
64
+ <% end -%>
65
+ end
@@ -87,11 +87,17 @@ module Plutonium
87
87
  #
88
88
  # @return [Hash] args to pass to `url_for`
89
89
  #
90
- def resource_url_args_for(*args, action: nil, parent: nil, association: nil, package: nil, **kwargs)
90
+ def resource_url_args_for(*args, action: nil, parent: nil, association: nil, package: nil, interaction: nil, **kwargs)
91
91
  element = args.first
92
92
 
93
93
  raise ArgumentError, "parent is required when using symbol association name" if element.is_a?(Symbol) && parent.nil?
94
94
 
95
+ if interaction
96
+ raise ArgumentError, "cannot pass both `interaction:` and `action:`" if action
97
+ action = interactive_action_type_for(element, ids: kwargs[:ids])
98
+ kwargs[:interactive_action] = interaction
99
+ end
100
+
95
101
  # For nested resources, use named route helpers to avoid Rails param recall ambiguity
96
102
  if parent.present?
97
103
  assoc_name = if element.is_a?(Symbol)
@@ -137,6 +143,17 @@ module Plutonium
137
143
 
138
144
  private
139
145
 
146
+ # Determine the interactive action type for the given element.
147
+ # Records → :interactive_record_action, classes/symbols with :ids → :interactive_bulk_action,
148
+ # otherwise :interactive_resource_action.
149
+ def interactive_action_type_for(element, ids: nil)
150
+ if element.is_a?(Class) || element.is_a?(Symbol) || element.nil?
151
+ ids.present? ? :interactive_bulk_action : :interactive_resource_action
152
+ else
153
+ :interactive_record_action
154
+ end
155
+ end
156
+
140
157
  def build_nested_resource_url_args(element, parent:, association_name:, route_config:, action: nil, **kwargs)
141
158
  prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
142
159
  is_singular = route_config[:route_type] == :resource
@@ -5,17 +5,30 @@ module Plutonium
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  class_methods do
8
- attr_reader :scoped_entity_class, :scoped_entity_strategy, :scoped_entity_param_key, :scoped_entity_route_key
8
+ attr_reader :scoped_entity_strategy, :scoped_entity_param_key, :scoped_entity_route_key
9
9
 
10
+ # Store the entity class *by name* and resolve it lazily on every call.
11
+ # Capturing the class object directly causes stale references after Rails
12
+ # autoreload: the constant is reloaded but @scoped_entity_class still
13
+ # points at the previous (now-orphaned) class object, which then fails
14
+ # type checks against freshly reloaded instances.
10
15
  def scope_to_entity(entity_class, strategy: :path, param_key: nil, route_key: nil)
11
- @scoped_entity_class = entity_class
16
+ @scoped_entity_class_name = entity_class.is_a?(Class) ? entity_class.name : entity_class.to_s
12
17
  @scoped_entity_strategy = strategy
13
- @scoped_entity_param_key = param_key || :"#{entity_class.model_name.singular_route_key}_scope"
14
- @scoped_entity_route_key = route_key || entity_class.model_name.singular.to_sym
18
+ # param_key / route_key are derived from the class once at declaration
19
+ # time they're stable strings and don't depend on the live class
20
+ # identity, so caching them is safe.
21
+ resolved = @scoped_entity_class_name.constantize
22
+ @scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}_scope"
23
+ @scoped_entity_route_key = route_key || resolved.model_name.singular.to_sym
24
+ end
25
+
26
+ def scoped_entity_class
27
+ @scoped_entity_class_name&.constantize
15
28
  end
16
29
 
17
30
  def scoped_to_entity?
18
- scoped_entity_class.present?
31
+ @scoped_entity_class_name.present?
19
32
  end
20
33
 
21
34
  def dom_id
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Testing
5
+ module AuthHelpers
6
+ extend ActiveSupport::Concern
7
+
8
+ def login_as(account, portal: nil)
9
+ portal ||= current_portal
10
+ if respond_to?(:sign_in_for_tests)
11
+ sign_in_for_tests(account, portal: portal)
12
+ else
13
+ default_rodauth_login(account, portal: portal)
14
+ end
15
+ instance_variable_set(:"@__current_account_#{portal}", account)
16
+ end
17
+
18
+ def sign_out(portal: nil)
19
+ portal ||= current_portal
20
+ post logout_path_for(portal)
21
+ follow_redirect! if response.redirect?
22
+ instance_variable_set(:"@__current_account_#{portal}", nil)
23
+ end
24
+
25
+ def current_account(portal: nil)
26
+ portal ||= current_portal
27
+ instance_variable_get(:"@__current_account_#{portal}")
28
+ end
29
+
30
+ def with_portal(portal)
31
+ prev = @__portal_override
32
+ @__portal_override = portal
33
+ yield
34
+ ensure
35
+ @__portal_override = prev
36
+ end
37
+
38
+ private
39
+
40
+ def default_rodauth_login(account, portal:)
41
+ post login_path_for(portal), params: {email: account.email, password: "password123"}
42
+ follow_redirect! if response.redirect?
43
+ end
44
+
45
+ def login_path_for(portal)
46
+ "/#{account_table_for(portal)}/login"
47
+ end
48
+
49
+ def logout_path_for(portal)
50
+ "/#{account_table_for(portal)}/logout"
51
+ end
52
+
53
+ def account_table_for(portal)
54
+ case portal
55
+ when :admin then "admins"
56
+ when :user, :org then "users"
57
+ else portal.to_s.pluralize
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Testing
5
+ module DSL
6
+ extend ActiveSupport::Concern
7
+
8
+ class PortalNotFound < StandardError; end
9
+
10
+ DEFAULT_ACTIONS = %i[index show new create edit update destroy].freeze
11
+
12
+ class_methods do
13
+ def resource_tests_for(resource_class, portal:, path_prefix: nil, parent: nil,
14
+ actions: DEFAULT_ACTIONS, skip: [],
15
+ associated_with: nil, sgid_routing: false, has_cents: [])
16
+ @resource_tests_config = {
17
+ resource: resource_class,
18
+ portal: portal,
19
+ path_prefix: path_prefix || resolve_portal_path_prefix(portal),
20
+ parent: parent,
21
+ actions: actions,
22
+ skip: skip,
23
+ associated_with: associated_with,
24
+ sgid_routing: sgid_routing,
25
+ has_cents: has_cents
26
+ }
27
+ end
28
+
29
+ def resource_tests_config
30
+ @resource_tests_config or raise "resource_tests_for not called on #{name}"
31
+ end
32
+
33
+ private
34
+
35
+ def resolve_portal_path_prefix(portal_sym)
36
+ engine_name = "#{portal_sym.to_s.camelize}Portal::Engine"
37
+ engine_const = engine_name.safe_constantize
38
+ unless engine_const
39
+ raise PortalNotFound, "Could not resolve portal :#{portal_sym} (looked for #{engine_name})"
40
+ end
41
+
42
+ mount = find_engine_mount(engine_const)
43
+ unless mount
44
+ raise PortalNotFound, "Engine #{engine_const} is not mounted in routes"
45
+ end
46
+
47
+ mount.path.spec.to_s.sub(/\(\.:format\)\z/, "").chomp("/")
48
+ end
49
+
50
+ def find_engine_mount(engine_const)
51
+ Rails.application.routes.routes.find do |route|
52
+ matches_engine?(route.app, engine_const)
53
+ end
54
+ end
55
+
56
+ def matches_engine?(app, engine_const)
57
+ return true if app == engine_const
58
+ return false unless app.respond_to?(:app)
59
+ return false if app.app == app
60
+ matches_engine?(app.app, engine_const)
61
+ end
62
+ end
63
+
64
+ def current_portal
65
+ @__portal_override || self.class.resource_tests_config.fetch(:portal)
66
+ end
67
+
68
+ def current_path_prefix
69
+ self.class.resource_tests_config.fetch(:path_prefix)
70
+ end
71
+ end
72
+ end
73
+ end