plutonium 0.60.5 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,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(¤t_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?.
|