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,7 @@
|
|
|
1
|
+
<%= render Plutonium::UI::Layout::BasicLayout.new do |layout| %>
|
|
2
|
+
<%# Shell-less wizards have no topbar, so lift the content off the viewport edge %>
|
|
3
|
+
<%# and cap its width — keeping it comfortably centered like an onboarding screen. %>
|
|
4
|
+
<div class="mx-auto w-full max-w-3xl pt-8 sm:pt-14">
|
|
5
|
+
<%= yield %>
|
|
6
|
+
</div>
|
|
7
|
+
<% end %>
|
|
@@ -1,48 +1,4 @@
|
|
|
1
1
|
<% flash.each do |type, msg| %>
|
|
2
|
-
<%
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
variant = {
|
|
6
|
-
success: "success",
|
|
7
|
-
warning: "warning",
|
|
8
|
-
alert: "danger", danger: "danger", error: "danger"
|
|
9
|
-
}.fetch(type.to_sym, "info")
|
|
10
|
-
%>
|
|
11
|
-
|
|
12
|
-
<div data-controller="resource-dismiss"
|
|
13
|
-
data-resource-dismiss-after-value="6000"
|
|
14
|
-
class="pu-toast pu-toast-<%= variant %>"
|
|
15
|
-
role="alert">
|
|
16
|
-
|
|
17
|
-
<div class="pu-toast-icon">
|
|
18
|
-
<% case type.to_sym %>
|
|
19
|
-
<% when :success %>
|
|
20
|
-
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
21
|
-
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"/>
|
|
22
|
-
</svg>
|
|
23
|
-
<% when :warning %>
|
|
24
|
-
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
25
|
-
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"/>
|
|
26
|
-
</svg>
|
|
27
|
-
<% when :alert, :danger, :error %>
|
|
28
|
-
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
29
|
-
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z"/>
|
|
30
|
-
</svg>
|
|
31
|
-
<% else %>
|
|
32
|
-
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 20">
|
|
33
|
-
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.147 15.085a7.159 7.159 0 0 1-6.189 3.307A6.713 6.713 0 0 1 3.1 15.444c-2.679-4.513.287-8.737.888-9.548A4.373 4.373 0 0 0 5 1.608c1.287.953 6.445 3.218 5.537 10.5 1.5-1.122 2.706-3.01 2.853-6.14 1.433 1.049 3.993 5.395 1.757 9.117Z"/>
|
|
34
|
-
</svg>
|
|
35
|
-
<% end %>
|
|
36
|
-
</div>
|
|
37
|
-
<div class="pu-toast-message"><%= msg %></div>
|
|
38
|
-
<button type="button"
|
|
39
|
-
class="pu-toast-close"
|
|
40
|
-
data-action="click->resource-dismiss#dismiss"
|
|
41
|
-
aria-label="Close">
|
|
42
|
-
<span class="sr-only">Close</span>
|
|
43
|
-
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
|
44
|
-
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
|
45
|
-
</svg>
|
|
46
|
-
</button>
|
|
47
|
-
</div>
|
|
2
|
+
<% next unless msg.present? %>
|
|
3
|
+
<%= render "plutonium/toast", type: type, msg: msg %>
|
|
48
4
|
<% end %>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Renders a single dismissable toast. Locals:
|
|
3
|
+
type — flash-style key (:success, :warning, :alert/:danger/:error, …)
|
|
4
|
+
msg — the message string
|
|
5
|
+
Shared by _flash_toasts (one per flash entry) and any turbo_stream that
|
|
6
|
+
wants to append a single toast without re-rendering the whole flash set
|
|
7
|
+
(e.g. the kanban move-rejection feedback).
|
|
8
|
+
%>
|
|
9
|
+
<%
|
|
10
|
+
variant = {
|
|
11
|
+
success: "success",
|
|
12
|
+
warning: "warning",
|
|
13
|
+
alert: "danger", danger: "danger", error: "danger"
|
|
14
|
+
}.fetch(type.to_sym, "info")
|
|
15
|
+
%>
|
|
16
|
+
|
|
17
|
+
<div data-controller="resource-dismiss"
|
|
18
|
+
data-resource-dismiss-after-value="6000"
|
|
19
|
+
class="pu-toast pu-toast-<%= variant %>"
|
|
20
|
+
role="alert">
|
|
21
|
+
|
|
22
|
+
<div class="pu-toast-icon">
|
|
23
|
+
<% case type.to_sym %>
|
|
24
|
+
<% when :success %>
|
|
25
|
+
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
26
|
+
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z"/>
|
|
27
|
+
</svg>
|
|
28
|
+
<% when :warning %>
|
|
29
|
+
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
30
|
+
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM10 15a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-4a1 1 0 0 1-2 0V6a1 1 0 0 1 2 0v5Z"/>
|
|
31
|
+
</svg>
|
|
32
|
+
<% when :alert, :danger, :error %>
|
|
33
|
+
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
34
|
+
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 11.793a1 1 0 1 1-1.414 1.414L10 11.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L8.586 10 6.293 7.707a1 1 0 0 1 1.414-1.414L10 8.586l2.293-2.293a1 1 0 0 1 1.414 1.414L11.414 10l2.293 2.293Z"/>
|
|
35
|
+
</svg>
|
|
36
|
+
<% else %>
|
|
37
|
+
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 20">
|
|
38
|
+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.147 15.085a7.159 7.159 0 0 1-6.189 3.307A6.713 6.713 0 0 1 3.1 15.444c-2.679-4.513.287-8.737.888-9.548A4.373 4.373 0 0 0 5 1.608c1.287.953 6.445 3.218 5.537 10.5 1.5-1.122 2.706-3.01 2.853-6.14 1.433 1.049 3.993 5.395 1.757 9.117Z"/>
|
|
39
|
+
</svg>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
<div class="pu-toast-message"><%= msg %></div>
|
|
43
|
+
<button type="button"
|
|
44
|
+
class="pu-toast-close"
|
|
45
|
+
data-action="click->resource-dismiss#dismiss"
|
|
46
|
+
aria-label="Close">
|
|
47
|
+
<span class="sr-only">Close</span>
|
|
48
|
+
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
|
49
|
+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
|
50
|
+
</svg>
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render build_kanban_board_shell %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePlutoniumWizardSessions < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :plutonium_wizard_sessions do |t|
|
|
6
|
+
t.string :wizard, null: false
|
|
7
|
+
t.string :status, null: false, default: "in_progress" # in_progress | completing | completed
|
|
8
|
+
t.string :current_step
|
|
9
|
+
|
|
10
|
+
# Identity — a deterministic digest of (wizard, scope, anchor, principal).
|
|
11
|
+
# A single unique column is required because nullable polymorphic columns
|
|
12
|
+
# can't enforce the singleton rule (NULL != NULL in unique indexes).
|
|
13
|
+
t.string :instance_key, null: false
|
|
14
|
+
|
|
15
|
+
# Polymorphic refs — for querying/listing, NOT identity. *_id is string-typed
|
|
16
|
+
# to accommodate bigint or uuid host primary keys.
|
|
17
|
+
t.string :scope_type
|
|
18
|
+
t.string :scope_id
|
|
19
|
+
t.string :owner_type
|
|
20
|
+
t.string :owner_id
|
|
21
|
+
t.string :anchor_type
|
|
22
|
+
t.string :anchor_id
|
|
23
|
+
t.string :token
|
|
24
|
+
|
|
25
|
+
# The portal (engine) this run was launched in, e.g. "OrgPortal::Engine" (the
|
|
26
|
+
# main app's class name for a public mount). Records the run's portal context
|
|
27
|
+
# so the "continue where you left off" listing only ever shows — and links —
|
|
28
|
+
# runs that belong to the portal being viewed (two portals can share an entity
|
|
29
|
+
# scope, so scope alone can't identify the portal).
|
|
30
|
+
t.string :engine
|
|
31
|
+
|
|
32
|
+
# Optimistic-merge version. The runner reads a row at one version and writes
|
|
33
|
+
# it back a request later; a concurrent advance bumps the version in between.
|
|
34
|
+
# The store compares the version the state was read at against the row's
|
|
35
|
+
# current version under a row lock and MERGES (rather than clobbers) when they
|
|
36
|
+
# differ, so two concurrent writers on the same run never lose each other's
|
|
37
|
+
# staged data.
|
|
38
|
+
t.integer :lock_version, null: false, default: 0
|
|
39
|
+
|
|
40
|
+
t.public_send(:jsonb, :data, null: false, default: {})
|
|
41
|
+
t.public_send(:jsonb, :tracked_records, null: false, default: {})
|
|
42
|
+
# Steps the user has actually visited+validated (§6.3 completeness). A
|
|
43
|
+
# zero-validation step is only "complete" once it's been visited.
|
|
44
|
+
t.public_send(:jsonb, :visited, null: false, default: [])
|
|
45
|
+
|
|
46
|
+
t.datetime :expires_at
|
|
47
|
+
t.datetime :completed_at
|
|
48
|
+
t.timestamps
|
|
49
|
+
|
|
50
|
+
t.index :instance_key, unique: true
|
|
51
|
+
t.index [:status, :expires_at]
|
|
52
|
+
t.index [:owner_type, :owner_id, :engine, :status]
|
|
53
|
+
t.index [:scope_type, :scope_id, :status]
|
|
54
|
+
t.index [:wizard, :anchor_type, :anchor_id, :status]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/docs/.vitepress/config.ts
CHANGED
|
@@ -88,6 +88,8 @@ export default defineConfig(withMermaid({
|
|
|
88
88
|
{ text: "Multi-tenancy", link: "/guides/multi-tenancy" },
|
|
89
89
|
{ text: "Search & Filtering", link: "/guides/search-filtering" },
|
|
90
90
|
{ text: "User Invites", link: "/guides/user-invites" },
|
|
91
|
+
{ text: "Wizards", link: "/guides/wizards" },
|
|
92
|
+
{ text: "Kanban Boards", link: "/guides/kanban" },
|
|
91
93
|
]
|
|
92
94
|
},
|
|
93
95
|
{
|
|
@@ -177,6 +179,28 @@ export default defineConfig(withMermaid({
|
|
|
177
179
|
{ text: "Invites", link: "/reference/tenancy/invites" },
|
|
178
180
|
]
|
|
179
181
|
},
|
|
182
|
+
{
|
|
183
|
+
text: "Wizard",
|
|
184
|
+
collapsed: false,
|
|
185
|
+
items: [
|
|
186
|
+
{ text: "Overview", link: "/reference/wizard/" },
|
|
187
|
+
{ text: "DSL", link: "/reference/wizard/dsl" },
|
|
188
|
+
{ text: "Anchoring & resume", link: "/reference/wizard/anchoring-resume" },
|
|
189
|
+
{ text: "Storage & config", link: "/reference/wizard/storage-config" },
|
|
190
|
+
{ text: "Registration & launch", link: "/reference/wizard/registration-launch" },
|
|
191
|
+
{ text: "One-time", link: "/reference/wizard/one-time" },
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
text: "Kanban",
|
|
196
|
+
collapsed: false,
|
|
197
|
+
items: [
|
|
198
|
+
{ text: "Overview", link: "/reference/kanban/" },
|
|
199
|
+
{ text: "DSL", link: "/reference/kanban/dsl" },
|
|
200
|
+
{ text: "Positioning", link: "/reference/kanban/positioning" },
|
|
201
|
+
{ text: "Authorization", link: "/reference/kanban/authorization" },
|
|
202
|
+
]
|
|
203
|
+
},
|
|
180
204
|
{
|
|
181
205
|
text: "Testing",
|
|
182
206
|
collapsed: false,
|
data/docs/guides/index.md
CHANGED
|
@@ -25,6 +25,8 @@ aside: false
|
|
|
25
25
|
{ name: 'Nested resources', link: '/plutonium-core/guides/nested-resources' },
|
|
26
26
|
{ name: 'Multi-tenancy', link: '/plutonium-core/guides/multi-tenancy' },
|
|
27
27
|
{ name: 'Search & filtering', link: '/plutonium-core/guides/search-filtering' },
|
|
28
|
+
{ name: 'Wizards', desc: 'Multi-step flows — onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
|
|
29
|
+
{ name: 'Kanban boards', desc: 'Drag-and-drop board view — columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
|
|
28
30
|
]},
|
|
29
31
|
{ group: 'Customization', items: [
|
|
30
32
|
{ name: 'Customizing the UI', desc: 'A map of the override surface — pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Kanban Boards
|
|
2
|
+
|
|
3
|
+
::: warning Experimental
|
|
4
|
+
Kanban boards are experimental — the DSL and behavior may change in a future release.
|
|
5
|
+
:::
|
|
6
|
+
|
|
7
|
+
Turn any resource index into a drag-and-drop kanban board — columns, WIP limits, quick-add, column actions, and opt-in realtime — all from a single `kanban do…end` block in your definition.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## What you get
|
|
12
|
+
|
|
13
|
+
- Drag cards between columns; the server persists the column change and the position within the column.
|
|
14
|
+
- Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
|
|
15
|
+
- Per-column `+ Add` button opens the resource's normal new form, pre-seeded for that column.
|
|
16
|
+
- Column actions run an interaction against all (or visible) cards in a column.
|
|
17
|
+
- WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
|
|
18
|
+
- Opt-in realtime: every connected viewer sees the same board state after any move.
|
|
19
|
+
|
|
20
|
+
## Worked example — Task board
|
|
21
|
+
|
|
22
|
+
A complete board for a `Task` model grouped by status — migration, model, definition, and policy.
|
|
23
|
+
|
|
24
|
+
### 1. Migration
|
|
25
|
+
|
|
26
|
+
The model needs a `decimal` position column. Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
class CreateTasks < ActiveRecord::Migration[8.1]
|
|
30
|
+
def change
|
|
31
|
+
create_table :tasks do |t|
|
|
32
|
+
t.string :title, null: false
|
|
33
|
+
t.string :status, null: false, default: "todo"
|
|
34
|
+
t.position # decimal :position, precision: 16, scale: 8
|
|
35
|
+
t.timestamps
|
|
36
|
+
|
|
37
|
+
t.index [:status, :position]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 2. Model
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class Task < ApplicationRecord
|
|
47
|
+
include Plutonium::Positioning
|
|
48
|
+
|
|
49
|
+
positioned_on :position, scope: :status
|
|
50
|
+
# ^^ auto-assigns position on create; reposition! scopes to the same status
|
|
51
|
+
|
|
52
|
+
validates :status, inclusion: { in: %w[todo doing done] }
|
|
53
|
+
|
|
54
|
+
def mark_done!
|
|
55
|
+
update!(status: "done")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3. Definition
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
class TaskDefinition < ResourceDefinition
|
|
64
|
+
kanban do
|
|
65
|
+
per_column 25
|
|
66
|
+
|
|
67
|
+
column :todo,
|
|
68
|
+
scope: -> { where(status: "todo") },
|
|
69
|
+
on_drop: ->(r) { r.update!(status: "todo") },
|
|
70
|
+
role: :backlog # shorthand for add: true
|
|
71
|
+
|
|
72
|
+
column :doing,
|
|
73
|
+
scope: -> { where(status: "doing") },
|
|
74
|
+
on_drop: ->(r) { r.update!(status: "doing") },
|
|
75
|
+
wip: 3
|
|
76
|
+
|
|
77
|
+
column :done,
|
|
78
|
+
scope: -> { where(status: "done") },
|
|
79
|
+
on_drop: :mark_done!, # Symbol → record.mark_done!
|
|
80
|
+
accepts: [:doing], # only cards from :doing can land here
|
|
81
|
+
role: :done do # shorthand for color: :green, collapsed: true
|
|
82
|
+
action :archive_all,
|
|
83
|
+
interaction: ArchiveTasksInteraction,
|
|
84
|
+
on: :all,
|
|
85
|
+
label: "Archive all"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Policy
|
|
92
|
+
|
|
93
|
+
A move is authorized via `kanban_move?`, which defaults to `update?`. Override it only when you want board-drag access to differ from full-edit access:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class TaskPolicy < ResourcePolicy
|
|
97
|
+
# Allow all authenticated members to move cards,
|
|
98
|
+
# but require :admin to edit the form directly.
|
|
99
|
+
def kanban_move?
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 5. Routes — no changes needed
|
|
106
|
+
|
|
107
|
+
The `kanban_move` member route is wired automatically when the controller includes `Plutonium::Resource::Controllers::KanbanActions` (included by default in all Plutonium resource controllers).
|
|
108
|
+
|
|
109
|
+
Visit the resource index and use the view switcher to select the Kanban view.
|
|
110
|
+
|
|
111
|
+

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

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

|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
class TaskDefinition < ResourceDefinition
|
|
129
|
+
show_in :modal # open show in a modal everywhere (table, grid, board)
|
|
130
|
+
|
|
131
|
+
kanban do
|
|
132
|
+
# show_in :page # …or override just this board back to full-page
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Set `show_in :modal` on the **definition** to open show in a modal from the table, grid, and board alike. Set it on the **kanban block** to change only the board. An unset board inherits the definition (which defaults to `:page`).
|
|
138
|
+
- The show modal is always **centered** — distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
|
|
139
|
+
- From inside the modal, an expand icon opens the record's full page in a new tab. ⌘/Ctrl-click (or middle-click) on a card does the same directly.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Worked example — Status enum board
|
|
144
|
+
|
|
145
|
+
A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout — no explicit `card_fields` needed.
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
class KitchenSinkDefinition < ResourceDefinition
|
|
149
|
+
kanban do
|
|
150
|
+
column :active, label: "Active", role: :backlog,
|
|
151
|
+
scope: -> { where(status: :active) },
|
|
152
|
+
on_drop: ->(ks) { ks.status = :active }
|
|
153
|
+
|
|
154
|
+
column :pending, label: "Pending", color: :yellow, wip: 5,
|
|
155
|
+
scope: -> { where(status: :pending) },
|
|
156
|
+
on_drop: ->(ks) { ks.status = :pending }
|
|
157
|
+
|
|
158
|
+
column :archived, label: "Archived", role: :done,
|
|
159
|
+
scope: -> { where(status: :archived) },
|
|
160
|
+
on_drop: ->(ks) { ks.status = :archived }
|
|
161
|
+
|
|
162
|
+
per_column 10
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Key points:
|
|
168
|
+
- `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
|
|
169
|
+
- `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
|
|
170
|
+
- `role: :done` collapses the Archived column by default and shows a green header dot.
|
|
171
|
+
- `on_drop` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_drop` returns — you do not need to call `update!` explicitly.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Columns
|
|
176
|
+
|
|
177
|
+
### Static columns
|
|
178
|
+
|
|
179
|
+
Declared at definition class-load time with `column :key, **opts`:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
kanban do
|
|
183
|
+
column :backlog,
|
|
184
|
+
label: "Product Backlog", # default: key.to_s.titleize
|
|
185
|
+
color: :blue, # dot color in the column header
|
|
186
|
+
scope: -> { where(stage: 0) }, # 0-arg lambda evaluated on the relation
|
|
187
|
+
on_drop: ->(r) { r.update!(stage: 0) }
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Dynamic columns
|
|
192
|
+
|
|
193
|
+
Use `columns do…end` when the column list depends on request context (`current_user`, `params`, etc.):
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
kanban do
|
|
197
|
+
columns do
|
|
198
|
+
# `self` is the view_context — current_user, params, helpers all work.
|
|
199
|
+
current_user.projects.map do |project|
|
|
200
|
+
Plutonium::Kanban::Column.new(
|
|
201
|
+
:"project_#{project.id}",
|
|
202
|
+
label: project.name,
|
|
203
|
+
scope: -> { where(project_id: project.id) },
|
|
204
|
+
on_drop: ->(r) { r.update!(project_id: project.id) }
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
::: warning Dynamic boards and column actions
|
|
212
|
+
Column actions declared inside a `columns do…end` block **cannot be auto-registered** at class-load time (the block is only evaluated at request time). Declare those interaction classes as top-level definition actions separately:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
class TaskDefinition < ResourceDefinition
|
|
216
|
+
# Must be a top-level action so the route exists at startup.
|
|
217
|
+
action :archive_project_tasks, interaction: ArchiveProjectTasksInteraction
|
|
218
|
+
|
|
219
|
+
kanban do
|
|
220
|
+
columns do
|
|
221
|
+
current_user.projects.map do |project|
|
|
222
|
+
col = Plutonium::Kanban::Column.new(:"project_#{project.id}", ...)
|
|
223
|
+
col.action :archive_project_tasks, interaction: ArchiveProjectTasksInteraction
|
|
224
|
+
col
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
:::
|
|
231
|
+
|
|
232
|
+
### Column options
|
|
233
|
+
|
|
234
|
+
| Option | Type | Default | Description |
|
|
235
|
+
|--------|------|---------|-------------|
|
|
236
|
+
| `label:` | String | `key.to_s.titleize` | Column header text |
|
|
237
|
+
| `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
|
|
238
|
+
| `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
|
|
239
|
+
| `on_drop:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
|
|
240
|
+
| `role:` | `:backlog`, `:done` | `nil` | Preset shorthand (see below) |
|
|
241
|
+
| `collapsed:` | Boolean | `false` | Start collapsed |
|
|
242
|
+
| `add:` | Boolean | `false` | Show `+ Add` quick-add button |
|
|
243
|
+
| `accepts:` | `true`, `false`, Array of keys, or Proc | `true` | Which drops are accepted. `true` = all, `false` = none, `[:doing]` = only from `:doing`. A 1-arg Proc `->(record) { … }` is evaluated **per-card on the server** at drop time and returns a boolean (e.g. `->(task) { task.status == "doing" }`) |
|
|
244
|
+
| `locked:` | Boolean | `false` | Prevent dragging cards **out of** this column |
|
|
245
|
+
| `wip:` | Integer | `nil` | Work-in-progress limit. Cross-column drops that would exceed this count are rejected |
|
|
246
|
+
|
|
247
|
+
### Role presets
|
|
248
|
+
|
|
249
|
+
| Role | Equivalent to |
|
|
250
|
+
|------|---------------|
|
|
251
|
+
| `:backlog` | `add: true` |
|
|
252
|
+
| `:done` | `color: :green, collapsed: true` |
|
|
253
|
+
|
|
254
|
+
Explicitly provided options override the preset.
|
|
255
|
+
|
|
256
|
+
**Collapse toggle:** Click the arrow button in any column header to collapse or expand it. Collapsed columns render as a thin vertical strip with the label rotated. The Stimulus controller persists each column's collapsed/expanded state to `localStorage` (key: `pu-kanban:<collection-path>:<column-key>:collapsed`) so the preference survives page reloads. The `collapsed:` DSL option sets the server-rendered initial state; `localStorage` takes precedence on subsequent loads.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Column actions
|
|
261
|
+
|
|
262
|
+
Declare actions inside a column block to run an interaction against that column's cards:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
column :done,
|
|
266
|
+
scope: -> { where(status: "done") },
|
|
267
|
+
on_drop: :mark_done! do
|
|
268
|
+
|
|
269
|
+
action :archive_all,
|
|
270
|
+
interaction: ArchiveTasksInteraction, # must be a bulk interaction (has `attribute :resources`)
|
|
271
|
+
on: :all, # :all (default) or :visible
|
|
272
|
+
label: "Archive all",
|
|
273
|
+
icon: Phlex::TablerIcons::Archive,
|
|
274
|
+
confirmation: "Archive all done tasks?"
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
- `on: :all` — passes IDs of **all** cards in the column (ignoring `per_column`).
|
|
279
|
+
- `on: :visible` — passes IDs of only the rendered, `per_column`-capped cards.
|
|
280
|
+
|
|
281
|
+
Column actions are rendered as buttons in the column header. They open the normal interactive-action modal (with form, authorization, success/failure handling) pre-loaded with the column's card IDs.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Positioning
|
|
286
|
+
|
|
287
|
+
By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
|
|
288
|
+
|
|
289
|
+
1. A `decimal` database column (precision ≥ 10, scale ≥ 6 recommended).
|
|
290
|
+
2. `include Plutonium::Positioning` in the model.
|
|
291
|
+
3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
|
|
292
|
+
|
|
293
|
+
### Position modes
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
kanban do
|
|
297
|
+
# Mode A (default) — delegate to Plutonium::Positioning.
|
|
298
|
+
# Uses :position attribute, requires the model concern.
|
|
299
|
+
position_on :position
|
|
300
|
+
|
|
301
|
+
# Mode A with a custom attribute name:
|
|
302
|
+
position_on :sort_order
|
|
303
|
+
|
|
304
|
+
# Mode B — BYO positioning. The block receives a Move struct.
|
|
305
|
+
# Use when you want to call a custom service or use a different ordering scheme.
|
|
306
|
+
position_on :sort_order do |move|
|
|
307
|
+
# move.record — the dropped record
|
|
308
|
+
# move.column — the destination column key (Symbol)
|
|
309
|
+
# move.prev — the record immediately before the drop slot (or nil)
|
|
310
|
+
# move.next — the record immediately after the drop slot (or nil)
|
|
311
|
+
# move.index — 0-based insertion index within the destination column
|
|
312
|
+
MyPositioningService.call(move.record, prev: move.prev, next: move.next)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Mode C — no ordering. Cards render in the relation's default order.
|
|
316
|
+
# On-drop still fires; position is just never updated.
|
|
317
|
+
position_on false
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Per-column card limit
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
kanban do
|
|
329
|
+
per_column 25
|
|
330
|
+
# ...
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Each column loads at most 25 cards. When the total exceeds the limit, a `+N more` footer appears. Column actions with `on: :visible` respect the cap; `on: :all` ignores it.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Quick-add
|
|
339
|
+
|
|
340
|
+
When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal, pre-filled with the values that `on_drop` would set.
|
|
341
|
+
|
|
342
|
+
Authorization: the button is only rendered when `create?` returns `true` in the current policy.
|
|
343
|
+
|
|
344
|
+
The pre-seeding works by doing a dry-run of `on_drop` against a sentinel record — it intercepts `save!`/`update!` to capture the attribute changes without writing to the database. Exotic `on_drop` callbacks with external side effects (API calls, background jobs) will fire on every `+ Add` click; keep `on_drop` to attribute assignment for clean quick-add behavior.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Authorization
|
|
349
|
+
|
|
350
|
+
Every drag-and-drop move is authorized by the `kanban_move?` policy predicate. By default it delegates to `update?`. Override it in your policy to decouple board move rights from full edit-form access:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
class TaskPolicy < ResourcePolicy
|
|
354
|
+
# Board drags require only :member role; full edit requires :admin.
|
|
355
|
+
def kanban_move?
|
|
356
|
+
user.member?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def update?
|
|
360
|
+
user.admin?
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
When `kanban_move?` returns `false`, the board is rendered read-only (dragging is disabled). See [Authorization reference](/reference/kanban/authorization) for details.
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Realtime updates
|
|
370
|
+
|
|
371
|
+
Enable opt-in realtime broadcasting so every viewer of the same board sees moves immediately:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
kanban do
|
|
375
|
+
realtime true
|
|
376
|
+
# ...
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
After a successful move, Plutonium broadcasts the updated column frames to all connected viewers on the same stream. Stream names are tenant-scoped: viewers of different tenant entities can never cross-contaminate each other's streams. See [Reference › Kanban › DSL](/reference/kanban/dsl#realtime) for the stream name format.
|
|
381
|
+
|
|
382
|
+
### Setup (required for realtime to actually update other viewers)
|
|
383
|
+
|
|
384
|
+
Plutonium emits the `<turbo-cable-stream-source>` subscription element and broadcasts on the server, but the **client must have an ActionCable consumer** to receive it. Plutonium's bundled JavaScript ships `@hotwired/turbo` only (no cable client), so you must wire the rest up yourself:
|
|
385
|
+
|
|
386
|
+
1. **Gems** — `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
|
|
387
|
+
2. **Cable adapter** (`config/cable.yml`) — `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
|
|
388
|
+
3. **Mount ActionCable** — `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
|
|
389
|
+
4. **Load the cable client in your app's JavaScript** — this is the step most people miss. Add **one** of:
|
|
390
|
+
```js
|
|
391
|
+
// app pack, alongside your other imports
|
|
392
|
+
import "@hotwired/turbo-rails" // registers <turbo-cable-stream-source> + a consumer
|
|
393
|
+
```
|
|
394
|
+
…or, if you only want ActionCable:
|
|
395
|
+
```js
|
|
396
|
+
import * as ActionCable from "@rails/actioncable"
|
|
397
|
+
window.ActionCable ||= ActionCable
|
|
398
|
+
```
|
|
399
|
+
Without this, the server broadcasts but no browser is subscribed, so other viewers won't update until they reload.
|
|
400
|
+
|
|
401
|
+
::: tip Verify it
|
|
402
|
+
With two browser tabs on the same board, move a card in one — the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
|
|
403
|
+
:::
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Lazy loading
|
|
408
|
+
|
|
409
|
+
By default (`lazy true`), each column is a Turbo Frame that loads its card list on demand when it enters the viewport. Set `lazy false` to load all columns eagerly on the initial page request:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
kanban do
|
|
413
|
+
lazy false
|
|
414
|
+
# ...
|
|
415
|
+
end
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Switching views
|
|
421
|
+
|
|
422
|
+
The index page renders a view-switcher toggle when more than one index view is available (`:table`, `:grid`, `:kanban`). Declare the default:
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
class TaskDefinition < ResourceDefinition
|
|
426
|
+
kanban do
|
|
427
|
+
# ...
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Call AFTER the kanban block — :kanban isn't a valid default until
|
|
431
|
+
# `kanban` has enabled the view. Reversing the order raises ArgumentError
|
|
432
|
+
# at class load.
|
|
433
|
+
default_index_view :kanban
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
To make the kanban board the **only** view (hide the switcher), call `index_views :kanban` after the block:
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
class TaskDefinition < ResourceDefinition
|
|
441
|
+
kanban do
|
|
442
|
+
# ...
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
index_views :kanban # drop :table/:grid; kanban is the sole view
|
|
446
|
+
end
|
|
447
|
+
```
|