plutonium 0.51.0 → 0.53.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-app/SKILL.md +2 -0
  3. data/.claude/skills/plutonium-auth/SKILL.md +6 -4
  4. data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
  5. data/.claude/skills/plutonium-resource/SKILL.md +6 -4
  6. data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
  7. data/.claude/skills/plutonium-testing/SKILL.md +3 -1
  8. data/.claude/skills/plutonium-ui/SKILL.md +32 -8
  9. data/CHANGELOG.md +33 -0
  10. data/app/assets/plutonium.css +1 -1
  11. data/app/assets/plutonium.js +258 -11
  12. data/app/assets/plutonium.js.map +4 -4
  13. data/app/assets/plutonium.min.js +39 -39
  14. data/app/assets/plutonium.min.js.map +4 -4
  15. data/app/views/plutonium/_resource_header.html.erb +2 -1
  16. data/docs/.vitepress/config.ts +2 -2
  17. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  18. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  19. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  20. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  21. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  22. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  23. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  24. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  25. data/docs/.vitepress/theme/custom.css +144 -0
  26. data/docs/.vitepress/theme/index.ts +58 -1
  27. data/docs/getting-started/index.md +33 -50
  28. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  29. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  30. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  31. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  32. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  33. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  34. data/docs/guides/authentication.md +11 -6
  35. data/docs/guides/authorization.md +3 -3
  36. data/docs/guides/creating-packages.md +8 -11
  37. data/docs/guides/custom-actions.md +8 -2
  38. data/docs/guides/customizing-ui.md +259 -0
  39. data/docs/guides/index.md +49 -32
  40. data/docs/guides/multi-tenancy.md +14 -6
  41. data/docs/guides/nested-resources.md +69 -0
  42. data/docs/guides/search-filtering.md +6 -0
  43. data/docs/guides/testing.md +5 -1
  44. data/docs/guides/theming.md +14 -1
  45. data/docs/guides/user-invites.md +10 -4
  46. data/docs/guides/user-profile.md +8 -0
  47. data/docs/index.md +10 -219
  48. data/docs/public/asciinema/home-scaffold.cast +305 -0
  49. data/docs/public/images/components/avatar.png +0 -0
  50. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  51. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  52. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  53. data/docs/public/images/guides/nested-inputs.png +0 -0
  54. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  55. data/docs/public/images/guides/search-filtering-index.png +0 -0
  56. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  57. data/docs/public/images/guides/theming-after.png +0 -0
  58. data/docs/public/images/guides/theming-before.png +0 -0
  59. data/docs/public/images/guides/user-invites-landing.png +0 -0
  60. data/docs/public/images/guides/user-profile-edit.png +0 -0
  61. data/docs/public/images/guides/user-profile-show.png +0 -0
  62. data/docs/public/images/home-index.png +0 -0
  63. data/docs/public/images/home-new.png +0 -0
  64. data/docs/public/images/home-show.png +0 -0
  65. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  66. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  67. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  68. data/docs/public/images/tutorial/02-new-form.png +0 -0
  69. data/docs/public/images/tutorial/03-create-account.png +0 -0
  70. data/docs/public/images/tutorial/03-login.png +0 -0
  71. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  72. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  73. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  74. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  75. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  76. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  77. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  78. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  79. data/docs/reference/app/generators.md +4 -4
  80. data/docs/reference/auth/accounts.md +7 -8
  81. data/docs/reference/auth/index.md +1 -1
  82. data/docs/reference/behavior/policies.md +2 -2
  83. data/docs/reference/configuration.md +61 -0
  84. data/docs/reference/index.md +67 -55
  85. data/docs/reference/resource/actions.md +2 -1
  86. data/docs/reference/resource/definition.md +5 -4
  87. data/docs/reference/tenancy/entity-scoping.md +14 -8
  88. data/docs/reference/tenancy/index.md +1 -1
  89. data/docs/reference/tenancy/invites.md +12 -5
  90. data/docs/reference/ui/components.md +53 -0
  91. data/docs/reference/ui/forms.md +1 -1
  92. data/docs/reference/ui/pages.md +6 -5
  93. data/docs/reference/ui/tables.md +8 -4
  94. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  95. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  96. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  97. data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
  98. data/gemfiles/rails_7.gemfile.lock +1 -1
  99. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  100. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  101. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  102. data/lib/generators/pu/invites/install_generator.rb +44 -0
  103. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  104. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
  105. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  106. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  107. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  108. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  109. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  110. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  111. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  112. data/lib/generators/pu/saas/membership/USAGE +4 -1
  113. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  114. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  115. data/lib/plutonium/action/base.rb +43 -63
  116. data/lib/plutonium/configuration.rb +7 -0
  117. data/lib/plutonium/definition/actions.rb +10 -11
  118. data/lib/plutonium/definition/base.rb +29 -0
  119. data/lib/plutonium/helpers/assets_helper.rb +0 -30
  120. data/lib/plutonium/helpers/content_helper.rb +0 -44
  121. data/lib/plutonium/helpers/display_helper.rb +0 -62
  122. data/lib/plutonium/helpers/turbo_helper.rb +17 -2
  123. data/lib/plutonium/helpers.rb +0 -2
  124. data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
  125. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  126. data/lib/plutonium/resource/definition.rb +0 -42
  127. data/lib/plutonium/ui/action_button.rb +4 -3
  128. data/lib/plutonium/ui/avatar.rb +182 -0
  129. data/lib/plutonium/ui/component/kit.rb +2 -0
  130. data/lib/plutonium/ui/component/methods.rb +1 -0
  131. data/lib/plutonium/ui/form/base.rb +32 -2
  132. data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
  133. data/lib/plutonium/ui/form/interaction.rb +1 -1
  134. data/lib/plutonium/ui/form/resource.rb +58 -0
  135. data/lib/plutonium/ui/form/theme.rb +8 -4
  136. data/lib/plutonium/ui/grid/card.rb +10 -26
  137. data/lib/plutonium/ui/modal/base.rb +36 -1
  138. data/lib/plutonium/ui/modal/centered.rb +24 -6
  139. data/lib/plutonium/ui/modal/slideover.rb +26 -11
  140. data/lib/plutonium/ui/nav_user.rb +3 -23
  141. data/lib/plutonium/ui/page/edit.rb +7 -4
  142. data/lib/plutonium/ui/page/interactive_action.rb +5 -3
  143. data/lib/plutonium/ui/page/new.rb +7 -4
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
  145. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  146. data/lib/plutonium/version.rb +1 -1
  147. data/package.json +4 -1
  148. data/src/css/components.css +38 -1
  149. data/src/css/slim_select.css +3 -2
  150. data/src/js/controllers/dirty_form_guard_controller.js +165 -0
  151. data/src/js/controllers/form_controller.js +5 -4
  152. data/src/js/controllers/register_controllers.js +2 -0
  153. data/src/js/controllers/remote_modal_controller.js +53 -19
  154. data/src/js/turbo/index.js +1 -0
  155. data/src/js/turbo/turbo_confirm.js +128 -0
  156. data/yarn.lock +108 -1
  157. metadata +52 -6
  158. data/lib/plutonium/helpers/attachment_helper.rb +0 -73
  159. data/lib/plutonium/helpers/table_helper.rb +0 -35
  160. /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
