plutonium 0.60.5 → 0.62.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 +378 -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 +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- 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/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -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 +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -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/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -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/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -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/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -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 +70 -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 +122 -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/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- 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 +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- 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/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- 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/table/resource.rb +1 -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 +92 -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 +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -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 +945 -286
- metadata +113 -2
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Resource
|
|
5
|
+
module Controllers
|
|
6
|
+
# Provides kanban-board endpoints for resources that declare a kanban block.
|
|
7
|
+
#
|
|
8
|
+
# ## Lazy column frame endpoint (Task 6)
|
|
9
|
+
#
|
|
10
|
+
# When a request hits the index action with view=kanban AND column=<key>,
|
|
11
|
+
# this concern intercepts via a before_action, renders ONLY the column's
|
|
12
|
+
# frame body (Plutonium::UI::Kanban::Column), and halts the normal
|
|
13
|
+
# index render. Unknown/absent column keys produce an empty frame body.
|
|
14
|
+
#
|
|
15
|
+
# ## Kanban move action (Task 7)
|
|
16
|
+
#
|
|
17
|
+
# POST <member>/kanban_move with params {from_column:, to_column:, to_index:}
|
|
18
|
+
# moves the member record to a new column and/or position. The action:
|
|
19
|
+
#
|
|
20
|
+
# 1. Authorizes via kanban_move? policy predicate.
|
|
21
|
+
# 2. Validates the drop (accepts? + locked?).
|
|
22
|
+
# 3. Enforces the destination WIP limit (cross-column drops only).
|
|
23
|
+
# 4. Applies the column's on_enter callback (Symbol or 1-arg Proc).
|
|
24
|
+
# 5. Repositions within the destination column via position_config.
|
|
25
|
+
# 6. Responds with Turbo Stream updates for the from + to column frames.
|
|
26
|
+
# On rejection responds 422 and re-renders the unchanged source frame
|
|
27
|
+
# so the Stimulus controller can snap the card back.
|
|
28
|
+
#
|
|
29
|
+
# Seam for Task 10 (full board shell):
|
|
30
|
+
# maybe_render_kanban_column only fires when params[:column] is present.
|
|
31
|
+
# Task 10 should handle the view=kanban case WITHOUT params[:column].
|
|
32
|
+
module KanbanActions
|
|
33
|
+
extend ActiveSupport::Concern
|
|
34
|
+
|
|
35
|
+
# Tags board-bound redirects with kanban_reload=1 so the permanent board
|
|
36
|
+
# refreshes its cached column frames on arrival (see #kanban_reload_url).
|
|
37
|
+
# Wraps ALL three redirect helpers — create/update (after_submit), destroy
|
|
38
|
+
# (after_destroy), and interactive record/resource/bulk actions
|
|
39
|
+
# (after_action_on). PREPENDED, not a plain override: after_action_on
|
|
40
|
+
# lives in InteractiveActions, which is included AFTER this concern, so a
|
|
41
|
+
# normal override here would be shadowed. Prepending wins regardless of
|
|
42
|
+
# include order, and `super` still reaches the real implementation.
|
|
43
|
+
module ReloadRedirects
|
|
44
|
+
def redirect_url_after_submit
|
|
45
|
+
kanban_reload_url(super)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def redirect_url_after_destroy
|
|
49
|
+
kanban_reload_url(super)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def redirect_url_after_action_on(*)
|
|
53
|
+
kanban_reload_url(super)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
included do
|
|
58
|
+
prepend ReloadRedirects
|
|
59
|
+
|
|
60
|
+
# Intercept index when view=kanban + column=<key> is present.
|
|
61
|
+
# Runs BEFORE setup_index_action! so no wasteful pagination query.
|
|
62
|
+
before_action :maybe_render_kanban_column, only: :index
|
|
63
|
+
|
|
64
|
+
# Exposed to views/partials so _resource_kanban.html.erb can call it.
|
|
65
|
+
helper_method :build_kanban_board_shell
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# POST <member>/kanban_move
|
|
69
|
+
#
|
|
70
|
+
# Params:
|
|
71
|
+
# from_column [String] source column key
|
|
72
|
+
# to_column [String] destination column key
|
|
73
|
+
# to_index [Integer] 0-based insertion index within destination
|
|
74
|
+
#
|
|
75
|
+
# Responds with Turbo Streams updating the from + to column frames on
|
|
76
|
+
# success, or 422 re-rendering the unchanged source frame on rejection.
|
|
77
|
+
def kanban_move
|
|
78
|
+
# Find record within authorized scope (satisfies scope verifier).
|
|
79
|
+
record = kanban_base_relation.find(params[:id])
|
|
80
|
+
|
|
81
|
+
unless current_definition.defined_kanban_block
|
|
82
|
+
# Not a kanban resource — a 404, not an authorized action; satisfy the
|
|
83
|
+
# authorize verifier explicitly since we skip the authorize below.
|
|
84
|
+
skip_verify_authorize_current!
|
|
85
|
+
head :not_found
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
board = current_kanban_board
|
|
90
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
91
|
+
from = columns.find { |c| c.key.to_s == params[:from_column].to_s }
|
|
92
|
+
to = columns.find { |c| c.key.to_s == params[:to_column].to_s }
|
|
93
|
+
|
|
94
|
+
# Single authorization point for the whole move (satisfies the authorize
|
|
95
|
+
# verifier). kanban_move? defaults to update?; the from/to columns are
|
|
96
|
+
# supplied via the authorization context so a policy can gate specific
|
|
97
|
+
# transitions (e.g. "only admins may enter :closed_won") without a
|
|
98
|
+
# per-column method. Hoisted above the accepts/WIP checks so a denied
|
|
99
|
+
# move is a clean 403 before any structural rejection. The enter
|
|
100
|
+
# interaction (if any) rides on THIS check — it has no policy of its own.
|
|
101
|
+
authorize_current! record, to: :kanban_move?,
|
|
102
|
+
context: {kanban_from: from, kanban_to: to}
|
|
103
|
+
|
|
104
|
+
# params[:from_column] is client-supplied and is passed to the policy as
|
|
105
|
+
# kanban_from. Verify the record ACTUALLY resides in the claimed source
|
|
106
|
+
# column before the move proceeds. This (a) makes kanban_from safe to
|
|
107
|
+
# authorize on — a spoofed from can't drive a move because the membership
|
|
108
|
+
# check rejects it — and (b) snaps back a stale-board drag whose card was
|
|
109
|
+
# moved out of `from` by someone else. Skipped when from is nil (the
|
|
110
|
+
# accepts check below handles an unknown source column).
|
|
111
|
+
if from && !record_in_kanban_column?(record, from)
|
|
112
|
+
return render_kanban_rejection(
|
|
113
|
+
params[:from_column],
|
|
114
|
+
reason: "This card is no longer in “#{from.label}”."
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# accepts?/locked? are purely structural (source-column topology), the
|
|
119
|
+
# server-side authority behind the client-side data-kanban-accepts hint.
|
|
120
|
+
unless from && to&.accepts?(from.key) && !from.locked?
|
|
121
|
+
reason =
|
|
122
|
+
if from&.locked?
|
|
123
|
+
"Cards can't be moved out of “#{from.label}”."
|
|
124
|
+
elsif to
|
|
125
|
+
"Cards can't be moved into “#{to.label}”."
|
|
126
|
+
else
|
|
127
|
+
"This card can't be moved there."
|
|
128
|
+
end
|
|
129
|
+
return render_kanban_rejection(params[:from_column], reason:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Build the destination card list excluding the moved record so the
|
|
133
|
+
# neighbor computation and WIP count are correct in all cases
|
|
134
|
+
# (cross-column, same-column reorder, record already in destination).
|
|
135
|
+
dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
|
|
136
|
+
dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
|
|
137
|
+
# to_index is client-supplied. Clamp to [0, dest_cards.size] so a negative
|
|
138
|
+
# value can't wrap via Ruby's negative array indexing (dest_cards[-1] would
|
|
139
|
+
# silently anchor the drop to the LAST card) and an over-large value simply
|
|
140
|
+
# appends. The real client only ever sends 0..dest_cards.size; this hardens
|
|
141
|
+
# the crafted-request path.
|
|
142
|
+
to_index = params[:to_index].to_i.clamp(0, dest_cards.size)
|
|
143
|
+
|
|
144
|
+
# WIP limit only applies to cross-column drops (reordering within the
|
|
145
|
+
# same column does not change its cardinality). This is a
|
|
146
|
+
# pre-transaction read — benign TOCTOU: two concurrent moves could
|
|
147
|
+
# momentarily push the column one over wip. Acceptable for a UI guard.
|
|
148
|
+
if to.wip && from.key != to.key && dest_cards.size + 1 > to.wip
|
|
149
|
+
return render_kanban_rejection(
|
|
150
|
+
params[:from_column],
|
|
151
|
+
reason: "“#{to.label}” is at its WIP limit (#{to.wip})."
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
prev_record = (to_index > 0) ? dest_cards[to_index - 1] : nil
|
|
156
|
+
next_record = dest_cards[to_index]
|
|
157
|
+
|
|
158
|
+
# Holds the enter_interaction outcome (when the destination declares
|
|
159
|
+
# one) so the post-transaction branch can distinguish a rolled-back
|
|
160
|
+
# failure from a successful atomic commit.
|
|
161
|
+
outcome = nil
|
|
162
|
+
|
|
163
|
+
# A same-column reorder (from.key == to.key) changes only rank, not
|
|
164
|
+
# membership, so it runs ONLY the positioning code — no on_enter, no
|
|
165
|
+
# enter_interaction. Both on_enter (the membership write) and
|
|
166
|
+
# enter_interaction (the transition) represent ENTERING a column; neither
|
|
167
|
+
# should fire when the card is already in it. Task 6 (client) must mirror
|
|
168
|
+
# this: a same-column drop posts a plain reposition and opens no modal.
|
|
169
|
+
cross_column = from.key != to.key
|
|
170
|
+
run_enter_interaction = to.enter_interaction? && cross_column
|
|
171
|
+
|
|
172
|
+
# A dynamic board (`columns do…end`) can't register its enter_interaction
|
|
173
|
+
# as an action at class-load time (its columns only exist per-request), so
|
|
174
|
+
# defined_actions has no entry for the column-scoped key and the interactive
|
|
175
|
+
# machinery below (build_interactive_record_action_interaction) would blow
|
|
176
|
+
# up. Reject gracefully with a clear log instead of a 500 morphing onto the
|
|
177
|
+
# board. (Static boards always register — see Definition::IndexViews.)
|
|
178
|
+
if run_enter_interaction && current_definition.defined_actions[to.enter_interaction_key].nil?
|
|
179
|
+
Rails.logger.warn { "[plutonium] kanban enter_interaction on column `#{to.key}` is not registered — enter_interaction is unsupported on dynamic (`columns do…end`) boards; rejecting the drop." }
|
|
180
|
+
return render_kanban_rejection(params[:from_column], reason: "This drop can’t be completed.")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# An input-less drop interaction is `immediate` — the client commits it
|
|
184
|
+
# via a DIRECT POST (no modal), so the response must be a Turbo Stream,
|
|
185
|
+
# not modal-form HTML (see the failure branch below).
|
|
186
|
+
drop_immediate = run_enter_interaction &&
|
|
187
|
+
!!current_definition.defined_actions[to.enter_interaction_key]&.immediate
|
|
188
|
+
|
|
189
|
+
# Bind the enter_interaction's auto-registered hidden record action (so
|
|
190
|
+
# interaction_params / build_interactive_record_action_interaction
|
|
191
|
+
# resolve it) and reuse the already-loaded record as resource_record!
|
|
192
|
+
# (param-extraction subject + form URL) — no re-query, no divergent copy.
|
|
193
|
+
# No authorize here: the move was already authorized by kanban_move?
|
|
194
|
+
# above, and the interaction has no policy method of its own.
|
|
195
|
+
if run_enter_interaction
|
|
196
|
+
params[:interactive_action] = to.enter_interaction_key
|
|
197
|
+
@resource_record = record
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
ActiveRecord::Base.transaction do
|
|
201
|
+
# (1) Apply on_exit (SOURCE column) then on_enter (DESTINATION column),
|
|
202
|
+
# CROSS-column moves only. A same-column reorder skips both (see
|
|
203
|
+
# cross_column above) and only repositions; on_exit/on_enter represent
|
|
204
|
+
# LEAVING and ENTERING a column, which a reorder does not do.
|
|
205
|
+
#
|
|
206
|
+
# on_exit runs FIRST, so it sees the pre-move state (still "in" from) —
|
|
207
|
+
# the counterpart hook for source-tied side effects (stop a timer,
|
|
208
|
+
# release a slot) that the destination's on_enter can't own.
|
|
209
|
+
#
|
|
210
|
+
# Symbol → record.public_send(sym) (named method on the record)
|
|
211
|
+
# Proc → evaluated with self = kanban_context (delegates to
|
|
212
|
+
# view_context so `current_user` etc. work as bare calls)
|
|
213
|
+
# and the record as the single block arg, matching the
|
|
214
|
+
# public 1-arg DSL form: on_enter: ->(task) { task.status = … }
|
|
215
|
+
if cross_column
|
|
216
|
+
if from.on_exit.is_a?(Symbol)
|
|
217
|
+
record.public_send(from.on_exit)
|
|
218
|
+
elsif from.on_exit
|
|
219
|
+
kanban_context.instance_exec(record, &from.on_exit)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if to.on_enter.is_a?(Symbol)
|
|
223
|
+
record.public_send(to.on_enter)
|
|
224
|
+
elsif to.on_enter
|
|
225
|
+
kanban_context.instance_exec(record, &to.on_enter)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Persist any in-memory attribute changes from on_exit/on_enter
|
|
229
|
+
# (blocks that call update! directly are already saved; this is a
|
|
230
|
+
# safety net for blocks that only assign attributes).
|
|
231
|
+
record.save! if record.changed?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# (2) Drop interaction — runs against the SAME record instance,
|
|
235
|
+
# atomic with the move. Only fires on a CROSS-column move (entering a
|
|
236
|
+
# new column is the transition the interaction represents); a
|
|
237
|
+
# same-column reorder skips it (see run_enter_interaction above). The
|
|
238
|
+
# interactive_action binding + @resource_record + authorize were
|
|
239
|
+
# hoisted out above the transaction.
|
|
240
|
+
if run_enter_interaction
|
|
241
|
+
# build_interactive_record_action_interaction renders the action
|
|
242
|
+
# form to EXTRACT its params (structured inputs / choices). It runs
|
|
243
|
+
# INSIDE the transaction on purpose: so a `choices:` proc (or any
|
|
244
|
+
# form logic) sees the post-on_enter record state. Do NOT hoist this
|
|
245
|
+
# out — doing so would change the record state the form is built
|
|
246
|
+
# against and silently alter param-extraction semantics.
|
|
247
|
+
build_interactive_record_action_interaction
|
|
248
|
+
outcome = @interaction.call
|
|
249
|
+
# Interaction validation failed → undo the on_enter write (and any
|
|
250
|
+
# partial execute) so nothing persists. The re-render happens
|
|
251
|
+
# after the transaction so the rollback is fully applied first.
|
|
252
|
+
raise ActiveRecord::Rollback if outcome.failure?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# (3) Reposition within the destination column.
|
|
256
|
+
# Mode A delegates to record.reposition! (calls update! for position).
|
|
257
|
+
# Mode B calls the user-supplied block.
|
|
258
|
+
# Mode C is a no-op (no ordering; position unchanged).
|
|
259
|
+
board.position_config.reposition!(
|
|
260
|
+
record:,
|
|
261
|
+
column: to.key,
|
|
262
|
+
prev_record:,
|
|
263
|
+
next_record:,
|
|
264
|
+
index: to_index
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Final save covers Mode C where reposition! is a no-op but on_enter
|
|
268
|
+
# only assigned in memory, or any other unsaved attribute changes.
|
|
269
|
+
record.save! if record.changed?
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Interaction failed → the transaction rolled back. Re-render the SAME
|
|
273
|
+
# modal (422) with the validation errors + the submitted hidden move
|
|
274
|
+
# fields, so the user can correct the input and resubmit the move.
|
|
275
|
+
if run_enter_interaction && outcome&.failure?
|
|
276
|
+
# Immediate interactions were committed via a direct POST (no modal
|
|
277
|
+
# open), so the client is processing a Turbo Stream response — render a
|
|
278
|
+
# snap-back rejection toast rather than modal-form HTML, which the
|
|
279
|
+
# stream-expecting fetch would silently drop. Input-collecting
|
|
280
|
+
# interactions re-render their modal with the errors so the user can
|
|
281
|
+
# fix the input and resubmit.
|
|
282
|
+
if drop_immediate
|
|
283
|
+
reason = @interaction.errors.full_messages.to_sentence.presence ||
|
|
284
|
+
"“#{to.label}” could not be applied."
|
|
285
|
+
return render_kanban_rejection(from.key, reason:)
|
|
286
|
+
end
|
|
287
|
+
return render :kanban_move_form, formats: [:html], **modal_render_options, status: :unprocessable_content
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
respond_to do |format|
|
|
291
|
+
format.turbo_stream do
|
|
292
|
+
# The column-frame updates are what other viewers need to see — this
|
|
293
|
+
# is the shared, broadcastable payload.
|
|
294
|
+
column_streams = [turbo_stream.update("kanban-col-#{from.key}", render_kanban_column_html(from))]
|
|
295
|
+
column_streams << turbo_stream.update("kanban-col-#{to.key}", render_kanban_column_html(to)) if from.key != to.key
|
|
296
|
+
|
|
297
|
+
# Broadcast the frame updates to other connected viewers of this
|
|
298
|
+
# board, when realtime broadcasting is enabled. The mover will also
|
|
299
|
+
# receive this broadcast (they are subscribed to the stream too) — but
|
|
300
|
+
# re-rendering the same frames is idempotent, so the double update is
|
|
301
|
+
# harmless. The modal-close stream is deliberately EXCLUDED: only the
|
|
302
|
+
# mover has this modal open, so closing it for everyone would blow
|
|
303
|
+
# away an unrelated modal another viewer might have open.
|
|
304
|
+
if board.realtime?
|
|
305
|
+
Plutonium::Kanban::Broadcaster.broadcast(
|
|
306
|
+
resource_class: resource_class,
|
|
307
|
+
scoped_entity: scoped_to_entity? ? current_scoped_entity : nil,
|
|
308
|
+
content: column_streams.join
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
streams = column_streams
|
|
313
|
+
# When the move arrived via the drop-interaction modal (cross-column
|
|
314
|
+
# only), close that modal by emptying the remote-modal frame AND
|
|
315
|
+
# surface the interaction's success message(s) as toast(s). Both are
|
|
316
|
+
# mover-only: only this viewer has the modal open, so they are
|
|
317
|
+
# deliberately EXCLUDED from the realtime broadcast above (appended
|
|
318
|
+
# to `streams`, never to `column_streams`). Plain moves and
|
|
319
|
+
# same-column reorders aren't in a modal, so nothing is appended.
|
|
320
|
+
if run_enter_interaction
|
|
321
|
+
streams += [turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")]
|
|
322
|
+
outcome.messages.each do |msg, type|
|
|
323
|
+
streams += [turbo_stream.append("kanban-flash", partial: "plutonium/toast",
|
|
324
|
+
locals: {type: ((type == :notice) ? :success : type), msg:})]
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
render turbo_stream: streams
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
rescue ::ActionPolicy::Unauthorized
|
|
332
|
+
# NOTE: the leading :: is required — Plutonium::ActionPolicy exists
|
|
333
|
+
# (action_policy/sti_policy_lookup.rb), so a bare ActionPolicy would
|
|
334
|
+
# resolve to that namespace and never match the raised exception,
|
|
335
|
+
# letting it fall through to the global rescue_from (which re-raises for
|
|
336
|
+
# turbo_stream requests → the HTML error page morph this fix prevents).
|
|
337
|
+
#
|
|
338
|
+
# A denied transition — the kanban_move? gate (the single move
|
|
339
|
+
# authorization; the enter_interaction has no policy of its own). Snap
|
|
340
|
+
# the source column back with a toast at 403 instead of letting the HTML
|
|
341
|
+
# error page reach the client: the drag POST expects a Turbo Stream, so
|
|
342
|
+
# a raw error page would be morphed into the board (the "page turns red"
|
|
343
|
+
# bug). Rendering a stream here keeps rejection feedback consistent with
|
|
344
|
+
# the WIP / accepts snap-backs above.
|
|
345
|
+
#
|
|
346
|
+
# authorize_count only bumps AFTER a successful authorize, so a denial
|
|
347
|
+
# raised by the board-wide gate (before any successful check) leaves the
|
|
348
|
+
# verifier unsatisfied — we've handled authorization by rejecting, so
|
|
349
|
+
# skip it explicitly.
|
|
350
|
+
skip_verify_authorize_current!
|
|
351
|
+
render_kanban_rejection(
|
|
352
|
+
params[:from_column],
|
|
353
|
+
reason: "You are not authorized to move this card there.",
|
|
354
|
+
status: :forbidden
|
|
355
|
+
)
|
|
356
|
+
rescue ActiveRecord::RecordNotFound
|
|
357
|
+
# The card was destroyed (e.g. concurrently) between board render and
|
|
358
|
+
# drop, so `find` raised. Snap the source column back — re-rendering it
|
|
359
|
+
# drops the now-gone card — instead of letting Rails' 404 HTML page get
|
|
360
|
+
# morphed into the board (same class of bug as the ActionPolicy rescue).
|
|
361
|
+
# `find` raised before authorize_current!, so satisfy that verifier; the
|
|
362
|
+
# scope verifier is already satisfied by kanban_base_relation.
|
|
363
|
+
skip_verify_authorize_current!
|
|
364
|
+
render_kanban_rejection(params[:from_column], reason: "This card no longer exists.")
|
|
365
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
366
|
+
# An on_exit/on_enter hook (or the interaction) left the record invalid,
|
|
367
|
+
# so save! raised and the transaction rolled back. Snap back with the
|
|
368
|
+
# validation reason rather than let a 500 HTML page morph into the board.
|
|
369
|
+
reason = e.record.errors.full_messages.to_sentence.presence ||
|
|
370
|
+
"This card could not be moved."
|
|
371
|
+
render_kanban_rejection(params[:from_column], reason:)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# GET <member>/kanban_move_form?from_column=&to_column=&to_index=
|
|
375
|
+
#
|
|
376
|
+
# Renders the drop-interaction modal for a card dropped into a column
|
|
377
|
+
# that declares a `enter_interaction:`. The modal shows the interaction's
|
|
378
|
+
# normal form, but wired to POST to `kanban_move` (Task 4) carrying the
|
|
379
|
+
# move context as hidden fields so the interaction runs AND the card is
|
|
380
|
+
# repositioned in one atomic request.
|
|
381
|
+
def kanban_move_form
|
|
382
|
+
@resource_record = kanban_base_relation.find(params[:id])
|
|
383
|
+
record = @resource_record
|
|
384
|
+
from = kanban_column_for(params[:from_column])
|
|
385
|
+
to = kanban_column_for(params[:to_column])
|
|
386
|
+
|
|
387
|
+
# No interaction to open a form for (invalid drop), OR the interaction is
|
|
388
|
+
# unregistered because this is a dynamic (`columns do…end`) board — which
|
|
389
|
+
# can't register enter_interactions and so can't render the modal chrome
|
|
390
|
+
# (current_interactive_action would be nil). Either way the drop is not
|
|
391
|
+
# actionable on this path — satisfy the verifier and bail cleanly instead
|
|
392
|
+
# of 500-ing in the view.
|
|
393
|
+
unless to&.enter_interaction? && current_definition.defined_actions[to.enter_interaction_key]
|
|
394
|
+
skip_verify_authorize_current!
|
|
395
|
+
head :unprocessable_content
|
|
396
|
+
return
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Same single gate as kanban_move: authorize the move via kanban_move?
|
|
400
|
+
# with the from/to columns in context (the interaction has no policy of
|
|
401
|
+
# its own). Opening the form is authorizing the move it will commit.
|
|
402
|
+
authorize_current! record, to: :kanban_move?,
|
|
403
|
+
context: {kanban_from: from, kanban_to: to}
|
|
404
|
+
|
|
405
|
+
# Belt-and-suspenders structural gate, mirroring kanban_move (POST) in
|
|
406
|
+
# the same order (membership → accepts?/locked?). Don't open a modal for
|
|
407
|
+
# a drop the commit will inevitably reject: the user would fill in the
|
|
408
|
+
# interaction form only to eat a 422 snap-back on submit. The client's
|
|
409
|
+
# accepts hint normally blocks this before the modal opens, but a stale
|
|
410
|
+
# board or a crafted request can still reach here. On rejection we render
|
|
411
|
+
# the SAME turbo-stream snap-back the POST does (Turbo processes it from
|
|
412
|
+
# the frame.src navigation) instead of the doomed form. authorize already
|
|
413
|
+
# succeeded (counter bumped), so these returns need no skip_verify.
|
|
414
|
+
if from && !record_in_kanban_column?(record, from)
|
|
415
|
+
return render_kanban_rejection(
|
|
416
|
+
params[:from_column],
|
|
417
|
+
reason: "This card is no longer in “#{from.label}”."
|
|
418
|
+
)
|
|
419
|
+
end
|
|
420
|
+
unless from && to.accepts?(from.key) && !from.locked?
|
|
421
|
+
reason =
|
|
422
|
+
if from&.locked?
|
|
423
|
+
"Cards can't be moved out of “#{from.label}”."
|
|
424
|
+
else
|
|
425
|
+
"Cards can't be moved into “#{to.label}”."
|
|
426
|
+
end
|
|
427
|
+
return render_kanban_rejection(params[:from_column], reason:)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Bind the enter_interaction's auto-registered record action as the
|
|
431
|
+
# current interactive action so the modal chrome (title, description,
|
|
432
|
+
# modal mode/size) resolves exactly like a standard record action.
|
|
433
|
+
params[:interactive_action] = to.enter_interaction_key
|
|
434
|
+
|
|
435
|
+
@interaction = to.enter_interaction.new(view_context:)
|
|
436
|
+
@interaction.resource = record
|
|
437
|
+
|
|
438
|
+
render :kanban_move_form, formats: [:html], **modal_render_options
|
|
439
|
+
rescue ::ActionPolicy::Unauthorized
|
|
440
|
+
# A denied transition into a enter_interaction column. Without this the
|
|
441
|
+
# exception reaches the global rescue_from, which RE-RAISES for html/
|
|
442
|
+
# turbo_stream (core/controller.rb) → a 403 HTML error page. The client
|
|
443
|
+
# opened this form via `frame.src`, so that error page lands in the
|
|
444
|
+
# remote-modal frame → a broken "content missing" modal. Render the same
|
|
445
|
+
# turbo-stream rejection the kanban_move POST does instead: the card was
|
|
446
|
+
# never moved (native DnD doesn't re-parent), so this just empties the
|
|
447
|
+
# modal frame, re-asserts the source column, and toasts the denial.
|
|
448
|
+
# authorize_current! raised before bumping its counter, so satisfy the
|
|
449
|
+
# verifier explicitly (mirrors the RecordNotFound branch below).
|
|
450
|
+
skip_verify_authorize_current!
|
|
451
|
+
render_kanban_rejection(
|
|
452
|
+
params[:from_column],
|
|
453
|
+
reason: "You are not authorized to move this card there.",
|
|
454
|
+
status: :forbidden
|
|
455
|
+
)
|
|
456
|
+
rescue ActiveRecord::RecordNotFound
|
|
457
|
+
# Card destroyed between board render and the modal-open request. `find`
|
|
458
|
+
# raised before authorize, so satisfy that verifier; return a plain 404
|
|
459
|
+
# (this GET only loads the modal frame — there is no board to morph).
|
|
460
|
+
skip_verify_authorize_current!
|
|
461
|
+
head :not_found
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
# Resolves a kanban column by its key (String/Symbol). Compares keys as
|
|
467
|
+
# strings so arbitrary request input isn't interned into symbols.
|
|
468
|
+
def kanban_column_for(key)
|
|
469
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(current_kanban_board, kanban_context)
|
|
470
|
+
columns.find { |c| c.key.to_s == key.to_s }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Builds the kanban board shell component for the index page.
|
|
474
|
+
#
|
|
475
|
+
# Used by the _resource_kanban partial (Task 10). The shell renders one
|
|
476
|
+
# lazy turbo-frame per column — no card data is fetched here; the frames
|
|
477
|
+
# load card bodies on demand via the Task 6 column endpoint.
|
|
478
|
+
#
|
|
479
|
+
# Resolves columns via Grouping.resolve_columns so dynamic boards work
|
|
480
|
+
# identically to static ones. grouped_data has empty card arrays because
|
|
481
|
+
# the shell header only needs the column metadata (label, color, key).
|
|
482
|
+
def build_kanban_board_shell
|
|
483
|
+
board = current_kanban_board
|
|
484
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
485
|
+
# collapsed: the effective (cookie-resolved) state, so the lazy-frame
|
|
486
|
+
# placeholder renders in the SAME shape the loaded column will — a
|
|
487
|
+
# collapsed column shows a strip from the first paint instead of a full
|
|
488
|
+
# header that then snaps to a strip.
|
|
489
|
+
grouped_data = columns.map do |col|
|
|
490
|
+
{column: col, cards: [], total: 0, collapsed: kanban_effective_collapsed(col)}
|
|
491
|
+
end
|
|
492
|
+
Plutonium::UI::Kanban::Resource.new(
|
|
493
|
+
board:,
|
|
494
|
+
grouped_data:,
|
|
495
|
+
resource_definition: current_definition,
|
|
496
|
+
resource_fields: permitted_attributes_for("index"),
|
|
497
|
+
resource_class: resource_class,
|
|
498
|
+
scoped_entity: scoped_to_entity? ? current_scoped_entity : nil
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Memoized kanban board. Prefers the board precompiled at definition
|
|
503
|
+
# class-load time (Definition::IndexViews.kanban); falls back to building
|
|
504
|
+
# from the block for safety and dynamic edge cases.
|
|
505
|
+
def current_kanban_board
|
|
506
|
+
@current_kanban_board ||= current_definition.defined_kanban_board ||
|
|
507
|
+
Plutonium::Kanban::DSL.build(¤t_definition.defined_kanban_block)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Authorized + query-applied UN-paginated relation.
|
|
511
|
+
#
|
|
512
|
+
# Mirrors filtered_resource_collection from IndexAction::CrudActions but
|
|
513
|
+
# without the Pagy pagination step. Reuses the same query pipeline so
|
|
514
|
+
# search, filters, scopes, and tenant/parent scoping all apply.
|
|
515
|
+
def kanban_base_relation
|
|
516
|
+
@kanban_base_relation ||= begin
|
|
517
|
+
query_params = current_definition
|
|
518
|
+
.query_form.new(nil, query_object: current_query_object, page_size: nil)
|
|
519
|
+
.extract_input(params, view_context:)[:q]
|
|
520
|
+
|
|
521
|
+
base_query = current_authorized_scope
|
|
522
|
+
current_query_object.apply(base_query, query_params, context: self)
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Intercepts the index action when view=kanban + column= is present.
|
|
527
|
+
# Renders only the turbo-frame body for the requested column and halts.
|
|
528
|
+
def maybe_render_kanban_column
|
|
529
|
+
return unless params[:view] == "kanban" && params[:column].present?
|
|
530
|
+
return unless current_definition.defined_kanban_block
|
|
531
|
+
|
|
532
|
+
# Fulfill authorization requirements so after_action verifiers pass.
|
|
533
|
+
authorize_current! resource_class
|
|
534
|
+
|
|
535
|
+
board = current_kanban_board
|
|
536
|
+
|
|
537
|
+
# Resolve only the requested column rather than grouping the whole
|
|
538
|
+
# board: Grouping.call would scope+count+limit every column (~2 queries
|
|
539
|
+
# each) on every lazy frame request. We compare keys as strings to
|
|
540
|
+
# avoid interning arbitrary request input into symbols.
|
|
541
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
542
|
+
column = columns.find { |c| c.key.to_s == params[:column] }
|
|
543
|
+
|
|
544
|
+
# The lazy `<turbo-frame id="kanban-col-<key>" src=…>` in the shell
|
|
545
|
+
# requires the response to contain a turbo-frame with the SAME id, or
|
|
546
|
+
# Turbo renders "Content missing". Wrap the column body in that frame.
|
|
547
|
+
# (The move action targets the frame via turbo_stream.update instead,
|
|
548
|
+
# so render_kanban_column_html stays body-only for that path.)
|
|
549
|
+
frame_id = "kanban-col-#{params[:column]}"
|
|
550
|
+
|
|
551
|
+
# Unknown column key — render an empty (matching) frame, no crash.
|
|
552
|
+
# kanban_base_relation is referenced so verify_current_authorized_scope
|
|
553
|
+
# still passes even on the empty path.
|
|
554
|
+
unless column
|
|
555
|
+
kanban_base_relation
|
|
556
|
+
empty = view_context.content_tag("turbo-frame", "", id: frame_id)
|
|
557
|
+
return render(html: empty, layout: false)
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
framed = view_context.content_tag("turbo-frame", render_kanban_column_html(column), id: frame_id)
|
|
561
|
+
render html: framed, layout: false
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Renders a single column component to an HTML-safe string.
|
|
565
|
+
#
|
|
566
|
+
# Accepts either a Plutonium::Kanban::Column object or a column key
|
|
567
|
+
# (String/Symbol). Returns an empty SafeBuffer for unknown keys.
|
|
568
|
+
def render_kanban_column_html(column_or_key)
|
|
569
|
+
board = current_kanban_board
|
|
570
|
+
|
|
571
|
+
column = if column_or_key.is_a?(Plutonium::Kanban::Column)
|
|
572
|
+
column_or_key
|
|
573
|
+
else
|
|
574
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
575
|
+
columns.find { |c| c.key.to_s == column_or_key.to_s }
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
return "".html_safe unless column
|
|
579
|
+
|
|
580
|
+
scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
|
|
581
|
+
ordered = board.position_config.order(scoped)
|
|
582
|
+
|
|
583
|
+
if board.per_column
|
|
584
|
+
total = ordered.count
|
|
585
|
+
cards = ordered.limit(board.per_column).to_a
|
|
586
|
+
else
|
|
587
|
+
cards = ordered.to_a
|
|
588
|
+
total = cards.size
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Cards are a read-only display, so resolve the visible fields from the
|
|
592
|
+
# index/read attribute set rather than the action name. This keeps the
|
|
593
|
+
# move action from needing a `permitted_attributes_for_kanban_move`
|
|
594
|
+
# method — kanban deliberately has no permitted-attributes concept.
|
|
595
|
+
column_action_data = column.actions.map do |col_action|
|
|
596
|
+
{action: col_action, ids: kanban_column_action_ids(column, on: col_action.on)}
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
column_add_url = if column.add? && current_policy.allowed_to?(:create?)
|
|
600
|
+
resource_url_for(resource_class, action: :new, kanban_column: column.key,
|
|
601
|
+
return_to: kanban_board_url)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Drop-interaction columns advertise the kanban_move_form member URL
|
|
605
|
+
# (with __ID__ for the dragged card's id) so the client opens the
|
|
606
|
+
# interaction modal on drop. Mirrors the board's move-url template
|
|
607
|
+
# (Kanban::Resource#kanban_move_url_template) — same collection path +
|
|
608
|
+
# __ID__ shape, only the trailing route segment differs (kanban_move_form
|
|
609
|
+
# vs kanban_move). Derived from resource_url_for (not request.path)
|
|
610
|
+
# because this method also runs under the kanban_move POST, whose
|
|
611
|
+
# request.path is the member move URL, not the collection path.
|
|
612
|
+
drop_form_url_template = nil
|
|
613
|
+
drop_immediate = false
|
|
614
|
+
drop_confirm = nil
|
|
615
|
+
if column.enter_interaction?
|
|
616
|
+
drop_form_url_template = "#{resource_url_for(resource_class).delete_suffix("/")}/__ID__/kanban_move_form"
|
|
617
|
+
# Look up the auto-registered drop action to honour its shape: an
|
|
618
|
+
# input-less interaction is `immediate` (commit directly, no modal),
|
|
619
|
+
# and carries an auto "<label>?" confirmation unless one was set.
|
|
620
|
+
registered_drop = current_definition.defined_actions[column.enter_interaction_key]
|
|
621
|
+
drop_immediate = registered_drop&.immediate || false
|
|
622
|
+
drop_confirm = registered_drop&.confirmation
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
component = Plutonium::UI::Kanban::Column.new(
|
|
626
|
+
column:,
|
|
627
|
+
cards:,
|
|
628
|
+
total:,
|
|
629
|
+
per_column: board.per_column,
|
|
630
|
+
resource_definition: current_definition,
|
|
631
|
+
resource_fields: permitted_attributes_for("index"),
|
|
632
|
+
column_action_data:,
|
|
633
|
+
column_add_url:,
|
|
634
|
+
board_url: kanban_board_url,
|
|
635
|
+
card_fields: board.card_fields,
|
|
636
|
+
card_show_frame: kanban_card_show_frame(board),
|
|
637
|
+
collapsed: kanban_effective_collapsed(column),
|
|
638
|
+
drop_form_url_template:,
|
|
639
|
+
drop_immediate:,
|
|
640
|
+
drop_confirm:
|
|
641
|
+
)
|
|
642
|
+
view_context.render(component).html_safe
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# The board's own URL — the collection path with view=kanban and any
|
|
646
|
+
# active board query (search / filter / scope), minus the per-column
|
|
647
|
+
# frame param. Used as the quick-add return_to so creating a card returns
|
|
648
|
+
# the user to the board (at their scroll + filters) instead of the new
|
|
649
|
+
# record's show page. Built from resource_url_for rather than request.path
|
|
650
|
+
# because this also runs under the kanban_move POST, whose request path is
|
|
651
|
+
# the member move URL, not the collection path.
|
|
652
|
+
def kanban_board_url
|
|
653
|
+
board_params = request.query_parameters.except("column", "format").merge("view" => "kanban")
|
|
654
|
+
"#{resource_url_for(resource_class)}?#{board_params.to_query}"
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Tags a board-bound redirect with the one-shot kanban_reload marker (and
|
|
658
|
+
# normalizes it to the FULL board) so a write/action that returns to the
|
|
659
|
+
# permanent board doesn't show stale columns (a new card missing, a
|
|
660
|
+
# deleted/archived one lingering). The kanban Stimulus controller consumes
|
|
661
|
+
# the marker on connect, re-fetches the column frames, and strips it from
|
|
662
|
+
# the URL. No-op for non-kanban resources and for redirects that don't
|
|
663
|
+
# land on the board (a show page, the table index). Called from
|
|
664
|
+
# ReloadRedirects for all three redirect helpers.
|
|
665
|
+
#
|
|
666
|
+
# Also strips column= so the redirect targets the full board rather than
|
|
667
|
+
# the bare single-column frame endpoint (maybe_render_kanban_column
|
|
668
|
+
# intercepts view=kanban + column=<key>). A card's edit/delete button
|
|
669
|
+
# rendered inside a lazy column frame defaults its return_to to that
|
|
670
|
+
# frame's URL, which carries column=<key>; without this strip the write
|
|
671
|
+
# would redirect onto that fragment instead of the board.
|
|
672
|
+
def kanban_reload_url(url)
|
|
673
|
+
return url unless current_definition.defined_kanban_board
|
|
674
|
+
uri = URI.parse(url.to_s)
|
|
675
|
+
params = Rack::Utils.parse_query(uri.query)
|
|
676
|
+
return url unless params["view"] == "kanban"
|
|
677
|
+
params.delete("column")
|
|
678
|
+
params["kanban_reload"] = "1"
|
|
679
|
+
uri.query = params.to_query
|
|
680
|
+
uri.to_s
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# The user's persisted collapse choice for this column, resolved against
|
|
684
|
+
# the column default. The cookie stores only columns flipped FROM their
|
|
685
|
+
# default (see Kanban::Resource.collapse_cookie_name), so a listed key
|
|
686
|
+
# means "the opposite of the default". Rendering this server-side is what
|
|
687
|
+
# keeps the board in the user's state across morph/stream/reload with no
|
|
688
|
+
# client re-apply — and therefore no flash.
|
|
689
|
+
def kanban_effective_collapsed(column)
|
|
690
|
+
flipped = kanban_collapse_flips.include?(column.key.to_s)
|
|
691
|
+
flipped ? !column.collapsed? : column.collapsed?
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def kanban_collapse_flips
|
|
695
|
+
@kanban_collapse_flips ||= Plutonium::UI::Kanban::Resource.collapse_flips(
|
|
696
|
+
cookies[Plutonium::UI::Kanban::Resource.collapse_cookie_name(resource_class)]
|
|
697
|
+
)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Resolves the turbo-frame a card's show link targets, from the board's
|
|
701
|
+
# effective show_in (the board's own value, or the definition's when the
|
|
702
|
+
# board doesn't override it):
|
|
703
|
+
#
|
|
704
|
+
# :modal → the remote-modal frame, so a card click opens the record in a
|
|
705
|
+
# centered dialog (the show page is always centered).
|
|
706
|
+
# :page → "_top", a full-page navigation to the show route.
|
|
707
|
+
#
|
|
708
|
+
# Either target escapes the column's lazy turbo-frame: "_top" replaces the
|
|
709
|
+
# whole page, and the remote-modal frame lives in the layout (document-wide),
|
|
710
|
+
# so Turbo resolves it outside the column frame.
|
|
711
|
+
def kanban_card_show_frame(board)
|
|
712
|
+
if board.show_in_for(current_definition) == :modal
|
|
713
|
+
Plutonium::REMOTE_MODAL_FRAME
|
|
714
|
+
else
|
|
715
|
+
"_top"
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Returns the primary-key ids for a column action based on `on:` scope.
|
|
720
|
+
#
|
|
721
|
+
# on: :all → ids of ALL records matching the column scope within
|
|
722
|
+
# the current kanban_base_relation (ignores per_column).
|
|
723
|
+
# on: :visible → ids of the rendered, per_column-capped subset (applies
|
|
724
|
+
# position ordering + limit, then plucks ids).
|
|
725
|
+
#
|
|
726
|
+
# Any other value falls back to :all behaviour.
|
|
727
|
+
def kanban_column_action_ids(column, on:)
|
|
728
|
+
scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
|
|
729
|
+
case on.to_sym
|
|
730
|
+
when :visible
|
|
731
|
+
board = current_kanban_board
|
|
732
|
+
ordered = board.position_config.order(scoped)
|
|
733
|
+
limited = board.per_column ? ordered.limit(board.per_column) : ordered
|
|
734
|
+
limited.pluck(resource_class.primary_key)
|
|
735
|
+
else # :all and any unknown value
|
|
736
|
+
scoped.pluck(resource_class.primary_key)
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Kanban quick-add, POST-create. The column "+ Add" link opens the normal
|
|
741
|
+
# New form carrying kanban_column (threaded to the create POST as a hidden
|
|
742
|
+
# field). After the record is created — with the model's DEFAULT grouping
|
|
743
|
+
# value, which the app author is responsible for setting — this applies the
|
|
744
|
+
# column's on_enter to the freshly-persisted record and positions it into
|
|
745
|
+
# the column. on_enter therefore runs against a REAL record exactly as it
|
|
746
|
+
# does for a drag; there is no dry-run / no stubbing.
|
|
747
|
+
#
|
|
748
|
+
# Failure policy (see after_create_persisted rescue): the create is NEVER
|
|
749
|
+
# rolled back. If on_enter/positioning raises, the record is KEPT in its
|
|
750
|
+
# default column (validly positioned there by the model's before_create
|
|
751
|
+
# hook) and the failure is logged + toasted.
|
|
752
|
+
def after_create_persisted
|
|
753
|
+
super
|
|
754
|
+
return unless params[:kanban_column].present?
|
|
755
|
+
return unless current_definition.defined_kanban_block
|
|
756
|
+
|
|
757
|
+
column = kanban_column_for(params[:kanban_column])
|
|
758
|
+
return unless column&.add?
|
|
759
|
+
|
|
760
|
+
apply_kanban_column_enter!(resource_record!, column)
|
|
761
|
+
rescue => e
|
|
762
|
+
Rails.logger.error { "[plutonium] kanban quick-add on_enter failed for column #{params[:kanban_column].inspect}: #{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" }
|
|
763
|
+
flash[:alert] = "Couldn’t place it in “#{column&.label || params[:kanban_column]}” — it stayed in its default column."
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Applies a column's on_enter to an already-persisted record, then appends
|
|
767
|
+
# it to the END of that column. Mirrors kanban_move's on_enter + reposition
|
|
768
|
+
# steps (Symbol → record.public_send; Proc → instance_exec in kanban_context),
|
|
769
|
+
# against the real record, so update!-style callbacks behave exactly as they
|
|
770
|
+
# do on a drag.
|
|
771
|
+
def apply_kanban_column_enter!(record, column)
|
|
772
|
+
if column.on_enter.is_a?(Symbol)
|
|
773
|
+
record.public_send(column.on_enter)
|
|
774
|
+
elsif column.on_enter
|
|
775
|
+
kanban_context.instance_exec(record, &column.on_enter)
|
|
776
|
+
end
|
|
777
|
+
record.save! if record.changed?
|
|
778
|
+
|
|
779
|
+
# Append to the end of the destination column (prev = last card, next =
|
|
780
|
+
# nil), via the board's position_config — the same path a drag-drop uses.
|
|
781
|
+
board = current_kanban_board
|
|
782
|
+
dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
|
|
783
|
+
dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
|
|
784
|
+
board.position_config.reposition!(
|
|
785
|
+
record:,
|
|
786
|
+
column: column.key,
|
|
787
|
+
prev_record: dest_cards.last,
|
|
788
|
+
next_record: nil,
|
|
789
|
+
index: dest_cards.size
|
|
790
|
+
)
|
|
791
|
+
record.save! if record.changed?
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
# Renders a 422 turbo stream response that re-renders the source column
|
|
795
|
+
# unchanged, allowing the Stimulus drag controller to snap the card back.
|
|
796
|
+
#
|
|
797
|
+
# When a reason is given, a single dismissable toast is appended to the
|
|
798
|
+
# board's #kanban-flash region so the snap-back is explained rather than
|
|
799
|
+
# silent. It renders the shared _toast partial directly (not via flash)
|
|
800
|
+
# so a stale, undisplayed flash from an earlier request can't leak into
|
|
801
|
+
# the turbo_stream response — these move POSTs never render the layout
|
|
802
|
+
# that would otherwise consume the flash.
|
|
803
|
+
def render_kanban_rejection(from_key, reason: nil, status: :unprocessable_content)
|
|
804
|
+
streams = [
|
|
805
|
+
turbo_stream.update(
|
|
806
|
+
"kanban-col-#{from_key}",
|
|
807
|
+
render_kanban_column_html(from_key.to_s)
|
|
808
|
+
)
|
|
809
|
+
]
|
|
810
|
+
|
|
811
|
+
if reason
|
|
812
|
+
streams << turbo_stream.append(
|
|
813
|
+
"kanban-flash",
|
|
814
|
+
partial: "plutonium/toast",
|
|
815
|
+
locals: {type: :warning, msg: reason}
|
|
816
|
+
)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
# Close the drop-interaction modal if one is open. A rejection can arrive
|
|
820
|
+
# AFTER an enter_interaction modal submit (a non-immediate column that also
|
|
821
|
+
# has accepts:/wip:, or an auth/validation failure), and the success and
|
|
822
|
+
# interaction-failure paths close the modal but the structural rejections
|
|
823
|
+
# did not — leaving it open behind the snap-back toast. Emptying the frame
|
|
824
|
+
# is idempotent for plain drags (no modal is open during a drag, so the
|
|
825
|
+
# remote-modal frame is already empty → no-op), so it is safe to always
|
|
826
|
+
# append here.
|
|
827
|
+
streams << turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")
|
|
828
|
+
|
|
829
|
+
render turbo_stream: streams, status:
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Whether `record` currently resides in `column`'s scope. Used to verify a
|
|
833
|
+
# client-claimed source column before trusting it for authorization / the
|
|
834
|
+
# move. A nil column or a scope-less column contains everything (true), so
|
|
835
|
+
# the caller's own nil/accepts handling takes over.
|
|
836
|
+
def record_in_kanban_column?(record, column)
|
|
837
|
+
return true if column.nil? || column.scope.nil?
|
|
838
|
+
Plutonium::Kanban::Grouping
|
|
839
|
+
.apply_scope(kanban_base_relation, column.scope)
|
|
840
|
+
.exists?(record.id)
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Evaluation context for dynamic `columns do…end` blocks — delegates to
|
|
844
|
+
# the view_context so the block can call current_user, params, etc.
|
|
845
|
+
def kanban_context
|
|
846
|
+
@kanban_context ||= Plutonium::Kanban::Context.new(view_context)
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
end
|
|
851
|
+
end
|