plutonium 0.60.5 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) 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 +313 -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 +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  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/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -0,0 +1,455 @@
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_drop 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
+ included do
36
+ # Intercept index when view=kanban + column=<key> is present.
37
+ # Runs BEFORE setup_index_action! so no wasteful pagination query.
38
+ before_action :maybe_render_kanban_column, only: :index
39
+
40
+ # Pre-fill the new form with the column's seed attributes when the
41
+ # user clicks "+ Add" on a kanban column (kanban_column= query param).
42
+ before_action :apply_kanban_column_defaults!, only: :new
43
+
44
+ # Exposed to views/partials so _resource_kanban.html.erb can call it.
45
+ helper_method :build_kanban_board_shell
46
+ end
47
+
48
+ # POST <member>/kanban_move
49
+ #
50
+ # Params:
51
+ # from_column [String] source column key
52
+ # to_column [String] destination column key
53
+ # to_index [Integer] 0-based insertion index within destination
54
+ #
55
+ # Responds with Turbo Streams updating the from + to column frames on
56
+ # success, or 422 re-rendering the unchanged source frame on rejection.
57
+ def kanban_move
58
+ # Find record within authorized scope (satisfies scope verifier).
59
+ record = kanban_base_relation.find(params[:id])
60
+ # Check move permission (satisfies authorize verifier).
61
+ authorize_current! record, to: :kanban_move?
62
+
63
+ unless current_definition.defined_kanban_block
64
+ head :not_found
65
+ return
66
+ end
67
+
68
+ board = current_kanban_board
69
+ columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
70
+ from = columns.find { |c| c.key.to_s == params[:from_column].to_s }
71
+ to = columns.find { |c| c.key.to_s == params[:to_column].to_s }
72
+
73
+ # accepts_record? evaluates Proc accepts: against the actual record
74
+ # (returning the Proc's boolean result) while delegating to accepts?
75
+ # semantics for true/false/Array values. This is the server-side
76
+ # authority; the client-side data-kanban-accepts attribute (which
77
+ # treats Proc as "all") is only a drop-hint.
78
+ unless from && to&.accepts_record?(record, from.key) && !from.locked?
79
+ reason =
80
+ if from&.locked?
81
+ "Cards can't be moved out of “#{from.label}”."
82
+ elsif to
83
+ "Cards can't be moved into “#{to.label}”."
84
+ else
85
+ "This card can't be moved there."
86
+ end
87
+ return render_kanban_rejection(params[:from_column], reason:)
88
+ end
89
+
90
+ # Build the destination card list excluding the moved record so the
91
+ # neighbor computation and WIP count are correct in all cases
92
+ # (cross-column, same-column reorder, record already in destination).
93
+ dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
94
+ dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
95
+ to_index = params[:to_index].to_i
96
+
97
+ # WIP limit only applies to cross-column drops (reordering within the
98
+ # same column does not change its cardinality). This is a
99
+ # pre-transaction read — benign TOCTOU: two concurrent moves could
100
+ # momentarily push the column one over wip. Acceptable for a UI guard.
101
+ if to.wip && from.key != to.key && dest_cards.size + 1 > to.wip
102
+ return render_kanban_rejection(
103
+ params[:from_column],
104
+ reason: "“#{to.label}” is at its WIP limit (#{to.wip})."
105
+ )
106
+ end
107
+
108
+ prev_record = (to_index > 0) ? dest_cards[to_index - 1] : nil
109
+ next_record = dest_cards[to_index]
110
+
111
+ ActiveRecord::Base.transaction do
112
+ # Apply on_drop:
113
+ # Symbol → record.public_send(sym) (named method on the record)
114
+ # Proc → evaluated with self = kanban_context (delegates to
115
+ # view_context so `current_user` etc. work as bare calls)
116
+ # and the record as the single block arg, matching the
117
+ # public 1-arg DSL form: on_drop: ->(task) { task.status = … }
118
+ if to.on_drop.is_a?(Symbol)
119
+ record.public_send(to.on_drop)
120
+ elsif to.on_drop
121
+ kanban_context.instance_exec(record, &to.on_drop)
122
+ end
123
+
124
+ # Persist any in-memory attribute changes from on_drop (on_drop
125
+ # blocks that call update! directly are already saved; this is a
126
+ # safety net for blocks that only assign attributes).
127
+ record.save! if record.changed?
128
+
129
+ # Reposition within the destination column.
130
+ # Mode A delegates to record.reposition! (calls update! for position).
131
+ # Mode B calls the user-supplied block.
132
+ # Mode C is a no-op (no ordering; position unchanged).
133
+ board.position_config.reposition!(
134
+ record:,
135
+ column: to.key,
136
+ prev_record:,
137
+ next_record:,
138
+ index: to_index
139
+ )
140
+
141
+ # Final save covers Mode C where reposition! is a no-op but on_drop
142
+ # only assigned in memory, or any other unsaved attribute changes.
143
+ record.save! if record.changed?
144
+ end
145
+
146
+ respond_to do |format|
147
+ format.turbo_stream do
148
+ streams = [turbo_stream.update("kanban-col-#{from.key}", render_kanban_column_html(from))]
149
+ streams << turbo_stream.update("kanban-col-#{to.key}", render_kanban_column_html(to)) if from.key != to.key
150
+
151
+ # Broadcast the same frame updates to other connected viewers of this
152
+ # board, when realtime broadcasting is enabled. The mover will also
153
+ # receive this broadcast (they are subscribed to the stream too) — but
154
+ # re-rendering the same frames is idempotent, so the double update is
155
+ # harmless.
156
+ if board.realtime?
157
+ Plutonium::Kanban::Broadcaster.broadcast(
158
+ resource_class: resource_class,
159
+ scoped_entity: scoped_to_entity? ? current_scoped_entity : nil,
160
+ content: streams.join
161
+ )
162
+ end
163
+
164
+ render turbo_stream: streams
165
+ end
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ # Builds the kanban board shell component for the index page.
172
+ #
173
+ # Used by the _resource_kanban partial (Task 10). The shell renders one
174
+ # lazy turbo-frame per column — no card data is fetched here; the frames
175
+ # load card bodies on demand via the Task 6 column endpoint.
176
+ #
177
+ # Resolves columns via Grouping.resolve_columns so dynamic boards work
178
+ # identically to static ones. grouped_data has empty card arrays because
179
+ # the shell header only needs the column metadata (label, color, key).
180
+ def build_kanban_board_shell
181
+ board = current_kanban_board
182
+ columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
183
+ grouped_data = columns.map { |col| {column: col, cards: [], total: 0} }
184
+ Plutonium::UI::Kanban::Resource.new(
185
+ board:,
186
+ grouped_data:,
187
+ resource_definition: current_definition,
188
+ resource_fields: permitted_attributes_for("index"),
189
+ resource_class: resource_class,
190
+ scoped_entity: scoped_to_entity? ? current_scoped_entity : nil
191
+ )
192
+ end
193
+
194
+ # Memoized kanban board. Prefers the board precompiled at definition
195
+ # class-load time (Definition::IndexViews.kanban); falls back to building
196
+ # from the block for safety and dynamic edge cases.
197
+ def current_kanban_board
198
+ @current_kanban_board ||= current_definition.defined_kanban_board ||
199
+ Plutonium::Kanban::DSL.build(&current_definition.defined_kanban_block)
200
+ end
201
+
202
+ # Authorized + query-applied UN-paginated relation.
203
+ #
204
+ # Mirrors filtered_resource_collection from IndexAction::CrudActions but
205
+ # without the Pagy pagination step. Reuses the same query pipeline so
206
+ # search, filters, scopes, and tenant/parent scoping all apply.
207
+ def kanban_base_relation
208
+ @kanban_base_relation ||= begin
209
+ query_params = current_definition
210
+ .query_form.new(nil, query_object: current_query_object, page_size: nil)
211
+ .extract_input(params, view_context:)[:q]
212
+
213
+ base_query = current_authorized_scope
214
+ current_query_object.apply(base_query, query_params, context: self)
215
+ end
216
+ end
217
+
218
+ # Intercepts the index action when view=kanban + column= is present.
219
+ # Renders only the turbo-frame body for the requested column and halts.
220
+ def maybe_render_kanban_column
221
+ return unless params[:view] == "kanban" && params[:column].present?
222
+ return unless current_definition.defined_kanban_block
223
+
224
+ # Fulfill authorization requirements so after_action verifiers pass.
225
+ authorize_current! resource_class
226
+
227
+ board = current_kanban_board
228
+
229
+ # Resolve only the requested column rather than grouping the whole
230
+ # board: Grouping.call would scope+count+limit every column (~2 queries
231
+ # each) on every lazy frame request. We compare keys as strings to
232
+ # avoid interning arbitrary request input into symbols.
233
+ columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
234
+ column = columns.find { |c| c.key.to_s == params[:column] }
235
+
236
+ # The lazy `<turbo-frame id="kanban-col-<key>" src=…>` in the shell
237
+ # requires the response to contain a turbo-frame with the SAME id, or
238
+ # Turbo renders "Content missing". Wrap the column body in that frame.
239
+ # (The move action targets the frame via turbo_stream.update instead,
240
+ # so render_kanban_column_html stays body-only for that path.)
241
+ frame_id = "kanban-col-#{params[:column]}"
242
+
243
+ # Unknown column key — render an empty (matching) frame, no crash.
244
+ # kanban_base_relation is referenced so verify_current_authorized_scope
245
+ # still passes even on the empty path.
246
+ unless column
247
+ kanban_base_relation
248
+ empty = view_context.content_tag("turbo-frame", "", id: frame_id)
249
+ return render(html: empty, layout: false)
250
+ end
251
+
252
+ framed = view_context.content_tag("turbo-frame", render_kanban_column_html(column), id: frame_id)
253
+ render html: framed, layout: false
254
+ end
255
+
256
+ # Renders a single column component to an HTML-safe string.
257
+ #
258
+ # Accepts either a Plutonium::Kanban::Column object or a column key
259
+ # (String/Symbol). Returns an empty SafeBuffer for unknown keys.
260
+ def render_kanban_column_html(column_or_key)
261
+ board = current_kanban_board
262
+
263
+ column = if column_or_key.is_a?(Plutonium::Kanban::Column)
264
+ column_or_key
265
+ else
266
+ columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
267
+ columns.find { |c| c.key.to_s == column_or_key.to_s }
268
+ end
269
+
270
+ return "".html_safe unless column
271
+
272
+ scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
273
+ ordered = board.position_config.order(scoped)
274
+
275
+ if board.per_column
276
+ total = ordered.count
277
+ cards = ordered.limit(board.per_column).to_a
278
+ else
279
+ cards = ordered.to_a
280
+ total = cards.size
281
+ end
282
+
283
+ # Cards are a read-only display, so resolve the visible fields from the
284
+ # index/read attribute set rather than the action name. This keeps the
285
+ # move action from needing a `permitted_attributes_for_kanban_move`
286
+ # method — kanban deliberately has no permitted-attributes concept.
287
+ column_action_data = column.actions.map do |col_action|
288
+ {action: col_action, ids: kanban_column_action_ids(column, on: col_action.on)}
289
+ end
290
+
291
+ column_add_url = if column.add? && current_policy.allowed_to?(:create?)
292
+ resource_url_for(resource_class, action: :new, kanban_column: column.key)
293
+ end
294
+
295
+ component = Plutonium::UI::Kanban::Column.new(
296
+ column:,
297
+ cards:,
298
+ total:,
299
+ per_column: board.per_column,
300
+ resource_definition: current_definition,
301
+ resource_fields: permitted_attributes_for("index"),
302
+ column_action_data:,
303
+ column_add_url:,
304
+ card_fields: board.card_fields,
305
+ card_show_frame: kanban_card_show_frame(board)
306
+ )
307
+ view_context.render(component).html_safe
308
+ end
309
+
310
+ # Resolves the turbo-frame a card's show link targets, from the board's
311
+ # effective show_in (the board's own value, or the definition's when the
312
+ # board doesn't override it):
313
+ #
314
+ # :modal → the remote-modal frame, so a card click opens the record in a
315
+ # centered dialog (the show page is always centered).
316
+ # :page → "_top", a full-page navigation to the show route.
317
+ #
318
+ # Either target escapes the column's lazy turbo-frame: "_top" replaces the
319
+ # whole page, and the remote-modal frame lives in the layout (document-wide),
320
+ # so Turbo resolves it outside the column frame.
321
+ def kanban_card_show_frame(board)
322
+ if board.show_in_for(current_definition) == :modal
323
+ Plutonium::REMOTE_MODAL_FRAME
324
+ else
325
+ "_top"
326
+ end
327
+ end
328
+
329
+ # Returns the primary-key ids for a column action based on `on:` scope.
330
+ #
331
+ # on: :all → ids of ALL records matching the column scope within
332
+ # the current kanban_base_relation (ignores per_column).
333
+ # on: :visible → ids of the rendered, per_column-capped subset (applies
334
+ # position ordering + limit, then plucks ids).
335
+ #
336
+ # Any other value falls back to :all behaviour.
337
+ def kanban_column_action_ids(column, on:)
338
+ scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
339
+ case on.to_sym
340
+ when :visible
341
+ board = current_kanban_board
342
+ ordered = board.position_config.order(scoped)
343
+ limited = board.per_column ? ordered.limit(board.per_column) : ordered
344
+ limited.pluck(resource_class.primary_key)
345
+ else # :all and any unknown value
346
+ scoped.pluck(resource_class.primary_key)
347
+ end
348
+ end
349
+
350
+ # Injects the column's seed attributes into params so the new form
351
+ # pre-fills the grouping attribute (e.g. status="todo").
352
+ #
353
+ # Triggered by the kanban_column= query param that the "+ Add" link
354
+ # carries. The seed is extracted by running a DRY-RUN of on_drop against
355
+ # a sentinel record whose save/update! methods are intercepted to prevent
356
+ # any DB write. The resulting attribute changes are merged into the
357
+ # resource params so maybe_apply_submitted_resource_params! sees them and
358
+ # pre-populates @resource_record before the form renders.
359
+ def apply_kanban_column_defaults!
360
+ return unless params[:kanban_column].present?
361
+ return unless current_definition.defined_kanban_block
362
+
363
+ board = current_kanban_board
364
+ columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
365
+ column = columns.find { |c| c.key.to_s == params[:kanban_column].to_s }
366
+ return unless column&.add?
367
+
368
+ # A raising on_drop must not 500 the new form — degrade to an unseeded
369
+ # form so the user can still create the record (and set the grouping
370
+ # field manually).
371
+ seed_attrs = begin
372
+ kanban_column_on_drop_seed(column)
373
+ rescue => e
374
+ Rails.logger.warn { "kanban quick-add seed failed for column #{column.key}: #{e.message}" }
375
+ return
376
+ end
377
+ return if seed_attrs.blank?
378
+
379
+ # Inject into params (indifferent access — string key is fine).
380
+ # Use ||= so an explicit user-provided value in the URL is preserved.
381
+ params[resource_param_key] ||= ActionController::Parameters.new({})
382
+ seed_attrs.stringify_keys.each { |k, v| params[resource_param_key][k] ||= v }
383
+ end
384
+
385
+ # Runs on_drop against a sentinel record that intercepts save/update!
386
+ # calls so no row is written to the DB. Returns the attribute changes
387
+ # the on_drop block would have applied (e.g. {"status" => "todo"}).
388
+ #
389
+ # NOTE: this only stubs save/save!/update/update! on the sentinel record.
390
+ # An on_drop that has external side effects (enqueuing jobs, API calls,
391
+ # touching OTHER records) would fire those side effects on every "+ Add"
392
+ # click, since they bypass the stubbed methods. This is acceptable for
393
+ # the common `attr = value` / `update!(attr: value)` pattern but is a
394
+ # footgun for exotic on_drop callbacks.
395
+ def kanban_column_on_drop_seed(column)
396
+ return {} unless column.on_drop
397
+
398
+ seed = resource_class.new
399
+ seed.define_singleton_method(:update!) { |attrs = {}|
400
+ assign_attributes(attrs)
401
+ self
402
+ }
403
+ seed.define_singleton_method(:update) { |attrs = {}|
404
+ assign_attributes(attrs)
405
+ true
406
+ }
407
+ seed.define_singleton_method(:save!) { |**| true }
408
+ seed.define_singleton_method(:save) { |**| true }
409
+
410
+ if column.on_drop.is_a?(Symbol)
411
+ seed.public_send(column.on_drop)
412
+ else
413
+ kanban_context.instance_exec(seed, &column.on_drop)
414
+ end
415
+
416
+ seed.changes.transform_values { |(_, new_val)| new_val }
417
+ end
418
+
419
+ # Renders a 422 turbo stream response that re-renders the source column
420
+ # unchanged, allowing the Stimulus drag controller to snap the card back.
421
+ #
422
+ # When a reason is given, a single dismissable toast is appended to the
423
+ # board's #kanban-flash region so the snap-back is explained rather than
424
+ # silent. It renders the shared _toast partial directly (not via flash)
425
+ # so a stale, undisplayed flash from an earlier request can't leak into
426
+ # the turbo_stream response — these move POSTs never render the layout
427
+ # that would otherwise consume the flash.
428
+ def render_kanban_rejection(from_key, reason: nil)
429
+ streams = [
430
+ turbo_stream.update(
431
+ "kanban-col-#{from_key}",
432
+ render_kanban_column_html(from_key.to_s)
433
+ )
434
+ ]
435
+
436
+ if reason
437
+ streams << turbo_stream.append(
438
+ "kanban-flash",
439
+ partial: "plutonium/toast",
440
+ locals: {type: :warning, msg: reason}
441
+ )
442
+ end
443
+
444
+ render turbo_stream: streams, status: :unprocessable_content
445
+ end
446
+
447
+ # Evaluation context for dynamic `columns do…end` blocks — delegates to
448
+ # the view_context so the block can call current_user, params, etc.
449
+ def kanban_context
450
+ @kanban_context ||= Plutonium::Kanban::Context.new(view_context)
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Resource-mounted wizard launch surface (§5.1 / Fix A). Anchored (and
7
+ # non-anchored) wizards registered via the `wizard` definition macro are
8
+ # auto-mounted as member/collection routes on the resource's OWN controller —
9
+ # the same way interactive record/resource actions are (see
10
+ # {InteractiveActions}). This is what makes the anchor IDOR-safe:
11
+ #
12
+ # - **member** actions (`wizard_record_action` / `commit_wizard_record_action`)
13
+ # resolve the anchor through the resource controller's scoped, policy-gated
14
+ # `resource_record!` — never an unscoped `find_by(id:)`. A record outside the
15
+ # portal's authorized scope 404s, exactly like a record action.
16
+ # - **collection** actions (`wizard_resource_action` /
17
+ # `commit_wizard_resource_action`) have no anchor (create flows).
18
+ #
19
+ # The runner-driving flow itself lives in {Plutonium::Wizard::Driving} (shared
20
+ # with the standalone {Plutonium::Wizard::Controller}); this concern only
21
+ # supplies the surface hooks (wizard class from the definition's registry, the
22
+ # anchor from `resource_record!`, the per-step URL) and the action authorization
23
+ # (the resource action policy predicate, mirroring interactive actions).
24
+ module WizardActions
25
+ extend ActiveSupport::Concern
26
+ include Plutonium::Wizard::Driving
27
+
28
+ included do
29
+ before_action :validate_wizard_action!, only: %i[
30
+ launch_wizard_record_action launch_wizard_resource_action
31
+ wizard_record_action commit_wizard_record_action
32
+ wizard_resource_action commit_wizard_resource_action
33
+ ]
34
+
35
+ before_action :authorize_wizard_record_action!, only: %i[
36
+ launch_wizard_record_action
37
+ wizard_record_action commit_wizard_record_action
38
+ ]
39
+
40
+ before_action :authorize_wizard_resource_action!, only: %i[
41
+ launch_wizard_resource_action
42
+ wizard_resource_action commit_wizard_resource_action
43
+ ]
44
+ end
45
+
46
+ # GET /resources/:id/wizards/:wizard_name — resolve the run, redirect to step.
47
+ def launch_wizard_record_action
48
+ wizard_launch
49
+ end
50
+
51
+ # GET /resources/wizards/:wizard_name — resolve the run, redirect to step.
52
+ def launch_wizard_resource_action
53
+ skip_verify_current_authorized_scope!
54
+ wizard_launch
55
+ end
56
+
57
+ # GET /resources/:id/wizards/:wizard_name/(:token)/:step
58
+ def wizard_record_action
59
+ wizard_show
60
+ end
61
+
62
+ # POST /resources/:id/wizards/:wizard_name/(:token)/:step
63
+ def commit_wizard_record_action
64
+ wizard_update
65
+ end
66
+
67
+ # GET /resources/wizards/:wizard_name/(:token)/:step
68
+ def wizard_resource_action
69
+ skip_verify_current_authorized_scope!
70
+ wizard_show
71
+ end
72
+
73
+ # POST /resources/wizards/:wizard_name/(:token)/:step
74
+ def commit_wizard_resource_action
75
+ skip_verify_current_authorized_scope!
76
+ wizard_update
77
+ end
78
+
79
+ private
80
+
81
+ # --- surface hooks (override Driving) ---
82
+
83
+ # The wizard class for this request, resolved from the resource definition's
84
+ # registry by the `:wizard_name` route segment.
85
+ def current_wizard_class
86
+ @current_wizard_class ||= current_wizard_registration.fetch(:wizard_class)
87
+ end
88
+
89
+ # The anchor for member (record) actions is the scoped, policy-gated record
90
+ # — the IDOR-safe path. Member routes carry `:id`; collection (create) routes
91
+ # don't, and have no anchor.
92
+ def resolved_wizard_anchor
93
+ return nil if params[:id].blank?
94
+
95
+ resource_record!
96
+ end
97
+
98
+ # Build the GET URL for a given step, preserving the `:id` (member),
99
+ # `:wizard_name`, and `:token` segments. Built through `resource_url_for`
100
+ # with the `wizard:` kwarg (mirroring how interactions build their URLs via
101
+ # `resource_url_for(..., interaction:)`) — never string-surgery on
102
+ # `request.path`, so the URL is always a same-host, route-validated path.
103
+ def wizard_step_url(step_key)
104
+ resource_url_for(
105
+ wizard_url_subject,
106
+ wizard: current_wizard_name,
107
+ step: step_key,
108
+ **wizard_token_param
109
+ )
110
+ end
111
+
112
+ # The URL anchor: the scoped record for member (record) actions, the
113
+ # resource class for collection (resource) actions.
114
+ def wizard_url_subject
115
+ params[:id].present? ? resource_record! : resource_class
116
+ end
117
+
118
+ def current_wizard_name
119
+ params[:wizard_name]
120
+ end
121
+
122
+ # Carry the `:token` segment for an authenticated repeatable (tokened) run,
123
+ # so a fresh GET resumes rather than forks (§4.5). Guest/keyed runs add no
124
+ # URL token (see Driving#wizard_url_token).
125
+ def wizard_token_param
126
+ token = wizard_url_token
127
+ token.present? ? {token: token} : {}
128
+ end
129
+
130
+ # --- registry / authorization ---
131
+
132
+ def registered_wizards
133
+ @registered_wizards ||= current_definition.class.registered_wizards
134
+ end
135
+
136
+ def current_wizard_registration
137
+ registered_wizards.fetch(params[:wizard_name]&.to_sym)
138
+ end
139
+
140
+ def wizard_page_description
141
+ current_definition.defined_actions[params[:wizard_name]&.to_sym]&.description
142
+ end
143
+
144
+ def validate_wizard_action!
145
+ key = params[:wizard_name]&.to_sym
146
+ unless registered_wizards.key?(key)
147
+ raise ::AbstractController::ActionNotFound, "Unknown wizard '#{key}'"
148
+ end
149
+ end
150
+
151
+ # Mirror interactive record-action authorization: gate via the resource
152
+ # action policy predicate named after the wizard key (e.g. `configure?`).
153
+ def authorize_wizard_record_action!
154
+ authorize_current! resource_record!, to: :"#{params[:wizard_name]}?"
155
+ end
156
+
157
+ # Mirror interactive resource-action authorization: gate via the resource
158
+ # class action policy predicate (e.g. `onboard?`).
159
+ def authorize_wizard_resource_action!
160
+ authorize_current! resource_class, to: :"#{params[:wizard_name]}?"
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -172,6 +172,14 @@ module Plutonium
172
172
  update?
173
173
  end
174
174
 
175
+ # Authorizes a kanban board move. Delegates to update? by default — override
176
+ # to allow board drags without granting full edit-form access.
177
+ #
178
+ # @return [Boolean] Delegates to update?.
179
+ def kanban_move?
180
+ update?
181
+ end
182
+
175
183
  # Checks if record search is permitted.
176
184
  #
177
185
  # @return [Boolean] Delegates to index?.