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,7 +1,7 @@
|
|
|
1
1
|
# Kanban DSL Reference
|
|
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
7
|
Complete reference for the `kanban do…end` block declared inside a resource Definition.
|
|
@@ -49,9 +49,9 @@ Default: `nil` (unlimited).
|
|
|
49
49
|
|
|
50
50
|
Controls how card positions are persisted after a drag-and-drop. Three modes:
|
|
51
51
|
|
|
52
|
-
**Inherited from the definition.** `position_on` is the same verb outside `kanban do…end`, where it makes the resource's [table and grid drag-reorderable](/reference/positioning). A board with no `position_on` of its own uses the **definition's**, falling back to the historic default (`:position`, Mode A). Declaring it inside the board overrides that. Resolution is lazy, so declaration order in the class body does not matter.
|
|
52
|
+
**Inherited from the definition.** `position_on` is the same verb outside `kanban do…end`, where it makes the resource's [table and grid drag-reorderable](/reference/resource/positioning). A board with no `position_on` of its own uses the **definition's**, falling back to the historic default (`:position`, Mode A). Declaring it inside the board overrides that. Resolution is lazy, so declaration order in the class body does not matter.
|
|
53
53
|
|
|
54
|
-
#### Mode A
|
|
54
|
+
#### Mode A: delegate to `Plutonium::Positioning::Model` (default)
|
|
55
55
|
|
|
56
56
|
```ruby
|
|
57
57
|
# Default: uses :position attribute
|
|
@@ -64,30 +64,30 @@ position_on :sort_order
|
|
|
64
64
|
Requires the model to:
|
|
65
65
|
1. `include Plutonium::Positioning::Model`
|
|
66
66
|
2. Call `positioned_on :position, scope: :grouping_attribute`
|
|
67
|
-
3. Have a `decimal` column for the position attribute
|
|
67
|
+
3. Have a `decimal` column for the position attribute: add it with the `t.position` migration helper (a tuned `decimal(16,8)`); see [Positioning › Migration](/reference/kanban/positioning#migration)
|
|
68
68
|
|
|
69
69
|
On drop, calls `record.reposition!(prev_record:, next_record:)` which computes the decimal midpoint and updates the record.
|
|
70
70
|
|
|
71
|
-
#### Mode B
|
|
71
|
+
#### Mode B: BYO block
|
|
72
72
|
|
|
73
73
|
```ruby
|
|
74
74
|
position_on :sort_order do |move|
|
|
75
75
|
# move is a Plutonium::Positioning::Move value object
|
|
76
76
|
# (also reachable as Plutonium::Kanban::Positioning::Move):
|
|
77
|
-
# move.record
|
|
78
|
-
# move.column
|
|
79
|
-
# move.prev
|
|
80
|
-
# move.next
|
|
81
|
-
# move.index
|
|
77
|
+
# move.record : the dropped ActiveRecord record
|
|
78
|
+
# move.column : destination column key (Symbol)
|
|
79
|
+
# move.prev : record immediately before the insertion slot, or nil
|
|
80
|
+
# move.next : record immediately after the insertion slot, or nil
|
|
81
|
+
# move.index : 0-based insertion index within the destination column
|
|
82
82
|
move.record.update!(sort_order: compute_position(move.prev, move.next))
|
|
83
83
|
end
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
The block is evaluated via `call` (not `instance_exec`)
|
|
86
|
+
The block is evaluated via `call` (not `instance_exec`); it is a plain Ruby proc/lambda.
|
|
87
87
|
|
|
88
88
|
Plutonium still orders column cards by `sort_order` (the first argument); your block is responsible only for persisting the new value.
|
|
89
89
|
|
|
90
|
-
#### Mode C
|
|
90
|
+
#### Mode C: disabled
|
|
91
91
|
|
|
92
92
|
```ruby
|
|
93
93
|
position_on false
|
|
@@ -114,9 +114,9 @@ Enables ActionCable broadcasting after every successful move. After a drop, Plut
|
|
|
114
114
|
- Tenant-scoped portals use the entity's Global ID parameter as the second segment.
|
|
115
115
|
- Portals without entity scoping use the literal string `"global"`.
|
|
116
116
|
|
|
117
|
-
Two viewers share a stream only if they have the same resource class **and** the same scoped entity
|
|
117
|
+
Two viewers share a stream only if they have the same resource class **and** the same scoped entity; cross-tenant leakage is impossible by construction.
|
|
118
118
|
|
|
119
|
-
Requires `turbo-rails` + ActionCable (gems), a cable adapter in `config/cable.yml` (Redis/Solid Cable in multi-process production), ActionCable mounted at `/cable`, **and** an ActionCable client loaded in your app's JavaScript. Plutonium's bundle ships `@hotwired/turbo` only
|
|
119
|
+
Requires `turbo-rails` + ActionCable (gems), a cable adapter in `config/cable.yml` (Redis/Solid Cable in multi-process production), ActionCable mounted at `/cable`, **and** an ActionCable client loaded in your app's JavaScript. Plutonium's bundle ships `@hotwired/turbo` only; without `@hotwired/turbo-rails` (or `@rails/actioncable`) in your pack, the `<turbo-cable-stream-source>` never connects and other viewers won't update. Server-side broadcasting works regardless; this is purely the client subscription. See the [guide's Realtime setup](/guides/kanban#setup-required-for-realtime-to-actually-update-other-viewers).
|
|
120
120
|
|
|
121
121
|
Default: `false`.
|
|
122
122
|
|
|
@@ -141,14 +141,14 @@ show_in :modal # open a card's show page in a centered modal dialog
|
|
|
141
141
|
show_in :page # navigate the whole page to the show route
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
Overrides
|
|
144
|
+
Overrides, **for this board**, where clicking a card opens the record's show page:
|
|
145
145
|
|
|
146
|
-
- `:modal
|
|
147
|
-
- `:page
|
|
146
|
+
- `:modal`: the card's show link targets the layout's `remote_modal` frame, so the show page renders in a **centered** dialog. (Show is always centered, deliberately not the definition's `modal_mode`, which styles `new`/`edit`.)
|
|
147
|
+
- `:page`: the card's show link targets `_top`, navigating the whole page to the show route.
|
|
148
148
|
|
|
149
149
|
When `show_in` is **not** set on the board, the board inherits the definition's [`show_in`](/reference/resource/definition#show_in) (which itself defaults to `:page`). So to open cards in a modal you can set it on the board, or once on the definition (which also covers the table and grid views).
|
|
150
150
|
|
|
151
|
-
Either mode escapes the column's lazy turbo-frame
|
|
151
|
+
Either mode escapes the column's lazy turbo-frame: `:page` replaces the whole page, and the `remote_modal` frame lives in the layout (resolved document-wide), so it opens outside the column. No per-card configuration is needed; the show page detects the modal frame (`in_modal?`) and wraps its details in the centered modal chrome. From inside the modal, an expand icon (or ⌘/Ctrl-click on the card) opens the full page in a new tab.
|
|
152
152
|
|
|
153
153
|
An unknown mode raises `ArgumentError`.
|
|
154
154
|
|
|
@@ -168,7 +168,7 @@ Overrides the slot layout for every kanban card on this board, using the same sl
|
|
|
168
168
|
|
|
169
169
|
When `card_fields` is not set, cards fall back to the resource definition's `grid_fields`. If neither is declared, the card renders the default header-only layout.
|
|
170
170
|
|
|
171
|
-
The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized
|
|
171
|
+
The `meta` slot renders each field as a colored badge, and formats values by type before badging: a `has_cents` field renders as currency, a `belongs_to` association renders as its label (not an object inspect), and everything else is humanized, with status-like enums (`active`, `pending`, `published`…) resolving to a semantic color. The badge color is deterministic per value, so a given status is the same color on every card.
|
|
172
172
|
|
|
173
173
|
---
|
|
174
174
|
|
|
@@ -203,12 +203,12 @@ end
|
|
|
203
203
|
| `color:` | Symbol or String | `nil` | Header color dot. Named colors: `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`. Raw CSS string also accepted |
|
|
204
204
|
| `scope:` | Symbol or Proc | `nil` | Relation filter for this column. **Symbol** → `relation.public_send(sym)` (named AR scope). **Proc** → 0-arg lambda called via `instance_exec` on the relation, e.g. `-> { where(status: "todo") }` |
|
|
205
205
|
| `on_enter:` | Symbol or Proc | `nil` | Fired when a card is dropped into this column. **Symbol** → `record.public_send(sym)`. **Proc** → 1-arg lambda `->(record) { … }` where `self` inside the block is the view context (giving access to `current_user`, helpers, etc.). The callback may assign attributes in memory (`r.status = :done`) or call `update!` directly; if the record has unsaved changes after `on_enter` returns the controller saves it automatically. |
|
|
206
|
-
| `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move
|
|
206
|
+
| `on_exit:` | Symbol or Proc | `nil` | The source-side counterpart to `on_enter:`, fired when a card **leaves** this column on a cross-column move. Same dispatch (**Symbol** → `record.public_send`; **Proc** → 1-arg lambda, `self` = view context). Runs **before** the destination's `on_enter`, inside the same move transaction, so it sees the pre-move state and rolls back if the move fails. Use it for source-tied side effects the destination can't own (stop a timer, release a slot). Fires only on a drag-move through `kanban_move`, not on destroy, a programmatic status change, or 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. |
|
|
207
207
|
| `enter_interaction:` | Class | `nil` | A **record-scoped** interaction class (declares `attribute :resource`) run when a card is dropped **into** this column from another column. Opens the interaction's form as a modal to collect input, then commits `on_enter` + the interaction + repositioning atomically. Auto-registered as a hidden record action under a column-scoped key (`:<column>_enter_interaction`), authorized by `kanban_move?` (no policy method of its own). See [enter_interaction](#drop-interaction) below |
|
|
208
208
|
| `role:` | `:backlog`, `:done`, `:lost` | `nil` | Applies a preset (see below) |
|
|
209
209
|
| `collapsed:` | Boolean | `false` | Column starts collapsed (a thin strip with the label rotated). The Stimulus controller persists the toggled state to `localStorage` (key: `pu-kanban:<path>:<column-key>:collapsed`) so the user preference survives page reloads; this DSL value sets the server-rendered initial state only. |
|
|
210
210
|
| `add:` | Boolean | `false` | Show a `+ Add` quick-add button |
|
|
211
|
-
| `accepts:` | `true`, `false`, or Array | `true` | Drop policy
|
|
211
|
+
| `accepts:` | `true`, `false`, or Array | `true` | Drop policy: **structural topology only**. `true` accepts any source column. `false` rejects all drops (display-only column). An Array of column key symbols accepts only those sources. This is client-hintable: the value is emitted as `data-kanban-accepts` so the drag UI greys out disallowed source columns before the drop. **Record- or user-conditional rules do not belong here**. Put them in `kanban_move?`, which sees the record and the `from`/`to` columns (see [Authorization](./authorization)). Passing a `Proc` raises `ArgumentError`. |
|
|
212
212
|
| `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
|
|
213
213
|
| `wip:` | Integer | `nil` | WIP limit. Reject cross-column drops when `dest_count + 1 > wip`. Has no effect on same-column reordering |
|
|
214
214
|
|
|
@@ -219,7 +219,7 @@ The old names are **deprecated aliases**:
|
|
|
219
219
|
- In **development and test** they raise an `ArgumentError` so the rename is caught before release.
|
|
220
220
|
- In **deployed environments** (production, staging) they log a deprecation warning and map onto the new option, so an in-flight deployment keeps working across the upgrade.
|
|
221
221
|
|
|
222
|
-
If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience
|
|
222
|
+
If both the old and new name are given, the new one wins. Update your definitions to `on_enter:` / `enter_interaction:` at your earliest convenience; a future release will drop the aliases entirely.
|
|
223
223
|
:::
|
|
224
224
|
|
|
225
225
|
### Role presets
|
|
@@ -230,7 +230,7 @@ If both the old and new name are given, the new one wins. Update your definition
|
|
|
230
230
|
| `:done` | `color: :green, collapsed: true` |
|
|
231
231
|
| `:lost` | `color: :red, collapsed: true` |
|
|
232
232
|
|
|
233
|
-
`:done` and `:lost` are the two terminal roles
|
|
233
|
+
`:done` and `:lost` are the two terminal roles, both collapsed by default, the
|
|
234
234
|
colour signalling the outcome (`:done` = positive close, `:lost` = negative
|
|
235
235
|
close). Use them as the won/lost pair in pipelines (leads, deals, tickets).
|
|
236
236
|
|
|
@@ -246,13 +246,13 @@ column :lost,
|
|
|
246
246
|
|
|
247
247
|
Runs an authorization-aware, input-collecting interaction when a card is dropped **into** this column from another column.
|
|
248
248
|
|
|
249
|
-
- **Must be a record-scoped interaction
|
|
250
|
-
- **Auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`)
|
|
251
|
-
- **Authorized by `kanban_move?` alone
|
|
252
|
-
- **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors
|
|
253
|
-
- **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create
|
|
254
|
-
- **Author contract
|
|
255
|
-
- **Success response limitation
|
|
249
|
+
- **Must be a record-scoped interaction.** The class declares `attribute :resource` (singular) and acts on the one dropped card. A `resources`-plural (bulk) interaction is not valid here; that shape is for [column actions](#column-actions).
|
|
250
|
+
- **Auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), unique by construction, so two columns can reuse the same interaction class without colliding. Hidden = it does not render as an action button on the show page, table rows, or grid cards; it is reachable only by a drop.
|
|
251
|
+
- **Authorized by `kanban_move?` alone.** The interaction has **no policy method of its own**. The move (and therefore the interaction) is gated by the single `kanban_move?` predicate, which can gate this specific transition via its `to` column context (e.g. `def kanban_move? = kanban_to&.key == :lost ? user.manager? : super`). See [Authorization](./authorization).
|
|
252
|
+
- **Move flow only.** Dropping cross-column opens the interaction's form as a modal; on submit `on_enter` + the interaction + repositioning commit in **one atomic transaction**. Validation failure rolls the whole transaction back (membership write included) and re-renders the modal with errors; nothing persists. Same-column reorders run positioning only (neither `on_enter` nor the interaction fires).
|
|
253
|
+
- **Quick-add (`+ Add`)** applies `on_enter` + positioning **post-create**: the record is created first (needs a grouping-column default), then `on_enter` places it in the column; the `enter_interaction` is not involved.
|
|
254
|
+
- **Author contract.** When a column declares both, `on_enter` owns the membership attribute (e.g. `status`) and the interaction owns the extras (reason, mail, audit). If the interaction also writes the membership attribute it must set the same value `on_enter` does (idempotent). With no `on_enter`, the interaction owns everything.
|
|
255
|
+
- **Success response limitation.** The interaction's success **message** (`.with_message`) surfaces as a toast, but a custom success *response* (`with_redirect_response`, `with_file_response`, …) is **not** honored on the drop path; the board just re-renders and closes the modal.
|
|
256
256
|
|
|
257
257
|
See the [guide's Interaction on drop section](/guides/kanban#interaction-on-drop) for a full worked example.
|
|
258
258
|
|
|
@@ -265,7 +265,7 @@ Use `columns do…end` when the column list depends on the current request:
|
|
|
265
265
|
```ruby
|
|
266
266
|
kanban do
|
|
267
267
|
columns do
|
|
268
|
-
# `self` is the view context
|
|
268
|
+
# `self` is the view context: current_user, params, and helpers all work.
|
|
269
269
|
current_user.visible_statuses.map do |status|
|
|
270
270
|
Plutonium::Kanban::Column.new(
|
|
271
271
|
:"status_#{status.id}",
|
|
@@ -299,7 +299,7 @@ class TaskDefinition < ResourceDefinition
|
|
|
299
299
|
end
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
-
**`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually
|
|
302
|
+
**`enter_interaction:` is not supported on dynamic boards.** Unlike a column action, its hidden action can't be registered manually: the key is column-scoped and internal, and the static registration pass has no columns to see. A drop into a dynamic column that declares `enter_interaction:` is rejected with a snap-back (it does not crash). Use a static board when a column needs an `enter_interaction:`.
|
|
303
303
|
:::
|
|
304
304
|
|
|
305
305
|
---
|
|
@@ -326,7 +326,7 @@ end
|
|
|
326
326
|
|
|
327
327
|
| Option | Type | Required | Description |
|
|
328
328
|
|--------|------|----------|-------------|
|
|
329
|
-
| `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural)
|
|
329
|
+
| `interaction:` | Class | Yes | An interaction class. Must have `attribute :resources` (plural); it runs as a bulk action |
|
|
330
330
|
| `on:` | `:all` or `:visible` | No (default `:all`) | `:all` passes IDs of all column cards (ignoring `per_column`). `:visible` passes only the rendered, capped subset |
|
|
331
331
|
| `label:` | String | No | Button text. Defaults to `key.to_s.humanize` |
|
|
332
332
|
| `icon:` | Phlex icon class | No | Icon rendered before the label |
|
|
@@ -6,7 +6,7 @@ Reference documentation for the Plutonium kanban board feature.
|
|
|
6
6
|
|
|
7
7
|
| Page | What it covers |
|
|
8
8
|
|------|---------------|
|
|
9
|
-
| [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL
|
|
9
|
+
| [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL: board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
|
|
10
10
|
| [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning::Model` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
|
|
11
11
|
| [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
|
|
12
12
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Kanban Positioning
|
|
2
2
|
|
|
3
3
|
::: tip Positioning is not kanban-only
|
|
4
|
-
The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder
|
|
4
|
+
The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder**; see [Positioning & drag-to-reorder](/reference/resource/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
|
|
5
5
|
:::
|
|
6
6
|
|
|
7
|
-
Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row
|
|
7
|
+
Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row, with no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).
|
|
8
8
|
|
|
9
9
|
## `Plutonium::Positioning::Model` concern
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ After calling `positioned_on`, the model gets:
|
|
|
34
34
|
|
|
35
35
|
### Migration
|
|
36
36
|
|
|
37
|
-
Use the **`t.position`** helper
|
|
37
|
+
Use the **`t.position`** helper: it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't get the scale wrong:
|
|
38
38
|
|
|
39
39
|
```ruby
|
|
40
40
|
create_table :tasks do |t|
|
|
@@ -56,6 +56,8 @@ class AddPositionToTasks < ActiveRecord::Migration[8.1]
|
|
|
56
56
|
end
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
For a model owned by a feature package (e.g. `Catalog::Product`), put this migration in `packages/<package>/db/migrate/`, next to the table's create migration. Each package's `db/migrate` is appended to the app's migration paths, so `rails db:migrate` picks it up. Plain `rails g migration` always writes to the main app's `db/migrate`, so move the file into the package afterwards.
|
|
60
|
+
|
|
59
61
|
`t.position` accepts a custom column name and any `column` options:
|
|
60
62
|
|
|
61
63
|
```ruby
|
|
@@ -65,7 +67,7 @@ t.position :position, scale: 10 # override precision/scale
|
|
|
65
67
|
```
|
|
66
68
|
|
|
67
69
|
::: tip Why the helper picks `scale: 8`
|
|
68
|
-
If you write the column by hand, give it at least **two more decimal places than `EPSILON` (`1e-6`)
|
|
70
|
+
If you write the column by hand, give it at least **two more decimal places than `EPSILON` (`1e-6`)**, i.e. `scale: 8` or higher. Rebalancing triggers when a gap drops below `1e-6`, so a column that can store smaller values still has room to write the final midpoint cleanly. A `scale: 6` column has no headroom: the last subdivision before a rebalance can round to a neighbor and momentarily collide. `t.position` defaults to `scale: 8`, which is safe.
|
|
69
71
|
:::
|
|
70
72
|
|
|
71
73
|
---
|
|
@@ -88,7 +90,7 @@ task.reposition!(prev_record: last_card, next_record: nil) # append
|
|
|
88
90
|
|
|
89
91
|
### Gap exhaustion (rebalancing) {#rebalancing}
|
|
90
92
|
|
|
91
|
-
Each midpoint insert into the *same* slot halves the gap (`1.0 → 0.5 → 0.25 → …`), so after roughly 20 consecutive insertions the gap drops below `EPSILON` (`1e-6`). At that point `reposition!` rebalances **only that scope group
|
|
93
|
+
Each midpoint insert into the *same* slot halves the gap (`1.0 → 0.5 → 0.25 → …`), so after roughly 20 consecutive insertions the gap drops below `EPSILON` (`1e-6`). At that point `reposition!` rebalances **only that scope group**, renumbering every row in the group to fresh integers (`1.0, 2.0, 3.0, …`) in current-position order, inside a transaction, then reloads the two neighbors and writes the new midpoint. Other scope groups are untouched. End moves (a `nil` neighbor) never rebalance: they always have integer room via `prev ± 1`.
|
|
92
94
|
|
|
93
95
|
---
|
|
94
96
|
|
|
@@ -108,7 +110,7 @@ Task.backfill_positions!(order: :created_at)
|
|
|
108
110
|
The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
|
|
109
111
|
|
|
110
112
|
::: tip A board inherits the definition's `position_on`
|
|
111
|
-
`position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end
|
|
113
|
+
`position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end`; the board picks up the same attribute and the same mode.
|
|
112
114
|
|
|
113
115
|
Resolution is lazy, so a `kanban do…end` written **above** `position_on` in the class body still sees it.
|
|
114
116
|
|
|
@@ -123,7 +125,7 @@ end
|
|
|
123
125
|
```
|
|
124
126
|
:::
|
|
125
127
|
|
|
126
|
-
### Mode A
|
|
128
|
+
### Mode A: delegate (default)
|
|
127
129
|
|
|
128
130
|
```ruby
|
|
129
131
|
kanban do
|
|
@@ -135,26 +137,26 @@ end
|
|
|
135
137
|
|
|
136
138
|
On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning::Model` and call `positioned_on`.
|
|
137
139
|
|
|
138
|
-
### Mode B
|
|
140
|
+
### Mode B: BYO block
|
|
139
141
|
|
|
140
142
|
```ruby
|
|
141
143
|
kanban do
|
|
142
144
|
position_on :sort_order do |move|
|
|
143
|
-
# move.record
|
|
144
|
-
# move.column
|
|
145
|
-
# move.prev
|
|
146
|
-
# move.next
|
|
147
|
-
# move.index
|
|
145
|
+
# move.record: the dropped record
|
|
146
|
+
# move.column: destination column key (Symbol)
|
|
147
|
+
# move.prev: record immediately before the slot (or nil)
|
|
148
|
+
# move.next: record immediately after the slot (or nil)
|
|
149
|
+
# move.index: 0-based insertion index within the destination column
|
|
148
150
|
move.record.update!(sort_order: my_position(move.prev, move.next))
|
|
149
151
|
end
|
|
150
152
|
end
|
|
151
153
|
```
|
|
152
154
|
|
|
153
|
-
Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`)
|
|
155
|
+
Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`); it is NOT `instance_exec`'d, so `self` is the proc's original binding.
|
|
154
156
|
|
|
155
|
-
On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/positioning#mode-b) for a worked `acts_as_list` example.
|
|
157
|
+
On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/resource/positioning#mode-b) for a worked `acts_as_list` example.
|
|
156
158
|
|
|
157
|
-
### Mode C
|
|
159
|
+
### Mode C: disabled
|
|
158
160
|
|
|
159
161
|
```ruby
|
|
160
162
|
kanban do
|
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
# Actions
|
|
2
2
|
|
|
3
|
-
Custom buttons that go beyond standard CRUD
|
|
3
|
+
Custom buttons that go beyond standard CRUD: publish, archive, import, send invitation, etc. Two flavors:
|
|
4
4
|
|
|
5
|
-
- **Simple actions
|
|
6
|
-
- **Interactive actions
|
|
5
|
+
- **Simple actions**: navigate to an existing URL.
|
|
6
|
+
- **Interactive actions**: run an [Interaction](/reference/behavior/interactions), optionally collecting input via a modal form.
|
|
7
7
|
|
|
8
8
|
## 🚨 Critical
|
|
9
9
|
|
|
10
10
|
- **Every custom action needs a policy method.** `action :publish` requires `def publish?` on the policy. Undefined methods return `false`, so the action silently disappears.
|
|
11
11
|
- **For interactive actions, visibility is inferred from the interaction's attributes.** Don't declare `record_action: true` / `bulk_action: true` etc. by hand unless you're opting OUT.
|
|
12
12
|
- **Bulk action authorization is per-record.** If any selected record fails the policy check, the entire request is rejected.
|
|
13
|
-
- **Always pass `as:`** on custom routes
|
|
13
|
+
- **Always pass `as:`** on custom routes: without it, `resource_url_for` can't generate URLs (critical for nested resources).
|
|
14
14
|
- **Prefer interactive actions over hand-written controller routes.** Anything a user triggers from a page belongs behind an interaction.
|
|
15
|
-
- **An interaction is the button, not the operation.** Logic may start in `execute`; once a job or an API also needs it, it moves to the model
|
|
15
|
+
- **An interaction is the button, not the operation.** Logic may start in `execute`; once a job or an API also needs it, it moves to the model, see [Behavior › Interactions](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
16
16
|
|
|
17
17
|
## Action visibility flags
|
|
18
18
|
|
|
19
19
|
| Flag | Where the button appears |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `resource_action: true` | Index page (top toolbar)
|
|
22
|
-
| `record_action: true` | Show page
|
|
23
|
-
| `collection_record_action: true` | Per-row in the index table
|
|
21
|
+
| `resource_action: true` | Index page (top toolbar): for actions that operate on the collection (Import, Export, Create) |
|
|
22
|
+
| `record_action: true` | Show page: for actions on a single record (Edit, Archive, Delete) |
|
|
23
|
+
| `collection_record_action: true` | Per-row in the index table: for quick actions (Edit, Show) |
|
|
24
24
|
| `bulk_action: true` | Bulk-actions toolbar (shown when records are selected) |
|
|
25
|
-
| `hidden: true` | **Nowhere.** Suppresses all four surfaces at once, while keeping the route and policy live
|
|
25
|
+
| `hidden: true` | **Nowhere.** Suppresses all four surfaces at once, while keeping the route and policy live, see [Hidden actions](#hidden-actions) |
|
|
26
26
|
|
|
27
27
|
### Inferred visibility (interactive actions)
|
|
28
28
|
|
|
29
|
-
For `interaction:`-based actions, all four flags are **inferred from the interaction's attributes
|
|
29
|
+
For `interaction:`-based actions, all four flags are **inferred from the interaction's attributes**; don't declare them by hand:
|
|
30
30
|
|
|
31
31
|
| Interaction declares | Inferred flags |
|
|
32
32
|
|---|---|
|
|
@@ -34,7 +34,7 @@ For `interaction:`-based actions, all four flags are **inferred from the interac
|
|
|
34
34
|
| `attribute :resources` (plural) | `bulk_action: true` |
|
|
35
35
|
| neither | `resource_action: true` |
|
|
36
36
|
|
|
37
|
-
User-supplied flags override the inferred ones, but only **opt-out** makes sense
|
|
37
|
+
User-supplied flags override the inferred ones, but only **opt-out** makes sense: the interaction's `attribute :resource` / `attribute :resources` already fixes its semantic shape:
|
|
38
38
|
|
|
39
39
|
```ruby
|
|
40
40
|
# :resource interaction → defaults to record_action + collection_record_action.
|
|
@@ -63,10 +63,10 @@ action :name,
|
|
|
63
63
|
collection_record_action: true,
|
|
64
64
|
bulk_action: true,
|
|
65
65
|
|
|
66
|
-
# Conditional visibility
|
|
66
|
+
# Conditional visibility: display-only proc, NOT authorization (see below)
|
|
67
67
|
condition: -> { params[:beta] == "1" },
|
|
68
68
|
|
|
69
|
-
# Never render, anywhere
|
|
69
|
+
# Never render, anywhere: route + policy stay live (see below)
|
|
70
70
|
hidden: true,
|
|
71
71
|
|
|
72
72
|
# Grouping
|
|
@@ -78,19 +78,19 @@ action :name,
|
|
|
78
78
|
turbo_frame: "_top",
|
|
79
79
|
return_to: "/custom/path",
|
|
80
80
|
route_options: {action: :foo},
|
|
81
|
-
modal: :slideover, # :slideover / :centered
|
|
82
|
-
size: :lg, # :sm / :md / :lg / :xl / :auto / :full
|
|
81
|
+
modal: :slideover, # :slideover / :centered; overrides the definition's modal mode
|
|
82
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full; overrides the definition's modal size
|
|
83
83
|
|
|
84
84
|
# HTML attributes (see below)
|
|
85
85
|
link: {target: "_blank", rel: "noopener"}, # merged onto the action's <a> renderings
|
|
86
86
|
button: {data: {analytics: "archive"}} # merged onto the button_to <form> (non-GET)
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
### HTML attributes
|
|
89
|
+
### HTML attributes: `link:` / `button:`
|
|
90
90
|
|
|
91
|
-
Two per-element attribute bags, deep-merged over the framework's own attributes at render time
|
|
91
|
+
Two per-element attribute bags, deep-merged over the framework's own attributes at render time; **the author wins on every key**, recursively through nested `data`:
|
|
92
92
|
|
|
93
|
-
- **`link:`** applies to every `<a>` rendered for the action: the toolbar link (GET), dropdown items (**any** HTTP method
|
|
93
|
+
- **`link:`** applies to every `<a>` rendered for the action: the toolbar link (GET), dropdown items (**any** HTTP method, dropdown items are always anchors, submitting via `data-turbo-method`), bulk-action links, kanban column action links, and the grid/kanban card's hidden show link (for `:show`).
|
|
94
94
|
- **`button:`** applies to the `button_to` **`<form>`** element of the non-GET toolbar rendering (the form wrapper, not the inner `<button>`).
|
|
95
95
|
|
|
96
96
|
```ruby
|
|
@@ -100,14 +100,14 @@ action :documentation,
|
|
|
100
100
|
link: {target: "_blank", rel: "noopener noreferrer", data: {analytics: "docs"}}
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Because the author wins, you can override anything
|
|
103
|
+
Because the author wins, you can override anything (`turbo_frame`, `class`, `data-*`) at your own risk. Two things to know:
|
|
104
104
|
|
|
105
|
-
- `class:` **replaces** the framework's classes (no token append)
|
|
105
|
+
- `class:` **replaces** the framework's classes (no token append): a bare `link: {class: "mt-2"}` removes the button styling entirely.
|
|
106
106
|
- Pass `data:` as a **hash**. The merge only recurses when both sides are hashes, so a scalar `data:` replaces the framework's data wholesale (dropping `turbo_confirm`/`turbo_frame`).
|
|
107
107
|
|
|
108
108
|
Both bags round-trip through [`with(...)`](#deriving-variants-action-with), so `defined_actions[:edit].with(link: {target: "_blank"})` works in `customize_actions`.
|
|
109
109
|
|
|
110
|
-
### Deriving variants
|
|
110
|
+
### Deriving variants: `Action#with(...)`
|
|
111
111
|
|
|
112
112
|
Action records are frozen value objects. Inside `customize_actions`, derive a copy with overrides:
|
|
113
113
|
|
|
@@ -117,11 +117,11 @@ def customize_actions
|
|
|
117
117
|
end
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
## Conditional visibility
|
|
120
|
+
## Conditional visibility: `condition:` {#conditional-visibility}
|
|
121
121
|
|
|
122
|
-
Like the `condition:` proc on [inputs/displays/columns](/reference/resource/definition), an action can be **defined but only rendered when a runtime proc is truthy**. It's purely a toggle on whether the **button is shown
|
|
122
|
+
Like the `condition:` proc on [inputs/displays/columns](/reference/resource/definition), an action can be **defined but only rendered when a runtime proc is truthy**. It's purely a toggle on whether the **button is shown**; the action (and its route) stays fully live either way.
|
|
123
123
|
|
|
124
|
-
The headline use case: **expose an action's endpoint without surfacing it in the UI
|
|
124
|
+
The headline use case: **expose an action's endpoint without surfacing it in the UI**, e.g. one you call from the API, a webhook, or another service. Hide the button with an always-falsy condition; the route still works:
|
|
125
125
|
|
|
126
126
|
```ruby
|
|
127
127
|
# Defined and callable (API / programmatic), but no button anywhere in the UI:
|
|
@@ -133,7 +133,7 @@ It also works as a dynamic toggle driven by the **record** or the **view/request
|
|
|
133
133
|
```ruby
|
|
134
134
|
# object → the row/shown record (record & collection-record actions):
|
|
135
135
|
action :reopen, interaction: ReopenInteraction, condition: -> { object.closed? }
|
|
136
|
-
# view/request state
|
|
136
|
+
# view/request state: feature flag, preview/beta mode:
|
|
137
137
|
action :preview, interaction: PreviewInteraction, condition: -> { params[:beta] == "1" }
|
|
138
138
|
```
|
|
139
139
|
|
|
@@ -144,34 +144,34 @@ Inside the proc, `object`/`record` is the contextual record, and every other cal
|
|
|
144
144
|
| `object` / `record` | The row/shown record for **record** and **collection-record** actions; **`nil`** for resource and bulk actions (no single record). Guard with `object&.…` if a condition is shared across action kinds. |
|
|
145
145
|
| `params`, `request` | Current request. |
|
|
146
146
|
| `current_user`, `current_parent` | The signed-in user and (nested) parent. |
|
|
147
|
-
| `resource_record!` | The shown record on the show page; raises on index/table
|
|
147
|
+
| `resource_record!` | The shown record on the show page; raises on index/table: prefer `object`. |
|
|
148
148
|
| `allowed_to?`, `policy_for`, other helpers | The usual view helpers. |
|
|
149
149
|
|
|
150
150
|
`object` is evaluated **per row** in tables and grids, so per-record show/hide works there too.
|
|
151
151
|
|
|
152
|
-
::: danger `condition:` is NOT authorization
|
|
152
|
+
::: danger `condition:` is NOT authorization: it only hides the button
|
|
153
153
|
A hidden action still has a **live route**: anyone who knows the URL can still trigger it. `condition:` decides whether the *button renders*, never whether the *request is allowed*.
|
|
154
154
|
|
|
155
155
|
```ruby
|
|
156
|
-
# 🚫 WRONG
|
|
156
|
+
# 🚫 WRONG: this does NOT stop non-admins. The route is live; they can POST to it.
|
|
157
157
|
action :wipe, interaction: WipeInteraction, condition: -> { current_user.admin? }
|
|
158
158
|
|
|
159
|
-
# ✅ RIGHT
|
|
159
|
+
# ✅ RIGHT: authorization belongs in the policy. The action only runs if this returns true.
|
|
160
160
|
class WidgetPolicy < ResourcePolicy
|
|
161
161
|
def wipe? = current_user.admin?
|
|
162
162
|
end
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
**Rule of thumb:** "who may run this" → **policy** (`def action_name?`). "is this UI relevant right now" → `condition:`. Authorization is enforced regardless of `condition:`; the two compose
|
|
165
|
+
**Rule of thumb:** "who may run this" → **policy** (`def action_name?`). "is this UI relevant right now" → `condition:`. Authorization is enforced regardless of `condition:`; the two compose: an action appears only when the policy permits **and** the condition is truthy.
|
|
166
166
|
:::
|
|
167
167
|
|
|
168
168
|
::: tip Per-record display vs. per-record authorization
|
|
169
|
-
`condition: -> { object.draft? }` is fine for **showing/hiding** a per-record button. But if the rule is about **who may run it** ("only while draft *and* nobody else has it locked"), put it in the policy
|
|
169
|
+
`condition: -> { object.draft? }` is fine for **showing/hiding** a per-record button. But if the rule is about **who may run it** ("only while draft *and* nobody else has it locked"), put it in the policy: `def publish? = record.draft?` is also evaluated per record (per row), and unlike `condition:` it actually gates execution.
|
|
170
170
|
:::
|
|
171
171
|
|
|
172
|
-
## Hidden actions
|
|
172
|
+
## Hidden actions: `hidden: true` {#hidden-actions}
|
|
173
173
|
|
|
174
|
-
An action declared `hidden: true` renders in **no** toolbar, row dropdown, card, or bulk bar
|
|
174
|
+
An action declared `hidden: true` renders in **no** toolbar, row dropdown, card, or bulk bar, regardless of its visibility flags, the policy, or `condition:`. Everything else about it stays live:
|
|
175
175
|
|
|
176
176
|
- the **route** is mounted;
|
|
177
177
|
- the **policy predicate** (`def name?`) is defined and enforced;
|
|
@@ -181,7 +181,7 @@ An action declared `hidden: true` renders in **no** toolbar, row dropdown, card,
|
|
|
181
181
|
action :reposition, hidden: true
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
The use case is an endpoint reached by **something other than a button
|
|
184
|
+
The use case is an endpoint reached by **something other than a button**: a drag gesture, a custom Stimulus controller, a client-side widget you wrote yourself. The framework uses it for exactly that: [`position_on`](/reference/resource/positioning) expands to `action :reposition, hidden: true`, and the kanban board's drop endpoint is declared the same way.
|
|
185
185
|
|
|
186
186
|
### `hidden:` vs `condition: -> { false }`
|
|
187
187
|
|
|
@@ -190,7 +190,7 @@ Both suppress the button, so pick by intent:
|
|
|
190
190
|
| | `hidden: true` | `condition: -> { false }` |
|
|
191
191
|
|---|---|---|
|
|
192
192
|
| Decided | at **class-load**, once | at **render time**, per row/request |
|
|
193
|
-
| Costs | nothing
|
|
193
|
+
| Costs | nothing: the surfaces filter it out before any policy or condition runs | a proc evaluation per rendering |
|
|
194
194
|
| Says | "this is never a button" | "this is a button, just not right now" |
|
|
195
195
|
|
|
196
196
|
Use `hidden:` for an action that is *structurally* not a button. Use `condition:` when visibility genuinely depends on the record, the user, or the request.
|
|
@@ -199,10 +199,10 @@ Use `hidden:` for an action that is *structurally* not a button. Use `condition:
|
|
|
199
199
|
This is the same trap as [`condition:`](#conditional-visibility), and it bears repeating because "hidden" reads more absolute than it is. A hidden action has a **live route**: anyone who can construct the URL can call it.
|
|
200
200
|
|
|
201
201
|
```ruby
|
|
202
|
-
# 🚫 WRONG
|
|
202
|
+
# 🚫 WRONG: hiding the button does not stop the request.
|
|
203
203
|
action :purge_all, interaction: PurgeInteraction, hidden: true
|
|
204
204
|
|
|
205
|
-
# ✅ RIGHT
|
|
205
|
+
# ✅ RIGHT: authorization belongs in the policy.
|
|
206
206
|
class WidgetPolicy < ResourcePolicy
|
|
207
207
|
def purge_all? = current_user.admin?
|
|
208
208
|
end
|
|
@@ -239,16 +239,16 @@ resources :posts do
|
|
|
239
239
|
end
|
|
240
240
|
```
|
|
241
241
|
|
|
242
|
-
Without it, `resource_url_for` can't build the URL
|
|
242
|
+
Without it, `resource_url_for` can't build the URL, particularly critical for nested resources.
|
|
243
243
|
:::
|
|
244
244
|
|
|
245
245
|
For anything with business logic, use an **interactive action** instead.
|
|
246
246
|
|
|
247
247
|
## Interactive actions
|
|
248
248
|
|
|
249
|
-
Run an [Interaction](/reference/behavior/interactions)
|
|
249
|
+
Run an [Interaction](/reference/behavior/interactions): automatically renders a form if the interaction declares attributes beyond `:resource`/`:resources`, otherwise executes immediately with a confirmation.
|
|
250
250
|
|
|
251
|
-
The interactions below call named model methods (`archive!`, `invite!`) rather than doing the work inline. That's not mandatory for a one-off
|
|
251
|
+
The interactions below call named model methods (`archive!`, `invite!`) rather than doing the work inline. That's not mandatory for a one-off; the trigger to extract is the second caller, since an interaction can only be built with a `view_context`. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
252
252
|
|
|
253
253
|
```ruby
|
|
254
254
|
class PostDefinition < Plutonium::Resource::Definition
|
|
@@ -341,7 +341,7 @@ action :bulk_archive, interaction: BulkArchiveInteraction
|
|
|
341
341
|
# bulk_action: true inferred from `attribute :resources`
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
-
Policy
|
|
344
|
+
Policy: checked per record; fails the whole request if ANY record is unauthorized:
|
|
345
345
|
|
|
346
346
|
```ruby
|
|
347
347
|
def bulk_archive?
|
|
@@ -349,7 +349,7 @@ def bulk_archive?
|
|
|
349
349
|
end
|
|
350
350
|
```
|
|
351
351
|
|
|
352
|
-
The UI only shows bulk actions that ALL selected records support. Records are fetched via `current_authorized_scope
|
|
352
|
+
The UI only shows bulk actions that ALL selected records support. Records are fetched via `current_authorized_scope`; users can only select records they can access.
|
|
353
353
|
|
|
354
354
|
### Resource action (no record)
|
|
355
355
|
|
|
@@ -375,7 +375,7 @@ action :import, interaction: ImportInteraction
|
|
|
375
375
|
|
|
376
376
|
## Running the work in the background
|
|
377
377
|
|
|
378
|
-
An interactive action executes inside the request. When that is too slow
|
|
378
|
+
An interactive action executes inside the request. When that is too slow (a bulk action over thousands of records, or a single call to something slow), replace `execute` with `async` and the interaction dispatches a persisted, resumable run instead:
|
|
379
379
|
|
|
380
380
|
```ruby
|
|
381
381
|
class BulkArchiveInteraction < ResourceInteraction
|
|
@@ -396,8 +396,8 @@ See [Async Interactions](/reference/behavior/async-interactions) for failure pol
|
|
|
396
396
|
|
|
397
397
|
| Interaction shape | Behavior |
|
|
398
398
|
|---|---|
|
|
399
|
-
| Only `:resource` / `:resources` (no extra inputs) | **Immediate
|
|
400
|
-
| Additional `attribute` / `input` declared | **Form
|
|
399
|
+
| Only `:resource` / `:resources` (no extra inputs) | **Immediate**: browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false`. |
|
|
400
|
+
| Additional `attribute` / `input` declared | **Form**: renders the action's form in a modal first; no auto-confirmation (the form is the confirmation). |
|
|
401
401
|
|
|
402
402
|
## Built-in CRUD actions
|
|
403
403
|
|
|
@@ -454,13 +454,13 @@ end
|
|
|
454
454
|
```
|
|
455
455
|
|
|
456
456
|
> **CSV export is not an action.** It's a built-in, policy-gated capability with its own
|
|
457
|
-
> button
|
|
457
|
+
> button; see [CSV Export](./export.md). Don't declare it with `action :export_csv`.
|
|
458
458
|
|
|
459
459
|
## Interaction responses
|
|
460
460
|
|
|
461
461
|
```ruby
|
|
462
462
|
def execute
|
|
463
|
-
# Success
|
|
463
|
+
# Success: redirects to resource automatically
|
|
464
464
|
succeed(resource).with_message("Done!")
|
|
465
465
|
|
|
466
466
|
# Different redirect destination
|
|
@@ -569,7 +569,7 @@ action :export,
|
|
|
569
569
|
|
|
570
570
|
## Related
|
|
571
571
|
|
|
572
|
-
- [Definition](./definition)
|
|
573
|
-
- [Query](./query)
|
|
574
|
-
- [Behavior › Interactions](/reference/behavior/interactions)
|
|
575
|
-
- [Behavior › Policy](/reference/behavior/policies)
|
|
572
|
+
- [Definition](./definition): fields, page chrome
|
|
573
|
+
- [Query](./query): search, filters, scopes
|
|
574
|
+
- [Behavior › Interactions](/reference/behavior/interactions): writing interaction classes
|
|
575
|
+
- [Behavior › Policy](/reference/behavior/policies): authorizing custom actions
|