plutonium 0.61.0 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +315 -38
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/docs/.vitepress/config.ts +21 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/kanban.md +128 -18
- data/docs/reference/kanban/authorization.md +25 -5
- data/docs/reference/kanban/dsl.md +49 -8
- data/docs/reference/kanban/index.md +3 -3
- data/docs/reference/kanban/positioning.md +1 -1
- data/docs/reference/resource/definition.md +10 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +41 -0
- data/docs/reference/wizard/dsl.md +5 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/plutonium/action/base.rb +8 -0
- data/lib/plutonium/configuration.rb +12 -0
- data/lib/plutonium/definition/index_views.rb +16 -0
- data/lib/plutonium/kanban/column.rb +80 -27
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/resource/controller.rb +22 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
- data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
- data/lib/plutonium/resource/policy.rb +6 -0
- data/lib/plutonium/routing/mapper_extensions.rb +1 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/form/base.rb +6 -0
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/uppy.rb +20 -2
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
- data/lib/plutonium/ui/form/resource.rb +12 -0
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/grid/card.rb +40 -13
- data/lib/plutonium/ui/kanban/column.rb +111 -24
- data/lib/plutonium/ui/kanban/resource.rb +118 -11
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +1 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +7 -2
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +5 -3
- data/src/css/components.css +5 -0
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +442 -55
- data/src/js/controllers/register_controllers.js +2 -0
- data/yarn.lock +674 -4
- metadata +14 -2
|
@@ -20,7 +20,7 @@ module Plutonium
|
|
|
20
20
|
# 1. Authorizes via kanban_move? policy predicate.
|
|
21
21
|
# 2. Validates the drop (accepts? + locked?).
|
|
22
22
|
# 3. Enforces the destination WIP limit (cross-column drops only).
|
|
23
|
-
# 4. Applies the column's
|
|
23
|
+
# 4. Applies the column's on_enter callback (Symbol or 1-arg Proc).
|
|
24
24
|
# 5. Repositions within the destination column via position_config.
|
|
25
25
|
# 6. Responds with Turbo Stream updates for the from + to column frames.
|
|
26
26
|
# On rejection responds 422 and re-renders the unchanged source frame
|
|
@@ -32,15 +32,35 @@ module Plutonium
|
|
|
32
32
|
module KanbanActions
|
|
33
33
|
extend ActiveSupport::Concern
|
|
34
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
|
+
|
|
35
57
|
included do
|
|
58
|
+
prepend ReloadRedirects
|
|
59
|
+
|
|
36
60
|
# Intercept index when view=kanban + column=<key> is present.
|
|
37
61
|
# Runs BEFORE setup_index_action! so no wasteful pagination query.
|
|
38
62
|
before_action :maybe_render_kanban_column, only: :index
|
|
39
63
|
|
|
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
64
|
# Exposed to views/partials so _resource_kanban.html.erb can call it.
|
|
45
65
|
helper_method :build_kanban_board_shell
|
|
46
66
|
end
|
|
@@ -57,10 +77,11 @@ module Plutonium
|
|
|
57
77
|
def kanban_move
|
|
58
78
|
# Find record within authorized scope (satisfies scope verifier).
|
|
59
79
|
record = kanban_base_relation.find(params[:id])
|
|
60
|
-
# Check move permission (satisfies authorize verifier).
|
|
61
|
-
authorize_current! record, to: :kanban_move?
|
|
62
80
|
|
|
63
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!
|
|
64
85
|
head :not_found
|
|
65
86
|
return
|
|
66
87
|
end
|
|
@@ -70,12 +91,33 @@ module Plutonium
|
|
|
70
91
|
from = columns.find { |c| c.key.to_s == params[:from_column].to_s }
|
|
71
92
|
to = columns.find { |c| c.key.to_s == params[:to_column].to_s }
|
|
72
93
|
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
|
|
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?
|
|
79
121
|
reason =
|
|
80
122
|
if from&.locked?
|
|
81
123
|
"Cards can't be moved out of “#{from.label}”."
|
|
@@ -92,7 +134,12 @@ module Plutonium
|
|
|
92
134
|
# (cross-column, same-column reorder, record already in destination).
|
|
93
135
|
dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
|
|
94
136
|
dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
|
|
95
|
-
to_index
|
|
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)
|
|
96
143
|
|
|
97
144
|
# WIP limit only applies to cross-column drops (reordering within the
|
|
98
145
|
# same column does not change its cardinality). This is a
|
|
@@ -108,25 +155,104 @@ module Plutonium
|
|
|
108
155
|
prev_record = (to_index > 0) ? dest_cards[to_index - 1] : nil
|
|
109
156
|
next_record = dest_cards[to_index]
|
|
110
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
|
+
|
|
111
200
|
ActiveRecord::Base.transaction do
|
|
112
|
-
# Apply
|
|
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
|
+
#
|
|
113
210
|
# Symbol → record.public_send(sym) (named method on the record)
|
|
114
211
|
# Proc → evaluated with self = kanban_context (delegates to
|
|
115
212
|
# view_context so `current_user` etc. work as bare calls)
|
|
116
213
|
# and the record as the single block arg, matching the
|
|
117
|
-
# public 1-arg DSL form:
|
|
118
|
-
if
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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?
|
|
122
232
|
end
|
|
123
233
|
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
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
|
|
128
254
|
|
|
129
|
-
# Reposition within the destination column.
|
|
255
|
+
# (3) Reposition within the destination column.
|
|
130
256
|
# Mode A delegates to record.reposition! (calls update! for position).
|
|
131
257
|
# Mode B calls the user-supplied block.
|
|
132
258
|
# Mode C is a no-op (no ordering; position unchanged).
|
|
@@ -138,36 +264,212 @@ module Plutonium
|
|
|
138
264
|
index: to_index
|
|
139
265
|
)
|
|
140
266
|
|
|
141
|
-
# Final save covers Mode C where reposition! is a no-op but
|
|
267
|
+
# Final save covers Mode C where reposition! is a no-op but on_enter
|
|
142
268
|
# only assigned in memory, or any other unsaved attribute changes.
|
|
143
269
|
record.save! if record.changed?
|
|
144
270
|
end
|
|
145
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
|
+
|
|
146
290
|
respond_to do |format|
|
|
147
291
|
format.turbo_stream do
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
150
296
|
|
|
151
|
-
# Broadcast the
|
|
297
|
+
# Broadcast the frame updates to other connected viewers of this
|
|
152
298
|
# board, when realtime broadcasting is enabled. The mover will also
|
|
153
299
|
# receive this broadcast (they are subscribed to the stream too) — but
|
|
154
300
|
# re-rendering the same frames is idempotent, so the double update is
|
|
155
|
-
# harmless.
|
|
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.
|
|
156
304
|
if board.realtime?
|
|
157
305
|
Plutonium::Kanban::Broadcaster.broadcast(
|
|
158
306
|
resource_class: resource_class,
|
|
159
307
|
scoped_entity: scoped_to_entity? ? current_scoped_entity : nil,
|
|
160
|
-
content:
|
|
308
|
+
content: column_streams.join
|
|
161
309
|
)
|
|
162
310
|
end
|
|
163
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
|
+
|
|
164
328
|
render turbo_stream: streams
|
|
165
329
|
end
|
|
166
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
|
|
167
462
|
end
|
|
168
463
|
|
|
169
464
|
private
|
|
170
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
|
+
|
|
171
473
|
# Builds the kanban board shell component for the index page.
|
|
172
474
|
#
|
|
173
475
|
# Used by the _resource_kanban partial (Task 10). The shell renders one
|
|
@@ -180,7 +482,13 @@ module Plutonium
|
|
|
180
482
|
def build_kanban_board_shell
|
|
181
483
|
board = current_kanban_board
|
|
182
484
|
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
183
|
-
|
|
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
|
|
184
492
|
Plutonium::UI::Kanban::Resource.new(
|
|
185
493
|
board:,
|
|
186
494
|
grouped_data:,
|
|
@@ -289,7 +597,29 @@ module Plutonium
|
|
|
289
597
|
end
|
|
290
598
|
|
|
291
599
|
column_add_url = if column.add? && current_policy.allowed_to?(:create?)
|
|
292
|
-
resource_url_for(resource_class, action: :new, kanban_column: column.key
|
|
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
|
|
293
623
|
end
|
|
294
624
|
|
|
295
625
|
component = Plutonium::UI::Kanban::Column.new(
|
|
@@ -301,12 +631,72 @@ module Plutonium
|
|
|
301
631
|
resource_fields: permitted_attributes_for("index"),
|
|
302
632
|
column_action_data:,
|
|
303
633
|
column_add_url:,
|
|
634
|
+
board_url: kanban_board_url,
|
|
304
635
|
card_fields: board.card_fields,
|
|
305
|
-
card_show_frame: kanban_card_show_frame(board)
|
|
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:
|
|
306
641
|
)
|
|
307
642
|
view_context.render(component).html_safe
|
|
308
643
|
end
|
|
309
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
|
+
|
|
310
700
|
# Resolves the turbo-frame a card's show link targets, from the board's
|
|
311
701
|
# effective show_in (the board's own value, or the definition's when the
|
|
312
702
|
# board doesn't override it):
|
|
@@ -347,73 +737,58 @@ module Plutonium
|
|
|
347
737
|
end
|
|
348
738
|
end
|
|
349
739
|
|
|
350
|
-
#
|
|
351
|
-
#
|
|
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.
|
|
352
747
|
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
#
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
def apply_kanban_column_defaults!
|
|
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
|
|
360
754
|
return unless params[:kanban_column].present?
|
|
361
755
|
return unless current_definition.defined_kanban_block
|
|
362
756
|
|
|
363
|
-
|
|
364
|
-
columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
|
|
365
|
-
column = columns.find { |c| c.key.to_s == params[:kanban_column].to_s }
|
|
757
|
+
column = kanban_column_for(params[:kanban_column])
|
|
366
758
|
return unless column&.add?
|
|
367
759
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
#
|
|
371
|
-
|
|
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 }
|
|
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."
|
|
383
764
|
end
|
|
384
765
|
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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)
|
|
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)
|
|
414
776
|
end
|
|
777
|
+
record.save! if record.changed?
|
|
415
778
|
|
|
416
|
-
|
|
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?
|
|
417
792
|
end
|
|
418
793
|
|
|
419
794
|
# Renders a 422 turbo stream response that re-renders the source column
|
|
@@ -425,7 +800,7 @@ module Plutonium
|
|
|
425
800
|
# so a stale, undisplayed flash from an earlier request can't leak into
|
|
426
801
|
# the turbo_stream response — these move POSTs never render the layout
|
|
427
802
|
# that would otherwise consume the flash.
|
|
428
|
-
def render_kanban_rejection(from_key, reason: nil)
|
|
803
|
+
def render_kanban_rejection(from_key, reason: nil, status: :unprocessable_content)
|
|
429
804
|
streams = [
|
|
430
805
|
turbo_stream.update(
|
|
431
806
|
"kanban-col-#{from_key}",
|
|
@@ -441,7 +816,28 @@ module Plutonium
|
|
|
441
816
|
)
|
|
442
817
|
end
|
|
443
818
|
|
|
444
|
-
|
|
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)
|
|
445
841
|
end
|
|
446
842
|
|
|
447
843
|
# Evaluation context for dynamic `columns do…end` blocks — delegates to
|