plutonium 0.62.2 → 0.63.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/SKILL.md +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/CHANGELOG.md +67 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +29 -11
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -42,14 +42,46 @@ module PlutoniumGenerators
|
|
|
42
42
|
constraint_file = "app/constraints/management_constraint.rb"
|
|
43
43
|
return if File.exist?(File.expand_path(constraint_file, destination_root))
|
|
44
44
|
|
|
45
|
-
create_file constraint_file,
|
|
45
|
+
create_file constraint_file, management_constraint_source
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prefer the Rodauth constraint when the app has an admin account to point
|
|
49
|
+
# at — `Rodauth::Rails.authenticate` redirects unauthenticated requests to
|
|
50
|
+
# the login page instead of 404ing on the mount.
|
|
51
|
+
def management_constraint_source
|
|
52
|
+
account = rodauth_management_account
|
|
53
|
+
return management_constraint_stub unless account
|
|
54
|
+
|
|
55
|
+
<<~RUBY
|
|
56
|
+
# frozen_string_literal: true
|
|
57
|
+
|
|
58
|
+
# Gates the management engines mounted in config/routes.rb.
|
|
59
|
+
class ManagementConstraint
|
|
60
|
+
AUTHENTICATE = Rodauth::Rails.authenticate(#{account})
|
|
61
|
+
|
|
62
|
+
def self.matches?(request)
|
|
63
|
+
AUTHENTICATE.call(request)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
RUBY
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def management_constraint_stub
|
|
70
|
+
<<~RUBY
|
|
46
71
|
# frozen_string_literal: true
|
|
47
72
|
|
|
73
|
+
# Gates the management engines mounted in config/routes.rb.
|
|
48
74
|
class ManagementConstraint
|
|
75
|
+
# Rodauth (redirects to the login page when signed out):
|
|
76
|
+
# AUTHENTICATE = Rodauth::Rails.authenticate(:admin)
|
|
77
|
+
#
|
|
78
|
+
# def self.matches?(request)
|
|
79
|
+
# AUTHENTICATE.call(request)
|
|
80
|
+
# end
|
|
81
|
+
|
|
49
82
|
def self.matches?(request)
|
|
50
83
|
false # TODO: Implement authentication
|
|
51
84
|
# Examples:
|
|
52
|
-
# Rodauth: request.env["rodauth.admin"]&.logged_in?
|
|
53
85
|
# Devise: request.env["warden"].user(:admin).present?
|
|
54
86
|
# Custom: request.session[:admin_id].present?
|
|
55
87
|
# HTTP Basic: authenticate_with_http_basic(request)
|
|
@@ -67,6 +99,19 @@ module PlutoniumGenerators
|
|
|
67
99
|
end
|
|
68
100
|
RUBY
|
|
69
101
|
end
|
|
102
|
+
|
|
103
|
+
# Returns the `Rodauth::Rails.authenticate` argument for the account that
|
|
104
|
+
# should guard management routes — only ever the `:admin` configuration.
|
|
105
|
+
# `nil` when the app has no admin account, so the constraint is left
|
|
106
|
+
# unimplemented rather than silently handing management routes to whatever
|
|
107
|
+
# other account happens to exist.
|
|
108
|
+
def rodauth_management_account
|
|
109
|
+
rodauth_app = File.expand_path("app/rodauth/rodauth_app.rb", destination_root)
|
|
110
|
+
return unless File.exist?(rodauth_app)
|
|
111
|
+
|
|
112
|
+
names = File.read(rodauth_app).scan(/^\s*configure\s+::\w+\s*,\s*:(\w+)/).flatten
|
|
113
|
+
":admin" if names.include?("admin")
|
|
114
|
+
end
|
|
70
115
|
end
|
|
71
116
|
end
|
|
72
117
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PlutoniumGenerators
|
|
4
|
+
module Concerns
|
|
5
|
+
module ResourceRegistration
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# Insert `register_resource ::<Klass>` into a routes file. Idempotent:
|
|
9
|
+
# skips if already present. Falls back when the conventional
|
|
10
|
+
# `# register resources above.` marker is missing.
|
|
11
|
+
def register_resource_in_routes(routes_path, resource, singular: false)
|
|
12
|
+
line = "register_resource ::#{resource}"
|
|
13
|
+
line += ", singular: true" if singular
|
|
14
|
+
content = File.read(File.join(destination_root, routes_path))
|
|
15
|
+
|
|
16
|
+
if /^\s*#{Regexp.escape(line)}\b/.match?(content)
|
|
17
|
+
say_status :identical, "#{routes_path} already registers #{resource}", :blue
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if /^\s*#\s*register resources above\b/.match?(content)
|
|
22
|
+
insert_into_file routes_path,
|
|
23
|
+
indent("#{line}\n", 2),
|
|
24
|
+
before: /^\s*#\s*register resources above\b.*/
|
|
25
|
+
elsif /^\s*Rails\.application\.routes\.draw do\b/.match?(content)
|
|
26
|
+
insert_into_file routes_path,
|
|
27
|
+
indent("#{line}\n", 2),
|
|
28
|
+
after: /^\s*Rails\.application\.routes\.draw do.*\n/
|
|
29
|
+
elsif (match = content.match(/^(\w+::Engine)\.routes\.draw do.*\n/))
|
|
30
|
+
insert_into_file routes_path,
|
|
31
|
+
indent("#{line}\n", 2),
|
|
32
|
+
after: /^\s*#{Regexp.escape(match[1])}\.routes\.draw do.*\n/
|
|
33
|
+
else
|
|
34
|
+
say_status :warn,
|
|
35
|
+
"Could not locate routes block in #{routes_path}; add manually: #{line}",
|
|
36
|
+
:yellow
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,6 +7,7 @@ module Pu
|
|
|
7
7
|
class ConnGenerator < Rails::Generators::Base
|
|
8
8
|
include PlutoniumGenerators::Generator
|
|
9
9
|
include PlutoniumGenerators::Concerns::ResourceSelector
|
|
10
|
+
include PlutoniumGenerators::Concerns::ResourceRegistration
|
|
10
11
|
|
|
11
12
|
source_root File.expand_path("templates", __dir__)
|
|
12
13
|
|
|
@@ -26,6 +27,9 @@ module Pu
|
|
|
26
27
|
class_option :definition, type: :boolean, default: false,
|
|
27
28
|
desc: "Create portal-specific definition even if base definition exists"
|
|
28
29
|
|
|
30
|
+
class_option :controller_for, type: :string, default: nil,
|
|
31
|
+
desc: "Explicit controller_for target, when the controller's name won't infer the resource on its own"
|
|
32
|
+
|
|
29
33
|
def start
|
|
30
34
|
selected_resources = resources_selection
|
|
31
35
|
@app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
|
|
@@ -36,7 +40,7 @@ module Pu
|
|
|
36
40
|
@resource_class = resource
|
|
37
41
|
|
|
38
42
|
if app_namespace == "MainApp"
|
|
39
|
-
register_resource_in_routes("config/routes.rb", resource)
|
|
43
|
+
register_resource_in_routes("config/routes.rb", resource, singular: options[:singular])
|
|
40
44
|
else
|
|
41
45
|
if options[:policy] || !expected_parent_policy
|
|
42
46
|
template "app/policies/resource_policy.rb",
|
|
@@ -51,7 +55,8 @@ module Pu
|
|
|
51
55
|
template "app/controllers/resource_controller.rb",
|
|
52
56
|
"packages/#{package_namespace}/app/controllers/#{package_namespace}/#{resource.pluralize.underscore}_controller.rb"
|
|
53
57
|
|
|
54
|
-
register_resource_in_routes("packages/#{package_namespace}/config/routes.rb", resource
|
|
58
|
+
register_resource_in_routes("packages/#{package_namespace}/config/routes.rb", resource,
|
|
59
|
+
singular: options[:singular])
|
|
55
60
|
end
|
|
56
61
|
end
|
|
57
62
|
rescue => e
|
|
@@ -62,45 +67,10 @@ module Pu
|
|
|
62
67
|
|
|
63
68
|
attr_reader :app_namespace, :resource_class
|
|
64
69
|
|
|
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
|
-
|
|
96
70
|
def package_namespace
|
|
97
71
|
app_namespace.underscore
|
|
98
72
|
end
|
|
99
73
|
|
|
100
|
-
def singular_option
|
|
101
|
-
options[:singular] ? ", singular: true" : ""
|
|
102
|
-
end
|
|
103
|
-
|
|
104
74
|
def resource_namespace
|
|
105
75
|
app_namespace.underscore
|
|
106
76
|
end
|
|
@@ -114,18 +84,28 @@ module Pu
|
|
|
114
84
|
expected_parent_controller || "#{app_namespace}::ResourceController"
|
|
115
85
|
end
|
|
116
86
|
|
|
87
|
+
# A gem-provided resource (e.g. Plutonium::Interaction::Async::Run) already has
|
|
88
|
+
# a working policy that subclasses Plutonium::Resource::Policy directly
|
|
89
|
+
# rather than through this host's ::ResourcePolicy — so the `< ::ResourcePolicy`
|
|
90
|
+
# check alone misses it and would regenerate a redundant, shadowing-prone
|
|
91
|
+
# override by default.
|
|
117
92
|
def expected_parent_policy
|
|
118
93
|
expected_parent_policy = "::#{resource_class.classify}Policy".safe_constantize
|
|
119
|
-
|
|
94
|
+
return unless expected_parent_policy.present?
|
|
95
|
+
|
|
96
|
+
expected_parent_policy if expected_parent_policy < ::ResourcePolicy || expected_parent_policy < Plutonium::Resource::Policy
|
|
120
97
|
end
|
|
121
98
|
|
|
122
99
|
def parent_policy
|
|
123
100
|
expected_parent_policy || "ResourcePolicy"
|
|
124
101
|
end
|
|
125
102
|
|
|
103
|
+
# See expected_parent_policy — same reasoning for definitions.
|
|
126
104
|
def expected_parent_definition
|
|
127
105
|
expected_parent_definition = "::#{resource_class.classify}Definition".safe_constantize
|
|
128
|
-
|
|
106
|
+
return unless expected_parent_definition.present?
|
|
107
|
+
|
|
108
|
+
expected_parent_definition if expected_parent_definition < ::ResourceDefinition || expected_parent_definition < Plutonium::Resource::Definition
|
|
129
109
|
end
|
|
130
110
|
|
|
131
111
|
def parent_definition
|
|
@@ -233,11 +233,11 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
|
|
|
233
233
|
|
|
234
234
|
# ==> Remember Feature
|
|
235
235
|
|
|
236
|
-
# Remember
|
|
237
|
-
after_login { remember_login }
|
|
236
|
+
# Only remember users who ticked the "Remember Me" checkbox on the login form.
|
|
237
|
+
after_login { remember_login if param_or_nil(remember_param) == remember_remember_param_value }
|
|
238
238
|
|
|
239
|
-
# Or
|
|
240
|
-
# after_login { remember_login
|
|
239
|
+
# Or remember every user who logs in, whether they asked to be or not.
|
|
240
|
+
# after_login { remember_login }
|
|
241
241
|
|
|
242
242
|
# Extend user's remember period when remembered via a cookie
|
|
243
243
|
extend_remember_deadline? true
|
|
@@ -246,8 +246,17 @@ class <%= account_path.classify %>RodauthPlugin < RodauthPlugin
|
|
|
246
246
|
remember_cookie_key "_<%= table_prefix %>_remember"
|
|
247
247
|
<% end -%>
|
|
248
248
|
|
|
249
|
-
# Session security
|
|
250
|
-
|
|
249
|
+
# Session security: namespace EVERY session key this configuration touches -
|
|
250
|
+
# the account id plus `authenticated_by`, `login_redirect`,
|
|
251
|
+
# `two_factor_auth_setup` and friends. Without this, configurations share those
|
|
252
|
+
# keys and clobber each other's state, and `session_isolation` cannot tell which
|
|
253
|
+
# entries belong to which configuration.
|
|
254
|
+
#
|
|
255
|
+
# Do not also set `session_key` here. An explicit `session_key` is NOT prefixed,
|
|
256
|
+
# so the account id key would rotate on a different schedule from every other
|
|
257
|
+
# key - and Rodauth crashes on a session that has an account id but no
|
|
258
|
+
# `authenticated_by` (`nil.include?` in `logged_in_via_remember_key?`).
|
|
259
|
+
session_key_prefix "<%= table_prefix %>_"
|
|
251
260
|
|
|
252
261
|
# ==> Hooks
|
|
253
262
|
|
|
@@ -9,6 +9,13 @@ class RodauthPlugin < Rodauth::Rails::Auth
|
|
|
9
9
|
# See the Rodauth documentation for the list of available config options:
|
|
10
10
|
# http://rodauth.jeremyevans.net/documentation.html
|
|
11
11
|
|
|
12
|
+
# Keep each configuration's session state to itself, so a person can be signed
|
|
13
|
+
# into more than one portal at a time. Without this, Rodauth's session reset on
|
|
14
|
+
# login (and on the `remember` feature's autologin) wipes the whole Rails
|
|
15
|
+
# session, evicting every other configuration's login.
|
|
16
|
+
# Relies on each configuration setting `session_key_prefix`.
|
|
17
|
+
enable :session_isolation
|
|
18
|
+
|
|
12
19
|
# List of authentication features that are loaded.
|
|
13
20
|
# enable :create_account, :verify_account, :verify_account_grace_period,
|
|
14
21
|
# :reset_password, :change_password, :change_password_notify,
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<%%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
|
|
14
14
|
<%% end %>
|
|
15
15
|
</head>
|
|
16
|
-
<body class="antialiased min-h-
|
|
17
|
-
<main class="p-4 min-h-
|
|
16
|
+
<body class="antialiased pu-min-h-viewport bg-[var(--pu-body)]">
|
|
17
|
+
<main class="p-4 pu-min-h-viewport flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">
|
|
18
18
|
<%% if flash.any? %>
|
|
19
19
|
<%% flash.each do |type, message| %>
|
|
20
20
|
<div class="fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 rounded-[var(--pu-radius-lg)]"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lib/plutonium_generators"
|
|
4
|
+
|
|
5
|
+
module Pu
|
|
6
|
+
module Wizards
|
|
7
|
+
# Turns the wizard subsystem on.
|
|
8
|
+
#
|
|
9
|
+
# App-wide, and takes no portal — unlike pu:async_interactions:install,
|
|
10
|
+
# which connects a RESOURCE and therefore needs somewhere to route it. A
|
|
11
|
+
# wizard mounts itself wherever it is declared (the `wizard` macro on a
|
|
12
|
+
# definition, or `register_wizard` in a portal or the main app), so there is
|
|
13
|
+
# nothing here to attach to a portal.
|
|
14
|
+
#
|
|
15
|
+
# What it does is the two steps every wizard app needs and neither of which
|
|
16
|
+
# a wizard can do for itself: flip the flag that registers the migration
|
|
17
|
+
# path, and schedule the sweep.
|
|
18
|
+
class InstallGenerator < Rails::Generators::Base
|
|
19
|
+
include PlutoniumGenerators::Generator
|
|
20
|
+
include PlutoniumGenerators::Concerns::ConfiguresRecurring
|
|
21
|
+
|
|
22
|
+
desc(
|
|
23
|
+
"Enable wizards and schedule the abandonment sweep\n\n" \
|
|
24
|
+
"e.g. rails g pu:wizards:install"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class_option :schedule, type: :string, default: "every 15 minutes",
|
|
28
|
+
desc: "Cron-style schedule for the SweepJob recurring task"
|
|
29
|
+
|
|
30
|
+
def start
|
|
31
|
+
enable_wizards
|
|
32
|
+
schedule_sweep_job
|
|
33
|
+
migration_reminder
|
|
34
|
+
rescue => e
|
|
35
|
+
exception "#{self.class} failed:", e
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# The flag gates the MIGRATION as well as the behaviour: while it is off
|
|
41
|
+
# the wizard migration path is never registered, so
|
|
42
|
+
# plutonium_wizard_sessions does not exist and nothing works.
|
|
43
|
+
def enable_wizards
|
|
44
|
+
configure_plutonium "config.wizards.enabled = true"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Idempotent via add_recurring_tasks, so re-running this is a no-op.
|
|
48
|
+
#
|
|
49
|
+
# Scheduling matters more than it looks. For an +execute+-only wizard an
|
|
50
|
+
# unscheduled sweep just leaves stale session rows, which is untidy; for an
|
|
51
|
+
# +on_submit+ wizard it is the only thing that cleans up the partial domain
|
|
52
|
+
# records an abandoned run left behind.
|
|
53
|
+
def schedule_sweep_job
|
|
54
|
+
if gem_in_bundle?("solid_queue")
|
|
55
|
+
unless add_recurring_tasks(sweep_job_task_yaml, marker: "sweep_abandoned_wizards")
|
|
56
|
+
log :skip, "SweepJob not scheduled (config/recurring.yml missing or already scheduled)"
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
log :info, "solid_queue not found — schedule Plutonium::Wizard::SweepJob yourself (see docs)"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sweep_job_task_yaml
|
|
64
|
+
<<~YAML
|
|
65
|
+
sweep_abandoned_wizards:
|
|
66
|
+
class: Plutonium::Wizard::SweepJob
|
|
67
|
+
schedule: "#{options[:schedule]}"
|
|
68
|
+
YAML
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Not run for you: the migration path is registered from the initializer at
|
|
72
|
+
# BOOT, and this process booted before the flag was written.
|
|
73
|
+
def migration_reminder
|
|
74
|
+
log :info, "wizards enabled — run `rails db:migrate` to create plutonium_wizard_sessions"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -6,7 +6,7 @@ module Plutonium
|
|
|
6
6
|
module Action
|
|
7
7
|
# Base class for all actions in the Plutonium framework.
|
|
8
8
|
class Base
|
|
9
|
-
attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition
|
|
9
|
+
attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition, :link, :button
|
|
10
10
|
|
|
11
11
|
def initialize(name, **options)
|
|
12
12
|
@name = name.to_sym
|
|
@@ -23,12 +23,20 @@ module Plutonium
|
|
|
23
23
|
@collection_record_action = options[:collection_record_action] || false
|
|
24
24
|
@record_action = options[:record_action] || false
|
|
25
25
|
@resource_action = options[:resource_action] || false
|
|
26
|
-
@
|
|
26
|
+
@hidden = options[:hidden] || false
|
|
27
27
|
@category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
|
|
28
28
|
@position = options[:position] || 50
|
|
29
29
|
@modal_mode = options[:modal]
|
|
30
30
|
@modal_size = options[:size]
|
|
31
31
|
@condition = options[:condition]
|
|
32
|
+
# Author-supplied HTML attributes, keyed by the element they target:
|
|
33
|
+
# `link:` merges onto every anchor rendering (the GET link and dropdown
|
|
34
|
+
# items — which are anchors even for non-GET actions), `button:` onto
|
|
35
|
+
# the button_to <form> (the non-GET toolbar rendering). Both deep-merge
|
|
36
|
+
# over the framework's attributes at render time, with the author
|
|
37
|
+
# winning.
|
|
38
|
+
@link = normalize_html_attribute_bag(options[:link], :link)
|
|
39
|
+
@button = normalize_html_attribute_bag(options[:button], :button)
|
|
32
40
|
validate_modal_mode!
|
|
33
41
|
validate_modal_size!
|
|
34
42
|
|
|
@@ -69,11 +77,14 @@ module Plutonium
|
|
|
69
77
|
def record_action? = @record_action
|
|
70
78
|
def resource_action? = @resource_action
|
|
71
79
|
|
|
72
|
-
# True when this action
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
80
|
+
# True when this action must never render. It still has a live route,
|
|
81
|
+
# policy predicate, and (if interactive) form + params machinery — it is
|
|
82
|
+
# simply reachable only by something other than a button: a kanban drop,
|
|
83
|
+
# a drag-reorder, a custom Stimulus controller.
|
|
84
|
+
#
|
|
85
|
+
# Display-only, like #condition_met? — NOT an authorization boundary.
|
|
86
|
+
# Keep authorization in the policy.
|
|
87
|
+
def hidden? = @hidden
|
|
77
88
|
|
|
78
89
|
def permitted_by?(policy)
|
|
79
90
|
policy.allowed_to?(:"#{name}?")
|
|
@@ -94,6 +105,21 @@ module Plutonium
|
|
|
94
105
|
ConditionContext.new(view_context, record).instance_exec(&@condition)
|
|
95
106
|
end
|
|
96
107
|
|
|
108
|
+
# Merge points for rendering surfaces: deep-merge the author's bag over
|
|
109
|
+
# the framework-built attributes for the element being rendered, with
|
|
110
|
+
# the author winning on every key. `link_attributes` is for anchors
|
|
111
|
+
# (the GET toolbar link, dropdown items — anchors even for non-GET
|
|
112
|
+
# actions — bulk-action links, kanban column actions, card show links);
|
|
113
|
+
# `button_attributes` is for the button_to <form> wrapper. Surfaces
|
|
114
|
+
# call these instead of merging #link/#button themselves.
|
|
115
|
+
def link_attributes(attributes)
|
|
116
|
+
merge_author_bag(@link, attributes)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def button_attributes(attributes)
|
|
120
|
+
merge_author_bag(@button, attributes)
|
|
121
|
+
end
|
|
122
|
+
|
|
97
123
|
# Returns a new Action with the given options merged over this one.
|
|
98
124
|
def with(**overrides)
|
|
99
125
|
self.class.new(name, **to_options.merge(overrides))
|
|
@@ -119,21 +145,57 @@ module Plutonium
|
|
|
119
145
|
collection_record_action: @collection_record_action,
|
|
120
146
|
record_action: @record_action,
|
|
121
147
|
resource_action: @resource_action,
|
|
122
|
-
|
|
148
|
+
hidden: @hidden,
|
|
123
149
|
category: @category.to_sym,
|
|
124
150
|
position: @position,
|
|
125
151
|
modal: @modal_mode,
|
|
126
152
|
size: @modal_size,
|
|
127
|
-
condition: @condition
|
|
153
|
+
condition: @condition,
|
|
154
|
+
link: @link,
|
|
155
|
+
button: @button
|
|
128
156
|
}
|
|
129
157
|
end
|
|
130
158
|
|
|
131
159
|
private
|
|
132
160
|
|
|
161
|
+
def merge_author_bag(bag, attributes)
|
|
162
|
+
bag.empty? ? attributes : attributes.deep_merge(bag)
|
|
163
|
+
end
|
|
164
|
+
|
|
133
165
|
def targets_remote_modal?
|
|
134
166
|
@turbo_frame == Plutonium::REMOTE_MODAL_FRAME
|
|
135
167
|
end
|
|
136
168
|
|
|
169
|
+
# Stores the action's own symbol-keyed copy of an author attribute bag.
|
|
170
|
+
# Symbolizing is load-bearing, not cosmetic: the render-time deep_merge
|
|
171
|
+
# matches keys exactly, so a string-keyed bag would sit alongside the
|
|
172
|
+
# framework's symbol keys and emit duplicate attributes instead of
|
|
173
|
+
# overriding them. Copying keeps the caller's hash untouched (no
|
|
174
|
+
# freezing objects we don't own), and the deep structural freeze makes
|
|
175
|
+
# the bag as immutable as the action itself.
|
|
176
|
+
def normalize_html_attribute_bag(bag, name)
|
|
177
|
+
return {}.freeze if bag.nil?
|
|
178
|
+
unless bag.is_a?(Hash)
|
|
179
|
+
raise ArgumentError, "#{name} must be a Hash of HTML attributes, got #{bag.inspect}"
|
|
180
|
+
end
|
|
181
|
+
deep_freeze_structure(bag.deep_symbolize_keys)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Freezes nested hashes/arrays (fresh copies from deep_symbolize_keys)
|
|
185
|
+
# but not leaf values, which are still shared with the caller.
|
|
186
|
+
def deep_freeze_structure(value)
|
|
187
|
+
case value
|
|
188
|
+
when Hash
|
|
189
|
+
value.each_value { |v| deep_freeze_structure(v) }
|
|
190
|
+
value.freeze
|
|
191
|
+
when Array
|
|
192
|
+
value.each { |v| deep_freeze_structure(v) }
|
|
193
|
+
value.freeze
|
|
194
|
+
else
|
|
195
|
+
value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
137
199
|
def validate_modal_mode!
|
|
138
200
|
return if @modal_mode.nil?
|
|
139
201
|
return if [:centered, :slideover].include?(@modal_mode)
|
|
@@ -35,6 +35,15 @@ module Plutonium
|
|
|
35
35
|
@immediate ? "#{label}?" : nil
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
# Extends Base's round-trip contract with the keywords this class's
|
|
41
|
+
# initialize requires — without them, #with would raise missing
|
|
42
|
+
# keywords when reconstructing the action.
|
|
43
|
+
def to_options
|
|
44
|
+
super.merge(interaction: @interaction, immediate: @immediate)
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
# Factory for creating Interactive actions
|
|
39
48
|
class Factory
|
|
40
49
|
# TODO: move these into Plutonium::Action::Interactive
|