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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. 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(&current_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