@@ -44,9 +44,13 @@ module Pu
44
44
 
45
45
  Rails.application.configure do
46
46
  config.solid_errors.connects_to = {database: {writing: :#{@db_name}}}
47
- config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present?
48
- config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"]
49
- config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"]
47
+ config.solid_errors.email_from = ENV["SOLID_ERRORS_EMAIL_FROM"].presence
48
+ config.solid_errors.email_to = ENV["SOLID_ERRORS_EMAIL_TO"].presence
49
+ # Only deliver notifications when explicitly opted in AND both addresses are
50
+ # configured. Enabling send_emails without a valid from/to makes Solid Errors
51
+ # attempt to deliver malformed mail.
52
+ config.solid_errors.send_emails = ENV["SOLID_ERRORS_SEND_EMAILS"].present? &&
53
+ config.solid_errors.email_from.present? && config.solid_errors.email_to.present?
50
54
  config.solid_errors.username = ENV.fetch("SOLID_ERRORS_USERNAME", nil)
51
55
  config.solid_errors.password = ENV.fetch("SOLID_ERRORS_PASSWORD", nil)
52
56
  end
@@ -51,7 +51,7 @@ module Pu
51
51
  end
52
52
 
53
53
  def customize_policy
54
- content = <<-RUBY.chomp
54
+ content = <<-RUBY
55
55
 
56
56
  # Profile is scoped to current user, not entity.
57
57
  # Note: `user` here is the policy's user method (current authenticated user),
@@ -94,7 +94,7 @@ module Pu
94
94
 
95
95
  def customize_controller
96
96
  # Set user automatically when creating profile
97
- content = <<-RUBY.chomp
97
+ content = <<-RUBY
98
98
 
99
99
  private
100
100
 
@@ -36,9 +36,7 @@ module Pu
36
36
  @resource_class = resource
37
37
 
38
38
  if app_namespace == "MainApp"
39
- insert_into_file "config/routes.rb",
40
- indent("register_resource ::#{resource}#{singular_option}\n", 2),
41
- after: /.*Rails\.application\.routes\.draw do.*\n/
39
+ register_resource_in_routes("config/routes.rb", resource)
42
40
  else
43
41
  if options[:policy] || !expected_parent_policy
44
42
  template "app/policies/resource_policy.rb",
@@ -53,9 +51,7 @@ module Pu
53
51
  template "app/controllers/resource_controller.rb",
54
52
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/#{resource.pluralize.underscore}_controller.rb"
55
53
 
56
- insert_into_file "packages/#{package_namespace}/config/routes.rb",
57
- indent("register_resource ::#{resource}#{singular_option}\n", 2),
58
- before: /.*# register resources above.*/
54
+ register_resource_in_routes("packages/#{package_namespace}/config/routes.rb", resource)
59
55
  end
60
56
  end
61
57
  rescue => e
@@ -66,6 +62,37 @@ module Pu
66
62
 
67
63
  attr_reader :app_namespace, :resource_class
68
64
 
65
+ # Insert `register_resource ::<Klass>` into a routes file. Idempotent:
66
+ # skips if already present. Falls back when the conventional
67
+ # `# register resources above.` marker is missing.
68
+ def register_resource_in_routes(routes_path, resource)
69
+ line = "register_resource ::#{resource}#{singular_option}"
70
+ content = File.read(File.join(destination_root, routes_path))
71
+
72
+ if /^\s*#{Regexp.escape(line)}\b/.match?(content)
73
+ say_status :identical, "#{routes_path} already registers #{resource}", :blue
74
+ return
75
+ end
76
+
77
+ if /^\s*#\s*register resources above\b/.match?(content)
78
+ insert_into_file routes_path,
79
+ indent("#{line}\n", 2),
80
+ before: /^\s*#\s*register resources above\b.*/
81
+ elsif /^\s*Rails\.application\.routes\.draw do\b/.match?(content)
82
+ insert_into_file routes_path,
83
+ indent("#{line}\n", 2),
84
+ after: /^\s*Rails\.application\.routes\.draw do.*\n/
85
+ elsif (match = content.match(/^(\w+::Engine)\.routes\.draw do.*\n/))
86
+ insert_into_file routes_path,
87
+ indent("#{line}\n", 2),
88
+ after: /^\s*#{Regexp.escape(match[1])}\.routes\.draw do.*\n/
89
+ else
90
+ say_status :warn,
91
+ "Could not locate routes block in #{routes_path}; add manually: #{line}",
92
+ :yellow
93
+ end
94
+ end
95
+
69
96
  def package_namespace
70
97
  app_namespace.underscore
71
98
  end
@@ -36,7 +36,11 @@ class <%= class_name %> < <%= [feature_package_name, "ResourceRecord"].join "::"
36
36
 
37
37
  <% attributes.select(&:required?).each do |attribute| -%>
38
38
  <%- next if attribute.reference? || attribute.rich_text? || attribute.token? || attribute.password_digest? -%>
39
+ <%- if attribute.type == :boolean -%>
40
+ validates :<%= attribute.attribute_name %>, inclusion: {in: [true, false]}
41
+ <%- else -%>
39
42
  validates :<%= attribute.attribute_name %>, presence: true
43
+ <%- end -%>
40
44
  <% end -%>
41
45
  # add validations above.
42
46
 
@@ -4,6 +4,7 @@ require "securerandom"
4
4
  require "#{__dir__}/concerns/configuration"
5
5
  require "#{__dir__}/concerns/account_selector"
6
6
  require "#{__dir__}/concerns/feature_selector"
7
+ require "#{__dir__}/migration_generator"
7
8
  require "#{__dir__}/../lib/plutonium_generators/concerns/actions"
8
9
 
9
10
  module Pu
@@ -96,7 +97,7 @@ module Pu
96
97
  def generate_rodauth_migration
97
98
  return if selected_migration_features.empty?
98
99
 
99
- invoke "pu:rodauth:migration", [table], features: selected_migration_features,
100
+ invoke Pu::Rodauth::MigrationGenerator, [table], features: selected_migration_features,
100
101
  name: kitchen_sink? ? "rodauth_kitchen_sink" : nil,
101
102
  migration_name: options[:migration_name],
102
103
  login_column: login_column,
@@ -1,5 +1,3 @@
1
- return unless defined?(Rodauth::Rails)
2
-
3
1
  require "rails/generators/base"
4
2
 
5
3
  require_relative "../lib/plutonium_generators"
@@ -1,5 +1,3 @@
1
- return unless defined?(Rodauth::Rails)
2
-
3
1
  require "rails/generators/base"
4
2
  require "rails/generators/active_record/migration"
5
3
  require "erb"
@@ -1,5 +1,3 @@
1
- return unless defined?(Rodauth::Rails)
2
-
3
1
  require "rails/generators/base"
4
2
 
5
3
  require "#{__dir__}/concerns/configuration"
@@ -6,9 +6,12 @@ Description:
6
6
 
7
7
  Example:
8
8
  rails g pu:saas:membership --user Customer --entity Organization
9
- rails g pu:saas:membership --user Customer --entity Organization --roles=member,admin,owner
9
+ rails g pu:saas:membership --user Customer --entity Organization --roles=admin,member
10
10
  rails g pu:saas:membership --user Customer --entity Organization --extra-attributes=joined_at:datetime
11
11
 
12
+ Role ordering: `owner` is auto-prepended as index 0 (most privileged) — do not include it
13
+ in --roles. Subsequent roles run from most-privileged to least.
14
+
12
15
  This creates:
13
16
  app/models/organization_customer.rb (with role enum and uniqueness validation)
14
17
  db/migrate/XXX_create_organization_customers.rb (with unique index)
@@ -16,6 +16,9 @@ module Pu
16
16
  class_option :entity, type: :string, required: true,
17
17
  desc: "The entity model name (e.g., Organization)"
18
18
 
19
+ class_option :dest, type: :string, default: "main_app",
20
+ desc: "Destination feature/package for entity, membership, and api_client (default: main_app)"
21
+
19
22
  class_option :allow_signup, type: :boolean, default: true,
20
23
  desc: "Whether to allow users to sign up to the platform"
21
24
 
@@ -52,6 +55,9 @@ module Pu
52
55
  class_option :profile, type: :boolean, default: true,
53
56
  desc: "Generate user profile resource"
54
57
 
58
+ class_option :profile_attributes, type: :array, default: %w[name:string],
59
+ desc: "Additional attributes for the user profile model (default: name:string)"
60
+
55
61
  class_option :api_client, type: :string, default: nil,
56
62
  desc: "Generate an API client model (e.g., ApiClient)"
57
63
 
@@ -130,9 +136,15 @@ module Pu
130
136
  end
131
137
 
132
138
  def generate_profile
133
- generate "pu:profile:setup",
134
- "--user-model=#{options[:user]} --dest=main_app" \
135
- "#{" --portal=#{portal_package}" if options[:portal]}"
139
+ klass = Rails::Generators.find_by_namespace("pu:profile:setup")
140
+ profile_options = {
141
+ user_model: options[:user],
142
+ dest: options[:dest],
143
+ force: options[:force],
144
+ skip: options[:skip]
145
+ }
146
+ profile_options[:portal] = portal_package if options[:portal]
147
+ klass.new([nil, *options[:profile_attributes]], profile_options).invoke_all
136
148
  end
137
149
 
138
150
  def generate_welcome
@@ -145,7 +157,7 @@ module Pu
145
157
 
146
158
  def generate_invites
147
159
  generate "pu:invites:install",
148
- "--entity-model=#{options[:entity]} --user-model=#{options[:user]} --dest=main_app" \
160
+ "--entity-model=#{options[:entity]} --user-model=#{options[:user]} --dest=#{options[:dest]}" \
149
161
  " --rodauth=#{rodauth_config}"
150
162
  end
151
163
 
@@ -63,7 +63,7 @@ class WelcomeController < AuthenticatedController
63
63
  <% end -%>
64
64
 
65
65
  def portal_root_path(<%= entity_table %>)
66
- <%= portal_engine %>::Engine.routes.url_helpers.<%= entity_table %>_root_path(<%= entity_table %>: <%= entity_table %>)
66
+ <%= portal_engine %>::Engine.routes.url_helpers.<%= entity_table %>_scoped_root_path(<%= entity_table %>_scoped: <%= entity_table %>)
67
67
  end
68
68
  helper_method :portal_root_path
69
69
  end
@@ -5,41 +5,9 @@ require "active_support/string_inquirer"
5
5
  module Plutonium
6
6
  module Action
7
7
  # Base class for all actions in the Plutonium framework.
8
- #
9
- # @attr_reader [Symbol] name The name of the action.
10
- # @attr_reader [String] label The human-readable label for the action.
11
- # @attr_reader [String, nil] icon The icon associated with the action.
12
- # @attr_reader [RouteOptions] route_options The routing options for the action.
13
- # @attr_reader [String, nil] confirmation The confirmation message for the action.
14
- # @attr_reader [String, nil] turbo_frame The Turbo Frame ID for the action.
15
- # @attr_reader [Symbol, nil] color The color associated with the action.
16
- # @attr_reader [Symbol, nil] category The category of the action.
17
- # @attr_reader [Integer] position The position of the action within its category.
18
8
  class Base
19
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :turbo_frame, :color, :category, :position, :return_to, :modal
20
-
21
- # Initialize a new action.
22
- #
23
- # @param [Symbol] name The name of the action.
24
- # @param [Hash] options The options for the action.
25
- # @option options [String] :label The human-readable label for the action.
26
- # @option options [String] :description The human-readable description for the action.
27
- # @option options [String] :icon The icon associated with the action (e.g., 'fa-edit' for Font Awesome).
28
- # @option options [Symbol] :color The color associated with the action (e.g., :primary, :secondary, :success, :warning, :danger).
29
- # @option options [String] :confirmation The confirmation message to display before executing the action.
30
- # @option options [RouteOptions, Hash] :route_options The routing options for the action.
31
- # @option options [String] :turbo_frame The Turbo Frame ID for the action (used in Hotwire/Turbo Drive applications).
32
- # @option options [String, Symbol] :return_to Override the return_to URL for this action. If not provided, defaults to current URL.
33
- # @option options [Boolean] :bulk_action (false) If true, applies to a bulk selection of records (e.g., "Mark Selected as Read").
34
- # @option options [Boolean] :collection_record_action (false) If true, applies to records in a collection (e.g., "Edit Record" button in a table).
35
- # @option options [Boolean] :record_action (false) If true, applies to an individual record (e.g., "Delete" button on a Show page).
36
- # @option options [Boolean] :resource_action (false) If true, applies to the entire resource and can be used in any context (e.g., "Import from CSV").
37
- # @option options [Symbol] :category The category of the action. Determines visibility and grouping.
38
- # Valid values include:
39
- # @option options [Symbol] :primary Always shown and given prominence in the UI.
40
- # @option options [Symbol] :secondary Shown in secondary menus or less prominent areas.
41
- # @option options [Symbol] :danger Actions that require caution, often destructive operations.
42
- # @option options [Integer] :position (50) The position of the action in its group. Lower numbers appear first.
9
+ attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to
10
+
43
11
  def initialize(name, **options)
44
12
  @name = name.to_sym
45
13
  @label = options[:label] || @name.to_s.titleize
@@ -57,51 +25,53 @@ module Plutonium
57
25
  @resource_action = options[:resource_action] || false
58
26
  @category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
59
27
  @position = options[:position] || 50
60
- @modal = options[:modal] || :centered
61
- validate_modal!
28
+ @modal_mode = options[:modal]
29
+ @modal_size = options[:size]
30
+ validate_modal_mode!
31
+ validate_modal_size!
62
32
 
63
33
  freeze
64
34
  end
65
35
 
66
- # @return [Boolean] Whether this is a bulk action.
67
- def bulk_action?
68
- @bulk_action
36
+ # Resolves to the definition's `modal_mode` when unset on the action.
37
+ def modal_mode(definition = nil)
38
+ return @modal_mode if @modal_mode || definition.nil?
39
+ definition.modal_mode
69
40
  end
70
41
 
71
- # @return [Boolean] Whether this is a collection record action.
72
- def collection_record_action?
73
- @collection_record_action
42
+ # Resolves to the definition's `modal_size` when unset on the action.
43
+ def modal_size(definition = nil)
44
+ return @modal_size if @modal_size || definition.nil?
45
+ definition.modal_size
74
46
  end
75
47
 
76
- # @return [Boolean] Whether this is a record action.
77
- def record_action?
78
- @record_action
48
+ # Downgrades the remote-modal frame to nil when the definition has
49
+ # `modal false`, so the link navigates as a full page instead of
50
+ # targeting a frame that won't exist. Other frames pass through.
51
+ def turbo_frame(definition = nil)
52
+ return nil if definition && targets_remote_modal? && definition.modal_mode == false
53
+ @turbo_frame
79
54
  end
80
55
 
81
- # @return [Boolean] Whether this is a resource action.
82
- def resource_action?
83
- @resource_action
84
- end
56
+ def bulk_action? = @bulk_action
57
+ def collection_record_action? = @collection_record_action
58
+ def record_action? = @record_action
59
+ def resource_action? = @resource_action
85
60
 
86
61
  def permitted_by?(policy)
87
62
  policy.allowed_to?(:"#{name}?")
88
63
  end
89
64
 
90
65
  # Returns a new Action with the given options merged over this one.
91
- # Used by the resource definition to derive variants (e.g. dropping
92
- # `turbo_frame` when `modal false` is configured) without mutating
93
- # the frozen original.
94
66
  def with(**overrides)
95
67
  self.class.new(name, **to_options.merge(overrides))
96
68
  end
97
69
 
98
70
  protected
99
71
 
100
- # Canonical representation for reconstruction via `with`. Every
72
+ # Canonical option hash for reconstruction via `with`. Every
101
73
  # attribute set in `initialize` MUST appear here; otherwise
102
74
  # `with(**overrides)` would silently drop it on round-trip.
103
- # `category` is exposed as a Symbol since `initialize` re-wraps
104
- # it in StringInquirer.
105
75
  def to_options
106
76
  {
107
77
  label: @label,
@@ -119,21 +89,31 @@ module Plutonium
119
89
  resource_action: @resource_action,
120
90
  category: @category.to_sym,
121
91
  position: @position,
122
- modal: @modal
92
+ modal: @modal_mode,
93
+ size: @modal_size
123
94
  }
124
95
  end
125
96
 
126
97
  private
127
98
 
128
- def validate_modal!
129
- return if [:centered, :slideover].include?(@modal)
130
- raise ArgumentError, "modal must be :centered or :slideover, got #{@modal.inspect}"
99
+ def targets_remote_modal?
100
+ @turbo_frame == Plutonium::REMOTE_MODAL_FRAME
101
+ end
102
+
103
+ def validate_modal_mode!
104
+ return if @modal_mode.nil?
105
+ return if [:centered, :slideover].include?(@modal_mode)
106
+ raise ArgumentError, "modal must be :centered or :slideover, got #{@modal_mode.inspect}"
107
+ end
108
+
109
+ def validate_modal_size!
110
+ return if @modal_size.nil?
111
+ return if Plutonium::UI::Modal::Base::VALID_SIZES.include?(@modal_size)
112
+ raise ArgumentError,
113
+ "size must be one of #{Plutonium::UI::Modal::Base::VALID_SIZES.inspect}, " \
114
+ "got #{@modal_size.inspect}"
131
115
  end
132
116
 
133
- # Build RouteOptions from the provided options
134
- #
135
- # @param [RouteOptions, Hash, nil] options The routing options
136
- # @return [RouteOptions] The built RouteOptions object
137
117
  def build_route_options(options)
138
118
  case options
139
119
  when RouteOptions
@@ -30,6 +30,12 @@ module Plutonium
30
30
  # @return [Symbol] :classic (legacy Header/Sidebar) or :modern (Topbar/IconRail)
31
31
  attr_accessor :shell
32
32
 
33
+ # @return [String] host URL of the Navii avatar service (no path), used by
34
+ # {Plutonium::UI::Avatar} as the default profile-image fallback. The
35
+ # component appends the `/avatar/:seed` route. Repoint this to self-host
36
+ # or proxy the service.
37
+ attr_accessor :navii_host_url
38
+
33
39
  # Map of version numbers to their default configurations
34
40
  VERSION_DEFAULTS = {
35
41
  1.0 => proc do |config|
@@ -52,6 +58,7 @@ module Plutonium
52
58
  @cache_discovery = !Rails.env.development?
53
59
  @enable_hotreload = Rails.env.development?
54
60
  @shell = :modern
61
+ @navii_host_url = "https://api.navii.dev"
55
62
  end
56
63
 
57
64
  # Load default configuration for a specific version
@@ -6,19 +6,19 @@ module Plutonium
6
6
  included do
7
7
  defineable_prop :action
8
8
 
9
- def self.action(name, interaction: nil, **)
9
+ def self.action(name, interaction: nil, **opts)
10
10
  defined_actions[name] = if interaction
11
- Plutonium::Action::Interactive::Factory.create(name, interaction:, **)
11
+ Plutonium::Action::Interactive::Factory.create(name, interaction:, **opts)
12
12
  else
13
- Plutonium::Action::Simple.new(name, **)
13
+ Plutonium::Action::Simple.new(name, **opts)
14
14
  end
15
15
  end
16
16
 
17
- def action(name, interaction: nil, **)
17
+ def action(name, interaction: nil, **opts)
18
18
  instance_defined_actions[name] = if interaction
19
- Plutonium::Action::Interactive::Factory.create(name, interaction:, **)
19
+ Plutonium::Action::Interactive::Factory.create(name, interaction:, **opts)
20
20
  else
21
- Plutonium::Action::Simple.new(name, **)
21
+ Plutonium::Action::Simple.new(name, **opts)
22
22
  end
23
23
  end
24
24
 
@@ -32,12 +32,10 @@ module Plutonium
32
32
 
33
33
  # standard CRUD actions
34
34
 
35
- # turbo_frame for :new and :edit is set by
36
- # Resource::Definition.configure_crud_modal_targets! based on the
37
- # `modal` config. Don't hard-code it here.
38
35
  action(:new, route_options: {action: :new},
39
36
  resource_action: true, category: :primary,
40
- icon: Phlex::TablerIcons::Plus, position: 10)
37
+ icon: Phlex::TablerIcons::Plus, position: 10,
38
+ turbo_frame: Plutonium::REMOTE_MODAL_FRAME)
41
39
 
42
40
  action(:show, route_options: {action: :show},
43
41
  collection_record_action: true, category: :primary,
@@ -45,7 +43,8 @@ module Plutonium
45
43
 
46
44
  action(:edit, route_options: {action: :edit},
47
45
  record_action: true, collection_record_action: true, category: :primary,
48
- icon: Phlex::TablerIcons::Edit, position: 20)
46
+ icon: Phlex::TablerIcons::Edit, position: 20,
47
+ turbo_frame: Plutonium::REMOTE_MODAL_FRAME)
49
48
 
50
49
  action(:destroy, route_options: {method: :delete},
51
50
  record_action: true, collection_record_action: true, category: :danger,
@@ -86,6 +86,35 @@ module Plutonium
86
86
  # false = always hide
87
87
  inheritable_config_attr :submit_and_continue
88
88
 
89
+ # modals — drive how :new / :edit and interactive actions render.
90
+ # Actions read these lazily at render time, so override order and
91
+ # subclass inheritance both work naturally.
92
+ VALID_MODAL_MODES = [:centered, :slideover, false].freeze
93
+
94
+ inheritable_config_attr :modal_mode, :modal_size
95
+ modal_mode :slideover
96
+ modal_size :md
97
+
98
+ # Sets `modal_mode` and `modal_size` together with validation.
99
+ #
100
+ # - :slideover (default) — slide-in panel from the right
101
+ # - :centered — centered dialog
102
+ # - false — no modal; new/edit are full standalone pages
103
+ #
104
+ # `size:` see Plutonium::UI::Modal::Base::VALID_SIZES. `:auto`
105
+ # hugs the form's natural width.
106
+ def self.modal(mode, size: :md)
107
+ unless VALID_MODAL_MODES.include?(mode)
108
+ raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
109
+ end
110
+ unless Plutonium::UI::Modal::Base::VALID_SIZES.include?(size)
111
+ raise ArgumentError,
112
+ "modal size must be one of #{Plutonium::UI::Modal::Base::VALID_SIZES.inspect}, got #{size.inspect}"
113
+ end
114
+ modal_mode mode
115
+ modal_size size
116
+ end
117
+
89
118
  def initialize
90
119
  super
91
120
  end
@@ -4,29 +4,6 @@ module Plutonium
4
4
  module Helpers
5
5
  # Helper module for managing asset-related functionality
6
6
  module AssetsHelper
7
- # Generate a stylesheet tag for the resource
8
- #
9
- # @return [ActiveSupport::SafeBuffer] HTML stylesheet link tag
10
- def resource_stylesheet_tag
11
- url = resource_asset_url_for(:css, resource_stylesheet_asset)
12
- stylesheet_link_tag(url, "data-turbo-track": "reload")
13
- end
14
-
15
- # Generate a script tag for the resource
16
- #
17
- # @return [ActiveSupport::SafeBuffer] HTML script tag
18
- def resource_script_tag
19
- url = resource_asset_url_for(:js, resource_script_asset)
20
- javascript_include_tag(url, "data-turbo-track": "reload", type: "module")
21
- end
22
-
23
- # Generate a favicon link tag
24
- #
25
- # @return [ActiveSupport::SafeBuffer] HTML favicon link tag
26
- def resource_favicon_tag
27
- favicon_link_tag(resource_favicon_asset)
28
- end
29
-
30
7
  # Generate an image tag for the logo
31
8
  #
32
9
  # @param classname [String] CSS class name for the image tag
@@ -56,13 +33,6 @@ module Plutonium
56
33
  Plutonium.configuration.assets.script
57
34
  end
58
35
 
59
- # Get the favicon asset path
60
- #
61
- # @return [String] path to the favicon asset
62
- def resource_favicon_asset
63
- Plutonium.configuration.assets.favicon
64
- end
65
-
66
36
  private
67
37
 
68
38
  # Generate the appropriate asset URL based on the environment
@@ -17,50 +17,6 @@ module Plutonium
17
17
  }
18
18
  )
19
19
  end
20
-
21
- def read_more(content, clamp = 4)
22
- return if content.blank?
23
-
24
- # Stimulus Read More (https://www.stimulus-components.com/docs/stimulus-read-more/)
25
- style = "overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; " \
26
- "-webkit-line-clamp: var(--read-more-line-clamp, #{clamp});"
27
-
28
- tag.div(
29
- data: {
30
- controller: "read-more",
31
- read_more_more_text_value: "Read more",
32
- read_more_less_text_value: "Read less"
33
- }
34
- ) do
35
- concat tag.div(content,
36
- style:,
37
- data: {read_more_target: "content"})
38
-
39
- next unless content.lines.size > clamp
40
-
41
- concat tag.button("Read more",
42
- class: "btn btn-sm btn-link text-decoration-none ps-0",
43
- data: {action: "read-more#toggle"})
44
- end
45
- end
46
-
47
- def quill(content)
48
- return if content.blank?
49
-
50
- tag.div(
51
- content,
52
- class: "ql-viewer",
53
- data: {
54
- controller: "quill-viewer"
55
- }
56
- )
57
- end
58
-
59
- def clamp_content(content)
60
- return if content.blank?
61
-
62
- tag.div content, class: "clamped-content"
63
- end
64
20
  end
65
21
  end
66
22
  end