plutonium 0.60.5 → 0.61.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 +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Wizards
|
|
2
|
+
|
|
3
|
+
::: warning Experimental
|
|
4
|
+
Wizards are experimental — the DSL and behavior may change in a future release.
|
|
5
|
+
:::
|
|
6
|
+
|
|
7
|
+
Build multi-step flows — onboarding, checkout, "create several related records across screens", branching questionnaires — as a single declarative Ruby class.
|
|
8
|
+
|
|
9
|
+
A wizard collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses Plutonium's existing field DSL (`attribute`/`input`/`validates`/`structured_input`/`form_layout`), form rendering, actions, and policies — it does **not** invent a parallel stack.
|
|
10
|
+
|
|
11
|
+
## Goal
|
|
12
|
+
|
|
13
|
+
The user lands on the first step, fills it in, clicks Next, and walks through the flow. Branching steps appear or disappear based on earlier answers. A built-in review step recaps everything and gates a Finish button. On finish, `execute` writes the records — atomically by default.
|
|
14
|
+
|
|
15
|
+
## Prerequisites — enable the subsystem
|
|
16
|
+
|
|
17
|
+
Wizards are core code, but the storage table is **opt-in** so apps that don't use wizards stay schema-clean. Enable it in your Plutonium initializer:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# config/initializers/plutonium.rb
|
|
21
|
+
Plutonium.configure do |config|
|
|
22
|
+
config.wizards.enabled = true # false by default; registers the gem migration
|
|
23
|
+
config.wizards.cleanup_after = 14.days # global default idle TTL for the sweep
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then run the migration (it ships in the gem and runs in place — no copy step):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rails db:migrate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
This creates the single framework table `plutonium_wizard_sessions`. See [Storage & config](/reference/wizard/storage-config) for the details.
|
|
34
|
+
|
|
35
|
+
::: warning Schedule the SweepJob for save-as-you-go wizards
|
|
36
|
+
For plain `execute`-only wizards, leaving the sweep unscheduled only leaves stale session rows (harmless). But if you use per-step `on_submit` (which creates **real records mid-flow**), `Plutonium::Wizard::SweepJob` is the **only** thing that cleans up abandoned partial records. Schedule it as a recurring job. See [Storage & config](/reference/wizard/storage-config#sweepjob).
|
|
37
|
+
:::
|
|
38
|
+
|
|
39
|
+
## A minimal wizard
|
|
40
|
+
|
|
41
|
+
The common case writes nothing until the end. Steps collect `data`; one `execute` does all the writes in a single transaction.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# app/wizards/company_onboarding_wizard.rb
|
|
45
|
+
class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
46
|
+
presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
|
|
47
|
+
|
|
48
|
+
step :company, label: "Company details" do
|
|
49
|
+
attribute :name, :string
|
|
50
|
+
attribute :subdomain, :string
|
|
51
|
+
input :name
|
|
52
|
+
input :subdomain
|
|
53
|
+
validates :name, :subdomain, presence: true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
step :plan, label: "Plan" do
|
|
57
|
+
attribute :plan, :string
|
|
58
|
+
input :plan, as: :radio_buttons, choices: %w[free pro]
|
|
59
|
+
validates :plan, presence: true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
review label: "Review & submit"
|
|
63
|
+
|
|
64
|
+
def execute
|
|
65
|
+
company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
|
|
66
|
+
succeed(company).with_message("You're all set!")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- A wizard is a plain class — `< Plutonium::Wizard::Base`. There is no generator (just like interactions); author it by hand.
|
|
72
|
+
- `presents label:/icon:` sets the launch button's label and icon, exactly like interactions; an optional `description:` renders as the wizard's header subheading.
|
|
73
|
+
- Each `step :key, label: do ... end` is one screen. Inside the block, declare its fields with the same DSL you use on a definition or interaction.
|
|
74
|
+
- `data` is **step-keyed**: `data.company.name` reads the **typed** value entered on the `:company` step (cast to the declared type), available from any step and from `execute`. Each step has its own sub-object, so two steps may use the same field name without colliding.
|
|
75
|
+
- `review` is a built-in terminal step (auto-summary + gated Finish). It must be **last**.
|
|
76
|
+
- `execute` runs once at the end and returns an `Outcome` (`succeed(...)` / `failed(...)`). **Use bang methods** (`create!`/`update!`) — failure is signalled by a raised exception, never a return value.
|
|
77
|
+
|
|
78
|
+
::: warning Use bang methods in `execute`
|
|
79
|
+
The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising — the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
|
|
80
|
+
:::
|
|
81
|
+
|
|
82
|
+
Each step renders as a focused card with a numbered stepper rail (the terminal `review` shows a finish flag, not a number) and a Back / Next / Cancel strip:
|
|
83
|
+
|
|
84
|
+

|
|
85
|
+
|
|
86
|
+
## Branching with `condition:`
|
|
87
|
+
|
|
88
|
+
A step's `condition:` lambda decides whether the step is included. Branching is **subtractive** — a falsy `condition:` removes the step from the visible path.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
step :plan, label: "Plan" do
|
|
92
|
+
attribute :plan, :string
|
|
93
|
+
input :plan, as: :radio_buttons, choices: %w[free pro]
|
|
94
|
+
validates :plan, presence: true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Only shown when the user picked "pro".
|
|
98
|
+
step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
|
|
99
|
+
attribute :card_token, :string
|
|
100
|
+
input :card_token
|
|
101
|
+
validates :card_token, presence: true
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
::: warning `condition:` lambdas must be nil-safe
|
|
106
|
+
A `condition:` runs against the typed `data` snapshot at **every** transition — including before its deciding step has been filled, when the value is still `nil`. `-> { data.plan.plan == "pro" }` is fine (`nil == "pro"` is `false`); `-> { data.plan.plan.upcase == "PRO" }` raises on `nil`. Always write conditions that tolerate `nil`.
|
|
107
|
+
:::
|
|
108
|
+
|
|
109
|
+
The condition can also read `anchor` (for [anchored wizards](#anchored-wizards)). Data belonging to branch-hidden steps is pruned before `execute`, so `execute` only ever sees data for steps that actually applied.
|
|
110
|
+
|
|
111
|
+
## Reusing a model's fields — `using:`
|
|
112
|
+
|
|
113
|
+
Instead of re-declaring fields a model already defines, import them with `using:`. It is a **step option** (not a block method), and it targets a **model (record class) only**.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Whole-step import — no block needed.
|
|
117
|
+
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
|
|
118
|
+
|
|
119
|
+
# Mix imported + wizard-local fields: using: plus a block for the extras.
|
|
120
|
+
step :details, label: "Details", using: Company, only: %i[tagline] do
|
|
121
|
+
attribute :referral_code, :string
|
|
122
|
+
input :referral_code
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
What `using:` imports from the model:
|
|
127
|
+
|
|
128
|
+
- **Field universe + types** from `Model.attribute_names` / `Model.attribute_types`. Selectors `fields:` (alias `only:`) and `except:` pick a subset.
|
|
129
|
+
- **Input styling** overlaid from the auto-resolved `<Model>Definition` (its `as:`, options, labels) — best-effort; no definition found is fine.
|
|
130
|
+
- **Validations** run via a transient `Model.new(slice).valid?`, keeping errors on the imported fields plus `:base`. Pass `validate: false` to skip and write your own inline `validates`.
|
|
131
|
+
- **`form_layout`** inherited from the `<Model>Definition` (filtered to imported fields). Pass `layout: false` to opt out.
|
|
132
|
+
|
|
133
|
+
`using:` is **declaration reuse only** — it never pulls in the model's persistence or callbacks. Data still stages into `data`; your `execute` does the writes. Full detail: [DSL reference › `using:`](/reference/wizard/dsl#using-a-model).
|
|
134
|
+
|
|
135
|
+
## Sectioning a step — `form_layout`
|
|
136
|
+
|
|
137
|
+
A step is its own form, so you can group its fields with the same `form_layout` DSL you use on a definition, scoped to that step:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
step :company, label: "Company details" do
|
|
141
|
+
attribute :name, :string
|
|
142
|
+
attribute :subdomain, :string
|
|
143
|
+
input :name
|
|
144
|
+
input :subdomain
|
|
145
|
+
validates :name, :subdomain, presence: true
|
|
146
|
+
|
|
147
|
+
form_layout do
|
|
148
|
+
section :identity, :name, :subdomain, label: "Identity", columns: 2
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Repeatable / structured fields
|
|
154
|
+
|
|
155
|
+
Because a step uses the existing form pipeline, `structured_input` works inside a step. The values land in `data.<step>.<name>` as an array of typed sub-objects:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
step :team, label: "Invite your team" do
|
|
159
|
+
structured_input :invites, repeat: 5 do |f|
|
|
160
|
+
f.input :email, as: :email
|
|
161
|
+
f.input :role, as: :select, choices: %w[admin member]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def execute
|
|
166
|
+
company = Company.create!(name: data.company.name)
|
|
167
|
+
data.team.invites.each { |i| company.invites.create!(email: i.email, role: i.role) }
|
|
168
|
+
succeed(company)
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Repeater rows rehydrate from staged `data` on GET, so navigating back (or resuming) re-renders the rows you already filled.
|
|
173
|
+
|
|
174
|
+

|
|
175
|
+
|
|
176
|
+
## File uploads (attachments)
|
|
177
|
+
|
|
178
|
+
A step can collect a file. You declare it like any other field — a **`:string`** attribute (it holds the upload **token**, not the bytes) plus a file input:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
step :photo, label: "Photo" do
|
|
182
|
+
attribute :photo, :string
|
|
183
|
+
input :photo, as: :file # also: as: :uppy / as: :attachment
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A wizard stages its `data` as JSON across several requests, so a file can't ride along — only a **token** does. The field stages the backend's upload token (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data); your `execute` assigns that token to the model's attachment, which both backends accept natively:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
def execute
|
|
191
|
+
member = Member.create!(name: data.profile.name)
|
|
192
|
+
member.photo.attach(data.photo.photo) if data.photo.photo.present? # ActiveStorage
|
|
193
|
+
# or, with active_shrine: Member.create!(photo: data.photo.photo)
|
|
194
|
+
succeed(member)
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The review summary and the step's preview (when you go Back or resume) render the file for you — reading `data.photo.photo` resolves the token to a displayable attachment automatically.
|
|
199
|
+
|
|
200
|
+
### Server-side vs direct upload
|
|
201
|
+
|
|
202
|
+
The same field works two ways:
|
|
203
|
+
|
|
204
|
+
- **Server-side (default)** — `input :photo, as: :file`. The file is submitted with the step (a plain file input) and the wizard uploads it to the backend's cache while staging. Nothing else to wire up; works for both ActiveStorage and active_shrine.
|
|
205
|
+
- **Direct upload** — `input :photo, as: :uppy, direct_upload: true, endpoint: "/upload"`. The browser uploads straight to the endpoint (with a progress UI) and posts back a token. Use this for large files or an async UX; it needs the backend's direct-upload endpoint reachable (ActiveStorage's direct uploads, or Shrine's `upload_endpoint`).
|
|
206
|
+
|
|
207
|
+
::: tip Match the backend to the model
|
|
208
|
+
In server-side mode the backend defaults to `config.wizards.attachment_backend` — auto-detected as Shrine when active_shrine is installed, else ActiveStorage. Override per field with `backend:` (`input :photo, as: :file, backend: :active_storage`). It must match the model your `execute` assigns to: an ActiveStorage model can't accept a Shrine token, and vice-versa.
|
|
209
|
+
|
|
210
|
+
For Shrine, you can also cache through a specific uploader — `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` — so that uploader's cache-stage plugins (mime/dimension extraction, `generate_location`, processing) run while staging. The minted token stays uploader-agnostic, so display and `execute` promotion are unchanged. That uploader's **validations are enforced on the step** too: a file that violates them is rejected right there with a field error (validated against the field's effective uploader — its `uploader:`, or base `Shrine`), rather than slipping through to `execute`.
|
|
211
|
+
:::
|
|
212
|
+
|
|
213
|
+
For **multiple** files, use an array attribute with `multiple: true`; the staged value is then an array of tokens. A staged-but-abandoned upload (cancel/sweep) is an unattached blob / cached file that each storage backend's own cleanup reaps.
|
|
214
|
+
|
|
215
|
+
## The review step
|
|
216
|
+
|
|
217
|
+
`review` is a built-in terminal step. It:
|
|
218
|
+
|
|
219
|
+
- Renders a read-only auto-summary of every visible step's data (reusing display components). The custom block, if any, renders **below** the summary.
|
|
220
|
+
- Lists invalid/unvisited visible steps as "fix this" jump links.
|
|
221
|
+
- Disables Finish until all visible steps are valid; clicking it runs `execute`.
|
|
222
|
+
|
|
223
|
+

|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
review label: "Review & submit"
|
|
227
|
+
|
|
228
|
+
# Custom content BELOW the auto-summary:
|
|
229
|
+
review label: "Review & submit" do |wizard|
|
|
230
|
+
"By submitting you agree to the #{wizard.data.plan.plan} plan terms."
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
You can hand the body fully to your own design. The custom block sits below the summary by default; `summary: false` lets it **replace** the summary, and `header: false` drops the step-header (label + prompt). With `summary: false` and no block you get a built-in "ready to complete" panel. Pair with the wizard-level `stepper false` for a fully chromeless flow:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
stepper false # no top rail
|
|
238
|
+
# ...
|
|
239
|
+
review summary: false, header: false # no header, no summary → "ready to complete" panel
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
See the [DSL reference](/reference/wizard/dsl#review) for the complete state table.
|
|
243
|
+
|
|
244
|
+
## Per-step writes — `on_submit` / `persist` / `on_rollback`
|
|
245
|
+
|
|
246
|
+
`execute` is the default — atomic, no orphans. Reach for per-step `on_submit` **only** when a real record must exist mid-flow (handing off to an external system that webhooks back, a reviewer who must see partial data, a payload too large for the session row).
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class ConfigureCompanyWizard < Plutonium::Wizard::Base
|
|
250
|
+
anchored with: Company
|
|
251
|
+
cleanup_after 7.days
|
|
252
|
+
|
|
253
|
+
step :billing, label: "Billing", condition: -> { anchor.paid_plan? } do
|
|
254
|
+
attribute :card_token, :string
|
|
255
|
+
input :card_token
|
|
256
|
+
validates :card_token, presence: true
|
|
257
|
+
|
|
258
|
+
# Runs when THIS step completes (opt-in save-as-you-go), in its own transaction.
|
|
259
|
+
on_submit do
|
|
260
|
+
charge = PaymentApi.authorize!(anchor, data.billing.card_token)
|
|
261
|
+
fail!("Card was declined") unless charge.ok? # → base error, stays on step
|
|
262
|
+
# `persist` registers the record for resume + cleanup → persisted[:billing]
|
|
263
|
+
persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# ADDITIONAL cleanup on Cancel/abandonment. The engine ALWAYS destroys the
|
|
267
|
+
# persist'd Billing record — on_rollback is only for side effects it can't see
|
|
268
|
+
# (here, refunding the external charge). It runs BEFORE the destroy, so
|
|
269
|
+
# persisted[:billing] is still alive to read.
|
|
270
|
+
on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def execute
|
|
274
|
+
anchor.update!(configured_at: Time.current)
|
|
275
|
+
succeed(anchor).with_message("Company configured.")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- `on_submit` runs in its own transaction when the step completes. Inside it, `persist record` registers record(s) the engine tracks for resume and cleanup — reachable later as `persisted[:step_key]`.
|
|
281
|
+
- `fail!("msg")` aborts the step with a base (form-level) error; `fail!(:field, "msg")` attaches it to a field. Both roll back the step's transaction and re-render with input intact.
|
|
282
|
+
- The engine **always** destroys every `persist`'d record on rollback (Cancel, abandonment-sweep, branch-prune), in reverse order, via `destroy!` (which respects a model's own soft-delete override). `on_rollback` is an **optional, additive** compensating block for side effects the engine can't see (refund a charge, call an external API), and runs **before** the destroy, so `persisted[:key]` is still alive inside it. Don't destroy the tracked record yourself; the engine does.
|
|
283
|
+
- Because `on_submit` writes mid-flow, it isn't atomic across steps — that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
|
|
284
|
+
|
|
285
|
+
## Anchored wizards
|
|
286
|
+
|
|
287
|
+
An **anchored** wizard runs against an existing record (like `attribute :resource` on an interaction). Read it via `anchor`.
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
class ConfigureCompanyWizard < Plutonium::Wizard::Base
|
|
291
|
+
anchored with: Company # operate on a Company
|
|
292
|
+
|
|
293
|
+
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
|
|
294
|
+
|
|
295
|
+
def execute
|
|
296
|
+
anchor.update!(configured_at: Time.current)
|
|
297
|
+
succeed(anchor)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- `anchored with: Company` → a single type. `anchored with: [Company, Organization]` → polymorphic. `anchored` (no `with:`) → generic, bound at registration.
|
|
303
|
+
- `anchor` raises `Plutonium::Wizard::NotAnchoredError` if the wizard wasn't declared `anchored` — it never returns `nil`.
|
|
304
|
+
- Omit `anchored` for a pure create flow (the wizard creates the records it names itself).
|
|
305
|
+
|
|
306
|
+
See [Anchoring & resume](/reference/wizard/anchoring-resume).
|
|
307
|
+
|
|
308
|
+
## One-time onboarding + gate
|
|
309
|
+
|
|
310
|
+
A one-time wizard is a keyed wizard (`concurrency_key`) that **retains** its completed row as a durable marker. A controller gate redirects users into it until they finish.
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
class WelcomeWizard < Plutonium::Wizard::Base
|
|
314
|
+
presents label: "Welcome"
|
|
315
|
+
|
|
316
|
+
concurrency_key { current_user } # the stable row to retain (tenant folded in)
|
|
317
|
+
one_time # retain on completion → run once
|
|
318
|
+
|
|
319
|
+
step :profile, label: "Your profile" do
|
|
320
|
+
attribute :full_name, :string
|
|
321
|
+
input :full_name
|
|
322
|
+
validates :full_name, presence: true
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
review label: "All set?"
|
|
326
|
+
|
|
327
|
+
def execute
|
|
328
|
+
current_user.update!(full_name: data.profile.full_name, onboarded_at: Time.current)
|
|
329
|
+
succeed.with_message("Welcome aboard!")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Standalone wizards have no resource policy — gate entry with `authorize?`.
|
|
333
|
+
def authorize?
|
|
334
|
+
current_user.present?
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Gate a controller behind it with the `Plutonium::Wizard::Gate` concern:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
module AdminPortal
|
|
343
|
+
class DashboardController < AdminPortal::PlutoniumController
|
|
344
|
+
include Plutonium::Wizard::Gate
|
|
345
|
+
ensure_wizard_completed ::WelcomeWizard
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
An un-completed user hitting the gate is redirected into the wizard (their destination stashed); on completion they're bounced back (PRG). Completed users pass straight through. Re-opening a finished one-time wizard renders an "already completed" page (override its body with a `completed do |wizard| … end` block) rather than re-running it:
|
|
351
|
+
|
|
352
|
+

|
|
353
|
+
|
|
354
|
+
See [One-time wizards](/reference/wizard/one-time).
|
|
355
|
+
|
|
356
|
+
## Registration & launch
|
|
357
|
+
|
|
358
|
+
A wizard reaches a user as a **resource action** (the `wizard` macro) or a **route-mounted entry** (`register_wizard`) — inside a portal, or on the main app. A portal mount inherits the portal's auth, tenant scoping, layout, and rendering; a main-app mount runs standalone.
|
|
359
|
+
|
|
360
|
+
### On a resource — the `wizard` macro
|
|
361
|
+
|
|
362
|
+
Register a wizard on a resource definition. Placement follows `anchored?` automatically: an anchored wizard becomes a **record** action (the show page *and* each index row, like `edit`/`destroy`); a non-anchored wizard becomes a collection-level **resource** action.
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
class CompanyDefinition < Plutonium::Resource::Definition
|
|
366
|
+
wizard :configure, ConfigureCompanyWizard # anchored → record action (/companies/:id/wizards/configure/:step)
|
|
367
|
+
wizard :onboard, CompanyOnboardingWizard # no anchor → resource action (/companies/wizards/onboard/:step)
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+

|
|
372
|
+
|
|
373
|
+
The anchor resolves through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe — an out-of-scope or missing id 404s), and the action is gated by a policy predicate named after the wizard key (`def configure? = update?`). For placement flags, routes, and the full option list see [Registration & launch › the `wizard` macro](/reference/wizard/registration-launch#on-a-resource-the-wizard-macro).
|
|
374
|
+
|
|
375
|
+
### Route-mounted — `register_wizard`
|
|
376
|
+
|
|
377
|
+
For a wizard not tied to a single resource (onboarding, welcome, set-up), mount it alongside `register_resource` — in a portal engine's routes or on the main app:
|
|
378
|
+
|
|
379
|
+
```ruby
|
|
380
|
+
# packages/admin_portal/config/routes.rb
|
|
381
|
+
AdminPortal::Engine.routes.draw do
|
|
382
|
+
register_wizard ::OnboardOrganizationWizard, at: "onboarding" # in-shell (portal default)
|
|
383
|
+
register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # bare (BasicLayout)
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
This draws the step routes within the host and gives you an `onboarding_wizard_path` helper. See [Registration & launch › `register_wizard`](/reference/wizard/registration-launch#route-mounted-register_wizard) for `at:`/`as:`/`public:`/`layout:`, the per-host layout defaults, and the controller override hook.
|
|
388
|
+
|
|
389
|
+
::: danger Portal-level wizards are open to any authenticated user by default
|
|
390
|
+
A `register_wizard` wizard has no resource policy and **defaults to allowed** — any authenticated portal user can run it. Always define `def authorize?` for anything privileged. (Resource-mounted wizards are gated by their action's policy predicate instead.)
|
|
391
|
+
:::
|
|
392
|
+
|
|
393
|
+
::: tip Authenticated main-app wizards: define your own controller
|
|
394
|
+
A portal mount inherits the portal's auth; a bare main-app mount has no `current_user`. An authenticated main-app wizard therefore needs you to define `::WizardsController` yourself (`include Plutonium::Wizard::Controller` + your auth concern) — the same "app owns the controller" contract as `register_resource`. See [Hosting & the controller override hook](/reference/wizard/registration-launch#hosting-the-controller-override-hook).
|
|
395
|
+
:::
|
|
396
|
+
|
|
397
|
+
### Guest (unauthenticated) wizards
|
|
398
|
+
|
|
399
|
+
Wizards require authentication by default — and every resume is **owner-scoped**, so a run id leaked in a URL can't be picked up by another user. Opt into pre-login access with the `anonymous` macro and mount it `public: true` (the default for `anonymous`). A guest run's identity is a server-minted id held in the **Rails session** (never a URL, no leak surface); it may authenticate only at its terminal `execute` (e.g. a signup that creates the account and logs in):
|
|
400
|
+
|
|
401
|
+
```ruby
|
|
402
|
+
class GuestSignupWizard < Plutonium::Wizard::Base
|
|
403
|
+
anonymous
|
|
404
|
+
|
|
405
|
+
step :account do
|
|
406
|
+
attribute :email, :string
|
|
407
|
+
input :email, as: :email
|
|
408
|
+
validates :email, presence: true
|
|
409
|
+
end
|
|
410
|
+
review label: "Review"
|
|
411
|
+
|
|
412
|
+
def execute
|
|
413
|
+
succeed(Account.create!(email: data.account.email)) # may also sign the user in here
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Full detail (owner-scoping, session-keying, the synthesized public controller): [Authentication](/reference/wizard/anchoring-resume#authentication) and [the public mount](/reference/wizard/registration-launch#public-mount-for-anonymous-wizards).
|
|
421
|
+
|
|
422
|
+
### Listing in-progress & resume-or-new
|
|
423
|
+
|
|
424
|
+
Build a "continue where you left off" dashboard with `Plutonium::Wizard.in_progress_for(view_context)` — it derives the owner, tenant scope, and portal from the view context and returns that user's in-progress runs for the current portal, each carrying `label` / `icon` / `current_step` / `updated_at` / `resume_url`:
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
Plutonium::Wizard.in_progress_for(view_context).each do |entry|
|
|
428
|
+
link_to entry.label, entry.resume_url if entry.resume_url # resume_url is nil when unresolvable here
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# narrow to one record's unfinished draft (query-time filters, index-covered):
|
|
432
|
+
Plutonium::Wizard.in_progress_for(view_context, wizard: ConfigureCompanyWizard, anchor: @company).first
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
A **tokened** wizard (no `concurrency_key`) doesn't silently fork on relaunch — by default it shows a resume-or-new chooser when a pending run exists (`on_relaunch :new` opts out). Keyed and guest wizards auto-resume their single run.
|
|
436
|
+
|
|
437
|
+

|
|
438
|
+
|
|
439
|
+
See [Anchoring & resume › Listing](/reference/wizard/anchoring-resume#listing-in-progress-wizards) for the full entry fields, portal-scoping rules, `resume_unresolved_reason`, and the filter performance notes.
|
|
440
|
+
|
|
441
|
+
## Where to go next
|
|
442
|
+
|
|
443
|
+
- [DSL reference](/reference/wizard/dsl) — every macro and accessor.
|
|
444
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys, resume.
|
|
445
|
+
- [Storage & config](/reference/wizard/storage-config) — the table, config, encryption, the sweep.
|
|
446
|
+
- [Registration & launch](/reference/wizard/registration-launch) — the `wizard` macro, `register_wizard`, routes.
|
|
447
|
+
- [One-time wizards](/reference/wizard/one-time) — completion markers + the gate.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -229,7 +229,7 @@ def permitted_associations
|
|
|
229
229
|
end
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
-
Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.
|
|
232
|
+
Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
|
|
233
233
|
|
|
234
234
|
Each named association must:
|
|
235
235
|
|
data/docs/reference/index.md
CHANGED
|
@@ -39,6 +39,14 @@ aside: false
|
|
|
39
39
|
{ name: 'Layouts', link: '/plutonium-core/reference/ui/layouts' },
|
|
40
40
|
{ name: 'Assets', link: '/plutonium-core/reference/ui/assets' },
|
|
41
41
|
]},
|
|
42
|
+
{ group: 'Wizard', items: [
|
|
43
|
+
{ name: 'Overview', link: '/plutonium-core/reference/wizard/' },
|
|
44
|
+
{ name: 'DSL', link: '/plutonium-core/reference/wizard/dsl' },
|
|
45
|
+
{ name: 'Anchoring & resume', link: '/plutonium-core/reference/wizard/anchoring-resume' },
|
|
46
|
+
{ name: 'Storage & config', link: '/plutonium-core/reference/wizard/storage-config' },
|
|
47
|
+
{ name: 'Registration & launch', link: '/plutonium-core/reference/wizard/registration-launch' },
|
|
48
|
+
{ name: 'One-time', link: '/plutonium-core/reference/wizard/one-time' },
|
|
49
|
+
]},
|
|
42
50
|
{ group: 'Auth', items: [
|
|
43
51
|
{ name: 'Overview', link: '/plutonium-core/reference/auth/' },
|
|
44
52
|
{ name: 'Accounts', link: '/plutonium-core/reference/auth/accounts' },
|
|
@@ -50,6 +58,12 @@ aside: false
|
|
|
50
58
|
{ name: 'Nested resources', link: '/plutonium-core/reference/tenancy/nested-resources' },
|
|
51
59
|
{ name: 'Invites', link: '/plutonium-core/reference/tenancy/invites' },
|
|
52
60
|
]},
|
|
61
|
+
{ group: 'Kanban', items: [
|
|
62
|
+
{ name: 'Overview', link: '/plutonium-core/reference/kanban/' },
|
|
63
|
+
{ name: 'DSL', link: '/plutonium-core/reference/kanban/dsl' },
|
|
64
|
+
{ name: 'Positioning', link: '/plutonium-core/reference/kanban/positioning' },
|
|
65
|
+
{ name: 'Authorization', link: '/plutonium-core/reference/kanban/authorization' },
|
|
66
|
+
]},
|
|
53
67
|
{ group: 'Testing', items: [
|
|
54
68
|
{ name: 'Overview', link: '/plutonium-core/reference/testing/' },
|
|
55
69
|
]},
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Kanban Authorization
|
|
2
|
+
|
|
3
|
+
## `kanban_move?` policy predicate
|
|
4
|
+
|
|
5
|
+
Every drag-and-drop move is authorized through the `kanban_move?` method on the resource's policy. The default implementation delegates to `update?`:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Plutonium::Resource::Policy — built-in default
|
|
9
|
+
def kanban_move?
|
|
10
|
+
update?
|
|
11
|
+
end
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Override it in your policy to give finer control:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class TaskPolicy < ResourcePolicy
|
|
18
|
+
# Allow all authenticated members to drag cards,
|
|
19
|
+
# but require :admin to open the edit form.
|
|
20
|
+
def kanban_move?
|
|
21
|
+
user.member?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update?
|
|
25
|
+
user.admin?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Read-only board
|
|
31
|
+
|
|
32
|
+
When `kanban_move?` returns `false` for the current user, the board is rendered read-only. Cards are displayed but dragging is disabled — no drag handles appear and the Stimulus controller does not register drop zones.
|
|
33
|
+
|
|
34
|
+
## Authorization flow on a move
|
|
35
|
+
|
|
36
|
+
When a card is dropped, the server:
|
|
37
|
+
|
|
38
|
+
1. Finds the record within the current authorized scope (the same policy `relation_scope` used by the index action).
|
|
39
|
+
2. Calls `authorize_current!(record, to: :kanban_move?)`. A `false` result halts the action with HTTP 403.
|
|
40
|
+
3. Validates the drop against the destination column's `accepts:` policy and `locked:` flag. A rejected drop responds with HTTP 422 and re-renders the source column (the Stimulus controller snaps the card back).
|
|
41
|
+
4. Enforces the destination column's `wip:` limit (cross-column moves only). Exceeding the WIP cap also responds 422.
|
|
42
|
+
5. Calls `on_drop` and repositions the record inside a transaction.
|
|
43
|
+
|
|
44
|
+
## No permitted attributes for moves
|
|
45
|
+
|
|
46
|
+
Kanban moves do **not** pass through `permitted_attributes_for_update` / `permitted_attributes_for_kanban_move`. The `on_drop` callback is author code that runs with full model access — it is the responsibility of the `on_drop` implementation to assign only the attributes appropriate for a column transition. This is intentional: the callback is trusted Ruby, not user-supplied form data.
|
|
47
|
+
|
|
48
|
+
## Column-level drop policies
|
|
49
|
+
|
|
50
|
+
The `accepts:`, `locked:`, and `wip:` column options enforce additional constraints beyond `kanban_move?`:
|
|
51
|
+
|
|
52
|
+
| Constraint | What it checks | On failure |
|
|
53
|
+
|------------|---------------|------------|
|
|
54
|
+
| `accepts:` | Source column key is allowed | 422 + card snap-back |
|
|
55
|
+
| `locked:` | Source column is not locked | 422 + card snap-back |
|
|
56
|
+
| `wip:` | Cross-column count within limit | 422 + card snap-back |
|
|
57
|
+
|
|
58
|
+
These checks run server-side after `kanban_move?` succeeds. The client-side Stimulus controller reads `data-kanban-accepts` and `data-kanban-locked` attributes to provide visual drop hints, but the server remains the authority.
|
|
59
|
+
|
|
60
|
+
## Quick-add authorization
|
|
61
|
+
|
|
62
|
+
The `+ Add` button (shown when `add: true` is set on a column) is only rendered when the current policy's `create?` returns `true`. The new form opened by quick-add is the standard resource new form and goes through the normal creation authorization flow.
|