plutonium 0.60.5 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="dirty-form-guard"
|
|
4
|
-
// Prompts before
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Prompts before discarding a form's unsaved changes. Two guard surfaces,
|
|
5
|
+
// both driven off the same dirtiness diff:
|
|
6
|
+
//
|
|
7
|
+
// • Modal dismissal — when the form is inside a <dialog>, guard Esc / the
|
|
8
|
+
// close button / backdrop cancel (the original behaviour).
|
|
9
|
+
// • Full-page leave — guard a click on any control marked
|
|
10
|
+
// `data-dirty-form-guard-leave="<message>"` that posts WITHOUT this form's
|
|
11
|
+
// fields (e.g. a wizard Back/Cancel). The attribute value is the prompt.
|
|
12
|
+
//
|
|
13
|
+
// Safe to attach to every form unconditionally: with no dialog and no leave
|
|
14
|
+
// controls it never prompts.
|
|
7
15
|
//
|
|
8
16
|
// Dirtiness is a diff against a baseline captured at the user's *first*
|
|
9
17
|
// real interaction — not at connect. Field widgets (intl-tel-input,
|
|
@@ -46,52 +54,79 @@ export default class extends Controller {
|
|
|
46
54
|
|
|
47
55
|
connect() {
|
|
48
56
|
this.dialog = this.element.closest("dialog");
|
|
49
|
-
if (!this.dialog) return;
|
|
50
57
|
|
|
51
58
|
this.baseline = null;
|
|
52
59
|
this.forceClose = false;
|
|
53
60
|
this.submitting = false;
|
|
54
61
|
|
|
55
62
|
this.onFirstIntent = this.#onFirstIntent.bind(this);
|
|
56
|
-
this.onCancel = this.#onCancel.bind(this);
|
|
57
63
|
this.onSubmit = this.#onSubmit.bind(this);
|
|
58
|
-
this.
|
|
59
|
-
this.
|
|
60
|
-
this.onKeydown = this.#onKeydown.bind(this);
|
|
64
|
+
this.onLeaveClick = this.#onLeaveClick.bind(this);
|
|
65
|
+
this.onSettled = this.#onSettled.bind(this);
|
|
61
66
|
|
|
62
67
|
// A trusted pointer/key action inside the form is the user starting to
|
|
63
68
|
// edit — capture the (settled, pre-edit) baseline then. Capture phase so
|
|
64
|
-
// a widget that stops propagation can't hide it from us.
|
|
69
|
+
// a widget that stops propagation can't hide it from us. Applies in both
|
|
70
|
+
// modal and full-page modes.
|
|
65
71
|
this.element.addEventListener("pointerdown", this.onFirstIntent, true);
|
|
66
72
|
this.element.addEventListener("keydown", this.onFirstIntent, true);
|
|
73
|
+
this.element.addEventListener("submit", this.onSubmit);
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
75
|
+
// When a submission settles, reset the transient guard flags. A failed submit
|
|
76
|
+
// (e.g. a validation error) can re-render the SAME form via Turbo morph, which
|
|
77
|
+
// preserves this element WITHOUT reconnecting the controller — so `submitting`
|
|
78
|
+
// / `forceClose` would otherwise stay set and silently kill the guard for the
|
|
79
|
+
// rest of the form's life. Also drop the baseline so the re-rendered form
|
|
80
|
+
// re-baselines against its new values on the next interaction.
|
|
81
|
+
this.element.addEventListener("turbo:submit-end", this.onSettled);
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
this
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
// Full-page leave guard: a `data-dirty-form-guard-leave` control can live
|
|
84
|
+
// outside this form (a sibling nav strip), so listen at the document in the
|
|
85
|
+
// capture phase to intercept its click before the form it submits. Only for
|
|
86
|
+
// non-modal forms — a modal is guarded by the Esc/close/cancel handlers below,
|
|
87
|
+
// so a modal form needn't install a document-wide listener.
|
|
88
|
+
if (!this.dialog) {
|
|
89
|
+
document.addEventListener("click", this.onLeaveClick, true);
|
|
90
|
+
}
|
|
77
91
|
|
|
78
|
-
if (this.
|
|
79
|
-
this.
|
|
92
|
+
if (this.dialog) {
|
|
93
|
+
this.onCancel = this.#onCancel.bind(this);
|
|
94
|
+
this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
|
|
95
|
+
this.onConfirmCancel = this.#onConfirmCancel.bind(this);
|
|
96
|
+
this.onKeydown = this.#onKeydown.bind(this);
|
|
97
|
+
|
|
98
|
+
document.addEventListener("keydown", this.onKeydown, true);
|
|
99
|
+
// Capture phase so this runs before remote-modal's cancel handler
|
|
100
|
+
// — that way `defaultPrevented` is visible there if we intervene.
|
|
101
|
+
this.dialog.addEventListener("cancel", this.onCancel, true);
|
|
102
|
+
this.#closeButtons().forEach((btn) =>
|
|
103
|
+
btn.addEventListener("click", this.onCloseButtonClick, true),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (this.hasConfirmDialogTarget) {
|
|
107
|
+
this.confirmDialogTarget.addEventListener("cancel", this.onConfirmCancel);
|
|
108
|
+
}
|
|
80
109
|
}
|
|
81
110
|
}
|
|
82
111
|
|
|
83
112
|
disconnect() {
|
|
84
|
-
if (!this.dialog) return;
|
|
85
113
|
this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
|
|
86
114
|
this.element.removeEventListener("keydown", this.onFirstIntent, true);
|
|
87
|
-
document.removeEventListener("keydown", this.onKeydown, true);
|
|
88
|
-
this.dialog.removeEventListener("cancel", this.onCancel, true);
|
|
89
115
|
this.element.removeEventListener("submit", this.onSubmit);
|
|
90
|
-
this
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
this.element.removeEventListener("turbo:submit-end", this.onSettled);
|
|
117
|
+
if (!this.dialog) {
|
|
118
|
+
document.removeEventListener("click", this.onLeaveClick, true);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this.dialog) {
|
|
122
|
+
document.removeEventListener("keydown", this.onKeydown, true);
|
|
123
|
+
this.dialog.removeEventListener("cancel", this.onCancel, true);
|
|
124
|
+
this.#closeButtons().forEach((btn) =>
|
|
125
|
+
btn.removeEventListener("click", this.onCloseButtonClick, true),
|
|
126
|
+
);
|
|
127
|
+
if (this.hasConfirmDialogTarget) {
|
|
128
|
+
this.confirmDialogTarget.removeEventListener("cancel", this.onConfirmCancel);
|
|
129
|
+
}
|
|
95
130
|
}
|
|
96
131
|
}
|
|
97
132
|
|
|
@@ -156,6 +191,99 @@ export default class extends Controller {
|
|
|
156
191
|
this.submitting = true;
|
|
157
192
|
}
|
|
158
193
|
|
|
194
|
+
// A submission settled. Reset the transient guards so a same-URL Turbo-morph
|
|
195
|
+
// re-render (which keeps this element and does NOT reconnect the controller)
|
|
196
|
+
// doesn't leave the guard permanently disabled. Re-baseline on next interaction.
|
|
197
|
+
#onSettled() {
|
|
198
|
+
this.submitting = false;
|
|
199
|
+
this.forceClose = false;
|
|
200
|
+
this.baseline = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Full-page leave guard. A control marked `data-dirty-form-guard-leave` posts
|
|
204
|
+
// without this form's fields, so its unsaved edits would be lost. If the form
|
|
205
|
+
// is dirty, confirm first through the app's themed dialog; the attribute's
|
|
206
|
+
// value is the prompt. We always intercept the click (the themed confirm is
|
|
207
|
+
// async), then re-submit the trigger's form if the user confirms.
|
|
208
|
+
async #onLeaveClick(event) {
|
|
209
|
+
const trigger = event.target.closest("[data-dirty-form-guard-leave]");
|
|
210
|
+
if (!trigger) return;
|
|
211
|
+
// The document listener fires for every guarded form on the page. A leave
|
|
212
|
+
// control discards exactly one form — the one it bypasses — so only that
|
|
213
|
+
// form's instance responds; otherwise unrelated forms would double-prompt.
|
|
214
|
+
if (this.#guardedFormFor(trigger) !== this.element) return;
|
|
215
|
+
if (this.forceClose || this.submitting) return;
|
|
216
|
+
if (!this.#isDirty()) return;
|
|
217
|
+
|
|
218
|
+
event.preventDefault();
|
|
219
|
+
event.stopPropagation();
|
|
220
|
+
|
|
221
|
+
const message =
|
|
222
|
+
trigger.getAttribute("data-dirty-form-guard-leave") ||
|
|
223
|
+
"You have unsaved changes that will be lost. Continue?";
|
|
224
|
+
const confirmed = await this.#confirm(message);
|
|
225
|
+
if (!confirmed) return;
|
|
226
|
+
|
|
227
|
+
// Approved — let the original navigation through without re-prompting. Pass
|
|
228
|
+
// the trigger as the SUBMITTER when it is itself a submit control, so its
|
|
229
|
+
// name/value (e.g. `_direction=back`) is included in the POST — `requestSubmit()`
|
|
230
|
+
// with no submitter drops it, which would silently turn a Back/Cancel into a
|
|
231
|
+
// finalize.
|
|
232
|
+
this.forceClose = true;
|
|
233
|
+
const form = trigger.closest("form");
|
|
234
|
+
if (form) {
|
|
235
|
+
const submitter = trigger.matches("button, input[type=submit], input[type=image]") ? trigger : null;
|
|
236
|
+
form.requestSubmit(submitter);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The CSS selector for a guarded form. The guard is attached as a Stimulus
|
|
241
|
+
// controller (`data-controller="… dirty-form-guard"`), NOT as a CSS class — so
|
|
242
|
+
// match on the controller token, not `form.dirty-form-guard` (which never
|
|
243
|
+
// matches the framework's forms, leaving the leave guard silently dormant).
|
|
244
|
+
static GUARDED_FORM_SELECTOR = "form[data-controller~='dirty-form-guard']";
|
|
245
|
+
|
|
246
|
+
// The single guarded form a leave control discards: the one containing it, or —
|
|
247
|
+
// for a control outside any form (a wizard's sibling nav strip) — the closest
|
|
248
|
+
// guarded form, i.e. the one sharing the deepest common ancestor with the
|
|
249
|
+
// trigger. Returns the only guarded form on simple pages.
|
|
250
|
+
#guardedFormFor(trigger) {
|
|
251
|
+
const selector = this.constructor.GUARDED_FORM_SELECTOR;
|
|
252
|
+
const inside = trigger.closest(selector);
|
|
253
|
+
if (inside) return inside;
|
|
254
|
+
|
|
255
|
+
let best = null;
|
|
256
|
+
let bestDepth = -1;
|
|
257
|
+
document.querySelectorAll(selector).forEach((form) => {
|
|
258
|
+
let ancestor = form;
|
|
259
|
+
while (ancestor && !ancestor.contains(trigger)) ancestor = ancestor.parentElement;
|
|
260
|
+
if (!ancestor) return;
|
|
261
|
+
const depth = this.#depthOf(ancestor);
|
|
262
|
+
if (depth > bestDepth) {
|
|
263
|
+
bestDepth = depth;
|
|
264
|
+
best = form;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return best;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#depthOf(node) {
|
|
271
|
+
let depth = 0;
|
|
272
|
+
while ((node = node.parentElement)) depth++;
|
|
273
|
+
return depth;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Defer to the themed Turbo confirm dialog the app installs as the global
|
|
277
|
+
// confirm method (a styled <dialog>, not the native chrome bar); fall back to
|
|
278
|
+
// window.confirm only if it isn't available. Returns a Promise<boolean>.
|
|
279
|
+
#confirm(message) {
|
|
280
|
+
const turboConfirm = window.Turbo?.config?.forms?.confirm;
|
|
281
|
+
if (typeof turboConfirm === "function") {
|
|
282
|
+
return Promise.resolve(turboConfirm(message));
|
|
283
|
+
}
|
|
284
|
+
return Promise.resolve(window.confirm(message));
|
|
285
|
+
}
|
|
286
|
+
|
|
159
287
|
#confirmIsOpen() {
|
|
160
288
|
return this.hasConfirmDialogTarget && this.confirmDialogTarget.open;
|
|
161
289
|
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="kanban"
|
|
4
|
+
//
|
|
5
|
+
// Enables drag-and-drop reordering of kanban cards across columns using the
|
|
6
|
+
// browser's native HTML5 drag-and-drop API (no additional npm dependency).
|
|
7
|
+
//
|
|
8
|
+
// ## DOM contract
|
|
9
|
+
//
|
|
10
|
+
// Board wrapper (this element):
|
|
11
|
+
// data-controller="kanban"
|
|
12
|
+
// data-kanban-move-url-template-value="/path/__ID__/kanban_move"
|
|
13
|
+
// — the collection path with an __ID__ placeholder; the controller
|
|
14
|
+
// substitutes the dragged card's record id at drop time.
|
|
15
|
+
//
|
|
16
|
+
// Column wrapper (rendered by Kanban::Column as the turbo-frame body):
|
|
17
|
+
// data-kanban-col="<key>" — unique wrapper identifier for JS
|
|
18
|
+
// data-kanban-accepts="all|none|<key>,<key>,…"
|
|
19
|
+
// — "all": any card may be dropped here
|
|
20
|
+
// — "none": no card may be dropped here
|
|
21
|
+
// — comma list: only cards whose source column key is in the list
|
|
22
|
+
// data-kanban-locked="true|false"
|
|
23
|
+
// — true: cards in this column cannot be dragged out of it
|
|
24
|
+
//
|
|
25
|
+
// Column drop zone (rendered by Kanban::Column inside each turbo-frame):
|
|
26
|
+
// data-kanban-target="column"
|
|
27
|
+
// data-kanban-column-key-value="<key>"
|
|
28
|
+
//
|
|
29
|
+
// Draggable card (rendered by Kanban::Card):
|
|
30
|
+
// draggable="true"
|
|
31
|
+
// data-kanban-record-id="<id>"
|
|
32
|
+
// data-kanban-column-key="<source-column-key>"
|
|
33
|
+
//
|
|
34
|
+
// Column toggle control (button inside the strip / expanded header):
|
|
35
|
+
// data-action="click->kanban#toggleColumn"
|
|
36
|
+
// data-kanban-column-key="<key>"
|
|
37
|
+
//
|
|
38
|
+
// ## Collapse toggle
|
|
39
|
+
//
|
|
40
|
+
// toggleColumn reads data-kanban-column-key from the clicked button, finds
|
|
41
|
+
// the matching [data-kanban-col] wrapper, and flips the CSS class
|
|
42
|
+
// `pu-kanban-column-collapsed` on it. CSS handles show/hide of strip vs body.
|
|
43
|
+
// The per-column state is persisted in localStorage keyed by the resource
|
|
44
|
+
// path + column key so the preference survives page reloads.
|
|
45
|
+
//
|
|
46
|
+
// On connect() the controller reads all persisted states and applies them
|
|
47
|
+
// before the first paint (wrappers are present in the DOM when the turbo-frame
|
|
48
|
+
// content loads; Stimulus's MutationObserver reconnects after each frame swap).
|
|
49
|
+
//
|
|
50
|
+
// ## Drop hints
|
|
51
|
+
//
|
|
52
|
+
// On dragstart:
|
|
53
|
+
// 1. Determine whether the source column is locked (cards cannot leave).
|
|
54
|
+
// 2. For each column wrapper, compute whether a drop would be accepted and
|
|
55
|
+
// add the CSS class `pu-kanban-no-drop` to wrappers that would reject.
|
|
56
|
+
// 3. Also suppress the browser's `dragover` preventDefault() for no-drop
|
|
57
|
+
// columns so the native "no entry" cursor shows.
|
|
58
|
+
// On dragend: clear all hint classes.
|
|
59
|
+
//
|
|
60
|
+
// ## Move flow
|
|
61
|
+
//
|
|
62
|
+
// 1. dragstart — record which card was grabbed and apply an opacity hint.
|
|
63
|
+
// 2. dragover — highlight the target column drop zone; suppress the browser's
|
|
64
|
+
// "forbidden" cursor by calling preventDefault(). For columns
|
|
65
|
+
// marked pu-kanban-no-drop we skip preventDefault so the native
|
|
66
|
+
// "no entry" cursor shows instead.
|
|
67
|
+
// 3. drop — compute to_index (vertical cursor position within the column),
|
|
68
|
+
// POST {from_column, to_column, to_index} to the move endpoint,
|
|
69
|
+
// and feed the Turbo Stream response to Turbo.renderStreamMessage.
|
|
70
|
+
// On success the server re-renders both column frames; on 422 it
|
|
71
|
+
// re-renders only the source column so the card snaps back — the
|
|
72
|
+
// controller never hand-manages rollback state.
|
|
73
|
+
// 4. dragend — clean up opacity + highlights + drop-hint classes.
|
|
74
|
+
export default class extends Controller {
|
|
75
|
+
static values = { moveUrlTemplate: String }
|
|
76
|
+
static targets = ["column"]
|
|
77
|
+
|
|
78
|
+
connect() {
|
|
79
|
+
this.draggedCard = null
|
|
80
|
+
|
|
81
|
+
this.onDragStart = this.#onDragStart.bind(this)
|
|
82
|
+
this.onDragOver = this.#onDragOver.bind(this)
|
|
83
|
+
this.onDragLeave = this.#onDragLeave.bind(this)
|
|
84
|
+
this.onDrop = this.#onDrop.bind(this)
|
|
85
|
+
this.onDragEnd = this.#onDragEnd.bind(this)
|
|
86
|
+
|
|
87
|
+
this.element.addEventListener("dragstart", this.onDragStart)
|
|
88
|
+
this.element.addEventListener("dragover", this.onDragOver)
|
|
89
|
+
this.element.addEventListener("dragleave", this.onDragLeave)
|
|
90
|
+
this.element.addEventListener("drop", this.onDrop)
|
|
91
|
+
this.element.addEventListener("dragend", this.onDragEnd)
|
|
92
|
+
|
|
93
|
+
// Apply any persisted collapse states from localStorage so columns
|
|
94
|
+
// retain the user's preference across page reloads.
|
|
95
|
+
this.#applyPersistedCollapseStates()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
disconnect() {
|
|
99
|
+
this.element.removeEventListener("dragstart", this.onDragStart)
|
|
100
|
+
this.element.removeEventListener("dragover", this.onDragOver)
|
|
101
|
+
this.element.removeEventListener("dragleave", this.onDragLeave)
|
|
102
|
+
this.element.removeEventListener("drop", this.onDrop)
|
|
103
|
+
this.element.removeEventListener("dragend", this.onDragEnd)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Collapse toggle ─────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
// Stimulus action: data-action="click->kanban#toggleColumn"
|
|
109
|
+
// Expected on the expand button in the collapsed strip and the collapse
|
|
110
|
+
// button in the expanded header. data-kanban-column-key on the button
|
|
111
|
+
// identifies which column to toggle.
|
|
112
|
+
toggleColumn(event) {
|
|
113
|
+
const key = event.currentTarget.dataset.kanbanColumnKey
|
|
114
|
+
if (!key) return
|
|
115
|
+
|
|
116
|
+
const wrapper = this.element.querySelector(`[data-kanban-col="${key}"]`)
|
|
117
|
+
if (!wrapper) return
|
|
118
|
+
|
|
119
|
+
const strip = wrapper.querySelector("[data-kanban-role='strip']")
|
|
120
|
+
const body = wrapper.querySelector("[data-kanban-role='body']")
|
|
121
|
+
if (!strip || !body) return
|
|
122
|
+
|
|
123
|
+
// `pu-kanban-column-collapsed` on the wrapper is what CSS uses to decide
|
|
124
|
+
// which half to show. Toggling the class is the only state mutation.
|
|
125
|
+
const isCollapsed = wrapper.classList.contains("pu-kanban-column-collapsed")
|
|
126
|
+
|
|
127
|
+
if (isCollapsed) {
|
|
128
|
+
wrapper.classList.remove("pu-kanban-column-collapsed")
|
|
129
|
+
this.#saveCollapseState(key, false)
|
|
130
|
+
} else {
|
|
131
|
+
wrapper.classList.add("pu-kanban-column-collapsed")
|
|
132
|
+
this.#saveCollapseState(key, true)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── drag lifecycle ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
#onDragStart(event) {
|
|
139
|
+
const card = event.target.closest("[data-kanban-record-id]")
|
|
140
|
+
if (!card) return
|
|
141
|
+
|
|
142
|
+
this.draggedCard = card
|
|
143
|
+
event.dataTransfer.effectAllowed = "move"
|
|
144
|
+
// Store the id so native DnD still carries data if the card is dropped
|
|
145
|
+
// outside the board (where we won't handle it, but no error).
|
|
146
|
+
event.dataTransfer.setData("text/plain", card.dataset.kanbanRecordId)
|
|
147
|
+
|
|
148
|
+
// Defer the opacity change so the drag ghost image is captured first.
|
|
149
|
+
requestAnimationFrame(() => card.classList.add("pu-kanban-dragging"))
|
|
150
|
+
|
|
151
|
+
// Mark columns that would reject a drop from this card's source column.
|
|
152
|
+
this.#applyDropHints(card.dataset.kanbanColumnKey)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#onDragOver(event) {
|
|
156
|
+
const column = event.target.closest("[data-kanban-target='column']")
|
|
157
|
+
if (!column) return
|
|
158
|
+
|
|
159
|
+
// Skip preventDefault for no-drop columns so the browser shows a
|
|
160
|
+
// "no entry" cursor rather than the move cursor.
|
|
161
|
+
const wrapper = event.target.closest("[data-kanban-col]")
|
|
162
|
+
if (wrapper?.classList.contains("pu-kanban-no-drop")) return
|
|
163
|
+
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
event.dataTransfer.dropEffect = "move"
|
|
166
|
+
this.#highlightColumn(column)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#onDragLeave(event) {
|
|
170
|
+
// Only clear the highlight when the cursor leaves the board entirely.
|
|
171
|
+
// relatedTarget is null when leaving the viewport, or a node outside
|
|
172
|
+
// the board wrapper when crossing the edge.
|
|
173
|
+
if (!this.element.contains(event.relatedTarget)) {
|
|
174
|
+
this.#clearHighlights()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async #onDrop(event) {
|
|
179
|
+
event.preventDefault()
|
|
180
|
+
this.#clearHighlights()
|
|
181
|
+
|
|
182
|
+
if (!this.draggedCard) return
|
|
183
|
+
|
|
184
|
+
// Respect client-side drop hints: skip the POST for columns the client
|
|
185
|
+
// knows would reject. The server enforces this authoritatively on every
|
|
186
|
+
// request, so skipping saves a round-trip and avoids a 422 flash.
|
|
187
|
+
const wrapper = event.target.closest("[data-kanban-col]")
|
|
188
|
+
if (wrapper?.classList.contains("pu-kanban-no-drop")) return
|
|
189
|
+
|
|
190
|
+
const column = event.target.closest("[data-kanban-target='column']")
|
|
191
|
+
if (!column) return
|
|
192
|
+
|
|
193
|
+
const recordId = this.draggedCard.dataset.kanbanRecordId
|
|
194
|
+
const fromColumn = this.draggedCard.dataset.kanbanColumnKey
|
|
195
|
+
const toColumn = column.dataset.kanbanColumnKeyValue
|
|
196
|
+
|
|
197
|
+
// Cards currently in the destination column, excluding the dragged card
|
|
198
|
+
// itself (it may already be there for a same-column reorder).
|
|
199
|
+
const existingCards = [...column.querySelectorAll("[data-kanban-record-id]")]
|
|
200
|
+
.filter(c => c !== this.draggedCard)
|
|
201
|
+
|
|
202
|
+
const toIndex = this.#computeDropIndex(event.clientY, existingCards)
|
|
203
|
+
const url = this.moveUrlTemplateValue.replace("__ID__", recordId)
|
|
204
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const response = await fetch(url, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: {
|
|
210
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
211
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
212
|
+
"X-CSRF-Token": csrfToken,
|
|
213
|
+
},
|
|
214
|
+
body: new URLSearchParams({
|
|
215
|
+
from_column: fromColumn,
|
|
216
|
+
to_column: toColumn,
|
|
217
|
+
to_index: toIndex,
|
|
218
|
+
}),
|
|
219
|
+
credentials: "same-origin",
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const body = await response.text()
|
|
223
|
+
// Turbo.renderStreamMessage processes <turbo-stream> elements in the
|
|
224
|
+
// response body. On success this re-renders the from + to column frames;
|
|
225
|
+
// on 422 it re-renders only the source frame, snapping the card back.
|
|
226
|
+
if (window.Turbo) {
|
|
227
|
+
Turbo.renderStreamMessage(body)
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error("[kanban] move request failed:", error)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#onDragEnd(_event) {
|
|
235
|
+
this.#clearHighlights()
|
|
236
|
+
this.#clearDropHints()
|
|
237
|
+
if (this.draggedCard) {
|
|
238
|
+
this.draggedCard.classList.remove("pu-kanban-dragging")
|
|
239
|
+
this.draggedCard = null
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── drop hints ──────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
// Marks each column wrapper with `pu-kanban-no-drop` when it would refuse
|
|
246
|
+
// a card dragged from sourceKey. The server remains the authority; this
|
|
247
|
+
// is a display-only hint to give the user immediate visual feedback.
|
|
248
|
+
#applyDropHints(sourceKey) {
|
|
249
|
+
// If the source column is locked, no card can leave it — all targets are
|
|
250
|
+
// effectively invalid.
|
|
251
|
+
const sourceWrapper = this.element.querySelector(`[data-kanban-col="${sourceKey}"]`)
|
|
252
|
+
const sourceLocked = sourceWrapper?.dataset.kanbanLocked === "true"
|
|
253
|
+
|
|
254
|
+
this.element.querySelectorAll("[data-kanban-col]").forEach(wrapper => {
|
|
255
|
+
const noDrop = sourceLocked || !this.#columnAccepts(wrapper.dataset.kanbanAccepts, sourceKey)
|
|
256
|
+
wrapper.classList.toggle("pu-kanban-no-drop", noDrop)
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#clearDropHints() {
|
|
261
|
+
this.element.querySelectorAll("[data-kanban-col]")
|
|
262
|
+
.forEach(w => w.classList.remove("pu-kanban-no-drop"))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Returns true if the column described by `accepts` (the serialised form
|
|
266
|
+
// from data-kanban-accepts) would accept a card from `sourceKey`.
|
|
267
|
+
#columnAccepts(accepts, sourceKey) {
|
|
268
|
+
if (!accepts || accepts === "all") return true
|
|
269
|
+
if (accepts === "none") return false
|
|
270
|
+
return accepts.split(",").map(k => k.trim()).includes(sourceKey)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── collapse persistence ─────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
// Applies localStorage collapse states to all column wrappers currently in
|
|
276
|
+
// the DOM. Called on connect() and implicitly after Turbo frame swaps
|
|
277
|
+
// because Stimulus re-connects the controller when the frame content changes.
|
|
278
|
+
#applyPersistedCollapseStates() {
|
|
279
|
+
this.element.querySelectorAll("[data-kanban-col]").forEach(wrapper => {
|
|
280
|
+
const key = wrapper.dataset.kanbanCol
|
|
281
|
+
const stored = localStorage.getItem(this.#storageKey(key))
|
|
282
|
+
if (stored === null) return // No stored preference; use server-rendered initial state.
|
|
283
|
+
|
|
284
|
+
const collapsed = stored === "1"
|
|
285
|
+
wrapper.classList.toggle("pu-kanban-column-collapsed", collapsed)
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#saveCollapseState(key, collapsed) {
|
|
290
|
+
// Safari private-browsing reports a 0-byte quota and throws
|
|
291
|
+
// QuotaExceededError on setItem. Swallow it so the toggle still works
|
|
292
|
+
// visually — it just won't persist across reloads in that mode.
|
|
293
|
+
try {
|
|
294
|
+
localStorage.setItem(this.#storageKey(key), collapsed ? "1" : "0")
|
|
295
|
+
} catch { /* private browsing: toggle still works, just won't persist */ }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Derives a unique localStorage key from the resource collection path so
|
|
299
|
+
// different boards (different resources / tenants) don't share state.
|
|
300
|
+
// The move URL template is "/path/__ID__/kanban_move"; strip the suffix to
|
|
301
|
+
// recover the collection path.
|
|
302
|
+
#storageKey(key) {
|
|
303
|
+
const path = this.moveUrlTemplateValue.replace("/__ID__/kanban_move", "")
|
|
304
|
+
return `pu-kanban:${path}:${key}:collapsed`
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
// Returns the 0-based insertion index within the destination column by
|
|
310
|
+
// comparing the cursor y-position against each card's vertical midpoint.
|
|
311
|
+
// The card is inserted before the first card whose midpoint is below the
|
|
312
|
+
// cursor, or appended after all cards if the cursor is below every midpoint.
|
|
313
|
+
#computeDropIndex(clientY, cards) {
|
|
314
|
+
for (let i = 0; i < cards.length; i++) {
|
|
315
|
+
const rect = cards[i].getBoundingClientRect()
|
|
316
|
+
if (clientY < rect.top + rect.height / 2) return i
|
|
317
|
+
}
|
|
318
|
+
return cards.length
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#highlightColumn(column) {
|
|
322
|
+
this.columnTargets.forEach(c => {
|
|
323
|
+
c.classList.toggle("pu-kanban-drop-target", c === column)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#clearHighlights() {
|
|
328
|
+
this.columnTargets.forEach(c => c.classList.remove("pu-kanban-drop-target"))
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// Guards a password field pre-filled with the server "unchanged" sentinel — a
|
|
4
|
+
// stored secret the user hasn't touched, masked behind a fixed placeholder.
|
|
5
|
+
//
|
|
6
|
+
// The sentinel must be edited all-or-nothing: a partial edit (e.g. one
|
|
7
|
+
// backspace) would leave a truncated sentinel that no longer matches on the
|
|
8
|
+
// server and gets saved as a literal new password. So the first edit gesture
|
|
9
|
+
// replaces the whole field — Backspace/Delete empties it (keep-or-clear), a
|
|
10
|
+
// typed/pasted character starts a fresh value. After that first edit the field
|
|
11
|
+
// behaves natively.
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
static values = { sentinel: String };
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this.armed = this.element.value === this.sentinelValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeinput(event) {
|
|
20
|
+
if (!this.armed) return;
|
|
21
|
+
|
|
22
|
+
// Replace the whole sentinel regardless of caret position, so a click into
|
|
23
|
+
// the middle followed by a keystroke can't corrupt it.
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
this.armed = false;
|
|
26
|
+
|
|
27
|
+
let next = "";
|
|
28
|
+
if (event.inputType === "insertText" && event.data != null) {
|
|
29
|
+
next = event.data;
|
|
30
|
+
} else if (event.inputType === "insertFromPaste" && event.dataTransfer) {
|
|
31
|
+
next = event.dataTransfer.getData("text");
|
|
32
|
+
}
|
|
33
|
+
// Backspace/Delete (deleteContent*) and anything else collapse to "".
|
|
34
|
+
|
|
35
|
+
this.element.value = next;
|
|
36
|
+
this.element.setSelectionRange(next.length, next.length);
|
|
37
|
+
this.element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -19,6 +19,7 @@ import AttachmentPreviewController from "./attachment_preview_controller.js"
|
|
|
19
19
|
import AttachmentPreviewContainerController from "./attachment_preview_container_controller.js"
|
|
20
20
|
import SidebarController from "./sidebar_controller.js"
|
|
21
21
|
import PasswordVisibilityController from "./password_visibility_controller.js"
|
|
22
|
+
import PasswordSentinelController from "./password_sentinel_controller.js"
|
|
22
23
|
import RemoteModalController from "./remote_modal_controller.js"
|
|
23
24
|
import KeyValueStoreController from "./key_value_store_controller.js"
|
|
24
25
|
import BulkActionsController from "./bulk_actions_controller.js"
|
|
@@ -34,10 +35,13 @@ import RowClickController from "./row_click_controller.js"
|
|
|
34
35
|
import ViewSwitcherController from "./view_switcher_controller.js"
|
|
35
36
|
import AutosubmitController from "./autosubmit_controller.js"
|
|
36
37
|
import DirtyFormGuardController from "./dirty_form_guard_controller.js"
|
|
38
|
+
import WizardController from "./wizard_controller.js"
|
|
39
|
+
import KanbanController from "./kanban_controller.js"
|
|
37
40
|
|
|
38
41
|
export default function (application) {
|
|
39
42
|
// Register controllers here
|
|
40
43
|
application.register("password-visibility", PasswordVisibilityController)
|
|
44
|
+
application.register("password-sentinel", PasswordSentinelController)
|
|
41
45
|
application.register("sidebar", SidebarController)
|
|
42
46
|
application.register("resource-header", ResourceHeaderController)
|
|
43
47
|
application.register("nested-resource-form-fields", NestedResourceFormFieldsController)
|
|
@@ -72,4 +76,6 @@ export default function (application) {
|
|
|
72
76
|
application.register("view-switcher", ViewSwitcherController)
|
|
73
77
|
application.register("autosubmit", AutosubmitController)
|
|
74
78
|
application.register("dirty-form-guard", DirtyFormGuardController)
|
|
79
|
+
application.register("wizard", WizardController)
|
|
80
|
+
application.register("kanban", KanbanController)
|
|
75
81
|
}
|
|
@@ -67,6 +67,16 @@ export default class extends Controller {
|
|
|
67
67
|
if (this._closing) return;
|
|
68
68
|
this._closing = true;
|
|
69
69
|
|
|
70
|
+
// Commit any in-flight enter transition to its open end-state before
|
|
71
|
+
// reversing it. Removing data-open while the enter is still running
|
|
72
|
+
// reverses that transition, and CSS shortens the reverse duration in
|
|
73
|
+
// proportion to how far the enter got — so a quick open→close snaps
|
|
74
|
+
// shut instead of animating, which reads as choppy. finish() jumps to
|
|
75
|
+
// the open state so the exit always plays its full duration. Scoped to
|
|
76
|
+
// the dialog's own transitions (not the subtree) so a descendant's
|
|
77
|
+
// infinite animation can't throw on finish().
|
|
78
|
+
this.element.getAnimations().forEach((animation) => animation.finish());
|
|
79
|
+
|
|
70
80
|
this.element.removeAttribute("data-open");
|
|
71
81
|
|
|
72
82
|
const animations = this.element.getAnimations({ subtree: true });
|
|
@@ -16,6 +16,19 @@ export default class extends Controller {
|
|
|
16
16
|
if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
|
|
17
17
|
return
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
const show = this.element.querySelector('[data-row-click-target="show"]')
|
|
21
|
+
if (!show) return
|
|
22
|
+
|
|
23
|
+
// Modifier-click (⌘/Ctrl) or middle-click opens the record's full page in a
|
|
24
|
+
// new tab — the standard "open in new tab" gesture — instead of following
|
|
25
|
+
// the show link's configured target (which may be a modal frame). A new
|
|
26
|
+
// browsing context sends no Turbo-Frame header, so it renders full-page.
|
|
27
|
+
if (event.metaKey || event.ctrlKey || event.button === 1) {
|
|
28
|
+
window.open(show.href, "_blank", "noopener")
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
show.click()
|
|
20
33
|
}
|
|
21
34
|
}
|