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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-app/SKILL.md +2 -0
- data/.claude/skills/plutonium-auth/SKILL.md +6 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
- data/.claude/skills/plutonium-testing/SKILL.md +3 -1
- data/.claude/skills/plutonium-ui/SKILL.md +32 -8
- data/CHANGELOG.md +33 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +258 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +2 -2
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -50
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/guides/authentication.md +11 -6
- data/docs/guides/authorization.md +3 -3
- data/docs/guides/creating-packages.md +8 -11
- data/docs/guides/custom-actions.md +8 -2
- data/docs/guides/customizing-ui.md +259 -0
- data/docs/guides/index.md +49 -32
- data/docs/guides/multi-tenancy.md +14 -6
- data/docs/guides/nested-resources.md +69 -0
- data/docs/guides/search-filtering.md +6 -0
- data/docs/guides/testing.md +5 -1
- data/docs/guides/theming.md +14 -1
- data/docs/guides/user-invites.md +10 -4
- data/docs/guides/user-profile.md +8 -0
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/reference/app/generators.md +4 -4
- data/docs/reference/auth/accounts.md +7 -8
- data/docs/reference/auth/index.md +1 -1
- data/docs/reference/behavior/policies.md +2 -2
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/index.md +67 -55
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +5 -4
- data/docs/reference/tenancy/entity-scoping.md +14 -8
- data/docs/reference/tenancy/index.md +1 -1
- data/docs/reference/tenancy/invites.md +12 -5
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/reference/ui/tables.md +8 -4
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
- data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/invites/install_generator.rb +44 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +17 -2
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/component/methods.rb +1 -0
- data/lib/plutonium/ui/form/base.rb +32 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +8 -4
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +7 -4
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +7 -4
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +4 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- data/yarn.lock +108 -1
- metadata +52 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /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.
|
|
48
|
-
config.solid_errors.
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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=
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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 %>
|
|
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, :
|
|
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
|
-
@
|
|
61
|
-
|
|
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
|
-
#
|
|
67
|
-
def
|
|
68
|
-
@
|
|
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
|
-
#
|
|
72
|
-
def
|
|
73
|
-
@
|
|
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
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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: @
|
|
92
|
+
modal: @modal_mode,
|
|
93
|
+
size: @modal_size
|
|
123
94
|
}
|
|
124
95
|
end
|
|
125
96
|
|
|
126
97
|
private
|
|
127
98
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
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
|