studio-engine 0.96.2.rc1 → 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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -1,454 +1,11 @@
1
1
  <%#
2
- studioBoard factory — the Alpine data behind the engine board primitive
3
- (studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
4
- board component template would be cloned/re-parsed and never run, so a consumer
5
- renders THIS once in its layout (like studio/_leveling_activity_assets homes
6
- levelingActionModal and studio/_birthday_assets homes birthdayModal), and the
7
- board anywhere below just references window.studioBoard. SortableJS is loaded
8
- globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
9
-
10
- ONE factory drives BOTH board shapes — it reads its selectors and behavior from
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>