studio-engine 0.96.2 → 0.97.0.rc1
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/CHANGELOG.md +183 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
- data/app/assets/tailwind/studio_engine/engine.css +8 -0
- data/app/javascript/studio/alpine_shims.js +23 -0
- data/app/javascript/studio/application.js +9 -21
- data/app/javascript/studio/board.js +593 -0
- data/app/javascript/studio/booking.js +176 -0
- data/app/javascript/studio/controllers/board_controller.js +41 -0
- data/app/javascript/studio/controllers/booking_controller.js +24 -0
- data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
- data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
- data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
- data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
- data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
- data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
- data/app/javascript/studio/controllers/survey_controller.js +18 -0
- data/app/javascript/studio/controllers/toast_controller.js +26 -0
- data/app/javascript/studio/email_banner.js +277 -0
- data/app/javascript/studio/email_banner_scale.js +60 -0
- data/app/javascript/studio/footer_map.js +143 -0
- data/app/javascript/studio/geo_settings.js +105 -0
- data/app/javascript/studio/hold_button.js +416 -0
- data/app/javascript/studio/hold_button_hooks.js +162 -0
- data/app/javascript/studio/lazy_controllers.js +138 -0
- data/app/javascript/studio/leveling_activity.js +252 -0
- data/app/javascript/studio/link_preview_card.js +34 -0
- data/app/javascript/studio/link_sidebar.js +157 -0
- data/app/javascript/studio/stimulus.js +72 -0
- data/app/javascript/studio/survey.js +316 -0
- data/app/javascript/studio/theme_editor.js +197 -0
- data/app/javascript/studio/toast.js +270 -0
- data/app/views/components/_link_sidebar.html.erb +5 -100
- data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
- data/app/views/layouts/studio/_flash.html.erb +13 -118
- data/app/views/layouts/studio/_head.html.erb +30 -14
- data/app/views/studio/_board_assets.html.erb +9 -452
- data/app/views/studio/_hold_button.html.erb +38 -445
- data/app/views/studio/_hold_button_guard.html.erb +172 -0
- data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
- data/app/views/studio/board/_board.html.erb +16 -5
- data/app/views/studio/booking/_assets.html.erb +5 -139
- data/app/views/studio/booking/_frame.html.erb +3 -3
- data/app/views/studio/booking/_popup.html.erb +4 -4
- data/app/views/studio/emails/_banner_editor.html.erb +4 -152
- data/app/views/studio/emails/_banner_scale.html.erb +5 -56
- data/app/views/studio/emails/index.html.erb +0 -1
- data/app/views/studio/geo_settings/edit.html.erb +1 -97
- data/app/views/studio/site_footer/_map.html.erb +7 -5
- data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
- data/app/views/studio/site_identities/edit.html.erb +1 -24
- data/app/views/studio/surveys/show.html.erb +4 -5
- data/app/views/style/_tasks.html.erb +3 -8
- data/app/views/style/_theme.html.erb +2 -52
- data/app/views/theme_settings/edit.html.erb +3 -82
- data/config/importmap.rb +6 -0
- data/docs/BOOKING.md +6 -5
- data/docs/SITE_FOOTER.md +16 -12
- data/lib/studio/version.rb +1 -1
- metadata +28 -3
- data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
- data/app/views/studio/surveys/_script.html.erb +0 -309
|
@@ -1,454 +1,11 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
opts, so the kanban cross-column board (group + move PATCH + toasts + counts) and
|
|
12
|
-
the within-lane depth chart (group:false, a drag handle, reorder-only) are the
|
|
13
|
-
same code with different opts. See studio/board/_board for the full opts contract.
|
|
14
|
-
|
|
15
|
-
CRITICAL BOUNDARY — this factory is UI ONLY. It PATCHes an app-supplied move_url,
|
|
16
|
-
POSTs an app-supplied reorder_url, and reacts to a neutral JSON contract; it holds
|
|
17
|
-
ZERO domain logic. Custom behavior attaches by LISTENING to the dispatched
|
|
18
|
-
window events (studio:board-moved / :board-reordered / :card-added), and heavier
|
|
19
|
-
animation is delegated OPAQUELY to a window[fx] module + window[hook] fns the
|
|
20
|
-
engine never inspects.
|
|
2
|
+
Renders nothing. A layout or page that renders studio/board_assets keeps
|
|
3
|
+
working; the render can be deleted.
|
|
4
|
+
|
|
5
|
+
The board primitive's behaviour is the studio/board module. studio/alpine_shims
|
|
6
|
+
publishes its factory as window.studioBoard on every page, before Alpine
|
|
7
|
+
starts, so x-data="studioBoard({...})" on studio/board/_board resolves with no
|
|
8
|
+
page-level script. The board controller (studio/controllers/board_controller,
|
|
9
|
+
named by the board's own data-studio-controller) loads the vendored SortableJS
|
|
10
|
+
on a page that renders a board.
|
|
21
11
|
%>
|
|
22
|
-
<script>
|
|
23
|
-
(function () {
|
|
24
|
-
if (window.studioBoard) return;
|
|
25
|
-
|
|
26
|
-
window.studioBoard = function (opts) {
|
|
27
|
-
opts = opts || {};
|
|
28
|
-
return {
|
|
29
|
-
// --- config (from opts) --------------------------------------------
|
|
30
|
-
zoneSelector: opts.zoneSelector || ".kanban-dropzone",
|
|
31
|
-
cardSelector: opts.draggable || ".kanban-card",
|
|
32
|
-
emptySelector: opts.emptySelector || null,
|
|
33
|
-
groupName: (opts.group === false ? null : (opts.group || "studio-board")),
|
|
34
|
-
handle: opts.handle || null,
|
|
35
|
-
sortFilter: opts.filter || ".kanban-empty",
|
|
36
|
-
idAttr: opts.idAttr || "slug",
|
|
37
|
-
zoneAttr: opts.zoneAttr || "stage",
|
|
38
|
-
domIdPrefix: opts.domIdPrefix || "card-",
|
|
39
|
-
moveUrl: opts.moveUrl || null,
|
|
40
|
-
moveParam: opts.moveParam || null,
|
|
41
|
-
reorderUrl: opts.reorderUrl || "",
|
|
42
|
-
reorderPayload: opts.reorderPayload || "slugs",
|
|
43
|
-
optimistic: opts.optimistic !== false,
|
|
44
|
-
toastsEnabled: opts.toasts !== false,
|
|
45
|
-
live: !!opts.live,
|
|
46
|
-
demo: !!opts.demo,
|
|
47
|
-
fxName: opts.fx || null,
|
|
48
|
-
onMoveHook: opts.onMoveHook || null,
|
|
49
|
-
onDropHook: opts.onDropHook || null,
|
|
50
|
-
labels: opts.labels || {},
|
|
51
|
-
// gap 3: the column key a data-exit-action="archive" remove re-parents INTO
|
|
52
|
-
// (its #dropzone-<key>) rather than deleting the card. null ⇒ 0.27.0 removal.
|
|
53
|
-
archiveZone: opts.archiveZone || null,
|
|
54
|
-
// DG4: selector for PINNED cards (a locked depth-chart starter). When set,
|
|
55
|
-
// locked cards are filtered from dragging AND a drag can't cross one, so a
|
|
56
|
-
// pinned card keeps its slot while the rest reflow. null ⇒ every card drags.
|
|
57
|
-
lockedSelector: opts.lockedSelector || null,
|
|
58
|
-
|
|
59
|
-
// --- state ---------------------------------------------------------
|
|
60
|
-
toasts: [],
|
|
61
|
-
// gap 1: board-level chrome state lives HERE, on the studioBoard scope, so a
|
|
62
|
-
// board's header_slot controls + column show_expr bind to it directly — no
|
|
63
|
-
// bespoke outer Alpine wrapper. Seeded from board_state; {} when omitted.
|
|
64
|
-
state: (opts.state && typeof opts.state === "object") ? opts.state : {},
|
|
65
|
-
|
|
66
|
-
// --- init ----------------------------------------------------------
|
|
67
|
-
init: function () {
|
|
68
|
-
// Set data-alpine-ready in $nextTick, NOT synchronously: x-init runs while
|
|
69
|
-
// Alpine is still walking this subtree, so inner @click handlers are not
|
|
70
|
-
// bound yet. $nextTick fires after the directive walk, so the flag then
|
|
71
|
-
// means "the board is fully wired and interactive" — the documented init
|
|
72
|
-
// flake fix carried over from the MS kanbanBoard.
|
|
73
|
-
var self = this;
|
|
74
|
-
this.$nextTick(function () {
|
|
75
|
-
self.$el.dataset.alpineReady = "true";
|
|
76
|
-
self.initSortables();
|
|
77
|
-
if (self.live) {
|
|
78
|
-
self.observeLive();
|
|
79
|
-
if (!self.fx()) self.installExitStreamFallback();
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
fx: function () { return this.fxName ? window[this.fxName] : null; },
|
|
85
|
-
cardId: function (el) { return el ? el.getAttribute("data-" + this.idAttr) : null; },
|
|
86
|
-
zoneKey: function (el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null; },
|
|
87
|
-
label: function (zone) { return this.labels[zone] || zone; },
|
|
88
|
-
|
|
89
|
-
// --- board-chrome state helpers (gap 1) ----------------------------
|
|
90
|
-
// Generic, domain-free operations over `state` so a board's header_slot /
|
|
91
|
-
// column show_expr drive filters + toggles without a bespoke wrapper.
|
|
92
|
-
toggleState: function (key) { this.state[key] = !this.state[key]; },
|
|
93
|
-
listHas: function (key, val) {
|
|
94
|
-
return Array.isArray(this.state[key]) && this.state[key].indexOf(val) !== -1;
|
|
95
|
-
},
|
|
96
|
-
toggleInState: function (key, val) {
|
|
97
|
-
if (!Array.isArray(this.state[key])) this.state[key] = [];
|
|
98
|
-
var i = this.state[key].indexOf(val);
|
|
99
|
-
if (i === -1) { this.state[key].push(val); } else { this.state[key].splice(i, 1); }
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
// --- SortableJS wiring ---------------------------------------------
|
|
103
|
-
initSortables: function () {
|
|
104
|
-
if (typeof Sortable === "undefined") return;
|
|
105
|
-
var self = this;
|
|
106
|
-
document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
|
|
107
|
-
var cfg = {
|
|
108
|
-
animation: 150,
|
|
109
|
-
ghostClass: "opacity-30",
|
|
110
|
-
draggable: self.cardSelector,
|
|
111
|
-
// DG4: locked cards join the undraggable filter so a pinned entry
|
|
112
|
-
// can't be picked up (empty state was already filtered).
|
|
113
|
-
filter: self.lockedSelector ? (self.sortFilter + ", " + self.lockedSelector) : self.sortFilter,
|
|
114
|
-
onStart: function () { window.__studioBoardDragging = true; },
|
|
115
|
-
onEnd: function (evt) {
|
|
116
|
-
self.handleSortEnd(evt);
|
|
117
|
-
requestAnimationFrame(function () { window.__studioBoardDragging = false; });
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
|
|
121
|
-
if (self.handle) cfg.handle = self.handle;
|
|
122
|
-
if (self.lockedSelector) {
|
|
123
|
-
// DG4: pin locked cards — refuse a move that would cross a locked
|
|
124
|
-
// sibling, so a dragged card can't leapfrog a pinned starter.
|
|
125
|
-
cfg.onMove = function (evt) {
|
|
126
|
-
return !(evt.related && evt.related.matches && evt.related.matches(self.lockedSelector));
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
Sortable.create(zone, cfg);
|
|
130
|
-
});
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
// A cross-column drop moves the card (PATCH); a same-zone drop is a pure
|
|
134
|
-
// reorder and must NEVER PATCH the zone. Source the from/to from the
|
|
135
|
-
// DROPZONES (evt.from / evt.to), never the card, so an in-place reorder
|
|
136
|
-
// can't mismove.
|
|
137
|
-
handleSortEnd: function (evt) {
|
|
138
|
-
var self = this;
|
|
139
|
-
var card = evt.item;
|
|
140
|
-
var fromZone = evt.from;
|
|
141
|
-
var toZone = evt.to;
|
|
142
|
-
var id = this.cardId(card);
|
|
143
|
-
var moved = fromZone !== toZone;
|
|
144
|
-
|
|
145
|
-
var afterMove = function (ok) {
|
|
146
|
-
if (moved && !ok) return; // reverted — leave the order alone
|
|
147
|
-
self.saveOrder(toZone);
|
|
148
|
-
self.updateCounts();
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
if (moved) {
|
|
152
|
-
Promise.resolve(this.applyMove(card, fromZone, toZone, id)).then(afterMove);
|
|
153
|
-
} else {
|
|
154
|
-
afterMove(true);
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
|
|
158
|
-
// Cross-column move: PATCH the zone attr; optimistic revert on failure.
|
|
159
|
-
applyMove: function (card, fromZone, toZone, id) {
|
|
160
|
-
var self = this;
|
|
161
|
-
var newZone = this.zoneKey(toZone);
|
|
162
|
-
var fromKey = this.zoneKey(fromZone);
|
|
163
|
-
|
|
164
|
-
// Moves not configured (or a within-zone board): a cross-zone drop is
|
|
165
|
-
// disallowed — snap back so a stray drag can't silently mismove. In demo
|
|
166
|
-
// mode with no move endpoint, still reflect the move locally.
|
|
167
|
-
if (!this.moveUrl || !this.moveParam) {
|
|
168
|
-
if (this.demo) {
|
|
169
|
-
this.setCardZone(card, newZone);
|
|
170
|
-
this.emit("board-moved", id, fromKey, newZone);
|
|
171
|
-
return true;
|
|
172
|
-
}
|
|
173
|
-
this.revert(card, fromZone);
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (this.demo) {
|
|
178
|
-
this.setCardZone(card, newZone);
|
|
179
|
-
this.toast("Moved to " + this.label(newZone), "success");
|
|
180
|
-
this.emit("board-moved", id, fromKey, newZone);
|
|
181
|
-
this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone });
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
var url = this.moveUrl.replace(":id", encodeURIComponent(id));
|
|
186
|
-
var body = {};
|
|
187
|
-
body[this.moveParam.resource] = {};
|
|
188
|
-
body[this.moveParam.resource][this.moveParam.attr] = newZone;
|
|
189
|
-
|
|
190
|
-
// request() rejects on a refusal (and reads the server's message), so this
|
|
191
|
-
// `then` runs only on a real success and the `catch` below is the ONE
|
|
192
|
-
// failure path. It used to unwrap the 4xx itself; that check now lives at
|
|
193
|
-
// the seam, where saveOrder gets it too.
|
|
194
|
-
return this.request(url, "PATCH", body).then(function () {
|
|
195
|
-
self.setCardZone(card, newZone);
|
|
196
|
-
self.toast("Moved to " + self.label(newZone), "success");
|
|
197
|
-
self.emit("board-moved", id, fromKey, newZone);
|
|
198
|
-
self.hook(self.onMoveHook, { record: id, from: fromKey, to: newZone });
|
|
199
|
-
return true;
|
|
200
|
-
}).catch(function (e) {
|
|
201
|
-
if (self.optimistic) self.revert(card, fromZone);
|
|
202
|
-
self.toast((e && e.message) || "Move failed", "error");
|
|
203
|
-
self.updateCounts();
|
|
204
|
-
return false;
|
|
205
|
-
});
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
// Save the destination column's order. Reads the ordered id list and POSTs
|
|
209
|
-
// it under the neutral payload key (`slugs` | `ids`) plus the advisory zone.
|
|
210
|
-
//
|
|
211
|
-
// A REFUSAL IS SHOWN, NOT SWALLOWED. Through 0.76.2 this was a bare
|
|
212
|
-
// `.catch(){}` on a `fetch` that resolves on 4xx: two layers of silence over
|
|
213
|
-
// a server that had already written the operator its reason ("That order does
|
|
214
|
-
// not match this week's games — reload and try again"). The board renumbered
|
|
215
|
-
// the cards as though the save had landed. request() now rejects on a refusal
|
|
216
|
-
// and the catch below toasts what the server said.
|
|
217
|
-
//
|
|
218
|
-
// NO AUTO-REVERT, deliberately. Unlike a single-card move there is no one
|
|
219
|
-
// card to snap back — the whole column re-ranked, and SortableJS hands us the
|
|
220
|
-
// drop with the pre-drop order already gone. Every reorder endpoint's refusal
|
|
221
|
-
// tells the operator to RELOAD, which restores the stored truth exactly; the
|
|
222
|
-
// board's job is to make sure that instruction is read.
|
|
223
|
-
//
|
|
224
|
-
// RETURNS A PROMISE (resolving true/false, never rejecting) so a caller can
|
|
225
|
-
// wait for the save to land. It resolves rather than rejects so the
|
|
226
|
-
// fire-and-forget call in handleSortEnd cannot raise an unhandled rejection.
|
|
227
|
-
saveOrder: function (toZone) {
|
|
228
|
-
var self = this;
|
|
229
|
-
var ids = Array.prototype.slice
|
|
230
|
-
.call(toZone.querySelectorAll(this.cardSelector))
|
|
231
|
-
.map(function (c) { return self.cardId(c); });
|
|
232
|
-
var zone = this.zoneKey(toZone);
|
|
233
|
-
this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
|
|
234
|
-
this.hook(this.onDropHook, { ids: ids, zone: zone });
|
|
235
|
-
if (this.demo || !this.reorderUrl) return Promise.resolve(true);
|
|
236
|
-
var payload = {};
|
|
237
|
-
payload[this.reorderPayload] = ids;
|
|
238
|
-
payload.zone = zone;
|
|
239
|
-
return this.request(this.reorderUrl, "POST", payload).then(function () {
|
|
240
|
-
return true;
|
|
241
|
-
}).catch(function (e) {
|
|
242
|
-
self.toast((e && e.message) || "Order save failed — reload and try again.", "error");
|
|
243
|
-
return false;
|
|
244
|
-
});
|
|
245
|
-
},
|
|
246
|
-
|
|
247
|
-
setCardZone: function (card, zone) { card.setAttribute("data-" + this.zoneAttr, zone); },
|
|
248
|
-
|
|
249
|
-
// Revert a failed move: drop the card back into its SOURCE zone (which
|
|
250
|
-
// always exists) above the empty state, and flash a red ring.
|
|
251
|
-
revert: function (card, fromZone) {
|
|
252
|
-
var anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null;
|
|
253
|
-
fromZone.insertBefore(card, anchor);
|
|
254
|
-
card.classList.add("ring-2", "ring-red-500");
|
|
255
|
-
setTimeout(function () { card.classList.remove("ring-2", "ring-red-500"); }, 1500);
|
|
256
|
-
},
|
|
257
|
-
|
|
258
|
-
updateCounts: function () {
|
|
259
|
-
var self = this;
|
|
260
|
-
document.querySelectorAll("[data-board-count]").forEach(function (badge) {
|
|
261
|
-
var key = badge.getAttribute("data-board-count");
|
|
262
|
-
var zone = document.getElementById("dropzone-" + key);
|
|
263
|
-
if (!zone) return;
|
|
264
|
-
var count = zone.querySelectorAll(self.cardSelector).length;
|
|
265
|
-
badge.textContent = count;
|
|
266
|
-
if (self.emptySelector) {
|
|
267
|
-
var empty = zone.querySelector(self.emptySelector);
|
|
268
|
-
if (empty) empty.style.display = count === 0 ? "flex" : "none";
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
},
|
|
272
|
-
|
|
273
|
-
// --- live updates (Turbo Streams) ----------------------------------
|
|
274
|
-
// The board subscribes via turbo_stream_from(live_channel); Turbo patches the
|
|
275
|
-
// DOM on every broadcast. This observer adds the flourish Turbo doesn't: a
|
|
276
|
-
// fade/scale-in on each patched-in card (deferred to window[fx] when present)
|
|
277
|
-
// and a count refresh after any column change.
|
|
278
|
-
observeLive: function () {
|
|
279
|
-
var self = this;
|
|
280
|
-
var onChange = function (mutations) {
|
|
281
|
-
for (var i = 0; i < mutations.length; i++) {
|
|
282
|
-
mutations[i].addedNodes.forEach(function (node) {
|
|
283
|
-
if (node.nodeType === 1 && node.matches && node.matches(self.cardSelector)) {
|
|
284
|
-
var fx = self.fx();
|
|
285
|
-
if (fx && fx.onAdd) { fx.onAdd(node); } else { self.animateIn(node); }
|
|
286
|
-
self.emit("card-added", self.cardId(node), null, self.zoneKey(node.parentElement));
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
self.updateCounts();
|
|
291
|
-
};
|
|
292
|
-
document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
|
|
293
|
-
new MutationObserver(onChange).observe(zone, { childList: true });
|
|
294
|
-
});
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
animateIn: function (el) {
|
|
298
|
-
el.style.opacity = "0";
|
|
299
|
-
el.style.transform = "scale(0.96)";
|
|
300
|
-
el.classList.add("ring-2", "ring-primary");
|
|
301
|
-
requestAnimationFrame(function () {
|
|
302
|
-
el.style.transition = "opacity 300ms ease, transform 300ms ease";
|
|
303
|
-
el.style.opacity = "";
|
|
304
|
-
el.style.transform = "";
|
|
305
|
-
});
|
|
306
|
-
setTimeout(function () { el.classList.remove("ring-2", "ring-primary"); el.style.transition = ""; }, 900);
|
|
307
|
-
},
|
|
308
|
-
|
|
309
|
-
// Archive re-parent / delete exit animation via a Turbo `remove` stream
|
|
310
|
-
// carrying data-exit-action. Installed only when no fx module owns exits.
|
|
311
|
-
installExitStreamFallback: function () {
|
|
312
|
-
if (window.__studioBoardExitFallback) return;
|
|
313
|
-
window.__studioBoardExitFallback = true;
|
|
314
|
-
var self = this;
|
|
315
|
-
document.addEventListener("turbo:before-stream-render", function (event) {
|
|
316
|
-
var stream = event.target;
|
|
317
|
-
if (stream.getAttribute("action") !== "remove") return;
|
|
318
|
-
var target = stream.getAttribute("target") || "";
|
|
319
|
-
var exitAction = stream.dataset.exitAction;
|
|
320
|
-
if (target.indexOf(self.domIdPrefix) !== 0 || !exitAction) return;
|
|
321
|
-
var renderNow = event.detail.render;
|
|
322
|
-
event.detail.render = function (el) {
|
|
323
|
-
var card = document.getElementById(target);
|
|
324
|
-
if (!card) { renderNow(el); return; }
|
|
325
|
-
self.animateCardExit(card, exitAction).then(function () {
|
|
326
|
-
// gap 3: an `archive` exit RE-PARENTS the card into the archive
|
|
327
|
-
// column (#dropzone-<archiveZone>) instead of vanishing, when the
|
|
328
|
-
// board named one and it exists. Everything else falls through to
|
|
329
|
-
// Turbo's remove (the 0.27.0 behavior).
|
|
330
|
-
if (exitAction === "archive" && self.archiveZone) {
|
|
331
|
-
var zone = document.getElementById("dropzone-" + self.archiveZone);
|
|
332
|
-
if (zone) {
|
|
333
|
-
var anchor = self.emptySelector ? zone.querySelector(self.emptySelector) : null;
|
|
334
|
-
zone.insertBefore(card, anchor);
|
|
335
|
-
self.setCardZone(card, self.archiveZone);
|
|
336
|
-
self.resetCardExit(card);
|
|
337
|
-
self.updateCounts();
|
|
338
|
-
return; // keep the card in the archive column — do NOT remove it
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
renderNow(el);
|
|
342
|
-
self.updateCounts();
|
|
343
|
-
});
|
|
344
|
-
};
|
|
345
|
-
});
|
|
346
|
-
},
|
|
347
|
-
|
|
348
|
-
animateCardExit: function (card, kind) {
|
|
349
|
-
if (!card) return Promise.resolve();
|
|
350
|
-
// gap 4: stamp an observable marker for WHICH exit is running (archive vs
|
|
351
|
-
// delete). Set before the reduced-motion guard so the marker is present
|
|
352
|
-
// even when the animation itself is skipped — the re-parent branch and any
|
|
353
|
-
// e2e both key off it.
|
|
354
|
-
card.dataset.exitAction = kind;
|
|
355
|
-
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve();
|
|
356
|
-
card.style.pointerEvents = "none";
|
|
357
|
-
var frames = kind === "archive"
|
|
358
|
-
? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
|
|
359
|
-
: [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }];
|
|
360
|
-
var anim = card.animate(frames, {
|
|
361
|
-
duration: kind === "archive" ? 500 : 420,
|
|
362
|
-
easing: "cubic-bezier(.35,0,.2,1)",
|
|
363
|
-
fill: "forwards"
|
|
364
|
-
});
|
|
365
|
-
return anim.finished.catch(function () {});
|
|
366
|
-
},
|
|
367
|
-
|
|
368
|
-
// Clear an exit animation + its marker so a re-parented card is fully
|
|
369
|
-
// visible again in the archive column (used by the gap-3 re-parent path).
|
|
370
|
-
resetCardExit: function (card) {
|
|
371
|
-
if (!card) return;
|
|
372
|
-
card.getAnimations({ subtree: false }).forEach(function (a) { a.cancel(); });
|
|
373
|
-
delete card.dataset.exitAction;
|
|
374
|
-
card.style.pointerEvents = "";
|
|
375
|
-
card.style.opacity = "";
|
|
376
|
-
card.style.transform = "";
|
|
377
|
-
card.style.filter = "";
|
|
378
|
-
},
|
|
379
|
-
|
|
380
|
-
// --- toasts --------------------------------------------------------
|
|
381
|
-
toast: function (message, type) {
|
|
382
|
-
if (!this.toastsEnabled) {
|
|
383
|
-
if (type === "error" && typeof window.alert === "function") window.alert(message);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
var self = this;
|
|
387
|
-
var id = Date.now() + Math.random();
|
|
388
|
-
this.toasts.push({ id: id, message: message, type: type, visible: true });
|
|
389
|
-
setTimeout(function () {
|
|
390
|
-
var t = self.toasts.find(function (x) { return x.id === id; });
|
|
391
|
-
if (t) t.visible = false;
|
|
392
|
-
setTimeout(function () {
|
|
393
|
-
self.toasts = self.toasts.filter(function (x) { return x.id !== id; });
|
|
394
|
-
}, 300);
|
|
395
|
-
}, 3000);
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
// --- helpers -------------------------------------------------------
|
|
399
|
-
// The board's ONE HTTP seam, and it REJECTS ON A SERVER REFUSAL. That is
|
|
400
|
-
// the semantics every caller in this factory already assumed by writing
|
|
401
|
-
// `.catch(…)`, and it is the semantics NEITHER underlying fetcher has:
|
|
402
|
-
//
|
|
403
|
-
// * `window.fetch` RESOLVES on 4xx/5xx. A 422 is an ordinary resolution,
|
|
404
|
-
// so a bare `.catch()` sees network-level failure and nothing else.
|
|
405
|
-
// That is how saveOrder discarded an operator-readable 422 in silence
|
|
406
|
-
// on all five boards that ride this factory.
|
|
407
|
-
// * `window.authedFetch` (the turf-monster fetcher this picks up when the
|
|
408
|
-
// host defines it) returns the response for anything that is not a 401
|
|
409
|
-
// — so a 422 resolves there too — and resolves to NULL on an expired
|
|
410
|
-
// session or a rate-limited tier, which `resp.ok` cannot be read from.
|
|
411
|
-
//
|
|
412
|
-
// Normalising both HERE is the point: a caller can no longer re-introduce
|
|
413
|
-
// the silence by forgetting a check. To ignore a refusal it must now say so
|
|
414
|
-
// out loud, in its own `.catch`.
|
|
415
|
-
request: function (url, method, body) {
|
|
416
|
-
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
|
417
|
-
var fetcher = window.authedFetch || window.fetch;
|
|
418
|
-
return fetcher(url, {
|
|
419
|
-
method: method,
|
|
420
|
-
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
421
|
-
body: JSON.stringify(body || {})
|
|
422
|
-
}).then(function (resp) {
|
|
423
|
-
// authedFetch's null — the session went away under the board. Name it,
|
|
424
|
-
// rather than throwing a TypeError off resp.ok on the next line.
|
|
425
|
-
if (!resp) throw new Error("Session expired — please sign in again.");
|
|
426
|
-
if (resp.ok) return resp;
|
|
427
|
-
// Prefer the SERVER'S OWN words. Every board endpoint this factory talks
|
|
428
|
-
// to renders `{ error: "…" }` beside its 4xx — Studio::Board::Reorderable
|
|
429
|
-
// here in the engine, and the hand-rolled siblings in the apps — and that
|
|
430
|
-
// text is written to be read by an operator.
|
|
431
|
-
return resp.json().catch(function () { return {}; }).then(function (err) {
|
|
432
|
-
throw new Error(err.error || ("Failed (" + resp.status + ")"));
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
},
|
|
436
|
-
|
|
437
|
-
// The primary extension seam — apps LISTEN for these on window and attach
|
|
438
|
-
// confetti / swipe / cart / analytics, never patching this factory.
|
|
439
|
-
emit: function (name, record, from, to, extra) {
|
|
440
|
-
try {
|
|
441
|
-
var detail = Object.assign({ record: record, from: from, to: to }, extra || {});
|
|
442
|
-
window.dispatchEvent(new CustomEvent("studio:" + name, { detail: detail }));
|
|
443
|
-
} catch (e) {}
|
|
444
|
-
},
|
|
445
|
-
|
|
446
|
-
hook: function (hookName, detail) {
|
|
447
|
-
if (!hookName) return;
|
|
448
|
-
var fn = window[hookName];
|
|
449
|
-
if (typeof fn === "function") { try { fn(detail); } catch (e) {} }
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
};
|
|
453
|
-
})();
|
|
454
|
-
</script>
|