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
|
@@ -3,6 +3,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
3
3
|
// Connects to data-controller="intl-tel-input"
|
|
4
4
|
export default class extends Controller {
|
|
5
5
|
static targets = ["input"]
|
|
6
|
+
static values = { options: Object }
|
|
6
7
|
|
|
7
8
|
connect() {
|
|
8
9
|
}
|
|
@@ -49,10 +50,13 @@ export default class extends Controller {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
#buildOptions() {
|
|
53
|
+
// Defaults first; the definition-supplied `options` value (e.g.
|
|
54
|
+
// { initialCountry: "gh", strictMode: false }) is spread last so it wins.
|
|
52
55
|
return {
|
|
53
56
|
strictMode: true,
|
|
54
57
|
hiddenInput: () => ({ phone: this.inputTarget.attributes.name.value }),
|
|
55
58
|
loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js",
|
|
59
|
+
...this.optionsValue,
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { morphTurboFrameElements } from "@hotwired/turbo"
|
|
2
3
|
|
|
3
4
|
// Connects to data-controller="kanban"
|
|
4
5
|
//
|
|
@@ -37,15 +38,31 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
37
38
|
//
|
|
38
39
|
// ## Collapse toggle
|
|
39
40
|
//
|
|
40
|
-
// toggleColumn reads data-kanban-column-key from the clicked button, finds
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// The per-column state is persisted in localStorage keyed by the resource
|
|
44
|
-
// path + column key so the preference survives page reloads.
|
|
41
|
+
// toggleColumn reads data-kanban-column-key from the clicked button, finds the
|
|
42
|
+
// matching [data-kanban-col] wrapper, and flips `pu-kanban-column-collapsed` on
|
|
43
|
+
// it for instant feedback. CSS handles show/hide of strip vs body.
|
|
45
44
|
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
45
|
+
// The choice is persisted in a per-board cookie (name/path supplied via the
|
|
46
|
+
// collapse-cookie / collapse-path values) storing ONLY columns whose state
|
|
47
|
+
// differs from their server default — read from data-kanban-default-collapsed.
|
|
48
|
+
// Because the SERVER reads that cookie and renders each column in the user's
|
|
49
|
+
// state, there is no client re-apply on any render path (lazy load, search
|
|
50
|
+
// morph, move stream) and therefore no collapse FOUC. The delta encoding keeps
|
|
51
|
+
// the cookie compact and self-trimming (default board → no cookie).
|
|
52
|
+
//
|
|
53
|
+
// ## Frozen board (search / filter / scope)
|
|
54
|
+
//
|
|
55
|
+
// The board wrapper is `data-turbo-permanent`, so it survives index
|
|
56
|
+
// navigations intact rather than being re-rendered as empty lazy shells (which
|
|
57
|
+
// blanked the columns on every search keystroke). Turbo transplants the
|
|
58
|
+
// permanent element into the new page, which disconnects→reconnects this
|
|
59
|
+
// controller — so connect() runs on every nav, and the frozen frames still
|
|
60
|
+
// carry the PREVIOUS URL's src. #syncColumnsToUrl (called from connect() and on
|
|
61
|
+
// every `turbo:load`) reconciles each frame's src with the current URL,
|
|
62
|
+
// reloading only the frames that differ; `turbo:before-frame-render` upgrades
|
|
63
|
+
// those reloads to a MORPH so cards diff in place instead of blanking. The sync
|
|
64
|
+
// is stateless (frame-src-vs-URL, no "last synced" flag) precisely because the
|
|
65
|
+
// reconnect would reset any such flag before it could be used.
|
|
49
66
|
//
|
|
50
67
|
// ## Drop hints
|
|
51
68
|
//
|
|
@@ -72,7 +89,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
72
89
|
// controller never hand-manages rollback state.
|
|
73
90
|
// 4. dragend — clean up opacity + highlights + drop-hint classes.
|
|
74
91
|
export default class extends Controller {
|
|
75
|
-
static values = { moveUrlTemplate: String }
|
|
92
|
+
static values = { moveUrlTemplate: String, collapseCookie: String, collapsePath: String }
|
|
76
93
|
static targets = ["column"]
|
|
77
94
|
|
|
78
95
|
connect() {
|
|
@@ -90,9 +107,66 @@ export default class extends Controller {
|
|
|
90
107
|
this.element.addEventListener("drop", this.onDrop)
|
|
91
108
|
this.element.addEventListener("dragend", this.onDragEnd)
|
|
92
109
|
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
110
|
+
// Preserve horizontal scroll across navs AND full page reloads. The browser
|
|
111
|
+
// never restores an inner container's scroll, and neither a Turbo reattach
|
|
112
|
+
// (permanent board detached→reattached, scrollLeft reset to 0) nor an F5
|
|
113
|
+
// (fresh DOM) keeps it — so we persist it in sessionStorage, keyed per board,
|
|
114
|
+
// and restore it below. scrollTarget is the in-memory copy (seeded from
|
|
115
|
+
// storage on every connect, so it survives reconnects too).
|
|
116
|
+
this.scrollTarget = this.#readStoredScroll()
|
|
117
|
+
|
|
118
|
+
this.captureScroll = this.#captureScroll.bind(this)
|
|
119
|
+
|
|
120
|
+
// Trailing debounce: a scroll event only (re)arms the timer — it reads NO
|
|
121
|
+
// layout, so it can't force a reflow / stutter. The capture reads
|
|
122
|
+
// scrollLeft/scrollWidth once, after scrolling settles.
|
|
123
|
+
this.onBoardScroll = () => {
|
|
124
|
+
if (this.restoringScroll) return
|
|
125
|
+
clearTimeout(this.scrollSaveTimer)
|
|
126
|
+
this.scrollSaveTimer = setTimeout(this.captureScroll, 120)
|
|
127
|
+
}
|
|
128
|
+
this.element.addEventListener("scroll", this.onBoardScroll, { passive: true })
|
|
129
|
+
|
|
130
|
+
// A genuine scroll gesture during the restore window means the user has taken
|
|
131
|
+
// over — stop pinning so we don't fight them.
|
|
132
|
+
this.onUserScrollIntent = () => this.#endScrollRestore()
|
|
133
|
+
this.element.addEventListener("wheel", this.onUserScrollIntent, { passive: true })
|
|
134
|
+
this.element.addEventListener("touchmove", this.onUserScrollIntent, { passive: true })
|
|
135
|
+
|
|
136
|
+
// Capture immediately before an F5 / cross-document nav unloads us, so a
|
|
137
|
+
// reload mid-scroll (before the debounce fires) still saves the position.
|
|
138
|
+
this.onPageHide = this.captureScroll
|
|
139
|
+
window.addEventListener("pagehide", this.onPageHide)
|
|
140
|
+
|
|
141
|
+
// ── Frozen-board sync (see class header) ──
|
|
142
|
+
this.onTurboLoad = this.#syncColumnsToUrl.bind(this)
|
|
143
|
+
this.onBeforeFrameRender = this.#onBeforeFrameRender.bind(this)
|
|
144
|
+
this.onFrameRender = this.#onFrameRender.bind(this)
|
|
145
|
+
this.onBeforeStreamRender = this.#onBeforeStreamRender.bind(this)
|
|
146
|
+
|
|
147
|
+
// After each navigation, reconcile the column frames' src with the new URL.
|
|
148
|
+
document.addEventListener("turbo:load", this.onTurboLoad)
|
|
149
|
+
// Force a MORPH render on column-frame reloads (a plain src change would
|
|
150
|
+
// blank→fill; morphing diffs cards in place).
|
|
151
|
+
document.addEventListener("turbo:before-frame-render", this.onBeforeFrameRender)
|
|
152
|
+
// turbo:frame-render (nav reload) and turbo:before-stream-render (move /
|
|
153
|
+
// realtime) are hooked only to keep the horizontal scroll pinned as columns
|
|
154
|
+
// re-render — collapse state no longer needs re-applying, because the server
|
|
155
|
+
// renders each column in the user's cookie-persisted state directly.
|
|
156
|
+
document.addEventListener("turbo:frame-render", this.onFrameRender)
|
|
157
|
+
document.addEventListener("turbo:before-stream-render", this.onBeforeStreamRender)
|
|
158
|
+
|
|
159
|
+
// Reconcile immediately: when Turbo transplants the permanent board it
|
|
160
|
+
// disconnects→reconnects this controller, so connect() itself runs on every
|
|
161
|
+
// nav. A stale frame (src carrying the previous URL's params) is reloaded
|
|
162
|
+
// here; a fresh board (frames already matching the URL) is a no-op.
|
|
163
|
+
this.#syncColumnsToUrl()
|
|
164
|
+
|
|
165
|
+
// connect() runs right after reattach — put the horizontal scroll back.
|
|
166
|
+
this.#restoreScrollLeft()
|
|
167
|
+
|
|
168
|
+
// Refresh cached columns after a write (see #reloadAfterWrite).
|
|
169
|
+
this.#reloadAfterWrite()
|
|
96
170
|
}
|
|
97
171
|
|
|
98
172
|
disconnect() {
|
|
@@ -101,6 +175,231 @@ export default class extends Controller {
|
|
|
101
175
|
this.element.removeEventListener("dragleave", this.onDragLeave)
|
|
102
176
|
this.element.removeEventListener("drop", this.onDrop)
|
|
103
177
|
this.element.removeEventListener("dragend", this.onDragEnd)
|
|
178
|
+
|
|
179
|
+
// Capture before we lose this instance to a Turbo reconnect, so the next
|
|
180
|
+
// connect reads an up-to-date position.
|
|
181
|
+
this.#captureScroll()
|
|
182
|
+
this.element.removeEventListener("scroll", this.onBoardScroll)
|
|
183
|
+
this.element.removeEventListener("wheel", this.onUserScrollIntent)
|
|
184
|
+
this.element.removeEventListener("touchmove", this.onUserScrollIntent)
|
|
185
|
+
window.removeEventListener("pagehide", this.onPageHide)
|
|
186
|
+
clearTimeout(this.restoreScrollTimer)
|
|
187
|
+
clearTimeout(this.scrollSaveTimer)
|
|
188
|
+
|
|
189
|
+
document.removeEventListener("turbo:load", this.onTurboLoad)
|
|
190
|
+
document.removeEventListener("turbo:before-frame-render", this.onBeforeFrameRender)
|
|
191
|
+
document.removeEventListener("turbo:frame-render", this.onFrameRender)
|
|
192
|
+
document.removeEventListener("turbo:before-stream-render", this.onBeforeStreamRender)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Frozen-board URL sync ────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
// Reconcile every column frame's src with the current URL's board params
|
|
198
|
+
// (q / scope / sort). The board is frozen (data-turbo-permanent), so its
|
|
199
|
+
// frames keep whatever src they were last loaded with; this is what reflects a
|
|
200
|
+
// new search / filter / scope into the columns.
|
|
201
|
+
//
|
|
202
|
+
// STATELESS by design: we compare each frame's actual src against the src the
|
|
203
|
+
// current URL implies and reload only the ones that differ. We deliberately do
|
|
204
|
+
// NOT track "last synced URL" — Turbo reconnects this controller on every nav
|
|
205
|
+
// (the permanent board is transplanted), so any per-connect state would be
|
|
206
|
+
// reset to the new URL before we could diff against it, and the sync would
|
|
207
|
+
// never fire. Comparing frame-src-vs-URL has no such blind spot and is
|
|
208
|
+
// self-limiting: once a frame matches the URL, it won't reload again.
|
|
209
|
+
// A create/update/destroy that returns to the board redirects to a URL the
|
|
210
|
+
// server tagged with kanban_reload=1 (see KanbanActions#kanban_reload_url).
|
|
211
|
+
// The board is data-turbo-permanent, so its already-loaded column frames are
|
|
212
|
+
// kept as-is on arrival — stale (missing a new card, still showing a deleted
|
|
213
|
+
// one). Force every column to re-fetch, then strip the marker so a later
|
|
214
|
+
// reload / back-nav doesn't re-trigger it. The restore window opened by
|
|
215
|
+
// #restoreScrollLeft keeps the scroll pinned as the fresh frames render.
|
|
216
|
+
#reloadAfterWrite() {
|
|
217
|
+
const url = new URL(window.location.href)
|
|
218
|
+
if (!url.searchParams.has("kanban_reload")) return
|
|
219
|
+
url.searchParams.delete("kanban_reload")
|
|
220
|
+
history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`)
|
|
221
|
+
this.#columnFrames().forEach(frame => frame.reload())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#syncColumnsToUrl() {
|
|
225
|
+
this.#columnFrames().forEach(frame => {
|
|
226
|
+
const desired = this.#columnFrameSrc(frame.dataset.kanbanColFrame)
|
|
227
|
+
const current = frame.getAttribute("src")
|
|
228
|
+
if (current && this.#canonicalUrl(current) === this.#canonicalUrl(desired)) return
|
|
229
|
+
// Assigning src reloads the frame; #onBeforeFrameRender upgrades that
|
|
230
|
+
// reload to a morph so the cards diff in place.
|
|
231
|
+
frame.src = desired
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The src a column frame should carry for the current URL: the page's query
|
|
236
|
+
// params with view=kanban + column=<key> forced on. Mirrors the server's
|
|
237
|
+
// Kanban::Resource#column_frame_src so a freshly-rendered board's frames
|
|
238
|
+
// already match (no spurious reload).
|
|
239
|
+
#columnFrameSrc(key) {
|
|
240
|
+
const params = new URLSearchParams(window.location.search)
|
|
241
|
+
params.set("view", "kanban")
|
|
242
|
+
params.set("column", key)
|
|
243
|
+
return `${window.location.pathname}?${params.toString()}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Order-independent identity for a frame src: path + alphabetically sorted
|
|
247
|
+
// query params. The server serialises params sorted (Hash#to_query) while
|
|
248
|
+
// URLSearchParams preserves insertion order, so raw string comparison would
|
|
249
|
+
// report a false difference and reload every frame on every nav.
|
|
250
|
+
#canonicalUrl(src) {
|
|
251
|
+
const url = new URL(src, window.location.origin)
|
|
252
|
+
url.searchParams.sort()
|
|
253
|
+
return `${url.pathname}?${url.searchParams.toString()}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Turbo fires this before a frame renders fetched content. For our column
|
|
257
|
+
// frames we swap in a morph render so the reload diffs the card list rather
|
|
258
|
+
// than replacing it (which would blank the column). Uses Turbo's own frame
|
|
259
|
+
// morph so before/after hooks (turbo:before-frame-morph) still fire.
|
|
260
|
+
#onBeforeFrameRender(event) {
|
|
261
|
+
if (!this.#isColumnFrame(event.target)) return
|
|
262
|
+
// Diff the card list in place instead of blank→fill. Collapse state rides
|
|
263
|
+
// along correctly because the server rendered the fetched frame in the
|
|
264
|
+
// user's state, so morphing to it keeps that state — no re-apply needed.
|
|
265
|
+
event.detail.render = (currentElement, newElement) =>
|
|
266
|
+
morphTurboFrameElements(currentElement, newElement)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#onFrameRender(event) {
|
|
270
|
+
if (!this.#isColumnFrame(event.target)) return
|
|
271
|
+
// Each column render can change the board's width and clamp scrollLeft. On a
|
|
272
|
+
// full reload the columns lazy-load one at a time AFTER connect, so keep
|
|
273
|
+
// pinning — and push the restore window out — with every frame that lands.
|
|
274
|
+
if (this.restoringScroll) {
|
|
275
|
+
this.#pinScroll()
|
|
276
|
+
this.#bumpRestoreTimer()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Apply the saved horizontal position: to the live max when the user was at
|
|
281
|
+
// the end (so it tracks late width changes), otherwise to the exact offset.
|
|
282
|
+
// No-op when nothing meaningful was saved so it can't yank a fresh board to 0.
|
|
283
|
+
#pinScroll() {
|
|
284
|
+
const t = this.scrollTarget
|
|
285
|
+
if (!t || (!t.l && !t.e)) return
|
|
286
|
+
this.element.scrollLeft = t.e ? this.element.scrollWidth : t.l
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Re-apply the saved horizontal scroll across a window that extends with each
|
|
290
|
+
// column render, because renders (morph on nav, replace on move, lazy load on
|
|
291
|
+
// full reload) settle their width late and each settle can clamp scrollLeft
|
|
292
|
+
// back toward 0. Used by the reattach, turbo-stream, and initial-load paths.
|
|
293
|
+
#scheduleScrollRestore() {
|
|
294
|
+
if (!this.scrollTarget || (!this.scrollTarget.l && !this.scrollTarget.e)) return
|
|
295
|
+
this.restoringScroll = true
|
|
296
|
+
this.#pinScroll()
|
|
297
|
+
requestAnimationFrame(() => this.#pinScroll())
|
|
298
|
+
this.#bumpRestoreTimer()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// (Re)arm the timer that ends the restore window. Reset on every column render
|
|
302
|
+
// so the window lives ~400ms past the LAST column to settle — enough for slow
|
|
303
|
+
// lazy frames on a fresh load without pinning forever.
|
|
304
|
+
#bumpRestoreTimer() {
|
|
305
|
+
clearTimeout(this.restoreScrollTimer)
|
|
306
|
+
this.restoreScrollTimer = setTimeout(() => this.#endScrollRestore(), 400)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#endScrollRestore() {
|
|
310
|
+
if (!this.restoringScroll) return
|
|
311
|
+
this.#pinScroll()
|
|
312
|
+
this.restoringScroll = false
|
|
313
|
+
clearTimeout(this.restoreScrollTimer)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Restore on connect — covers Turbo reattach (search/filter) and a full page
|
|
317
|
+
// reload (F5), since scrollTarget is seeded from sessionStorage either way.
|
|
318
|
+
#restoreScrollLeft() {
|
|
319
|
+
this.#scheduleScrollRestore()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── scroll persistence (sessionStorage) ──
|
|
323
|
+
// Per-tab and auto-cleared on tab close, so it can't accumulate. Keyed by the
|
|
324
|
+
// board's collection path (tenant + resource scoped) via the move template.
|
|
325
|
+
|
|
326
|
+
#scrollKey() {
|
|
327
|
+
const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "")
|
|
328
|
+
return `pu-kanban-scroll:${path}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#readStoredScroll() {
|
|
332
|
+
try {
|
|
333
|
+
const raw = sessionStorage.getItem(this.#scrollKey())
|
|
334
|
+
return raw ? JSON.parse(raw) : null
|
|
335
|
+
} catch {
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Read the live position (unless a restore currently owns scrollTarget) and
|
|
341
|
+
// persist it. Runs on the scroll debounce, on pagehide, and on disconnect —
|
|
342
|
+
// never on the raw scroll event, so the layout read can't stutter scrolling.
|
|
343
|
+
#captureScroll() {
|
|
344
|
+
const el = this.element
|
|
345
|
+
// Skip the read when the board has no layout: on a nav teardown the element
|
|
346
|
+
// is detached, so scrollLeft/scrollWidth/clientWidth all read 0 — which would
|
|
347
|
+
// compute a bogus "at-end" (0 >= -2) and pin the next restore to the far end.
|
|
348
|
+
// The last in-memory target already reflects the user's real position, so
|
|
349
|
+
// keep it. `e` also requires genuine overflow, never a zero-width board.
|
|
350
|
+
if (!this.restoringScroll && el.clientWidth > 0) {
|
|
351
|
+
const maxScroll = el.scrollWidth - el.clientWidth
|
|
352
|
+
this.scrollTarget = {
|
|
353
|
+
l: el.scrollLeft,
|
|
354
|
+
e: maxScroll > 0 && el.scrollLeft >= maxScroll - 2
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!this.scrollTarget) return
|
|
358
|
+
try {
|
|
359
|
+
sessionStorage.setItem(this.#scrollKey(), JSON.stringify(this.scrollTarget))
|
|
360
|
+
} catch { /* private mode / quota — in-memory target still works this session */ }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// A move / realtime update re-renders columns via turbo-stream (replace). The
|
|
364
|
+
// server renders them in the user's collapse state, so we only need to protect
|
|
365
|
+
// the horizontal scroll: freeze tracking BEFORE the swap (replacing a column
|
|
366
|
+
// briefly removes it, narrowing the board so scrollLeft clamps toward 0 — that
|
|
367
|
+
// clamp would otherwise be saved as the new position, a visible jump at the
|
|
368
|
+
// far end), then restore once the columns are back.
|
|
369
|
+
#onBeforeStreamRender(event) {
|
|
370
|
+
// Only interfere with streams that target one of THIS board's column
|
|
371
|
+
// frames. Any other stream (a redirect, a flash append, another board's
|
|
372
|
+
// update, the remote_modal empty on drop-interaction success) must render
|
|
373
|
+
// untouched so unrelated stream actions don't surface through this wrapper.
|
|
374
|
+
if (!this.#streamTargetsColumn(event.target)) return
|
|
375
|
+
|
|
376
|
+
const render = event.detail.render
|
|
377
|
+
event.detail.render = async (streamElement) => {
|
|
378
|
+
this.restoringScroll = true
|
|
379
|
+
await render(streamElement)
|
|
380
|
+
this.#scheduleScrollRestore()
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// True when the <turbo-stream> element targets a column frame contained by
|
|
385
|
+
// this board — via its `target` (frame id) or `targets` (CSS selector).
|
|
386
|
+
#streamTargetsColumn(streamElement) {
|
|
387
|
+
if (!streamElement) return false
|
|
388
|
+
const target = streamElement.getAttribute("target")
|
|
389
|
+
if (target) return this.#isColumnFrame(document.getElementById(target))
|
|
390
|
+
const targets = streamElement.getAttribute("targets")
|
|
391
|
+
if (targets) {
|
|
392
|
+
return [...document.querySelectorAll(targets)].some(el => this.#isColumnFrame(el))
|
|
393
|
+
}
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#columnFrames() {
|
|
398
|
+
return this.element.querySelectorAll("turbo-frame[data-kanban-col-frame]")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#isColumnFrame(el) {
|
|
402
|
+
return el?.matches?.("turbo-frame[data-kanban-col-frame]") && this.element.contains(el)
|
|
104
403
|
}
|
|
105
404
|
|
|
106
405
|
// ─── Collapse toggle ─────────────────────────────────────────────────────────
|
|
@@ -121,16 +420,12 @@ export default class extends Controller {
|
|
|
121
420
|
if (!strip || !body) return
|
|
122
421
|
|
|
123
422
|
// `pu-kanban-column-collapsed` on the wrapper is what CSS uses to decide
|
|
124
|
-
// which half to show.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} else {
|
|
131
|
-
wrapper.classList.add("pu-kanban-column-collapsed")
|
|
132
|
-
this.#saveCollapseState(key, true)
|
|
133
|
-
}
|
|
423
|
+
// which half to show. Flip it for instant feedback, then persist the choice
|
|
424
|
+
// as a delta from the column's server default so the next render (from the
|
|
425
|
+
// server) comes back in this same state.
|
|
426
|
+
const nowCollapsed = wrapper.classList.toggle("pu-kanban-column-collapsed")
|
|
427
|
+
const defaultCollapsed = wrapper.dataset.kanbanDefaultCollapsed === "true"
|
|
428
|
+
this.#persistCollapse(key, nowCollapsed !== defaultCollapsed)
|
|
134
429
|
}
|
|
135
430
|
|
|
136
431
|
// ─── drag lifecycle ──────────────────────────────────────────────────────────
|
|
@@ -200,6 +495,39 @@ export default class extends Controller {
|
|
|
200
495
|
.filter(c => c !== this.draggedCard)
|
|
201
496
|
|
|
202
497
|
const toIndex = this.#computeDropIndex(event.clientY, existingCards)
|
|
498
|
+
|
|
499
|
+
// Columns that declare an enter_interaction, on a CROSS-column drop:
|
|
500
|
+
// • immediate (input-less) interaction → commit directly via the normal
|
|
501
|
+
// POST (the server runs the interaction with no params); honour an
|
|
502
|
+
// optional confirmation first. No empty modal.
|
|
503
|
+
// • input-collecting interaction → open the interaction modal; the
|
|
504
|
+
// card is left in place until the modal resolves.
|
|
505
|
+
// Same-column reorders (from == toColumn) fall through to the direct POST,
|
|
506
|
+
// which the server treats as positioning-only — mirroring the server's rule.
|
|
507
|
+
const destWrapper = column.closest("[data-kanban-col]")
|
|
508
|
+
if (destWrapper?.dataset.kanbanDropInteraction === "true" && fromColumn !== toColumn) {
|
|
509
|
+
if (destWrapper.dataset.kanbanDropImmediate === "true") {
|
|
510
|
+
const confirmMsg = destWrapper.dataset.kanbanDropConfirm
|
|
511
|
+
// Abort on decline — the card was never moved in the DOM, so there is
|
|
512
|
+
// nothing to restore.
|
|
513
|
+
if (confirmMsg && !window.confirm(confirmMsg)) return
|
|
514
|
+
// fall through to #submitMove (direct commit, no modal)
|
|
515
|
+
} else if (this.#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex })) {
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
// Immediate (confirmed), or the modal frame was unavailable — fall through
|
|
519
|
+
// to the direct POST so a drop is never silently dropped.
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
this.#submitMove(recordId, { fromColumn, toColumn, toIndex })
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Direct move: POST {from_column, to_column, to_index} to the move endpoint
|
|
526
|
+
// and feed the Turbo Stream response to Turbo. On success the server
|
|
527
|
+
// re-renders the from + to column frames; on 422 it re-renders only the
|
|
528
|
+
// source frame so the card snaps back — the controller never hand-manages
|
|
529
|
+
// rollback state.
|
|
530
|
+
async #submitMove(recordId, { fromColumn, toColumn, toIndex }) {
|
|
203
531
|
const url = this.moveUrlTemplateValue.replace("__ID__", recordId)
|
|
204
532
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
205
533
|
|
|
@@ -219,18 +547,71 @@ export default class extends Controller {
|
|
|
219
547
|
credentials: "same-origin",
|
|
220
548
|
})
|
|
221
549
|
|
|
222
|
-
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
550
|
+
// Only feed genuine Turbo Stream responses to Turbo. A rejected move can
|
|
551
|
+
// come back as a plain HTML document — e.g. a 403 Unauthorized renders an
|
|
552
|
+
// error page, not a stream. Passing that to renderStreamMessage makes
|
|
553
|
+
// Turbo morph the error page's markup into the board (the "page turns red"
|
|
554
|
+
// bug). Native HTML5 DnD never re-parented the card, so on a non-stream
|
|
555
|
+
// rejection there is nothing to snap back — the board already shows the
|
|
556
|
+
// card in its source column.
|
|
557
|
+
const contentType = response.headers.get("Content-Type") || ""
|
|
558
|
+
const isTurboStream = contentType.includes("text/vnd.turbo-stream.html")
|
|
559
|
+
|
|
560
|
+
if (isTurboStream && window.Turbo) {
|
|
561
|
+
const body = await response.text()
|
|
562
|
+
// #onBeforeStreamRender re-asserts the persisted collapse state for each
|
|
563
|
+
// column the stream re-renders (synchronously, before paint), so the
|
|
564
|
+
// move doesn't reset a column the user expanded/collapsed.
|
|
227
565
|
Turbo.renderStreamMessage(body)
|
|
566
|
+
} else if (!response.ok) {
|
|
567
|
+
console.error(`[kanban] move rejected (${response.status}); leaving card in place`)
|
|
568
|
+
} else {
|
|
569
|
+
// 2xx but NOT a Turbo Stream. The most common cause is an auth boundary
|
|
570
|
+
// that 302-redirected to a login page, which fetch transparently follows,
|
|
571
|
+
// yielding a 200 HTML document — so the move silently did nothing. Don't
|
|
572
|
+
// swallow it: log it so an expired session is diagnosable. The card is left
|
|
573
|
+
// in place (native DnD never re-parented it).
|
|
574
|
+
console.warn("[kanban] move returned a non-stream response (session expired?); leaving card in place")
|
|
228
575
|
}
|
|
229
576
|
} catch (error) {
|
|
230
577
|
console.error("[kanban] move request failed:", error)
|
|
231
578
|
}
|
|
232
579
|
}
|
|
233
580
|
|
|
581
|
+
// ─── drop-interaction modal ────────────────────────────────────────────────
|
|
582
|
+
//
|
|
583
|
+
// A cross-column drop into an enter_interaction column opens the interaction's
|
|
584
|
+
// form in the shared remote-modal frame instead of committing the move.
|
|
585
|
+
//
|
|
586
|
+
// Native HTML5 drag-and-drop never re-parents the card's DOM node — the card
|
|
587
|
+
// physically stays in its SOURCE column throughout, so there is nothing to
|
|
588
|
+
// snap back on cancel: dismissing the modal simply leaves the board as it
|
|
589
|
+
// already is. On success the server's turbo-stream re-renders the kanban-col-*
|
|
590
|
+
// frames and empties the remote_modal frame, so the board updates naturally.
|
|
591
|
+
//
|
|
592
|
+
// Returns true when the modal was opened (caller should stop), false when the
|
|
593
|
+
// remote-modal frame is unavailable (caller falls back to the direct POST).
|
|
594
|
+
#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex }) {
|
|
595
|
+
const template = destWrapper.dataset.kanbanDropFormUrlTemplate
|
|
596
|
+
// Plutonium::REMOTE_MODAL_FRAME — rendered once by the layout, outside this
|
|
597
|
+
// (permanent) board element.
|
|
598
|
+
const frame = document.getElementById("remote_modal")
|
|
599
|
+
if (!frame || !template) return false
|
|
600
|
+
|
|
601
|
+
const params = new URLSearchParams({
|
|
602
|
+
from_column: fromColumn,
|
|
603
|
+
to_column: toColumn,
|
|
604
|
+
to_index: toIndex,
|
|
605
|
+
})
|
|
606
|
+
const url = `${template.replace("__ID__", recordId)}?${params.toString()}`
|
|
607
|
+
|
|
608
|
+
// Point the modal frame at the interaction form. On success the server
|
|
609
|
+
// empties this frame; on 422 it re-renders the form (errors + preserved
|
|
610
|
+
// hidden fields) in place.
|
|
611
|
+
frame.src = url
|
|
612
|
+
return true
|
|
613
|
+
}
|
|
614
|
+
|
|
234
615
|
#onDragEnd(_event) {
|
|
235
616
|
this.#clearHighlights()
|
|
236
617
|
this.#clearDropHints()
|
|
@@ -270,38 +651,44 @@ export default class extends Controller {
|
|
|
270
651
|
return accepts.split(",").map(k => k.trim()).includes(sourceKey)
|
|
271
652
|
}
|
|
272
653
|
|
|
273
|
-
// ─── collapse persistence
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
654
|
+
// ─── collapse persistence (cookie delta) ───────────────────────────────────
|
|
655
|
+
//
|
|
656
|
+
// The cookie stores ONLY the column keys whose state differs from the server
|
|
657
|
+
// default, comma-joined (e.g. "todo,done"). The server reads it and renders
|
|
658
|
+
// each column in the user's state directly, so there's no client re-apply and
|
|
659
|
+
// no FOUC on any render path. The delta encoding keeps it compact and
|
|
660
|
+
// self-trimming: a board at its defaults has no cookie, and toggling a column
|
|
661
|
+
// back to default drops its key (and an empty set deletes the cookie).
|
|
662
|
+
|
|
663
|
+
// `flipped` = the column's state now differs from its default.
|
|
664
|
+
#persistCollapse(key, flipped) {
|
|
665
|
+
const keys = new Set(this.#readCollapseCookie())
|
|
666
|
+
if (flipped) keys.add(key)
|
|
667
|
+
else keys.delete(key)
|
|
668
|
+
this.#writeCollapseCookie([...keys])
|
|
287
669
|
}
|
|
288
670
|
|
|
289
|
-
#
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
} catch { /* private browsing: toggle still works, just won't persist */ }
|
|
671
|
+
#readCollapseCookie() {
|
|
672
|
+
const name = this.collapseCookieValue
|
|
673
|
+
if (!name) return []
|
|
674
|
+
const entry = document.cookie.split("; ").find(c => c.startsWith(`${name}=`))
|
|
675
|
+
if (!entry) return []
|
|
676
|
+
return decodeURIComponent(entry.slice(name.length + 1)).split(",").filter(Boolean)
|
|
296
677
|
}
|
|
297
678
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
679
|
+
#writeCollapseCookie(keys) {
|
|
680
|
+
const name = this.collapseCookieValue
|
|
681
|
+
if (!name) return
|
|
682
|
+
const path = this.collapsePathValue || "/"
|
|
683
|
+
if (keys.length === 0) {
|
|
684
|
+
// Board back to all-defaults — drop the cookie entirely.
|
|
685
|
+
document.cookie = `${name}=; path=${path}; max-age=0; SameSite=Lax`
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const value = encodeURIComponent(keys.join(","))
|
|
689
|
+
// ~6 months: refreshed on every toggle, so boards in active use persist and
|
|
690
|
+
// stale ones expire on their own — another guard against unbounded growth.
|
|
691
|
+
document.cookie = `${name}=${value}; path=${path}; max-age=${60 * 60 * 24 * 180}; SameSite=Lax`
|
|
305
692
|
}
|
|
306
693
|
|
|
307
694
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -37,6 +37,7 @@ import AutosubmitController from "./autosubmit_controller.js"
|
|
|
37
37
|
import DirtyFormGuardController from "./dirty_form_guard_controller.js"
|
|
38
38
|
import WizardController from "./wizard_controller.js"
|
|
39
39
|
import KanbanController from "./kanban_controller.js"
|
|
40
|
+
import CurrencyInputController from "./currency_input_controller.js"
|
|
40
41
|
|
|
41
42
|
export default function (application) {
|
|
42
43
|
// Register controllers here
|
|
@@ -78,4 +79,5 @@ export default function (application) {
|
|
|
78
79
|
application.register("dirty-form-guard", DirtyFormGuardController)
|
|
79
80
|
application.register("wizard", WizardController)
|
|
80
81
|
application.register("kanban", KanbanController)
|
|
82
|
+
application.register("currency-input", CurrencyInputController)
|
|
81
83
|
}
|