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
data/docs/guides/index.md
CHANGED
|
@@ -25,11 +25,12 @@ aside: false
|
|
|
25
25
|
{ name: 'Nested resources', link: '/plutonium-core/guides/nested-resources' },
|
|
26
26
|
{ name: 'Multi-tenancy', link: '/plutonium-core/guides/multi-tenancy' },
|
|
27
27
|
{ name: 'Search & filtering', link: '/plutonium-core/guides/search-filtering' },
|
|
28
|
-
{ name: 'Wizards', desc: 'Multi-step flows
|
|
29
|
-
{ name: 'Kanban boards', desc: 'Drag-and-drop board view
|
|
28
|
+
{ name: 'Wizards', desc: 'Multi-step flows: onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
|
|
29
|
+
{ name: 'Kanban boards', desc: 'Drag-and-drop board view: columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
|
|
30
|
+
{ name: 'Dashboards', desc: 'Metric, chart and free-form cards, each loaded in its own lazy turbo frame.', link: '/plutonium-core/guides/dashboards' },
|
|
30
31
|
]},
|
|
31
32
|
{ group: 'Customization', items: [
|
|
32
|
-
{ name: 'Customizing the UI', desc: 'A map of the override surface
|
|
33
|
+
{ name: 'Customizing the UI', desc: 'A map of the override surface: pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
|
|
33
34
|
{ name: 'Theming', desc: 'Design tokens and brand colors.', link: '/plutonium-core/guides/theming' },
|
|
34
35
|
]},
|
|
35
36
|
{ group: 'Quality', items: [
|
data/docs/guides/kanban.md
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
# Kanban Boards
|
|
2
2
|
|
|
3
3
|
::: warning Experimental
|
|
4
|
-
Kanban boards are experimental
|
|
4
|
+
Kanban boards are experimental: the DSL and behavior may change in a future release.
|
|
5
5
|
:::
|
|
6
6
|
|
|
7
|
-
Turn any resource index into a drag-and-drop kanban board
|
|
7
|
+
Turn any resource index into a drag-and-drop kanban board: columns, WIP limits, quick-add, column actions, and opt-in realtime, all from a single `kanban do…end` block in your definition.
|
|
8
8
|
|
|
9
|
-

|
|
10
10
|
|
|
11
11
|
## What you get
|
|
12
12
|
|
|
13
13
|
- Drag cards between columns; the server persists the column change and the position within the column.
|
|
14
|
-
- Decimal fractional positioning
|
|
14
|
+
- Decimal fractional positioning: cards always land exactly where you drop them without renumbering.
|
|
15
15
|
- Per-column `+ Add` button opens the resource's normal new form; the new card is placed in that column (`on_enter` + positioning applied post-create).
|
|
16
16
|
- Column actions run an interaction against all (or visible) cards in a column.
|
|
17
17
|
- WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
|
|
18
18
|
- Opt-in realtime: every connected viewer sees the same board state after any move.
|
|
19
19
|
|
|
20
|
-
## Worked example
|
|
20
|
+
## Worked example: Task board
|
|
21
21
|
|
|
22
|
-
A complete board for a `Task` model grouped by status
|
|
22
|
+
A complete board for a `Task` model grouped by status, migration, model, definition, and policy.
|
|
23
23
|
|
|
24
24
|
### 1. Migration
|
|
25
25
|
|
|
26
|
-
The model needs a `decimal` position column. Use the **`t.position`** helper
|
|
26
|
+
The model needs a `decimal` position column. Use the **`t.position`** helper, it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
|
|
27
27
|
|
|
28
28
|
```ruby
|
|
29
29
|
class CreateTasks < ActiveRecord::Migration[8.1]
|
|
@@ -102,25 +102,25 @@ class TaskPolicy < ResourcePolicy
|
|
|
102
102
|
end
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
### 5. Routes
|
|
105
|
+
### 5. Routes: no changes needed
|
|
106
106
|
|
|
107
107
|
The `kanban_move` member route is wired automatically when the controller includes `Plutonium::Resource::Controllers::KanbanActions` (included by default in all Plutonium resource controllers).
|
|
108
108
|
|
|
109
109
|
Visit the resource index and use the view switcher to select the Kanban view.
|
|
110
110
|
|
|
111
|
-

|
|
112
112
|
|
|
113
113
|
### When a drop is rejected
|
|
114
114
|
|
|
115
|
-
If a move is refused server-side
|
|
115
|
+
If a move is refused server-side: the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:`, then the card snaps back to where it started **and** a dismissable toast explains why:
|
|
116
116
|
|
|
117
117
|

|
|
118
118
|
|
|
119
|
-
The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check
|
|
119
|
+
The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check, most commonly a WIP-full column or a `kanban_move?` denial.
|
|
120
120
|
|
|
121
121
|
### Opening a card
|
|
122
122
|
|
|
123
|
-
Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in)
|
|
123
|
+
Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in), full-page by default, or a **centered modal** that keeps the board visible behind it:
|
|
124
124
|
|
|
125
125
|

|
|
126
126
|
|
|
@@ -135,14 +135,14 @@ end
|
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
- Set `show_in :modal` on the **definition** to open show in a modal from the table, grid, and board alike. Set it on the **kanban block** to change only the board. An unset board inherits the definition (which defaults to `:page`).
|
|
138
|
-
- The show modal is always **centered
|
|
138
|
+
- The show modal is always **centered**: distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
|
|
139
139
|
- From inside the modal, an expand icon opens the record's full page in a new tab. ⌘/Ctrl-click (or middle-click) on a card does the same directly.
|
|
140
140
|
|
|
141
141
|
---
|
|
142
142
|
|
|
143
|
-
## Worked example
|
|
143
|
+
## Worked example: Status enum board
|
|
144
144
|
|
|
145
|
-
A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout
|
|
145
|
+
A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout, so no explicit `card_fields` needed.
|
|
146
146
|
|
|
147
147
|
```ruby
|
|
148
148
|
class KitchenSinkDefinition < ResourceDefinition
|
|
@@ -168,7 +168,7 @@ Key points:
|
|
|
168
168
|
- `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
|
|
169
169
|
- `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
|
|
170
170
|
- `role: :done` collapses the Archived column by default and shows a green header dot.
|
|
171
|
-
- `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns
|
|
171
|
+
- `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns; you do not need to call `update!` explicitly.
|
|
172
172
|
|
|
173
173
|
---
|
|
174
174
|
|
|
@@ -195,7 +195,7 @@ Use `columns do…end` when the column list depends on request context (`current
|
|
|
195
195
|
```ruby
|
|
196
196
|
kanban do
|
|
197
197
|
columns do
|
|
198
|
-
# `self` is the view_context
|
|
198
|
+
# `self` is the view_context: current_user, params, helpers all work.
|
|
199
199
|
current_user.projects.map do |project|
|
|
200
200
|
Plutonium::Kanban::Column.new(
|
|
201
201
|
:"project_#{project.id}",
|
|
@@ -228,7 +228,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
228
228
|
end
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
-
Note that **`enter_interaction:` is not supported on dynamic boards
|
|
231
|
+
Note that **`enter_interaction:` is not supported on dynamic boards**: its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
|
|
232
232
|
:::
|
|
233
233
|
|
|
234
234
|
### Column options
|
|
@@ -236,11 +236,11 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
|
|
|
236
236
|
| Option | Type | Default | Description |
|
|
237
237
|
|--------|------|---------|-------------|
|
|
238
238
|
| `label:` | String | `key.to_s.titleize` | Column header text |
|
|
239
|
-
| `color:` | Symbol or String | `nil` | Dot color in the column header
|
|
239
|
+
| `color:` | Symbol or String | `nil` | Dot color in the column header, `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
|
|
240
240
|
| `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
|
|
241
241
|
| `on_enter:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
|
|
242
|
-
| `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter
|
|
243
|
-
| `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column
|
|
242
|
+
| `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:`: called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders. Use a model callback instead only when other save paths (edit form, API, import) must be covered too, and then drop the `on_exit:` so the work isn't done twice. |
|
|
243
|
+
| `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column; opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
|
|
244
244
|
| `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
|
|
245
245
|
| `collapsed:` | Boolean | `false` | Start collapsed |
|
|
246
246
|
| `add:` | Boolean | `false` | Show `+ Add` quick-add button |
|
|
@@ -256,7 +256,7 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
|
|
|
256
256
|
| `:done` | `color: :green, collapsed: true` |
|
|
257
257
|
| `:lost` | `color: :red, collapsed: true` |
|
|
258
258
|
|
|
259
|
-
`:done` and `:lost` are the two terminal roles (both collapsed by default)
|
|
259
|
+
`:done` and `:lost` are the two terminal roles (both collapsed by default), the
|
|
260
260
|
won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
|
|
261
261
|
outcome.
|
|
262
262
|
|
|
@@ -284,8 +284,8 @@ column :done,
|
|
|
284
284
|
end
|
|
285
285
|
```
|
|
286
286
|
|
|
287
|
-
- `on: :all
|
|
288
|
-
- `on: :visible
|
|
287
|
+
- `on: :all`: passes IDs of **all** cards in the column (ignoring `per_column`).
|
|
288
|
+
- `on: :visible`: passes IDs of only the rendered, `per_column`-capped cards.
|
|
289
289
|
|
|
290
290
|
Column actions are rendered as buttons in the column header. They open the normal interactive-action modal (with form, authorization, success/failure handling) pre-loaded with the column's card IDs.
|
|
291
291
|
|
|
@@ -293,7 +293,7 @@ Column actions are rendered as buttons in the column header. They open the norma
|
|
|
293
293
|
|
|
294
294
|
## Interaction on drop
|
|
295
295
|
|
|
296
|
-
A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip
|
|
296
|
+
A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip, a reason, a notification email, an audit entry.
|
|
297
297
|
|
|
298
298
|
```ruby
|
|
299
299
|
column :lost,
|
|
@@ -301,13 +301,13 @@ column :lost,
|
|
|
301
301
|
enter_interaction: MarkLostInteraction
|
|
302
302
|
```
|
|
303
303
|
|
|
304
|
-
`enter_interaction:` takes an **Interaction class**. It must be **record-scoped
|
|
304
|
+
`enter_interaction:` takes an **Interaction class**. It must be **record-scoped**: it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
|
|
305
305
|
|
|
306
|
-
The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards
|
|
306
|
+
The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards, it is reachable only by dropping a card into the column.
|
|
307
307
|
|
|
308
308
|
### The interaction
|
|
309
309
|
|
|
310
|
-
A drop interaction is an ordinary record-scoped interaction
|
|
310
|
+
A drop interaction is an ordinary record-scoped interaction with nothing kanban-specific in the class:
|
|
311
311
|
|
|
312
312
|
```ruby
|
|
313
313
|
class MarkLostInteraction < ResourceInteraction
|
|
@@ -330,7 +330,7 @@ end
|
|
|
330
330
|
|
|
331
331
|
### Authorization
|
|
332
332
|
|
|
333
|
-
The drop is authorized by the single **`kanban_move?`** predicate
|
|
333
|
+
The drop is authorized by the single **`kanban_move?`** predicate, the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
|
|
334
334
|
|
|
335
335
|
```ruby
|
|
336
336
|
class TaskPolicy < ResourcePolicy
|
|
@@ -341,7 +341,7 @@ class TaskPolicy < ResourcePolicy
|
|
|
341
341
|
end
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
-
This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition
|
|
344
|
+
This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition: no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
|
|
345
345
|
|
|
346
346
|
### Two flows, split by intent
|
|
347
347
|
|
|
@@ -353,9 +353,9 @@ This keeps authorization in one place: `kanban_move?` gates every move, and the
|
|
|
353
353
|
A column can declare `on_enter:` and `enter_interaction:` together. When it does:
|
|
354
354
|
|
|
355
355
|
- `on_enter` owns the **membership attribute** (the column's grouping value, e.g. `status`).
|
|
356
|
-
- `enter_interaction` owns the **extras
|
|
356
|
+
- `enter_interaction` owns the **extras**: the reason, the mail, the audit trail.
|
|
357
357
|
|
|
358
|
-
If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that
|
|
358
|
+
If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that, `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
|
|
359
359
|
|
|
360
360
|
```ruby
|
|
361
361
|
column :blocked,
|
|
@@ -364,25 +364,25 @@ column :blocked,
|
|
|
364
364
|
enter_interaction: BlockTaskInteraction
|
|
365
365
|
```
|
|
366
366
|
|
|
367
|
-
When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything
|
|
367
|
+
When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything, including the membership write, because there is no `on_enter` to do it.
|
|
368
368
|
|
|
369
369
|
### Same-column drops run positioning only
|
|
370
370
|
|
|
371
|
-
Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires
|
|
371
|
+
Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires; both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
|
|
372
372
|
|
|
373
373
|
### Atomicity and failure
|
|
374
374
|
|
|
375
|
-
Interaction validation failure rolls the **whole transaction back
|
|
375
|
+
Interaction validation failure rolls the **whole transaction back**, the membership write included, and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
|
|
376
376
|
|
|
377
377
|
### Success feedback and the response limitation
|
|
378
378
|
|
|
379
379
|
On success the board's column frames re-render and the modal closes. The interaction's success **message** (`succeed(resource).with_message("Marked as lost")`) is surfaced as a toast.
|
|
380
380
|
|
|
381
381
|
::: warning Custom success responses are not honored on the drop path
|
|
382
|
-
A drop interaction's custom success *response
|
|
382
|
+
A drop interaction's custom success *response*, `with_redirect_response`, `with_file_response`, etc., is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
|
|
383
383
|
:::
|
|
384
384
|
|
|
385
|
-
There is no card "snap-back" to worry about on cancel
|
|
385
|
+
There is no card "snap-back" to worry about on cancel: native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
|
|
386
386
|
|
|
387
387
|
---
|
|
388
388
|
|
|
@@ -390,41 +390,41 @@ There is no card "snap-back" to worry about on cancel — native drag never move
|
|
|
390
390
|
|
|
391
391
|
By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
|
|
392
392
|
|
|
393
|
-
1. A `decimal` database column
|
|
393
|
+
1. A `decimal` database column: use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
|
|
394
394
|
2. `include Plutonium::Positioning::Model` in the model.
|
|
395
|
-
3. `positioned_on :position, scope: :status
|
|
395
|
+
3. `positioned_on :position, scope: :status`: the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
|
|
396
396
|
|
|
397
397
|
### Position modes
|
|
398
398
|
|
|
399
|
-
`position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter
|
|
399
|
+
`position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/resource/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter; the board resolves this lazily.
|
|
400
400
|
|
|
401
401
|
```ruby
|
|
402
402
|
kanban do
|
|
403
|
-
# Mode A (default)
|
|
403
|
+
# Mode A (default): delegate to Plutonium::Positioning::Model.
|
|
404
404
|
# Uses :position attribute, requires the model concern.
|
|
405
405
|
position_on :position
|
|
406
406
|
|
|
407
407
|
# Mode A with a custom attribute name:
|
|
408
408
|
position_on :sort_order
|
|
409
409
|
|
|
410
|
-
# Mode B
|
|
410
|
+
# Mode B: BYO positioning. The block receives a Move struct.
|
|
411
411
|
# Use when you want to call a custom service or use a different ordering scheme.
|
|
412
412
|
position_on :sort_order do |move|
|
|
413
|
-
# move.record
|
|
414
|
-
# move.column
|
|
415
|
-
# move.prev
|
|
416
|
-
# move.next
|
|
417
|
-
# move.index
|
|
413
|
+
# move.record : the dropped record
|
|
414
|
+
# move.column : the destination column key (Symbol)
|
|
415
|
+
# move.prev : the record immediately before the drop slot (or nil)
|
|
416
|
+
# move.next : the record immediately after the drop slot (or nil)
|
|
417
|
+
# move.index : 0-based insertion index within the destination column
|
|
418
418
|
MyPositioningService.call(move.record, prev: move.prev, next: move.next)
|
|
419
419
|
end
|
|
420
420
|
|
|
421
|
-
# Mode C
|
|
421
|
+
# Mode C: no ordering. Cards render in the relation's default order.
|
|
422
422
|
# On-drop still fires; position is just never updated.
|
|
423
423
|
position_on false
|
|
424
424
|
end
|
|
425
425
|
```
|
|
426
426
|
|
|
427
|
-
See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/positioning) for the table/grid side of the same feature.
|
|
427
|
+
See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/resource/positioning) for the table/grid side of the same feature.
|
|
428
428
|
|
|
429
429
|
---
|
|
430
430
|
|
|
@@ -445,7 +445,7 @@ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more
|
|
|
445
445
|
|
|
446
446
|
When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal.
|
|
447
447
|
|
|
448
|
-
The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record
|
|
448
|
+
The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record, so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
|
|
449
449
|
|
|
450
450
|
::: warning Give your grouping column a default
|
|
451
451
|
Because `on_enter` runs **after** the record is saved, the record must be creatable **without** a grouping value. Give your grouping column (e.g. `status`) a database or model default. If it is `NOT NULL` with no default, quick-add create fails validation before `on_enter` can set it.
|
|
@@ -495,10 +495,10 @@ After a successful move, Plutonium broadcasts the updated column frames to all c
|
|
|
495
495
|
|
|
496
496
|
Plutonium emits the `<turbo-cable-stream-source>` subscription element and broadcasts on the server, but the **client must have an ActionCable consumer** to receive it. Plutonium's bundled JavaScript ships `@hotwired/turbo` only (no cable client), so you must wire the rest up yourself:
|
|
497
497
|
|
|
498
|
-
1. **Gems
|
|
499
|
-
2. **Cable adapter** (`config/cable.yml`)
|
|
500
|
-
3. **Mount ActionCable
|
|
501
|
-
4. **Load the cable client in your app's JavaScript
|
|
498
|
+
1. **Gems**: `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
|
|
499
|
+
2. **Cable adapter** (`config/cable.yml`): `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
|
|
500
|
+
3. **Mount ActionCable**: `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
|
|
501
|
+
4. **Load the cable client in your app's JavaScript**: this is the step most people miss. Add **one** of:
|
|
502
502
|
```js
|
|
503
503
|
// app pack, alongside your other imports
|
|
504
504
|
import "@hotwired/turbo-rails" // registers <turbo-cable-stream-source> + a consumer
|
|
@@ -511,7 +511,7 @@ Plutonium emits the `<turbo-cable-stream-source>` subscription element and broad
|
|
|
511
511
|
Without this, the server broadcasts but no browser is subscribed, so other viewers won't update until they reload.
|
|
512
512
|
|
|
513
513
|
::: tip Verify it
|
|
514
|
-
With two browser tabs on the same board, move a card in one
|
|
514
|
+
With two browser tabs on the same board, move a card in one; the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
|
|
515
515
|
:::
|
|
516
516
|
|
|
517
517
|
---
|
|
@@ -539,7 +539,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
539
539
|
# ...
|
|
540
540
|
end
|
|
541
541
|
|
|
542
|
-
# Call AFTER the kanban block
|
|
542
|
+
# Call AFTER the kanban block: :kanban isn't a valid default until
|
|
543
543
|
# `kanban` has enabled the view. Reversing the order raises ArgumentError
|
|
544
544
|
# at class load.
|
|
545
545
|
default_index_view :kanban
|
|
@@ -8,15 +8,15 @@ Each tenant sees only their own records. Queries are filtered, forms inject the
|
|
|
8
8
|
|
|
9
9
|
## 🚨 Critical
|
|
10
10
|
|
|
11
|
-
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain
|
|
11
|
+
- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain: explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
|
|
12
12
|
- **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
|
|
13
|
-
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}
|
|
13
|
+
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`, without this, uniqueness leaks across tenants.
|
|
14
14
|
|
|
15
15
|
After login, users with memberships in multiple entities land on a workspace selector:
|
|
16
16
|
|
|
17
17
|

|
|
18
18
|
|
|
19
|
-
Picking one lands them on the entity-scoped dashboard
|
|
19
|
+
Picking one lands them on the entity-scoped dashboard: note the entity slug in the URL:
|
|
20
20
|
|
|
21
21
|

|
|
22
22
|
|
|
@@ -62,14 +62,17 @@ end
|
|
|
62
62
|
|
|
63
63
|
Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
|
|
64
64
|
|
|
65
|
-
### 4.
|
|
65
|
+
### 4. Check the mount
|
|
66
|
+
|
|
67
|
+
`pu:pkg:portal` already mounted the engine in `packages/customer_portal/config/routes.rb`:
|
|
66
68
|
|
|
67
69
|
```ruby
|
|
68
|
-
# config/routes.rb
|
|
69
70
|
mount CustomerPortal::Engine, at: "/customer"
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
To change the path, edit `at:` there. Don't add a second `mount` to `config/routes.rb`; Rails raises `Invalid route name, already in use`.
|
|
74
|
+
|
|
75
|
+
URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models: `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
|
|
73
76
|
|
|
74
77
|
### 5. Compound uniqueness
|
|
75
78
|
|
|
@@ -82,6 +85,10 @@ end
|
|
|
82
85
|
|
|
83
86
|
🚨 Without the `scope:`, the same slug in different orgs would collide.
|
|
84
87
|
|
|
88
|
+
### 6. Leave the entity in the policy
|
|
89
|
+
|
|
90
|
+
`organization` can stay in `permitted_attributes_for_create`. In the scoped portal the controller removes it from the form and params and sets it from the current entity (a forged `organization_id` is overwritten), while an unscoped admin portal using the same policy still gets a normal select.
|
|
91
|
+
|
|
85
92
|
## Strategies
|
|
86
93
|
|
|
87
94
|
### Path strategy (default)
|
|
@@ -143,7 +150,7 @@ class Membership < ResourceRecord
|
|
|
143
150
|
end
|
|
144
151
|
```
|
|
145
152
|
|
|
146
|
-
### 3. Grandchild
|
|
153
|
+
### 3. Grandchild: `has_one :through`
|
|
147
154
|
|
|
148
155
|
```ruby
|
|
149
156
|
class Post < ResourceRecord
|
|
@@ -187,9 +194,9 @@ relation_scope do |relation|
|
|
|
187
194
|
end
|
|
188
195
|
```
|
|
189
196
|
|
|
190
|
-
🚨 `default_relation_scope(relation)` must be called somewhere in the chain
|
|
197
|
+
🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
|
|
191
198
|
|
|
192
|
-
## Cross-tenant operations
|
|
199
|
+
## Cross-tenant operations: super-admin portal
|
|
193
200
|
|
|
194
201
|
Create a separate portal **without** `scope_to_entity`:
|
|
195
202
|
|
|
@@ -197,7 +204,7 @@ Create a separate portal **without** `scope_to_entity`:
|
|
|
197
204
|
module SuperAdminPortal
|
|
198
205
|
class Engine < Rails::Engine
|
|
199
206
|
include Plutonium::Portal::Engine
|
|
200
|
-
# No scope_to_entity
|
|
207
|
+
# No scope_to_entity: sees all tenants
|
|
201
208
|
end
|
|
202
209
|
end
|
|
203
210
|
```
|
|
@@ -206,14 +213,14 @@ This portal's policies see everything. Don't enable public signup here.
|
|
|
206
213
|
|
|
207
214
|
## Multiple associations to the same entity
|
|
208
215
|
|
|
209
|
-
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`),
|
|
216
|
+
If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), the controller raises:
|
|
210
217
|
|
|
211
218
|
```
|
|
212
219
|
Match has multiple associations to Competition::Team: home_team, away_team.
|
|
213
220
|
Plutonium cannot auto-detect which one to use for entity scoping.
|
|
214
221
|
```
|
|
215
222
|
|
|
216
|
-
Override on the controller:
|
|
223
|
+
Override on the portal controller:
|
|
217
224
|
|
|
218
225
|
```ruby
|
|
219
226
|
class MatchesController < ::ResourceController
|
|
@@ -222,18 +229,24 @@ class MatchesController < ::ResourceController
|
|
|
222
229
|
end
|
|
223
230
|
```
|
|
224
231
|
|
|
232
|
+
Query scoping (`associated_with`) raises `AmbiguousAssociationError` too, so add an explicit scope on the model:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
scope :associated_with_competition_team, ->(team) { where(home_team: team) }
|
|
236
|
+
```
|
|
237
|
+
|
|
225
238
|
## Common issues
|
|
226
239
|
|
|
227
|
-
- **`verify_default_relation_scope_applied!` raises
|
|
228
|
-
- **`Could not resolve the association between 'Model' and 'Entity'
|
|
229
|
-
- **Records leak across tenants
|
|
230
|
-
- **Forms show the entity field anyway
|
|
231
|
-
- **Want to bypass scoping in one place
|
|
240
|
+
- **`verify_default_relation_scope_applied!` raises**: your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
|
|
241
|
+
- **`Could not resolve the association between 'Model' and 'Entity'`**: the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
|
|
242
|
+
- **Records leak across tenants**: likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
|
|
243
|
+
- **Forms show the entity field anyway**: check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
|
|
244
|
+
- **Want to bypass scoping in one place**: use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
|
|
232
245
|
|
|
233
246
|
## Related
|
|
234
247
|
|
|
235
|
-
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping)
|
|
236
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies)
|
|
237
|
-
- [Reference › App › Portals](/reference/app/portals)
|
|
238
|
-
- [Nested resources](./nested-resources)
|
|
239
|
-
- [User invites](./user-invites)
|
|
248
|
+
- [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): full surface
|
|
249
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies): `relation_scope` syntax
|
|
250
|
+
- [Reference › App › Portals](/reference/app/portals): `scope_to_entity` engine config
|
|
251
|
+
- [Nested resources](./nested-resources): parent scoping (takes precedence over entity scoping)
|
|
252
|
+
- [User invites](./user-invites): invitation-based membership onboarding
|
|
@@ -11,7 +11,7 @@ Set up parent/child relationships so `/companies/:id/nested_properties` works au
|
|
|
11
11
|
- Forms that auto-fill the parent (no manual hidden field).
|
|
12
12
|
- Queries scoped to the parent (sibling companies' properties invisible).
|
|
13
13
|
|
|
14
|
-
All of this happens with no manual route wiring
|
|
14
|
+
All of this happens with no manual route wiring; Plutonium generates it from the association.
|
|
15
15
|
|
|
16
16
|
## Steps
|
|
17
17
|
|
|
@@ -65,7 +65,7 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
|
|
|
65
65
|
| `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
|
|
66
66
|
| `/companies/:company_id/nested_company_profile/new` | `has_one` new |
|
|
67
67
|
|
|
68
|
-
`has_one` associations get singular routes
|
|
68
|
+
`has_one` associations get singular routes: index redirects to show (or new if no record exists).
|
|
69
69
|
|
|
70
70
|
Every routable association gets one by default. To draw only some of them:
|
|
71
71
|
|
|
@@ -149,7 +149,7 @@ end
|
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
::: warning Always pass `as:`
|
|
152
|
-
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails
|
|
152
|
+
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails: no named route to look up.
|
|
153
153
|
:::
|
|
154
154
|
|
|
155
155
|
## Policy authorization context
|
|
@@ -167,13 +167,13 @@ class PropertyPolicy < ResourcePolicy
|
|
|
167
167
|
end
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
The parent is authorized for `:read?` before `current_parent` returns
|
|
170
|
+
The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
|
|
171
171
|
|
|
172
172
|
## Parent scoping vs entity scoping
|
|
173
173
|
|
|
174
|
-
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization
|
|
174
|
+
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization; double-scoping isn't needed.
|
|
175
175
|
|
|
176
|
-
In the child policy, just call `default_relation_scope
|
|
176
|
+
In the child policy, just call `default_relation_scope`: it handles both cases:
|
|
177
177
|
|
|
178
178
|
```ruby
|
|
179
179
|
relation_scope do |relation|
|
|
@@ -194,7 +194,7 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
|
|
|
194
194
|
|
|
195
195
|
## Nested inputs (sub-records inside a parent form)
|
|
196
196
|
|
|
197
|
-
A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form
|
|
197
|
+
A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form: a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
|
|
198
198
|
|
|
199
199
|
Use nested inputs when the children are conceptually part of the parent (line items on an order, variants on a product, contact methods on a person) and don't deserve their own page.
|
|
200
200
|
|
|
@@ -214,7 +214,7 @@ class PostDefinition < ResourceDefinition
|
|
|
214
214
|
end
|
|
215
215
|
end
|
|
216
216
|
|
|
217
|
-
# Policy
|
|
217
|
+
# Policy: list the association name (NOT `comments_attributes`)
|
|
218
218
|
class PostPolicy < ResourcePolicy
|
|
219
219
|
def permitted_attributes_for_create
|
|
220
220
|
[:title, :body, :comments]
|
|
@@ -223,7 +223,7 @@ end
|
|
|
223
223
|
```
|
|
224
224
|
|
|
225
225
|
::: warning Permit the association, not the strong-params shape
|
|
226
|
-
List `:comments` in `permitted_attributes_for_
|
|
226
|
+
List `:comments` in `permitted_attributes_for_*`: Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
|
|
227
227
|
:::
|
|
228
228
|
|
|
229
229
|
### Result
|
|
@@ -251,13 +251,13 @@ nested_input :profile, macro: :has_one # singular sub-form, no Add button
|
|
|
251
251
|
|
|
252
252
|
| | Nested inputs (`nested_input :comments`) | Nested resources (this guide's main topic) |
|
|
253
253
|
|---|---|---|
|
|
254
|
-
| URL | None
|
|
255
|
-
| Submit | One
|
|
254
|
+
| URL | None, inline in parent form | `/posts/:id/nested_comments` |
|
|
255
|
+
| Submit | One, saves parent + children together | Independent CRUD per child |
|
|
256
256
|
| Discoverability | Always visible in parent form | Tab on parent show page (with `permitted_associations`) |
|
|
257
257
|
| Best for | Tightly-owned children (line items, variants) | Children users browse on their own (orders, posts) |
|
|
258
258
|
| Backing | `accepts_nested_attributes_for` | Plutonium's nested controller routing |
|
|
259
259
|
|
|
260
|
-
You can use both on the same association
|
|
260
|
+
You can use both on the same association; they're not mutually exclusive.
|
|
261
261
|
|
|
262
262
|
## Inline `+` add on the parent form
|
|
263
263
|
|
|
@@ -265,15 +265,15 @@ When a form has an association select (e.g. picking the company on a Property fo
|
|
|
265
265
|
|
|
266
266
|
## Common issues
|
|
267
267
|
|
|
268
|
-
- **Nested route doesn't exist
|
|
269
|
-
- **Parent shows up in the form anyway
|
|
270
|
-
- **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`)
|
|
271
|
-
- **`resource_url_for` returns wrong URL for a nested resource
|
|
268
|
+
- **Nested route doesn't exist**: both parent AND child must be registered in the same portal (`pu:res:conn`).
|
|
269
|
+
- **Parent shows up in the form anyway**: check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
|
|
270
|
+
- **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`): give the parent one `has_many` per side (`has_many :home_matches, class_name: "Match", foreign_key: :home_team_id`). Each becomes its own nested route, and Plutonium fills in the matching `belongs_to` through `inverse_of` or the foreign key, so nothing raises. `scoped_entity_association` only matters when the parent class is also the portal's entity; see [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
|
|
271
|
+
- **`resource_url_for` returns wrong URL for a nested resource**: check that custom routes use `as:`.
|
|
272
272
|
|
|
273
273
|
## Related
|
|
274
274
|
|
|
275
|
-
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources)
|
|
276
|
-
- [Reference › Behavior › Controllers](/reference/behavior/controllers)
|
|
277
|
-
- [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions)
|
|
278
|
-
- [Multi-tenancy](./multi-tenancy)
|
|
279
|
-
- [Adding resources](./adding-resources)
|
|
275
|
+
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): full surface
|
|
276
|
+
- [Reference › Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
|
|
277
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions): `permitted_associations`
|
|
278
|
+
- [Multi-tenancy](./multi-tenancy): how entity scoping interacts with parent scoping
|
|
279
|
+
- [Adding resources](./adding-resources): basic resource setup
|
data/docs/guides/performance.md
CHANGED
|
@@ -20,7 +20,7 @@ Index pages, kanban boards and CSV exports already eager-load the associations a
|
|
|
20
20
|
|
|
21
21
|
Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
|
|
22
22
|
|
|
23
|
-
It covers every association kind
|
|
23
|
+
It covers every association kind (`belongs_to`, `has_one`, `has_many`) and attachments on both ActiveStorage and Shrine.
|
|
24
24
|
|
|
25
25
|
Turn it off globally:
|
|
26
26
|
|
|
@@ -99,6 +99,6 @@ Those need to leave the request rather than be optimised inside it. An interacti
|
|
|
99
99
|
|
|
100
100
|
## Related
|
|
101
101
|
|
|
102
|
-
- **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables
|
|
102
|
+
- **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables; see [Resource › Query](/reference/resource/query#search).
|
|
103
103
|
- **Page size.** Query cost scales with rows per page.
|
|
104
|
-
- [Async Interactions](/reference/behavior/async-interactions)
|
|
104
|
+
- [Async Interactions](/reference/behavior/async-interactions): moving slow work out of the request
|