plutonium 0.64.0 → 0.66.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 -41
- data/.claude/skills/plutonium-app/SKILL.md +115 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +236 -145
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +171 -95
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +80 -0
- data/CLAUDE.md +282 -210
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium-charts.js +20604 -0
- data/app/assets/plutonium-charts.js.map +7 -0
- data/app/assets/plutonium-charts.min.js +41 -0
- data/app/assets/plutonium-charts.min.js.map +7 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +794 -629
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +48 -48
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_flash_alerts.html.erb +2 -2
- data/app/views/plutonium/_resource_header.html.erb +2 -2
- data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
- data/app/views/plutonium/_toast.html.erb +2 -2
- data/app/views/rodauth/_login_form.html.erb +1 -1
- data/app/views/rodauth/_password_visibility.html.erb +1 -1
- data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
- data/app/views/rodauth/change_login.html.erb +2 -2
- data/app/views/rodauth/create_account.html.erb +4 -4
- data/app/views/rodauth/reset_password_request.html.erb +1 -1
- data/app/views/rodauth/verify_account_resend.html.erb +1 -1
- data/app/views/rodauth/webauthn_remove.html.erb +1 -1
- data/config/brakeman.ignore +4 -4
- data/config/initializers/rabl.rb +0 -40
- data/config/locales/en/api_client.yml +22 -0
- data/config/locales/en/async.yml +34 -0
- data/config/locales/en/dashboard.yml +25 -0
- data/config/locales/en/invites.yml +37 -0
- data/config/locales/en/js.yml +40 -0
- data/config/locales/en/kanban.yml +28 -0
- data/config/locales/en/plutonium.yml +26 -0
- data/config/locales/en/positioning.yml +14 -0
- data/config/locales/en/profile.yml +29 -0
- data/config/locales/en/resource.yml +60 -0
- data/config/locales/en/ui.yml +93 -0
- data/config/locales/en/wizard.yml +46 -0
- data/docs/.vitepress/config.ts +34 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
- data/docs/.vitepress/theme/custom.css +34 -377
- data/docs/blog/introducing-plutonium-dashboards.md +174 -0
- data/docs/blog/introducing-plutonium-i18n.md +90 -0
- data/docs/blog/introducing-plutonium.md +4 -5
- data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +300 -0
- data/docs/guides/index.md +4 -3
- data/docs/guides/kanban.md +56 -56
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/public/images/blog/dashboards-card-error.png +0 -0
- data/docs/public/images/blog/dashboards-loading.png +0 -0
- data/docs/public/images/blog/dashboards-overview.png +0 -0
- data/docs/public/images/blog/dashboards-sidebar.png +0 -0
- data/docs/public/images/blog/i18n-es-admin.png +0 -0
- data/docs/public/images/guides/dashboard-card-error.png +0 -0
- data/docs/public/images/guides/dashboard-loading.png +0 -0
- data/docs/public/images/guides/dashboard-overview.png +0 -0
- data/docs/public/images/guides/dashboard-sidebar.png +0 -0
- data/docs/reference/app/generators.md +24 -24
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +77 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +88 -31
- data/docs/reference/dashboard/dsl.md +123 -0
- data/docs/reference/dashboard/index.md +35 -0
- data/docs/reference/dashboard/registration.md +76 -0
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +262 -0
- data/docs/reference/index.md +11 -2
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +33 -33
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +18 -16
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +166 -88
- data/docs/reference/resource/export.md +11 -7
- data/docs/reference/resource/index.md +16 -14
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
- data/docs/reference/resource/query.md +14 -12
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +65 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +19 -22
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +45 -45
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/esbuild.config.js +5 -2
- data/gemfiles/rails_7.gemfile.lock +2 -2
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/active_model/validations/array_validator.rb +1 -1
- data/lib/active_model/validations/attached_validator.rb +1 -1
- data/lib/active_model/validations/url_validator.rb +2 -2
- data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
- data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +3 -2
- data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
- data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
- data/lib/generators/pu/docker/install/install_generator.rb +58 -0
- data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
- data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
- data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
- data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
- data/lib/generators/pu/invites/invitable_generator.rb +18 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
- data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
- data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
- data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
- data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
- data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
- data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
- data/lib/plutonium/action/base.rb +27 -3
- data/lib/plutonium/action/interactive.rb +17 -4
- data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
- data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
- data/lib/plutonium/auth/sequel_adapter.rb +1 -1
- data/lib/plutonium/configuration.rb +5 -0
- data/lib/plutonium/core/controller.rb +25 -1
- data/lib/plutonium/core/controllers/authorizable.rb +1 -1
- data/lib/plutonium/dashboard/base.rb +114 -0
- data/lib/plutonium/dashboard/card.rb +200 -0
- data/lib/plutonium/dashboard/controller.rb +101 -0
- data/lib/plutonium/dashboard/dsl.rb +104 -0
- data/lib/plutonium/dashboard/register.rb +40 -0
- data/lib/plutonium/dashboard/route_resolution.rb +30 -0
- data/lib/plutonium/dashboard.rb +42 -0
- data/lib/plutonium/definition/actions.rb +6 -1
- data/lib/plutonium/definition/base.rb +41 -3
- data/lib/plutonium/definition/index_views.rb +1 -1
- data/lib/plutonium/definition/positioning.rb +1 -1
- data/lib/plutonium/definition/presentable.rb +2 -2
- data/lib/plutonium/helpers/assets_helper.rb +15 -2
- data/lib/plutonium/helpers/display_helper.rb +10 -2
- data/lib/plutonium/interaction/async/context.rb +6 -8
- data/lib/plutonium/interaction/async/executor.rb +6 -7
- data/lib/plutonium/interaction/async/run.rb +2 -2
- data/lib/plutonium/interaction/async/run_definition.rb +1 -1
- data/lib/plutonium/interaction/base.rb +2 -0
- data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
- data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
- data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
- data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
- data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
- data/lib/plutonium/invites/controller.rb +11 -11
- data/lib/plutonium/kanban/column.rb +13 -3
- data/lib/plutonium/kanban/dsl.rb +7 -4
- data/lib/plutonium/models/has_cents.rb +1 -1
- data/lib/plutonium/profile/security_section.rb +10 -18
- data/lib/plutonium/query/filter.rb +10 -1
- data/lib/plutonium/query/filters/association.rb +27 -2
- data/lib/plutonium/query/filters/boolean.rb +4 -4
- data/lib/plutonium/query/filters/date.rb +4 -17
- data/lib/plutonium/query/filters/date_range.rb +2 -2
- data/lib/plutonium/query/filters/select.rb +10 -1
- data/lib/plutonium/query/filters/text.rb +9 -12
- data/lib/plutonium/railtie.rb +17 -0
- data/lib/plutonium/resource/controller.rb +10 -5
- data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
- data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
- data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
- data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
- data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
- data/lib/plutonium/resource/query_object.rb +30 -3
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/routing/dashboard_registration.rb +137 -0
- data/lib/plutonium/routing/resource_registration.rb +5 -0
- data/lib/plutonium/routing/route_set_extensions.rb +2 -1
- data/lib/plutonium/translation.rb +227 -0
- data/lib/plutonium/ui/actions_dropdown.rb +2 -2
- data/lib/plutonium/ui/breadcrumbs.rb +3 -3
- data/lib/plutonium/ui/color_mode_selector.rb +1 -1
- data/lib/plutonium/ui/component/behaviour.rb +46 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/dashboard/board.rb +80 -0
- data/lib/plutonium/ui/dashboard/card.rb +127 -0
- data/lib/plutonium/ui/dashboard/chart.rb +43 -0
- data/lib/plutonium/ui/dashboard/custom.rb +32 -0
- data/lib/plutonium/ui/dashboard/frame.rb +25 -0
- data/lib/plutonium/ui/dashboard/metric.rb +137 -0
- data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
- data/lib/plutonium/ui/display/components/attachment.rb +1 -1
- data/lib/plutonium/ui/display/components/badge.rb +7 -1
- data/lib/plutonium/ui/display/components/boolean.rb +2 -2
- data/lib/plutonium/ui/display/resource.rb +23 -16
- data/lib/plutonium/ui/export_button.rb +3 -3
- data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
- data/lib/plutonium/ui/form/components/password.rb +2 -2
- data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
- data/lib/plutonium/ui/form/components/uppy.rb +11 -3
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/ui/form/query.rb +8 -8
- data/lib/plutonium/ui/form/resource.rb +54 -26
- data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
- data/lib/plutonium/ui/grid/card.rb +3 -3
- data/lib/plutonium/ui/grid/resource.rb +3 -3
- data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
- data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
- data/lib/plutonium/ui/kanban/column.rb +6 -6
- data/lib/plutonium/ui/layout/base.rb +19 -1
- data/lib/plutonium/ui/layout/header.rb +1 -1
- data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
- data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
- data/lib/plutonium/ui/layout/sidebar.rb +1 -1
- data/lib/plutonium/ui/layout/topbar.rb +2 -2
- data/lib/plutonium/ui/modal/base.rb +4 -4
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
- data/lib/plutonium/ui/nav_user.rb +2 -2
- data/lib/plutonium/ui/page/base.rb +10 -2
- data/lib/plutonium/ui/page/dashboard.rb +54 -0
- data/lib/plutonium/ui/page/edit.rb +3 -3
- data/lib/plutonium/ui/page/interactive_action.rb +2 -2
- data/lib/plutonium/ui/page/new.rb +3 -3
- data/lib/plutonium/ui/page/show.rb +2 -2
- data/lib/plutonium/ui/page/wizard.rb +22 -16
- data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
- data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
- data/lib/plutonium/ui/skeleton_table.rb +1 -1
- data/lib/plutonium/ui/table/base.rb +2 -2
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
- data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
- data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
- data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
- data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
- data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
- data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
- data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
- data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
- data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
- data/lib/plutonium/ui/table/resource.rb +53 -17
- data/lib/plutonium/ui/wizard/review.rb +7 -7
- data/lib/plutonium/ui/wizard/stepper.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/base.rb +2 -0
- data/lib/plutonium/wizard/driving.rb +8 -2
- data/lib/plutonium/wizard/dsl.rb +4 -2
- data/lib/plutonium/wizard/field_importer.rb +1 -8
- data/lib/plutonium/wizard/review_step.rb +8 -1
- data/lib/plutonium/wizard/step.rb +14 -3
- data/package.json +4 -2
- data/plutonium.gemspec +4 -1
- data/postcss-gem-import.cjs +34 -0
- data/postcss-gem-import.js +3 -28
- data/src/css/components.css +101 -10
- data/src/css/slim_select.css +0 -38
- data/src/css/tokens.css +24 -0
- data/src/js/controllers/attachment_input_controller.js +15 -6
- data/src/js/controllers/chart_controller.js +162 -0
- data/src/js/controllers/clipboard_controller.js +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +4 -3
- data/src/js/controllers/easymde_controller.js +3 -0
- data/src/js/controllers/filter_panel_controller.js +15 -6
- data/src/js/controllers/flatpickr_controller.js +8 -0
- data/src/js/controllers/frame_refresh_controller.js +59 -0
- data/src/js/controllers/intl_tel_input_controller.js +5 -0
- data/src/js/controllers/register_controllers.js +4 -0
- data/src/js/controllers/slim_select_controller.js +79 -176
- data/src/js/core.js +9 -1
- data/src/js/i18n.js +69 -0
- data/src/js/plutonium-charts.js +15 -0
- data/src/js/turbo/turbo_confirm.js +5 -3
- metadata +64 -16
- data/.yarnrc.yml +0 -8
- data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
- data/lib/generators/pu/field/input/input_generator.rb +0 -32
- data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
- data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
- data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
- data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
- data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
- data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
- data/public/plutonium-assets/application.js +0 -31419
- data/public/plutonium-assets/plutonium.ico +0 -0
- data/yarn.lock +0 -6858
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Registration & launch
|
|
2
2
|
|
|
3
|
-
A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`)
|
|
3
|
+
A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`), inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth); see [Hosting & the controller override hook](#hosting-the-controller-override-hook).
|
|
4
4
|
|
|
5
|
-
## On a resource
|
|
5
|
+
## On a resource: the `wizard` macro
|
|
6
6
|
|
|
7
|
-
A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action
|
|
7
|
+
A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action, sugar over the Action system.
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
class CompanyDefinition < Plutonium::Resource::Definition
|
|
@@ -14,19 +14,19 @@ class CompanyDefinition < Plutonium::Resource::Definition
|
|
|
14
14
|
end
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
- **Placement is dictated by the wizard, not chosen
|
|
18
|
-
- **Auto-mounted on the resource controller
|
|
19
|
-
- **The anchor is IDOR-safe
|
|
20
|
-
- **Bulk wizards are not supported
|
|
21
|
-
- **Authorization mirrors actions
|
|
17
|
+
- **Placement is dictated by the wizard, not chosen**: an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
|
|
18
|
+
- **Auto-mounted on the resource controller**: the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
|
|
19
|
+
- **The anchor is IDOR-safe**: an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
|
|
20
|
+
- **Bulk wizards are not supported**: wizards are inherently per-instance flows. Use a bulk interaction instead.
|
|
21
|
+
- **Authorization mirrors actions**: a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
|
|
22
22
|
|
|
23
23
|
| Option | Meaning |
|
|
24
24
|
|---|---|
|
|
25
25
|
| `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
|
|
26
26
|
| `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
|
|
27
|
-
| `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome
|
|
27
|
+
| `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome, any `action` option passes through. |
|
|
28
28
|
|
|
29
|
-
Placement isn't an option
|
|
29
|
+
Placement isn't an option; it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
|
|
30
30
|
|
|
31
31
|
### Synthesized routes (resource-mounted)
|
|
32
32
|
|
|
@@ -42,9 +42,9 @@ GET/POST /companies/wizards/:wizard_name(/:token)/:step
|
|
|
42
42
|
|
|
43
43
|
The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
|
|
44
44
|
|
|
45
|
-
## Route-mounted
|
|
45
|
+
## Route-mounted: `register_wizard`
|
|
46
46
|
|
|
47
|
-
For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard
|
|
47
|
+
For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard`, **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
# packages/admin_portal/config/routes.rb
|
|
@@ -55,7 +55,7 @@ AdminPortal::Engine.routes.draw do
|
|
|
55
55
|
register_resource ::Company
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
# config/routes.rb
|
|
58
|
+
# config/routes.rb: a main-app (portal-less) wizard
|
|
59
59
|
Rails.application.routes.draw do
|
|
60
60
|
register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
|
|
61
61
|
end
|
|
@@ -66,21 +66,21 @@ end
|
|
|
66
66
|
| `at:` (required) | The host-relative base path for the wizard's steps. |
|
|
67
67
|
| `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
|
|
68
68
|
| `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
|
|
69
|
-
| `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host
|
|
69
|
+
| `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host, **portal → the resource shell**, **main-app → `:basic`**. |
|
|
70
70
|
|
|
71
71
|
This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
|
|
72
72
|
|
|
73
73
|
### Hosting & the controller override hook
|
|
74
74
|
|
|
75
|
-
`register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource
|
|
75
|
+
`register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource`, there is no hand-written file unless you want to customize.
|
|
76
76
|
|
|
77
77
|
| Host | Controller | Base / auth |
|
|
78
78
|
|---|---|---|
|
|
79
79
|
| **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
|
|
80
|
-
| **Main app, authenticated** | `::WizardsController
|
|
80
|
+
| **Main app, authenticated** | `::WizardsController`, **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
|
|
81
81
|
| **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
|
|
82
82
|
|
|
83
|
-
The synthesized **main-app** controller is **bare
|
|
83
|
+
The synthesized **main-app** controller is **bare**, rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
|
|
84
84
|
|
|
85
85
|
```ruby
|
|
86
86
|
# app/controllers/wizards_controller.rb
|
|
@@ -94,17 +94,17 @@ end
|
|
|
94
94
|
|
|
95
95
|
### Layout
|
|
96
96
|
|
|
97
|
-
`layout:` is the **Rails layout** the wizard renders in
|
|
97
|
+
`layout:` is the **Rails layout** the wizard renders in, a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
|
|
98
98
|
|
|
99
99
|
| `layout:` | Appearance | Layout |
|
|
100
100
|
|---|---|---|
|
|
101
|
-
| `:basic` | no sidebar/topbar
|
|
101
|
+
| `:basic` | no sidebar/topbar, e.g. "set up your organization" | `basic` (`BasicLayout`) |
|
|
102
102
|
| `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
|
|
103
103
|
| *(any app layout)* | whatever that layout renders | passed straight to Rails |
|
|
104
|
-
| *(omitted)* | host default
|
|
104
|
+
| *(omitted)* | host default, see below | - |
|
|
105
105
|
|
|
106
106
|
- **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
|
|
107
|
-
- **Turbo-frame requests are always layout-less** regardless of the setting
|
|
107
|
+
- **Turbo-frame requests are always layout-less** regardless of the setting; that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
|
|
108
108
|
- `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
|
|
109
109
|
|
|
110
110
|
### Synthesized routes
|
|
@@ -123,7 +123,7 @@ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel s
|
|
|
123
123
|
|
|
124
124
|
## Entry authorization
|
|
125
125
|
|
|
126
|
-
A portal-
|
|
126
|
+
A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry with an `authorize?` instance method on the wizard, checked before each request:
|
|
127
127
|
|
|
128
128
|
```ruby
|
|
129
129
|
class WelcomeWizard < Plutonium::Wizard::Base
|
|
@@ -135,12 +135,12 @@ end
|
|
|
135
135
|
|
|
136
136
|
A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
|
|
137
137
|
|
|
138
|
-
::: danger A
|
|
139
|
-
A
|
|
138
|
+
::: danger A `register_wizard` wizard with no `authorize?` is open to ANY authenticated user
|
|
139
|
+
A wizard registered with `register_wizard` (in a portal or on the main app) has **no resource policy**, and the base class defines `def authorize? = true`. If you omit it, every authenticated user of that portal or app can run it. **Define `def authorize?` on every `register_wizard` wizard.** A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. For a flow that is genuinely fine for any signed-in user (e.g. self-service onboarding), say so with `def authorize? = true`.
|
|
140
140
|
:::
|
|
141
141
|
|
|
142
142
|
::: warning As-built: `authorize?` is an instance method
|
|
143
|
-
Define `def authorize?` on the wizard. The controller checks it
|
|
143
|
+
Define `def authorize?` on the wizard. The controller checks it before every request; the inherited default returns `true`.
|
|
144
144
|
:::
|
|
145
145
|
|
|
146
146
|
## Public mount for `anonymous` wizards
|
|
@@ -154,12 +154,12 @@ register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
- `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
|
|
157
|
-
- A **non-`anonymous`** wizard may **not** be mounted public (raises)
|
|
157
|
+
- A **non-`anonymous`** wizard may **not** be mounted public (raises), and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
|
|
158
158
|
- The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
|
|
159
159
|
|
|
160
160
|
## One-time gating
|
|
161
161
|
|
|
162
|
-
To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time)
|
|
162
|
+
To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time), a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
|
|
163
163
|
|
|
164
164
|
For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
|
|
165
165
|
|
|
@@ -167,11 +167,11 @@ For a **one-time** wizard, the launch action this macro synthesizes also **hides
|
|
|
167
167
|
|
|
168
168
|
- **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
|
|
169
169
|
- **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
|
|
170
|
-
- **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time
|
|
170
|
+
- **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time, give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
|
|
171
171
|
|
|
172
172
|
## Related
|
|
173
173
|
|
|
174
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
175
|
-
- [Anchoring & resume](/reference/wizard/anchoring-resume)
|
|
176
|
-
- [One-time wizards](/reference/wizard/one-time)
|
|
177
|
-
- [Custom actions guide](/guides/custom-actions)
|
|
174
|
+
- [DSL reference](/reference/wizard/dsl): `authorize?`, the wizard body.
|
|
175
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys.
|
|
176
|
+
- [One-time wizards](/reference/wizard/one-time): completion + gating.
|
|
177
|
+
- [Custom actions guide](/guides/custom-actions): the Action system the `wizard` macro builds on.
|
|
@@ -23,25 +23,25 @@ rails db:migrate
|
|
|
23
23
|
|
|
24
24
|
| Config | Default | Meaning |
|
|
25
25
|
|---|---|---|
|
|
26
|
-
| `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes
|
|
26
|
+
| `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes, both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
|
|
27
27
|
| `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
|
|
28
|
-
| `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only
|
|
28
|
+
| `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only**, see below. |
|
|
29
29
|
| `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
|
|
30
|
-
| `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects
|
|
30
|
+
| `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects, active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
|
|
31
31
|
|
|
32
32
|
## Gem-shipped migration
|
|
33
33
|
|
|
34
|
-
The migration ships **in the gem** and Rails runs it **in place
|
|
34
|
+
The migration ships **in the gem** and Rails runs it **in place**; there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
|
|
35
35
|
|
|
36
36
|
- Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
|
|
37
37
|
- Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
|
|
38
|
-
- `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed)
|
|
38
|
+
- `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed), standard for gem-shipped migrations.
|
|
39
39
|
|
|
40
40
|
::: warning v1 supports the primary database only
|
|
41
|
-
The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use
|
|
41
|
+
The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use**, multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
|
|
42
42
|
:::
|
|
43
43
|
|
|
44
|
-
## The table
|
|
44
|
+
## The table: `plutonium_wizard_sessions`
|
|
45
45
|
|
|
46
46
|
One framework-owned table serves everything; **no changes to your models.**
|
|
47
47
|
|
|
@@ -51,7 +51,7 @@ One framework-owned table serves everything; **no changes to your models.**
|
|
|
51
51
|
| `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
|
|
52
52
|
| `current_step` | The step cursor. |
|
|
53
53
|
| `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
|
|
54
|
-
| `owner_type` / `owner_id` | The user (nullable
|
|
54
|
+
| `owner_type` / `owner_id` | The user (nullable, `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
|
|
55
55
|
| `anchor_type` / `anchor_id` | The anchor record (nullable). |
|
|
56
56
|
| `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
|
|
57
57
|
| `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
|
|
@@ -64,14 +64,14 @@ One framework-owned table serves everything; **no changes to your models.**
|
|
|
64
64
|
|
|
65
65
|
What the single table powers:
|
|
66
66
|
|
|
67
|
-
- **Resume
|
|
68
|
-
- **One-time check
|
|
69
|
-
- **In-progress listing
|
|
70
|
-
- **Multi-tenancy
|
|
71
|
-
- **Sweep
|
|
67
|
+
- **Resume**: look up the `in_progress` row by `instance_key`.
|
|
68
|
+
- **One-time check**: does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
|
|
69
|
+
- **In-progress listing**: by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
|
|
70
|
+
- **Multi-tenancy**: the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
|
|
71
|
+
- **Sweep**: idle `in_progress`/`completing` rows past `expires_at`.
|
|
72
72
|
|
|
73
73
|
::: tip The `persisted` / `tracked_records` naming
|
|
74
|
-
The column is `tracked_records`, not `persisted
|
|
74
|
+
The column is `tracked_records`, not `persisted`: an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
|
|
75
75
|
:::
|
|
76
76
|
|
|
77
77
|
## Encryption
|
|
@@ -85,7 +85,7 @@ class CheckoutWizard < Plutonium::Wizard::Base
|
|
|
85
85
|
end
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
This encrypts the `data` column (the staged step values)
|
|
88
|
+
This encrypts the `data` column (the staged step values), off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
|
|
89
89
|
|
|
90
90
|
**Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
|
|
91
91
|
|
|
@@ -99,7 +99,7 @@ class PublicSurveyWizard < Plutonium::Wizard::Base
|
|
|
99
99
|
end
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys
|
|
102
|
+
Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys, see the warning below.
|
|
103
103
|
|
|
104
104
|
**How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
|
|
105
105
|
|
|
@@ -107,17 +107,17 @@ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard alwa
|
|
|
107
107
|
{ "_enc": "<ciphertext>" }
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data
|
|
110
|
+
A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?`, so toggling the flag never strands existing runs.
|
|
111
111
|
|
|
112
112
|
::: warning Requires ActiveRecord encryption keys
|
|
113
|
-
`encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard
|
|
113
|
+
`encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard, rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
|
|
114
114
|
:::
|
|
115
115
|
|
|
116
116
|
## Files
|
|
117
117
|
|
|
118
118
|
A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
|
|
119
119
|
|
|
120
|
-
A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it
|
|
120
|
+
A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it, the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
|
|
121
121
|
|
|
122
122
|
## Cleanup & the SweepJob
|
|
123
123
|
|
|
@@ -126,7 +126,7 @@ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Eac
|
|
|
126
126
|
`Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
|
|
127
127
|
|
|
128
128
|
::: tip The `completing` state and its grace window
|
|
129
|
-
A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window
|
|
129
|
+
A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window, long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
|
|
130
130
|
:::
|
|
131
131
|
|
|
132
132
|
### SweepJob is load-bearing for save-as-you-go
|
|
@@ -147,6 +147,6 @@ On completion of a one-time wizard, the row is kept as the durable marker but it
|
|
|
147
147
|
|
|
148
148
|
## Related
|
|
149
149
|
|
|
150
|
-
- [Anchoring & resume](/reference/wizard/anchoring-resume)
|
|
151
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
152
|
-
- [One-time wizards](/reference/wizard/one-time)
|
|
150
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume): `instance_key`, resume.
|
|
151
|
+
- [DSL reference](/reference/wizard/dsl): `cleanup_after`, `encrypt_data`, `persist`.
|
|
152
|
+
- [One-time wizards](/reference/wizard/one-time): durable completion markers.
|
data/esbuild.config.js
CHANGED
|
@@ -6,7 +6,8 @@ if (process.argv.includes("--dev")) {
|
|
|
6
6
|
_context({
|
|
7
7
|
outdir: "src/build",
|
|
8
8
|
entryPoints: [
|
|
9
|
-
"src/js/plutonium.js"
|
|
9
|
+
"src/js/plutonium.js",
|
|
10
|
+
"src/js/plutonium-charts.js"
|
|
10
11
|
],
|
|
11
12
|
plugins: [
|
|
12
13
|
manifestPlugin({
|
|
@@ -24,6 +25,7 @@ else {
|
|
|
24
25
|
outdir: "src/dist/js",
|
|
25
26
|
entryPoints: [
|
|
26
27
|
"src/js/plutonium.js",
|
|
28
|
+
"src/js/plutonium-charts.js"
|
|
27
29
|
],
|
|
28
30
|
minify,
|
|
29
31
|
sourcemap: true,
|
|
@@ -34,7 +36,8 @@ else {
|
|
|
34
36
|
_build({
|
|
35
37
|
outdir: "app/assets",
|
|
36
38
|
entryPoints: [
|
|
37
|
-
"src/js/plutonium.js"
|
|
39
|
+
"src/js/plutonium.js",
|
|
40
|
+
"src/js/plutonium-charts.js"
|
|
38
41
|
],
|
|
39
42
|
minify,
|
|
40
43
|
sourcemap: true,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
plutonium (0.
|
|
4
|
+
plutonium (0.64.0)
|
|
5
5
|
action_policy (~> 0.7.0)
|
|
6
6
|
csv
|
|
7
7
|
listen (~> 3.8)
|
|
@@ -601,7 +601,7 @@ CHECKSUMS
|
|
|
601
601
|
phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
|
|
602
602
|
phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
|
|
603
603
|
phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
|
|
604
|
-
plutonium (0.
|
|
604
|
+
plutonium (0.64.0)
|
|
605
605
|
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
|
606
606
|
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
|
607
607
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
@@ -52,7 +52,7 @@ module ActiveModel
|
|
|
52
52
|
errors = record.errors.delete attribute
|
|
53
53
|
return if errors.nil?
|
|
54
54
|
|
|
55
|
-
@existing_errors += errors.map { |e| "
|
|
55
|
+
@existing_errors += errors.map { |e| I18n.t("errors.messages.array_item", index: index + 1, message: e) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def skip?(value, validator_options)
|
|
@@ -10,7 +10,7 @@ module ActiveModel
|
|
|
10
10
|
#
|
|
11
11
|
class AttachedValidator < EachValidator
|
|
12
12
|
def validate_each(record, attribute, value)
|
|
13
|
-
record.errors.add(attribute, options[:message] ||
|
|
13
|
+
record.errors.add(attribute, options[:message] || :not_attached) unless value.attached?
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -35,7 +35,7 @@ module ActiveModel
|
|
|
35
35
|
def validate_url
|
|
36
36
|
return true if URL_PATTERN.match?(value)
|
|
37
37
|
|
|
38
|
-
record.errors.add attribute, (options[:message] ||
|
|
38
|
+
record.errors.add attribute, (options[:message] || :invalid_url)
|
|
39
39
|
false
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -43,7 +43,7 @@ module ActiveModel
|
|
|
43
43
|
return true unless options[:image].present?
|
|
44
44
|
return true unless FastImage.type(value).nil?
|
|
45
45
|
|
|
46
|
-
record.errors.add(attribute, options[:message] ||
|
|
46
|
+
record.errors.add(attribute, options[:message] || :invalid_image_url)
|
|
47
47
|
false
|
|
48
48
|
end
|
|
49
49
|
end
|
|
@@ -25,15 +25,23 @@ module Pu
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
# The asset pipeline assumes the app was generated with esbuild + Tailwind.
|
|
29
|
-
# Without those, `application.tailwind.css`
|
|
30
|
-
#
|
|
28
|
+
# The asset pipeline assumes the app was generated with esbuild + Tailwind + Stimulus.
|
|
29
|
+
# Without those, `application.tailwind.css` (Tailwind) and/or
|
|
30
|
+
# `app/javascript/controllers/index.js` (Stimulus) don't exist, and the generator
|
|
31
|
+
# later crashes with a cryptic inject_into_file error. Fail early listing every
|
|
32
|
+
# missing file with a fix.
|
|
31
33
|
def verify_prerequisites
|
|
32
|
-
|
|
34
|
+
required = [
|
|
35
|
+
"app/assets/stylesheets/application.tailwind.css",
|
|
36
|
+
"app/javascript/controllers/index.js"
|
|
37
|
+
]
|
|
38
|
+
missing = required.reject { |path| File.exist?(path) }
|
|
39
|
+
return if missing.empty?
|
|
33
40
|
|
|
34
41
|
error <<~MSG
|
|
35
|
-
Plutonium assets require a Rails app generated with esbuild and
|
|
36
|
-
|
|
42
|
+
Plutonium assets require a Rails app generated with esbuild, Tailwind, and Stimulus.
|
|
43
|
+
Missing files:
|
|
44
|
+
- #{missing.join("\n - ")}
|
|
37
45
|
|
|
38
46
|
Re-create the app with the required flags:
|
|
39
47
|
rails new myapp -a propshaft -j esbuild -c tailwind \\
|
|
@@ -46,28 +54,35 @@ module Pu
|
|
|
46
54
|
copy_file "postcss.config.js", force: true
|
|
47
55
|
end
|
|
48
56
|
|
|
57
|
+
# Every package goes through one `add` built for the app's package manager
|
|
58
|
+
# (bun, yarn 1, yarn 2+, npm or pnpm; see Concerns::JsPackageManager).
|
|
59
|
+
# Versions ride inline because `pkg@latest` and `pkg@^x.y.z` are the one
|
|
60
|
+
# spelling all of them share; yarn 1's `upgrade --latest` does not exist
|
|
61
|
+
# on yarn 2+, and running yarn inside a bun app leaves two lockfiles.
|
|
49
62
|
def install_dependencies
|
|
50
|
-
|
|
63
|
+
ensure_yarn_node_modules_linker
|
|
51
64
|
|
|
65
|
+
command = js_add_command(npm_packages)
|
|
66
|
+
return if run(command)
|
|
67
|
+
|
|
68
|
+
say_status :warn,
|
|
69
|
+
"`#{command}` failed — your app may not boot until you re-run it",
|
|
70
|
+
:yellow
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def npm_packages
|
|
52
74
|
[
|
|
53
|
-
|
|
75
|
+
# Pinned to the gem so the two halves never drift; pu:core:update
|
|
76
|
+
# keeps them together afterwards.
|
|
77
|
+
"@radioactive-labs/plutonium@^#{Plutonium::VERSION}",
|
|
78
|
+
# cssbundling-rails already installs the latest Tailwind on new apps;
|
|
79
|
+
# apps created on Tailwind 3 are moved to 4 here.
|
|
80
|
+
"tailwindcss@latest",
|
|
54
81
|
"postcss", "postcss-cli", "postcss-import",
|
|
55
82
|
"@tailwindcss/postcss", "@tailwindcss/forms", "@tailwindcss/typography",
|
|
56
83
|
"cssnano", "marked",
|
|
57
84
|
"flowbite-typography"
|
|
58
|
-
]
|
|
59
|
-
run "yarn add #{package}"
|
|
60
|
-
failed << "yarn add #{package}" unless $?.success?
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
run "yarn upgrade tailwindcss --latest"
|
|
64
|
-
failed << "yarn upgrade tailwindcss --latest" unless $?.success?
|
|
65
|
-
|
|
66
|
-
return if failed.empty?
|
|
67
|
-
|
|
68
|
-
say_status :warn,
|
|
69
|
-
"the following commands failed — your app may not boot until you resolve them:\n #{failed.join("\n ")}",
|
|
70
|
-
:yellow
|
|
85
|
+
]
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
def configure_application
|
|
@@ -3,7 +3,7 @@ const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
|
|
|
3
3
|
|
|
4
4
|
module.exports = {
|
|
5
5
|
plugins: {
|
|
6
|
-
[`${plutoniumGemPath}/postcss-gem-import.
|
|
6
|
+
[`${plutoniumGemPath}/postcss-gem-import.cjs`]: {},
|
|
7
7
|
'postcss-import': {},
|
|
8
8
|
'@tailwindcss/postcss': {},
|
|
9
9
|
cssnano: {}
|
|
@@ -45,9 +45,10 @@ module Pu
|
|
|
45
45
|
|
|
46
46
|
def update_npm_package
|
|
47
47
|
gem_version = installed_gem_version
|
|
48
|
+
package = gem_version ? "@radioactive-labs/plutonium@^#{gem_version}" : "@radioactive-labs/plutonium@latest"
|
|
48
49
|
|
|
49
|
-
say_status :update, "Updating
|
|
50
|
-
run
|
|
50
|
+
say_status :update, "Updating #{package} with #{js_package_manager}...", :green
|
|
51
|
+
run js_add_command(package)
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def installed_gem_version
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../lib/plutonium_generators"
|
|
4
|
+
|
|
5
|
+
module Pu
|
|
6
|
+
# Scaffolds a dashboard class and mounts it in a portal (or the main app).
|
|
7
|
+
#
|
|
8
|
+
# rails g pu:dashboard Sales --dest=admin_portal
|
|
9
|
+
# rails g pu:dashboard Home --dest=admin_portal --at=/ # the portal root
|
|
10
|
+
#
|
|
11
|
+
# Writes `app/dashboards/<portal>/<name>_dashboard.rb` inside the package
|
|
12
|
+
# and adds `register_dashboard` to its routes. A root mount replaces the
|
|
13
|
+
# generated `root to: "dashboard#index"` line.
|
|
14
|
+
class DashboardGenerator < Rails::Generators::NamedBase
|
|
15
|
+
include PlutoniumGenerators::Generator
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
desc(
|
|
20
|
+
"Create a dashboard and mount it in a portal\n\n" \
|
|
21
|
+
"e.g. rails g pu:dashboard Sales --dest=admin_portal\n" \
|
|
22
|
+
" rails g pu:dashboard Home --dest=admin_portal --at=/"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
class_option :at, type: :string, default: nil,
|
|
26
|
+
desc: "Mount path under the portal's dashboards/ segment (default: the dashboard name; \"/\" mounts at the root)"
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
|
|
30
|
+
|
|
31
|
+
template "dashboard.rb", dashboard_file_path
|
|
32
|
+
register_dashboard_in_routes
|
|
33
|
+
rescue => e
|
|
34
|
+
exception "#{self.class} failed:", e
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :app_namespace
|
|
40
|
+
|
|
41
|
+
def main_app? = app_namespace == "MainApp"
|
|
42
|
+
|
|
43
|
+
def package_namespace = app_namespace.underscore
|
|
44
|
+
|
|
45
|
+
def dashboard_name = "#{class_name.delete_suffix("Dashboard")}Dashboard"
|
|
46
|
+
|
|
47
|
+
def dashboard_class_name
|
|
48
|
+
main_app? ? dashboard_name : "#{app_namespace}::#{dashboard_name}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dashboard_label = dashboard_name.delete_suffix("Dashboard").titleize
|
|
52
|
+
|
|
53
|
+
def mount_path
|
|
54
|
+
path = options[:at].presence || dashboard_name.delete_suffix("Dashboard").underscore
|
|
55
|
+
path.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def root_mount? = mount_path.empty?
|
|
59
|
+
|
|
60
|
+
def dashboard_file_path
|
|
61
|
+
file = "#{dashboard_name.underscore}.rb"
|
|
62
|
+
main_app? ? "app/dashboards/#{file}" : "packages/#{package_namespace}/app/dashboards/#{package_namespace}/#{file}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def routes_path
|
|
66
|
+
main_app? ? "config/routes.rb" : "packages/#{package_namespace}/config/routes.rb"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def register_line
|
|
70
|
+
constant = main_app? ? "::#{dashboard_class_name}" : dashboard_class_name
|
|
71
|
+
%(register_dashboard #{constant}, at: "#{root_mount? ? "/" : mount_path}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Idempotent: skips when the routes already register this dashboard. A root
|
|
75
|
+
# mount replaces the portal's generated `root to:` line, since two roots
|
|
76
|
+
# would clash.
|
|
77
|
+
def register_dashboard_in_routes
|
|
78
|
+
content = File.read(File.join(destination_root, routes_path))
|
|
79
|
+
|
|
80
|
+
if /^\s*register_dashboard #{Regexp.escape("::" + dashboard_class_name)}\b|^\s*register_dashboard #{Regexp.escape(dashboard_class_name)}\b/.match?(content)
|
|
81
|
+
say_status :identical, "#{routes_path} already registers #{dashboard_class_name}", :blue
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if root_mount? && (match = content.match(/^\s*root to: "dashboard#index".*\n/))
|
|
86
|
+
gsub_file routes_path, match[0], indent("#{register_line}\n", 2)
|
|
87
|
+
elsif /^\s*#\s*register resources above\b/.match?(content)
|
|
88
|
+
insert_into_file routes_path, indent("#{register_line}\n", 2), before: /^\s*#\s*register resources above\b.*/
|
|
89
|
+
elsif (match = content.match(/^\s*(\w+::Engine|Rails\.application)\.routes\.draw do.*\n/))
|
|
90
|
+
insert_into_file routes_path, indent("#{register_line}\n", 2), after: match[0]
|
|
91
|
+
else
|
|
92
|
+
say_status :warn, "Could not locate routes block in #{routes_path}; add manually: #{register_line}", :yellow
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%- if main_app? -%>
|
|
2
|
+
class <%= dashboard_name %> < Plutonium::Dashboard::Base
|
|
3
|
+
presents label: "<%= dashboard_label %>"
|
|
4
|
+
|
|
5
|
+
# Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
|
|
6
|
+
# overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
|
|
7
|
+
#
|
|
8
|
+
# metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
|
|
9
|
+
# chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
|
|
10
|
+
# card(:welcome) { p { "Signed in as #{current_user.email}" } }
|
|
11
|
+
end
|
|
12
|
+
<%- else -%>
|
|
13
|
+
module <%= app_namespace %>
|
|
14
|
+
class <%= dashboard_name %> < Plutonium::Dashboard::Base
|
|
15
|
+
presents label: "<%= dashboard_label %>"
|
|
16
|
+
|
|
17
|
+
# Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
|
|
18
|
+
# overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
|
|
19
|
+
#
|
|
20
|
+
# metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
|
|
21
|
+
# chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
|
|
22
|
+
# card(:welcome) { p { "Signed in as #{current_user.email}" } }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
<%- end -%>
|