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
data/app/assets/plutonium.js
CHANGED
|
@@ -17340,6 +17340,7 @@ ${text2}</tr>
|
|
|
17340
17340
|
// src/js/controllers/intl_tel_input_controller.js
|
|
17341
17341
|
var intl_tel_input_controller_default = class extends Controller {
|
|
17342
17342
|
static targets = ["input"];
|
|
17343
|
+
static values = { options: Object };
|
|
17343
17344
|
connect() {
|
|
17344
17345
|
}
|
|
17345
17346
|
disconnect() {
|
|
@@ -17376,7 +17377,8 @@ ${text2}</tr>
|
|
|
17376
17377
|
return {
|
|
17377
17378
|
strictMode: true,
|
|
17378
17379
|
hiddenInput: () => ({ phone: this.inputTarget.attributes.name.value }),
|
|
17379
|
-
loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js"
|
|
17380
|
+
loadUtilsOnInit: "https://cdn.jsdelivr.net/npm/intl-tel-input@24.8.1/build/js/utils.js",
|
|
17381
|
+
...this.optionsValue
|
|
17380
17382
|
};
|
|
17381
17383
|
}
|
|
17382
17384
|
};
|
|
@@ -28913,7 +28915,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
28913
28915
|
|
|
28914
28916
|
// src/js/controllers/kanban_controller.js
|
|
28915
28917
|
var kanban_controller_default = class extends Controller {
|
|
28916
|
-
static values = { moveUrlTemplate: String };
|
|
28918
|
+
static values = { moveUrlTemplate: String, collapseCookie: String, collapsePath: String };
|
|
28917
28919
|
static targets = ["column"];
|
|
28918
28920
|
connect() {
|
|
28919
28921
|
this.draggedCard = null;
|
|
@@ -28927,7 +28929,30 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
28927
28929
|
this.element.addEventListener("dragleave", this.onDragLeave);
|
|
28928
28930
|
this.element.addEventListener("drop", this.onDrop);
|
|
28929
28931
|
this.element.addEventListener("dragend", this.onDragEnd);
|
|
28930
|
-
this.#
|
|
28932
|
+
this.scrollTarget = this.#readStoredScroll();
|
|
28933
|
+
this.captureScroll = this.#captureScroll.bind(this);
|
|
28934
|
+
this.onBoardScroll = () => {
|
|
28935
|
+
if (this.restoringScroll) return;
|
|
28936
|
+
clearTimeout(this.scrollSaveTimer);
|
|
28937
|
+
this.scrollSaveTimer = setTimeout(this.captureScroll, 120);
|
|
28938
|
+
};
|
|
28939
|
+
this.element.addEventListener("scroll", this.onBoardScroll, { passive: true });
|
|
28940
|
+
this.onUserScrollIntent = () => this.#endScrollRestore();
|
|
28941
|
+
this.element.addEventListener("wheel", this.onUserScrollIntent, { passive: true });
|
|
28942
|
+
this.element.addEventListener("touchmove", this.onUserScrollIntent, { passive: true });
|
|
28943
|
+
this.onPageHide = this.captureScroll;
|
|
28944
|
+
window.addEventListener("pagehide", this.onPageHide);
|
|
28945
|
+
this.onTurboLoad = this.#syncColumnsToUrl.bind(this);
|
|
28946
|
+
this.onBeforeFrameRender = this.#onBeforeFrameRender.bind(this);
|
|
28947
|
+
this.onFrameRender = this.#onFrameRender.bind(this);
|
|
28948
|
+
this.onBeforeStreamRender = this.#onBeforeStreamRender.bind(this);
|
|
28949
|
+
document.addEventListener("turbo:load", this.onTurboLoad);
|
|
28950
|
+
document.addEventListener("turbo:before-frame-render", this.onBeforeFrameRender);
|
|
28951
|
+
document.addEventListener("turbo:frame-render", this.onFrameRender);
|
|
28952
|
+
document.addEventListener("turbo:before-stream-render", this.onBeforeStreamRender);
|
|
28953
|
+
this.#syncColumnsToUrl();
|
|
28954
|
+
this.#restoreScrollLeft();
|
|
28955
|
+
this.#reloadAfterWrite();
|
|
28931
28956
|
}
|
|
28932
28957
|
disconnect() {
|
|
28933
28958
|
this.element.removeEventListener("dragstart", this.onDragStart);
|
|
@@ -28935,6 +28960,189 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
28935
28960
|
this.element.removeEventListener("dragleave", this.onDragLeave);
|
|
28936
28961
|
this.element.removeEventListener("drop", this.onDrop);
|
|
28937
28962
|
this.element.removeEventListener("dragend", this.onDragEnd);
|
|
28963
|
+
this.#captureScroll();
|
|
28964
|
+
this.element.removeEventListener("scroll", this.onBoardScroll);
|
|
28965
|
+
this.element.removeEventListener("wheel", this.onUserScrollIntent);
|
|
28966
|
+
this.element.removeEventListener("touchmove", this.onUserScrollIntent);
|
|
28967
|
+
window.removeEventListener("pagehide", this.onPageHide);
|
|
28968
|
+
clearTimeout(this.restoreScrollTimer);
|
|
28969
|
+
clearTimeout(this.scrollSaveTimer);
|
|
28970
|
+
document.removeEventListener("turbo:load", this.onTurboLoad);
|
|
28971
|
+
document.removeEventListener("turbo:before-frame-render", this.onBeforeFrameRender);
|
|
28972
|
+
document.removeEventListener("turbo:frame-render", this.onFrameRender);
|
|
28973
|
+
document.removeEventListener("turbo:before-stream-render", this.onBeforeStreamRender);
|
|
28974
|
+
}
|
|
28975
|
+
// ─── Frozen-board URL sync ────────────────────────────────────────────────────
|
|
28976
|
+
// Reconcile every column frame's src with the current URL's board params
|
|
28977
|
+
// (q / scope / sort). The board is frozen (data-turbo-permanent), so its
|
|
28978
|
+
// frames keep whatever src they were last loaded with; this is what reflects a
|
|
28979
|
+
// new search / filter / scope into the columns.
|
|
28980
|
+
//
|
|
28981
|
+
// STATELESS by design: we compare each frame's actual src against the src the
|
|
28982
|
+
// current URL implies and reload only the ones that differ. We deliberately do
|
|
28983
|
+
// NOT track "last synced URL" — Turbo reconnects this controller on every nav
|
|
28984
|
+
// (the permanent board is transplanted), so any per-connect state would be
|
|
28985
|
+
// reset to the new URL before we could diff against it, and the sync would
|
|
28986
|
+
// never fire. Comparing frame-src-vs-URL has no such blind spot and is
|
|
28987
|
+
// self-limiting: once a frame matches the URL, it won't reload again.
|
|
28988
|
+
// A create/update/destroy that returns to the board redirects to a URL the
|
|
28989
|
+
// server tagged with kanban_reload=1 (see KanbanActions#kanban_reload_url).
|
|
28990
|
+
// The board is data-turbo-permanent, so its already-loaded column frames are
|
|
28991
|
+
// kept as-is on arrival — stale (missing a new card, still showing a deleted
|
|
28992
|
+
// one). Force every column to re-fetch, then strip the marker so a later
|
|
28993
|
+
// reload / back-nav doesn't re-trigger it. The restore window opened by
|
|
28994
|
+
// #restoreScrollLeft keeps the scroll pinned as the fresh frames render.
|
|
28995
|
+
#reloadAfterWrite() {
|
|
28996
|
+
const url = new URL(window.location.href);
|
|
28997
|
+
if (!url.searchParams.has("kanban_reload")) return;
|
|
28998
|
+
url.searchParams.delete("kanban_reload");
|
|
28999
|
+
history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
|
29000
|
+
this.#columnFrames().forEach((frame) => frame.reload());
|
|
29001
|
+
}
|
|
29002
|
+
#syncColumnsToUrl() {
|
|
29003
|
+
this.#columnFrames().forEach((frame) => {
|
|
29004
|
+
const desired = this.#columnFrameSrc(frame.dataset.kanbanColFrame);
|
|
29005
|
+
const current = frame.getAttribute("src");
|
|
29006
|
+
if (current && this.#canonicalUrl(current) === this.#canonicalUrl(desired)) return;
|
|
29007
|
+
frame.src = desired;
|
|
29008
|
+
});
|
|
29009
|
+
}
|
|
29010
|
+
// The src a column frame should carry for the current URL: the page's query
|
|
29011
|
+
// params with view=kanban + column=<key> forced on. Mirrors the server's
|
|
29012
|
+
// Kanban::Resource#column_frame_src so a freshly-rendered board's frames
|
|
29013
|
+
// already match (no spurious reload).
|
|
29014
|
+
#columnFrameSrc(key) {
|
|
29015
|
+
const params = new URLSearchParams(window.location.search);
|
|
29016
|
+
params.set("view", "kanban");
|
|
29017
|
+
params.set("column", key);
|
|
29018
|
+
return `${window.location.pathname}?${params.toString()}`;
|
|
29019
|
+
}
|
|
29020
|
+
// Order-independent identity for a frame src: path + alphabetically sorted
|
|
29021
|
+
// query params. The server serialises params sorted (Hash#to_query) while
|
|
29022
|
+
// URLSearchParams preserves insertion order, so raw string comparison would
|
|
29023
|
+
// report a false difference and reload every frame on every nav.
|
|
29024
|
+
#canonicalUrl(src) {
|
|
29025
|
+
const url = new URL(src, window.location.origin);
|
|
29026
|
+
url.searchParams.sort();
|
|
29027
|
+
return `${url.pathname}?${url.searchParams.toString()}`;
|
|
29028
|
+
}
|
|
29029
|
+
// Turbo fires this before a frame renders fetched content. For our column
|
|
29030
|
+
// frames we swap in a morph render so the reload diffs the card list rather
|
|
29031
|
+
// than replacing it (which would blank the column). Uses Turbo's own frame
|
|
29032
|
+
// morph so before/after hooks (turbo:before-frame-morph) still fire.
|
|
29033
|
+
#onBeforeFrameRender(event) {
|
|
29034
|
+
if (!this.#isColumnFrame(event.target)) return;
|
|
29035
|
+
event.detail.render = (currentElement, newElement) => morphTurboFrameElements(currentElement, newElement);
|
|
29036
|
+
}
|
|
29037
|
+
#onFrameRender(event) {
|
|
29038
|
+
if (!this.#isColumnFrame(event.target)) return;
|
|
29039
|
+
if (this.restoringScroll) {
|
|
29040
|
+
this.#pinScroll();
|
|
29041
|
+
this.#bumpRestoreTimer();
|
|
29042
|
+
}
|
|
29043
|
+
}
|
|
29044
|
+
// Apply the saved horizontal position: to the live max when the user was at
|
|
29045
|
+
// the end (so it tracks late width changes), otherwise to the exact offset.
|
|
29046
|
+
// No-op when nothing meaningful was saved so it can't yank a fresh board to 0.
|
|
29047
|
+
#pinScroll() {
|
|
29048
|
+
const t4 = this.scrollTarget;
|
|
29049
|
+
if (!t4 || !t4.l && !t4.e) return;
|
|
29050
|
+
this.element.scrollLeft = t4.e ? this.element.scrollWidth : t4.l;
|
|
29051
|
+
}
|
|
29052
|
+
// Re-apply the saved horizontal scroll across a window that extends with each
|
|
29053
|
+
// column render, because renders (morph on nav, replace on move, lazy load on
|
|
29054
|
+
// full reload) settle their width late and each settle can clamp scrollLeft
|
|
29055
|
+
// back toward 0. Used by the reattach, turbo-stream, and initial-load paths.
|
|
29056
|
+
#scheduleScrollRestore() {
|
|
29057
|
+
if (!this.scrollTarget || !this.scrollTarget.l && !this.scrollTarget.e) return;
|
|
29058
|
+
this.restoringScroll = true;
|
|
29059
|
+
this.#pinScroll();
|
|
29060
|
+
requestAnimationFrame(() => this.#pinScroll());
|
|
29061
|
+
this.#bumpRestoreTimer();
|
|
29062
|
+
}
|
|
29063
|
+
// (Re)arm the timer that ends the restore window. Reset on every column render
|
|
29064
|
+
// so the window lives ~400ms past the LAST column to settle — enough for slow
|
|
29065
|
+
// lazy frames on a fresh load without pinning forever.
|
|
29066
|
+
#bumpRestoreTimer() {
|
|
29067
|
+
clearTimeout(this.restoreScrollTimer);
|
|
29068
|
+
this.restoreScrollTimer = setTimeout(() => this.#endScrollRestore(), 400);
|
|
29069
|
+
}
|
|
29070
|
+
#endScrollRestore() {
|
|
29071
|
+
if (!this.restoringScroll) return;
|
|
29072
|
+
this.#pinScroll();
|
|
29073
|
+
this.restoringScroll = false;
|
|
29074
|
+
clearTimeout(this.restoreScrollTimer);
|
|
29075
|
+
}
|
|
29076
|
+
// Restore on connect — covers Turbo reattach (search/filter) and a full page
|
|
29077
|
+
// reload (F5), since scrollTarget is seeded from sessionStorage either way.
|
|
29078
|
+
#restoreScrollLeft() {
|
|
29079
|
+
this.#scheduleScrollRestore();
|
|
29080
|
+
}
|
|
29081
|
+
// ── scroll persistence (sessionStorage) ──
|
|
29082
|
+
// Per-tab and auto-cleared on tab close, so it can't accumulate. Keyed by the
|
|
29083
|
+
// board's collection path (tenant + resource scoped) via the move template.
|
|
29084
|
+
#scrollKey() {
|
|
29085
|
+
const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "");
|
|
29086
|
+
return `pu-kanban-scroll:${path}`;
|
|
29087
|
+
}
|
|
29088
|
+
#readStoredScroll() {
|
|
29089
|
+
try {
|
|
29090
|
+
const raw = sessionStorage.getItem(this.#scrollKey());
|
|
29091
|
+
return raw ? JSON.parse(raw) : null;
|
|
29092
|
+
} catch {
|
|
29093
|
+
return null;
|
|
29094
|
+
}
|
|
29095
|
+
}
|
|
29096
|
+
// Read the live position (unless a restore currently owns scrollTarget) and
|
|
29097
|
+
// persist it. Runs on the scroll debounce, on pagehide, and on disconnect —
|
|
29098
|
+
// never on the raw scroll event, so the layout read can't stutter scrolling.
|
|
29099
|
+
#captureScroll() {
|
|
29100
|
+
const el = this.element;
|
|
29101
|
+
if (!this.restoringScroll && el.clientWidth > 0) {
|
|
29102
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
29103
|
+
this.scrollTarget = {
|
|
29104
|
+
l: el.scrollLeft,
|
|
29105
|
+
e: maxScroll > 0 && el.scrollLeft >= maxScroll - 2
|
|
29106
|
+
};
|
|
29107
|
+
}
|
|
29108
|
+
if (!this.scrollTarget) return;
|
|
29109
|
+
try {
|
|
29110
|
+
sessionStorage.setItem(this.#scrollKey(), JSON.stringify(this.scrollTarget));
|
|
29111
|
+
} catch {
|
|
29112
|
+
}
|
|
29113
|
+
}
|
|
29114
|
+
// A move / realtime update re-renders columns via turbo-stream (replace). The
|
|
29115
|
+
// server renders them in the user's collapse state, so we only need to protect
|
|
29116
|
+
// the horizontal scroll: freeze tracking BEFORE the swap (replacing a column
|
|
29117
|
+
// briefly removes it, narrowing the board so scrollLeft clamps toward 0 — that
|
|
29118
|
+
// clamp would otherwise be saved as the new position, a visible jump at the
|
|
29119
|
+
// far end), then restore once the columns are back.
|
|
29120
|
+
#onBeforeStreamRender(event) {
|
|
29121
|
+
if (!this.#streamTargetsColumn(event.target)) return;
|
|
29122
|
+
const render = event.detail.render;
|
|
29123
|
+
event.detail.render = async (streamElement) => {
|
|
29124
|
+
this.restoringScroll = true;
|
|
29125
|
+
await render(streamElement);
|
|
29126
|
+
this.#scheduleScrollRestore();
|
|
29127
|
+
};
|
|
29128
|
+
}
|
|
29129
|
+
// True when the <turbo-stream> element targets a column frame contained by
|
|
29130
|
+
// this board — via its `target` (frame id) or `targets` (CSS selector).
|
|
29131
|
+
#streamTargetsColumn(streamElement) {
|
|
29132
|
+
if (!streamElement) return false;
|
|
29133
|
+
const target = streamElement.getAttribute("target");
|
|
29134
|
+
if (target) return this.#isColumnFrame(document.getElementById(target));
|
|
29135
|
+
const targets = streamElement.getAttribute("targets");
|
|
29136
|
+
if (targets) {
|
|
29137
|
+
return [...document.querySelectorAll(targets)].some((el) => this.#isColumnFrame(el));
|
|
29138
|
+
}
|
|
29139
|
+
return false;
|
|
29140
|
+
}
|
|
29141
|
+
#columnFrames() {
|
|
29142
|
+
return this.element.querySelectorAll("turbo-frame[data-kanban-col-frame]");
|
|
29143
|
+
}
|
|
29144
|
+
#isColumnFrame(el) {
|
|
29145
|
+
return el?.matches?.("turbo-frame[data-kanban-col-frame]") && this.element.contains(el);
|
|
28938
29146
|
}
|
|
28939
29147
|
// ─── Collapse toggle ─────────────────────────────────────────────────────────
|
|
28940
29148
|
// Stimulus action: data-action="click->kanban#toggleColumn"
|
|
@@ -28949,14 +29157,9 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
28949
29157
|
const strip = wrapper.querySelector("[data-kanban-role='strip']");
|
|
28950
29158
|
const body = wrapper.querySelector("[data-kanban-role='body']");
|
|
28951
29159
|
if (!strip || !body) return;
|
|
28952
|
-
const
|
|
28953
|
-
|
|
28954
|
-
|
|
28955
|
-
this.#saveCollapseState(key, false);
|
|
28956
|
-
} else {
|
|
28957
|
-
wrapper.classList.add("pu-kanban-column-collapsed");
|
|
28958
|
-
this.#saveCollapseState(key, true);
|
|
28959
|
-
}
|
|
29160
|
+
const nowCollapsed = wrapper.classList.toggle("pu-kanban-column-collapsed");
|
|
29161
|
+
const defaultCollapsed = wrapper.dataset.kanbanDefaultCollapsed === "true";
|
|
29162
|
+
this.#persistCollapse(key, nowCollapsed !== defaultCollapsed);
|
|
28960
29163
|
}
|
|
28961
29164
|
// ─── drag lifecycle ──────────────────────────────────────────────────────────
|
|
28962
29165
|
#onDragStart(event) {
|
|
@@ -28995,6 +29198,23 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
28995
29198
|
const toColumn = column.dataset.kanbanColumnKeyValue;
|
|
28996
29199
|
const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")].filter((c4) => c4 !== this.draggedCard);
|
|
28997
29200
|
const toIndex = this.#computeDropIndex(event.clientY, existingCards);
|
|
29201
|
+
const destWrapper = column.closest("[data-kanban-col]");
|
|
29202
|
+
if (destWrapper?.dataset.kanbanDropInteraction === "true" && fromColumn !== toColumn) {
|
|
29203
|
+
if (destWrapper.dataset.kanbanDropImmediate === "true") {
|
|
29204
|
+
const confirmMsg = destWrapper.dataset.kanbanDropConfirm;
|
|
29205
|
+
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
|
29206
|
+
} else if (this.#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex })) {
|
|
29207
|
+
return;
|
|
29208
|
+
}
|
|
29209
|
+
}
|
|
29210
|
+
this.#submitMove(recordId, { fromColumn, toColumn, toIndex });
|
|
29211
|
+
}
|
|
29212
|
+
// Direct move: POST {from_column, to_column, to_index} to the move endpoint
|
|
29213
|
+
// and feed the Turbo Stream response to Turbo. On success the server
|
|
29214
|
+
// re-renders the from + to column frames; on 422 it re-renders only the
|
|
29215
|
+
// source frame so the card snaps back — the controller never hand-manages
|
|
29216
|
+
// rollback state.
|
|
29217
|
+
async #submitMove(recordId, { fromColumn, toColumn, toIndex }) {
|
|
28998
29218
|
const url = this.moveUrlTemplateValue.replace("__ID__", recordId);
|
|
28999
29219
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? "";
|
|
29000
29220
|
try {
|
|
@@ -29012,14 +29232,46 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
29012
29232
|
}),
|
|
29013
29233
|
credentials: "same-origin"
|
|
29014
29234
|
});
|
|
29015
|
-
const
|
|
29016
|
-
|
|
29235
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
29236
|
+
const isTurboStream = contentType.includes("text/vnd.turbo-stream.html");
|
|
29237
|
+
if (isTurboStream && window.Turbo) {
|
|
29238
|
+
const body = await response.text();
|
|
29017
29239
|
Turbo.renderStreamMessage(body);
|
|
29240
|
+
} else if (!response.ok) {
|
|
29241
|
+
console.error(`[kanban] move rejected (${response.status}); leaving card in place`);
|
|
29242
|
+
} else {
|
|
29243
|
+
console.warn("[kanban] move returned a non-stream response (session expired?); leaving card in place");
|
|
29018
29244
|
}
|
|
29019
29245
|
} catch (error2) {
|
|
29020
29246
|
console.error("[kanban] move request failed:", error2);
|
|
29021
29247
|
}
|
|
29022
29248
|
}
|
|
29249
|
+
// ─── drop-interaction modal ────────────────────────────────────────────────
|
|
29250
|
+
//
|
|
29251
|
+
// A cross-column drop into an enter_interaction column opens the interaction's
|
|
29252
|
+
// form in the shared remote-modal frame instead of committing the move.
|
|
29253
|
+
//
|
|
29254
|
+
// Native HTML5 drag-and-drop never re-parents the card's DOM node — the card
|
|
29255
|
+
// physically stays in its SOURCE column throughout, so there is nothing to
|
|
29256
|
+
// snap back on cancel: dismissing the modal simply leaves the board as it
|
|
29257
|
+
// already is. On success the server's turbo-stream re-renders the kanban-col-*
|
|
29258
|
+
// frames and empties the remote_modal frame, so the board updates naturally.
|
|
29259
|
+
//
|
|
29260
|
+
// Returns true when the modal was opened (caller should stop), false when the
|
|
29261
|
+
// remote-modal frame is unavailable (caller falls back to the direct POST).
|
|
29262
|
+
#openDropInteraction(destWrapper, { recordId, fromColumn, toColumn, toIndex }) {
|
|
29263
|
+
const template = destWrapper.dataset.kanbanDropFormUrlTemplate;
|
|
29264
|
+
const frame = document.getElementById("remote_modal");
|
|
29265
|
+
if (!frame || !template) return false;
|
|
29266
|
+
const params = new URLSearchParams({
|
|
29267
|
+
from_column: fromColumn,
|
|
29268
|
+
to_column: toColumn,
|
|
29269
|
+
to_index: toIndex
|
|
29270
|
+
});
|
|
29271
|
+
const url = `${template.replace("__ID__", recordId)}?${params.toString()}`;
|
|
29272
|
+
frame.src = url;
|
|
29273
|
+
return true;
|
|
29274
|
+
}
|
|
29023
29275
|
#onDragEnd(_event) {
|
|
29024
29276
|
this.#clearHighlights();
|
|
29025
29277
|
this.#clearDropHints();
|
|
@@ -29050,32 +29302,38 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
29050
29302
|
if (accepts === "none") return false;
|
|
29051
29303
|
return accepts.split(",").map((k4) => k4.trim()).includes(sourceKey);
|
|
29052
29304
|
}
|
|
29053
|
-
// ─── collapse persistence
|
|
29054
|
-
//
|
|
29055
|
-
//
|
|
29056
|
-
//
|
|
29057
|
-
|
|
29058
|
-
|
|
29059
|
-
|
|
29060
|
-
|
|
29061
|
-
|
|
29062
|
-
|
|
29063
|
-
|
|
29064
|
-
|
|
29065
|
-
|
|
29066
|
-
|
|
29067
|
-
|
|
29068
|
-
|
|
29069
|
-
|
|
29305
|
+
// ─── collapse persistence (cookie delta) ───────────────────────────────────
|
|
29306
|
+
//
|
|
29307
|
+
// The cookie stores ONLY the column keys whose state differs from the server
|
|
29308
|
+
// default, comma-joined (e.g. "todo,done"). The server reads it and renders
|
|
29309
|
+
// each column in the user's state directly, so there's no client re-apply and
|
|
29310
|
+
// no FOUC on any render path. The delta encoding keeps it compact and
|
|
29311
|
+
// self-trimming: a board at its defaults has no cookie, and toggling a column
|
|
29312
|
+
// back to default drops its key (and an empty set deletes the cookie).
|
|
29313
|
+
// `flipped` = the column's state now differs from its default.
|
|
29314
|
+
#persistCollapse(key, flipped) {
|
|
29315
|
+
const keys = new Set(this.#readCollapseCookie());
|
|
29316
|
+
if (flipped) keys.add(key);
|
|
29317
|
+
else keys.delete(key);
|
|
29318
|
+
this.#writeCollapseCookie([...keys]);
|
|
29319
|
+
}
|
|
29320
|
+
#readCollapseCookie() {
|
|
29321
|
+
const name = this.collapseCookieValue;
|
|
29322
|
+
if (!name) return [];
|
|
29323
|
+
const entry = document.cookie.split("; ").find((c4) => c4.startsWith(`${name}=`));
|
|
29324
|
+
if (!entry) return [];
|
|
29325
|
+
return decodeURIComponent(entry.slice(name.length + 1)).split(",").filter(Boolean);
|
|
29326
|
+
}
|
|
29327
|
+
#writeCollapseCookie(keys) {
|
|
29328
|
+
const name = this.collapseCookieValue;
|
|
29329
|
+
if (!name) return;
|
|
29330
|
+
const path = this.collapsePathValue || "/";
|
|
29331
|
+
if (keys.length === 0) {
|
|
29332
|
+
document.cookie = `${name}=; path=${path}; max-age=0; SameSite=Lax`;
|
|
29333
|
+
return;
|
|
29070
29334
|
}
|
|
29071
|
-
|
|
29072
|
-
|
|
29073
|
-
// different boards (different resources / tenants) don't share state.
|
|
29074
|
-
// The move URL template is "/path/__ID__/kanban_move"; strip the suffix to
|
|
29075
|
-
// recover the collection path.
|
|
29076
|
-
#storageKey(key) {
|
|
29077
|
-
const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "");
|
|
29078
|
-
return `pu-kanban:${path}:${key}:collapsed`;
|
|
29335
|
+
const value = encodeURIComponent(keys.join(","));
|
|
29336
|
+
document.cookie = `${name}=${value}; path=${path}; max-age=${60 * 60 * 24 * 180}; SameSite=Lax`;
|
|
29079
29337
|
}
|
|
29080
29338
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
29081
29339
|
// Returns the 0-based insertion index within the destination column by
|
|
@@ -29099,6 +29357,24 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
29099
29357
|
}
|
|
29100
29358
|
};
|
|
29101
29359
|
|
|
29360
|
+
// src/js/controllers/currency_input_controller.js
|
|
29361
|
+
var currency_input_controller_default = class extends Controller {
|
|
29362
|
+
static targets = ["prefix", "field"];
|
|
29363
|
+
// Space between the prefix and the first digit, in px.
|
|
29364
|
+
static values = { gap: { type: Number, default: 6 } };
|
|
29365
|
+
connect() {
|
|
29366
|
+
this.#pad();
|
|
29367
|
+
document.fonts.ready.then(() => this.#pad());
|
|
29368
|
+
}
|
|
29369
|
+
#pad() {
|
|
29370
|
+
this.fieldTarget.style.setProperty(
|
|
29371
|
+
"padding-left",
|
|
29372
|
+
`${this.prefixTarget.offsetWidth + this.gapValue}px`,
|
|
29373
|
+
"important"
|
|
29374
|
+
);
|
|
29375
|
+
}
|
|
29376
|
+
};
|
|
29377
|
+
|
|
29102
29378
|
// src/js/controllers/register_controllers.js
|
|
29103
29379
|
function register_controllers_default(application2) {
|
|
29104
29380
|
application2.register("password-visibility", password_visibility_controller_default);
|
|
@@ -29139,6 +29415,7 @@ this.ifd0Offset: ${this.ifd0Offset}, file.byteLength: ${e4.byteLength}`), e4.tif
|
|
|
29139
29415
|
application2.register("dirty-form-guard", dirty_form_guard_controller_default);
|
|
29140
29416
|
application2.register("wizard", wizard_controller_default);
|
|
29141
29417
|
application2.register("kanban", kanban_controller_default);
|
|
29418
|
+
application2.register("currency-input", currency_input_controller_default);
|
|
29142
29419
|
}
|
|
29143
29420
|
|
|
29144
29421
|
// src/js/turbo/turbo_actions.js
|