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
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// studio/board: the board primitive (studio/board/_board): a kanban whose cards
|
|
2
|
+
// move between columns, and a depth chart whose cards rank inside one lane. One
|
|
3
|
+
// module drives both; a board reads its selectors and behaviour from its opts.
|
|
4
|
+
//
|
|
5
|
+
// <section x-data="studioBoard({...})" data-studio-controller="board">
|
|
6
|
+
//
|
|
7
|
+
// THREE PARTS, AND WHO LOADS EACH.
|
|
8
|
+
// - studioBoard(opts) is the Alpine scope: the toast list, the chrome state
|
|
9
|
+
// bag, the move and reorder requests, the counts. Alpine evaluates the
|
|
10
|
+
// x-data before any lazy module can arrive, so studio/alpine_shims imports
|
|
11
|
+
// this module and publishes the factory as window.studioBoard.
|
|
12
|
+
// - The board controller (studio/controllers/board_controller) is registered
|
|
13
|
+
// lazily, on a page that renders a board. It loads SortableJS and hands it
|
|
14
|
+
// to wire(), which makes the zones draggable and marks the board ready.
|
|
15
|
+
// - loadSortable() fetches the vendored SortableJS through the "sortablejs"
|
|
16
|
+
// pin, once, on the first board or the first Sortable.create a page makes.
|
|
17
|
+
//
|
|
18
|
+
// UI ONLY. A board PATCHes an app-supplied move URL, POSTs an app-supplied
|
|
19
|
+
// reorder URL and reads a neutral JSON contract; it holds no domain logic. An
|
|
20
|
+
// app extends it by listening for the window events it dispatches
|
|
21
|
+
// (studio:board-moved, studio:board-reordered, studio:card-added), by naming a
|
|
22
|
+
// window[fx] animation module, and by naming window[hook] functions. The
|
|
23
|
+
// engine inspects none of them.
|
|
24
|
+
//
|
|
25
|
+
// It imports nothing; test/javascript/board.test.mjs loads it as a data: module.
|
|
26
|
+
|
|
27
|
+
const CONTROLLER = "board"
|
|
28
|
+
const CONTROLLER_ATTRIBUTE = "data-studio-controller"
|
|
29
|
+
|
|
30
|
+
// ---- the opts a board is rendered with ------------------------------------
|
|
31
|
+
|
|
32
|
+
// The board's settings, from the opts studio/board/_board serialises.
|
|
33
|
+
// group false keeps a drag inside its zone (the depth chart).
|
|
34
|
+
// archiveZone the column a data-exit-action="archive" remove re-parents
|
|
35
|
+
// its card into; null removes the card.
|
|
36
|
+
// lockedSelector pinned cards: they do not drag and no drag crosses one.
|
|
37
|
+
export function boardConfig(opts) {
|
|
38
|
+
opts = opts || {}
|
|
39
|
+
return {
|
|
40
|
+
zoneSelector: opts.zoneSelector || ".kanban-dropzone",
|
|
41
|
+
cardSelector: opts.draggable || ".kanban-card",
|
|
42
|
+
emptySelector: opts.emptySelector || null,
|
|
43
|
+
groupName: opts.group === false ? null : (opts.group || "studio-board"),
|
|
44
|
+
handle: opts.handle || null,
|
|
45
|
+
sortFilter: opts.filter || ".kanban-empty",
|
|
46
|
+
idAttr: opts.idAttr || "slug",
|
|
47
|
+
zoneAttr: opts.zoneAttr || "stage",
|
|
48
|
+
domIdPrefix: opts.domIdPrefix || "card-",
|
|
49
|
+
moveUrl: opts.moveUrl || null,
|
|
50
|
+
moveParam: opts.moveParam || null,
|
|
51
|
+
reorderUrl: opts.reorderUrl || "",
|
|
52
|
+
reorderPayload: opts.reorderPayload || "slugs",
|
|
53
|
+
optimistic: opts.optimistic !== false,
|
|
54
|
+
toastsEnabled: opts.toasts !== false,
|
|
55
|
+
live: !!opts.live,
|
|
56
|
+
demo: !!opts.demo,
|
|
57
|
+
fxName: opts.fx || null,
|
|
58
|
+
onMoveHook: opts.onMoveHook || null,
|
|
59
|
+
onDropHook: opts.onDropHook || null,
|
|
60
|
+
labels: opts.labels || {},
|
|
61
|
+
archiveZone: opts.archiveZone || null,
|
|
62
|
+
lockedSelector: opts.lockedSelector || null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- the drop: a lane move, or a rank inside a lane ------------------------
|
|
67
|
+
|
|
68
|
+
// A drop moves a card only when it lands in another zone. The zones decide,
|
|
69
|
+
// never the card: a card dropped back into its own zone is a reorder, and a
|
|
70
|
+
// reorder never PATCHes the zone.
|
|
71
|
+
export function isLaneMove(fromZone, toZone) {
|
|
72
|
+
return fromZone !== toZone
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The ids of a zone's cards in DOM order: the rank a reorder saves.
|
|
76
|
+
export function rankedIds(zone, cardSelector, idAttr) {
|
|
77
|
+
return Array.prototype.slice
|
|
78
|
+
.call(zone.querySelectorAll(cardSelector))
|
|
79
|
+
.map((card) => card.getAttribute("data-" + idAttr))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The reorder POST body: the ranked ids under the app's key (slugs or ids),
|
|
83
|
+
// with the zone they were ranked in.
|
|
84
|
+
export function reorderBody(payloadKey, ids, zone) {
|
|
85
|
+
const body = {}
|
|
86
|
+
body[payloadKey] = ids
|
|
87
|
+
body.zone = zone
|
|
88
|
+
return body
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The move PATCH: ":id" in the URL template becomes the card's id, and the
|
|
92
|
+
// body is { resource: { attr: zone } }. Null when the board has no move
|
|
93
|
+
// endpoint, which is how a reorder-only board refuses a move.
|
|
94
|
+
export function moveRequest(moveUrl, moveParam, id, zone) {
|
|
95
|
+
if (!moveUrl || !moveParam) return null
|
|
96
|
+
|
|
97
|
+
const body = {}
|
|
98
|
+
body[moveParam.resource] = {}
|
|
99
|
+
body[moveParam.resource][moveParam.attr] = zone
|
|
100
|
+
return { url: moveUrl.replace(":id", encodeURIComponent(id)), body }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What SortableJS is created with for one zone. No group keeps a drag inside
|
|
104
|
+
// its zone. A locked card joins the undraggable filter, and onMove refuses a
|
|
105
|
+
// drag that would cross one, so a pinned card keeps its slot.
|
|
106
|
+
export function sortableOptions(config, { onStart, onEnd }) {
|
|
107
|
+
const locked = config.lockedSelector
|
|
108
|
+
const options = {
|
|
109
|
+
animation: 150,
|
|
110
|
+
ghostClass: "opacity-30",
|
|
111
|
+
draggable: config.cardSelector,
|
|
112
|
+
filter: locked ? `${config.sortFilter}, ${locked}` : config.sortFilter,
|
|
113
|
+
onStart,
|
|
114
|
+
onEnd
|
|
115
|
+
}
|
|
116
|
+
if (config.groupName) options.group = config.groupName
|
|
117
|
+
if (config.handle) options.handle = config.handle
|
|
118
|
+
if (locked) {
|
|
119
|
+
options.onMove = (event) => !(event.related && event.related.matches && event.related.matches(locked))
|
|
120
|
+
}
|
|
121
|
+
return options
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- a card leaving on a Turbo `remove` stream -----------------------------
|
|
125
|
+
|
|
126
|
+
// What a `remove` stream does to a card:
|
|
127
|
+
// "ignore" not this board's stream: another action, another dom id
|
|
128
|
+
// prefix, or no data-exit-action. Turbo renders it untouched.
|
|
129
|
+
// "reparent" an archive exit on a board whose archive column is on the
|
|
130
|
+
// page: the card moves into that column and stays.
|
|
131
|
+
// "remove" the card animates out and Turbo removes it.
|
|
132
|
+
export function exitPlan({ action, target, exitAction, domIdPrefix, archiveZone, archiveZonePresent }) {
|
|
133
|
+
if (action !== "remove") return "ignore"
|
|
134
|
+
if (!exitAction || String(target || "").indexOf(domIdPrefix) !== 0) return "ignore"
|
|
135
|
+
if (exitAction === "archive" && archiveZone && archiveZonePresent) return "reparent"
|
|
136
|
+
return "remove"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The exit animation: an archive sinks and shrinks, a delete shrinks.
|
|
140
|
+
export function exitAnimation(kind) {
|
|
141
|
+
const archive = kind === "archive"
|
|
142
|
+
return {
|
|
143
|
+
frames: archive
|
|
144
|
+
? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
|
|
145
|
+
: [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }],
|
|
146
|
+
timing: { duration: archive ? 500 : 420, easing: "cubic-bezier(.35,0,.2,1)", fill: "forwards" }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- SortableJS, fetched once ----------------------------------------------
|
|
151
|
+
|
|
152
|
+
// SortableJS itself is a constructor. window.Sortable can also hold the
|
|
153
|
+
// loading shim below, which is a plain object.
|
|
154
|
+
export function isSortable(candidate) {
|
|
155
|
+
return typeof candidate === "function" && typeof candidate.create === "function"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let sortableLoad = null
|
|
159
|
+
|
|
160
|
+
// Resolves with SortableJS. The vendored build is a classic script that assigns
|
|
161
|
+
// window.Sortable; a host that pins its own "sortablejs" may export it instead.
|
|
162
|
+
// A failed load is forgotten, so the next board tries again.
|
|
163
|
+
export function loadSortable({ win = window, importer = () => import("sortablejs") } = {}) {
|
|
164
|
+
if (isSortable(win.Sortable)) return Promise.resolve(win.Sortable)
|
|
165
|
+
if (sortableLoad) return sortableLoad
|
|
166
|
+
|
|
167
|
+
sortableLoad = Promise.resolve()
|
|
168
|
+
.then(importer)
|
|
169
|
+
.then((module) => {
|
|
170
|
+
const loaded = [module && module.default, module && module.Sortable, win.Sortable].find(isSortable)
|
|
171
|
+
if (!loaded) throw new Error("SortableJS loaded without defining Sortable")
|
|
172
|
+
if (!isSortable(win.Sortable)) win.Sortable = loaded
|
|
173
|
+
return loaded
|
|
174
|
+
})
|
|
175
|
+
.catch((error) => {
|
|
176
|
+
sortableLoad = null
|
|
177
|
+
throw error
|
|
178
|
+
})
|
|
179
|
+
return sortableLoad
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// window.Sortable until SortableJS is on the page: a page script that calls
|
|
183
|
+
// Sortable.create(el, options) loads the library with that call, and the
|
|
184
|
+
// sortable is created when it arrives. SortableJS replaces this object when it
|
|
185
|
+
// loads. create() returns nothing; a caller that needs the instance awaits
|
|
186
|
+
// load(), which resolves with SortableJS.
|
|
187
|
+
export function sortableShim(load, report) {
|
|
188
|
+
const complain = report || ((error) => console.error("[studio] SortableJS failed to load", error))
|
|
189
|
+
return {
|
|
190
|
+
studioShim: true,
|
|
191
|
+
load,
|
|
192
|
+
create(element, options) {
|
|
193
|
+
load().then((Sortable) => Sortable.create(element, options)).catch(complain)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- the scope and its controller find each other --------------------------
|
|
199
|
+
|
|
200
|
+
// A data-studio-controller value that names the board controller once.
|
|
201
|
+
export function withBoardController(value) {
|
|
202
|
+
const names = String(value || "").split(/\s+/).filter(Boolean)
|
|
203
|
+
if (names.indexOf(CONTROLLER) === -1) names.push(CONTROLLER)
|
|
204
|
+
return names.join(" ")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const scopes = new WeakMap()
|
|
208
|
+
const waiting = new WeakMap()
|
|
209
|
+
|
|
210
|
+
// The factory's init announces its scope for the element it sits on.
|
|
211
|
+
export function announceScope(element, scope) {
|
|
212
|
+
scopes.set(element, scope)
|
|
213
|
+
const resolvers = waiting.get(element) || []
|
|
214
|
+
waiting.delete(element)
|
|
215
|
+
resolvers.forEach((resolve) => resolve(scope))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resolves with the element's board scope, now or when Alpine initialises it.
|
|
219
|
+
// The controller may connect before or after Alpine reaches the element.
|
|
220
|
+
export function scopeFor(element) {
|
|
221
|
+
if (scopes.has(element)) return Promise.resolve(scopes.get(element))
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
waiting.set(element, (waiting.get(element) || []).concat(resolve))
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---- the Alpine scope ------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
// x-data="studioBoard({...})". Every name on the returned object is one a
|
|
230
|
+
// consumer's markup or script may read: a board's header slot binds `state`
|
|
231
|
+
// and the three state helpers, and a host reaches toast(), updateCounts() and
|
|
232
|
+
// animateCardExit() through Alpine.$data.
|
|
233
|
+
export function studioBoard(opts, { win = window, doc = document } = {}) {
|
|
234
|
+
opts = opts || {}
|
|
235
|
+
return {
|
|
236
|
+
...boardConfig(opts),
|
|
237
|
+
|
|
238
|
+
toasts: [],
|
|
239
|
+
// Board-level chrome state: a header slot's controls and a column's
|
|
240
|
+
// show_expr bind to it, so a board with filters needs no wrapper component.
|
|
241
|
+
state: (opts.state && typeof opts.state === "object") ? opts.state : {},
|
|
242
|
+
|
|
243
|
+
// Alpine runs this while it is still walking the subtree. It only makes the
|
|
244
|
+
// scope findable: the board controller wires the zones (wire, below).
|
|
245
|
+
init() {
|
|
246
|
+
const element = this.$el
|
|
247
|
+
const named = withBoardController(element.getAttribute(CONTROLLER_ATTRIBUTE))
|
|
248
|
+
if (element.getAttribute(CONTROLLER_ATTRIBUTE) !== named) element.setAttribute(CONTROLLER_ATTRIBUTE, named)
|
|
249
|
+
announceScope(element, this)
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// Makes the board interactive: the zones become sortable, a live board
|
|
253
|
+
// starts watching its zones, and the element is marked ready. `Sortable` is
|
|
254
|
+
// null when the library failed to load; the board then still renders, counts
|
|
255
|
+
// and follows live updates.
|
|
256
|
+
//
|
|
257
|
+
// data-alpine-ready is set after the next tick, so it means "the directives
|
|
258
|
+
// are bound and the zones drag". A test waits on it before it drags.
|
|
259
|
+
//
|
|
260
|
+
// Returns the function that undoes it.
|
|
261
|
+
wire(Sortable) {
|
|
262
|
+
const element = this.$el
|
|
263
|
+
const sortables = this.initSortables(Sortable)
|
|
264
|
+
const observers = this.live ? this.observeLive() : []
|
|
265
|
+
if (this.live && !this.fx()) this.installExitStreamFallback()
|
|
266
|
+
|
|
267
|
+
let wired = true
|
|
268
|
+
this.$nextTick(() => { if (wired) element.dataset.alpineReady = "true" })
|
|
269
|
+
|
|
270
|
+
return () => {
|
|
271
|
+
wired = false
|
|
272
|
+
delete element.dataset.alpineReady
|
|
273
|
+
sortables.forEach((sortable) => { if (sortable && sortable.destroy) sortable.destroy() })
|
|
274
|
+
observers.forEach((observer) => observer.disconnect())
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// This board's own zones. A page may hold several boards, and each wires,
|
|
279
|
+
// watches and counts only the zones inside its element.
|
|
280
|
+
zones() {
|
|
281
|
+
return Array.prototype.slice.call((this.$el || doc).querySelectorAll(this.zoneSelector))
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
fx() { return this.fxName ? win[this.fxName] : null },
|
|
285
|
+
cardId(el) { return el ? el.getAttribute("data-" + this.idAttr) : null },
|
|
286
|
+
zoneKey(el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null },
|
|
287
|
+
label(zone) { return this.labels[zone] || zone },
|
|
288
|
+
|
|
289
|
+
// ---- chrome state helpers --------------------------------------------
|
|
290
|
+
toggleState(key) { this.state[key] = !this.state[key] },
|
|
291
|
+
listHas(key, value) {
|
|
292
|
+
return Array.isArray(this.state[key]) && this.state[key].indexOf(value) !== -1
|
|
293
|
+
},
|
|
294
|
+
toggleInState(key, value) {
|
|
295
|
+
if (!Array.isArray(this.state[key])) this.state[key] = []
|
|
296
|
+
const index = this.state[key].indexOf(value)
|
|
297
|
+
if (index === -1) { this.state[key].push(value) } else { this.state[key].splice(index, 1) }
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// ---- SortableJS --------------------------------------------------------
|
|
301
|
+
// One sortable per zone. Returns them, so wire() can destroy them.
|
|
302
|
+
initSortables(Sortable) {
|
|
303
|
+
Sortable = Sortable || win.Sortable
|
|
304
|
+
if (!isSortable(Sortable)) return []
|
|
305
|
+
|
|
306
|
+
const options = sortableOptions(this, {
|
|
307
|
+
onStart: () => { win.__studioBoardDragging = true },
|
|
308
|
+
onEnd: (event) => {
|
|
309
|
+
this.handleSortEnd(event)
|
|
310
|
+
win.requestAnimationFrame(() => { win.__studioBoardDragging = false })
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
return this.zones().map((zone) => Sortable.create(zone, { ...options }))
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
// A drop in another zone moves the card and then saves that zone's order. A
|
|
317
|
+
// drop in the same zone only saves the order. A refused move is reverted
|
|
318
|
+
// and saves nothing.
|
|
319
|
+
handleSortEnd(event) {
|
|
320
|
+
const card = event.item
|
|
321
|
+
const fromZone = event.from
|
|
322
|
+
const toZone = event.to
|
|
323
|
+
const moved = isLaneMove(fromZone, toZone)
|
|
324
|
+
|
|
325
|
+
const afterMove = (ok) => {
|
|
326
|
+
if (moved && !ok) return
|
|
327
|
+
this.saveOrder(toZone)
|
|
328
|
+
this.updateCounts()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (moved) {
|
|
332
|
+
Promise.resolve(this.applyMove(card, fromZone, toZone, this.cardId(card))).then(afterMove)
|
|
333
|
+
} else {
|
|
334
|
+
afterMove(true)
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// A move between zones. Resolves true when the card stays in its new zone.
|
|
339
|
+
applyMove(card, fromZone, toZone, id) {
|
|
340
|
+
const newZone = this.zoneKey(toZone)
|
|
341
|
+
const fromKey = this.zoneKey(fromZone)
|
|
342
|
+
const request = moveRequest(this.moveUrl, this.moveParam, id, newZone)
|
|
343
|
+
|
|
344
|
+
// No move endpoint: a demo board still reflects the move, and any other
|
|
345
|
+
// board snaps the card back, so a stray drag cannot mismove it.
|
|
346
|
+
if (!request) {
|
|
347
|
+
if (this.demo) {
|
|
348
|
+
this.setCardZone(card, newZone)
|
|
349
|
+
this.emit("board-moved", id, fromKey, newZone)
|
|
350
|
+
return true
|
|
351
|
+
}
|
|
352
|
+
this.revert(card, fromZone)
|
|
353
|
+
return false
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const landed = () => {
|
|
357
|
+
this.setCardZone(card, newZone)
|
|
358
|
+
this.toast("Moved to " + this.label(newZone), "success")
|
|
359
|
+
this.emit("board-moved", id, fromKey, newZone)
|
|
360
|
+
this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone })
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (this.demo) return landed()
|
|
365
|
+
|
|
366
|
+
// request() rejects on a refusal with the server's own message, so the
|
|
367
|
+
// catch is the one failure path.
|
|
368
|
+
return this.request(request.url, "PATCH", request.body).then(landed).catch((error) => {
|
|
369
|
+
if (this.optimistic) this.revert(card, fromZone)
|
|
370
|
+
this.toast((error && error.message) || "Move failed", "error")
|
|
371
|
+
this.updateCounts()
|
|
372
|
+
return false
|
|
373
|
+
})
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
// Saves a zone's order: POSTs its ranked ids under the app's key.
|
|
377
|
+
//
|
|
378
|
+
// A REFUSAL IS SHOWN. request() rejects on a 4xx and the catch toasts what
|
|
379
|
+
// the server said ("That order does not match this week's games — reload
|
|
380
|
+
// and try again").
|
|
381
|
+
//
|
|
382
|
+
// NOTHING IS REVERTED. The whole column re-ranked, and SortableJS hands
|
|
383
|
+
// over the drop with the order before it already gone. A reorder endpoint's
|
|
384
|
+
// refusal tells the operator to reload, which restores the stored order; the
|
|
385
|
+
// board's job is that the instruction is read.
|
|
386
|
+
//
|
|
387
|
+
// RESOLVES true or false and never rejects, so the call in handleSortEnd,
|
|
388
|
+
// which nothing awaits, cannot raise an unhandled rejection.
|
|
389
|
+
saveOrder(toZone) {
|
|
390
|
+
const ids = rankedIds(toZone, this.cardSelector, this.idAttr)
|
|
391
|
+
const zone = this.zoneKey(toZone)
|
|
392
|
+
this.emit("board-reordered", null, zone, zone, { ids, zone })
|
|
393
|
+
this.hook(this.onDropHook, { ids, zone })
|
|
394
|
+
if (this.demo || !this.reorderUrl) return Promise.resolve(true)
|
|
395
|
+
|
|
396
|
+
return this.request(this.reorderUrl, "POST", reorderBody(this.reorderPayload, ids, zone))
|
|
397
|
+
.then(() => true)
|
|
398
|
+
.catch((error) => {
|
|
399
|
+
this.toast((error && error.message) || "Order save failed — reload and try again.", "error")
|
|
400
|
+
return false
|
|
401
|
+
})
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
setCardZone(card, zone) { card.setAttribute("data-" + this.zoneAttr, zone) },
|
|
405
|
+
|
|
406
|
+
// A refused move: the card goes back into the zone it left, above the empty
|
|
407
|
+
// state, and flashes a red ring.
|
|
408
|
+
revert(card, fromZone) {
|
|
409
|
+
const anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null
|
|
410
|
+
fromZone.insertBefore(card, anchor)
|
|
411
|
+
card.classList.add("ring-2", "ring-red-500")
|
|
412
|
+
setTimeout(() => { card.classList.remove("ring-2", "ring-red-500") }, 1500)
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
// A host may wrap this on its own scope (the hub's task board adds the
|
|
416
|
+
// cards a capped column keeps off the page).
|
|
417
|
+
updateCounts() {
|
|
418
|
+
const zones = {}
|
|
419
|
+
this.zones().forEach((zone) => { zones[zone.id] = zone })
|
|
420
|
+
|
|
421
|
+
const badges = (this.$el || doc).querySelectorAll("[data-board-count]")
|
|
422
|
+
badges.forEach((badge) => {
|
|
423
|
+
const zone = zones["dropzone-" + badge.getAttribute("data-board-count")]
|
|
424
|
+
if (!zone) return
|
|
425
|
+
const count = zone.querySelectorAll(this.cardSelector).length
|
|
426
|
+
badge.textContent = count
|
|
427
|
+
if (this.emptySelector) {
|
|
428
|
+
const empty = zone.querySelector(this.emptySelector)
|
|
429
|
+
if (empty) empty.style.display = count === 0 ? "flex" : "none"
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
// ---- live updates (Turbo Streams) --------------------------------------
|
|
435
|
+
// The board subscribes with turbo_stream_from, and Turbo patches the DOM on
|
|
436
|
+
// every broadcast. These observers add what Turbo does not: an entrance on
|
|
437
|
+
// each card it patches in (window[fx].onAdd when the app names one) and a
|
|
438
|
+
// recount after any change to a zone. Returns the observers.
|
|
439
|
+
observeLive() {
|
|
440
|
+
const onChange = (mutations) => {
|
|
441
|
+
mutations.forEach((mutation) => {
|
|
442
|
+
mutation.addedNodes.forEach((node) => {
|
|
443
|
+
if (node.nodeType !== 1 || !node.matches || !node.matches(this.cardSelector)) return
|
|
444
|
+
const fx = this.fx()
|
|
445
|
+
if (fx && fx.onAdd) { fx.onAdd(node) } else { this.animateIn(node) }
|
|
446
|
+
this.emit("card-added", this.cardId(node), null, this.zoneKey(node.parentElement))
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
this.updateCounts()
|
|
450
|
+
}
|
|
451
|
+
return this.zones().map((zone) => {
|
|
452
|
+
const observer = new win.MutationObserver(onChange)
|
|
453
|
+
observer.observe(zone, { childList: true })
|
|
454
|
+
return observer
|
|
455
|
+
})
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
animateIn(el) {
|
|
459
|
+
el.style.opacity = "0"
|
|
460
|
+
el.style.transform = "scale(0.96)"
|
|
461
|
+
el.classList.add("ring-2", "ring-primary")
|
|
462
|
+
win.requestAnimationFrame(() => {
|
|
463
|
+
el.style.transition = "opacity 300ms ease, transform 300ms ease"
|
|
464
|
+
el.style.opacity = ""
|
|
465
|
+
el.style.transform = ""
|
|
466
|
+
})
|
|
467
|
+
setTimeout(() => { el.classList.remove("ring-2", "ring-primary"); el.style.transition = "" }, 900)
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
// A Turbo `remove` stream carrying data-exit-action animates its card out
|
|
471
|
+
// (exitPlan says which way). Installed once per page, by the first live
|
|
472
|
+
// board with no fx module; a board with one leaves exits to it.
|
|
473
|
+
installExitStreamFallback() {
|
|
474
|
+
if (win.__studioBoardExitFallback) return
|
|
475
|
+
win.__studioBoardExitFallback = true
|
|
476
|
+
|
|
477
|
+
doc.addEventListener("turbo:before-stream-render", (event) => {
|
|
478
|
+
const stream = event.target
|
|
479
|
+
const target = stream.getAttribute("target") || ""
|
|
480
|
+
const exitAction = stream.dataset.exitAction
|
|
481
|
+
const archive = this.archiveZone ? doc.getElementById("dropzone-" + this.archiveZone) : null
|
|
482
|
+
const plan = exitPlan({
|
|
483
|
+
action: stream.getAttribute("action"),
|
|
484
|
+
target,
|
|
485
|
+
exitAction,
|
|
486
|
+
domIdPrefix: this.domIdPrefix,
|
|
487
|
+
archiveZone: this.archiveZone,
|
|
488
|
+
archiveZonePresent: !!archive
|
|
489
|
+
})
|
|
490
|
+
if (plan === "ignore") return
|
|
491
|
+
|
|
492
|
+
const renderNow = event.detail.render
|
|
493
|
+
event.detail.render = (streamElement) => {
|
|
494
|
+
const card = doc.getElementById(target)
|
|
495
|
+
if (!card) { renderNow(streamElement); return }
|
|
496
|
+
|
|
497
|
+
this.animateCardExit(card, exitAction).then(() => {
|
|
498
|
+
if (plan === "reparent") {
|
|
499
|
+
const anchor = this.emptySelector ? archive.querySelector(this.emptySelector) : null
|
|
500
|
+
archive.insertBefore(card, anchor)
|
|
501
|
+
this.setCardZone(card, this.archiveZone)
|
|
502
|
+
this.resetCardExit(card)
|
|
503
|
+
} else {
|
|
504
|
+
renderNow(streamElement)
|
|
505
|
+
}
|
|
506
|
+
this.updateCounts()
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
// Marks which exit is running (data-exit-action) before anything else, so
|
|
513
|
+
// the marker is there even when reduced motion skips the animation.
|
|
514
|
+
animateCardExit(card, kind) {
|
|
515
|
+
if (!card) return Promise.resolve()
|
|
516
|
+
card.dataset.exitAction = kind
|
|
517
|
+
if (win.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve()
|
|
518
|
+
|
|
519
|
+
card.style.pointerEvents = "none"
|
|
520
|
+
const { frames, timing } = exitAnimation(kind)
|
|
521
|
+
return card.animate(frames, timing).finished.catch(() => {})
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
// Clears an exit animation and its marker, so a re-parented card is fully
|
|
525
|
+
// visible in the archive column.
|
|
526
|
+
resetCardExit(card) {
|
|
527
|
+
if (!card) return
|
|
528
|
+
card.getAnimations({ subtree: false }).forEach((animation) => animation.cancel())
|
|
529
|
+
delete card.dataset.exitAction
|
|
530
|
+
card.style.pointerEvents = ""
|
|
531
|
+
card.style.opacity = ""
|
|
532
|
+
card.style.transform = ""
|
|
533
|
+
card.style.filter = ""
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
// ---- toasts ------------------------------------------------------------
|
|
537
|
+
// A board rendered with toasts: false alerts an error instead, so a refusal
|
|
538
|
+
// still reaches the operator.
|
|
539
|
+
toast(message, type) {
|
|
540
|
+
if (!this.toastsEnabled) {
|
|
541
|
+
if (type === "error" && typeof win.alert === "function") win.alert(message)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
const id = Date.now() + Math.random()
|
|
545
|
+
this.toasts.push({ id, message, type, visible: true })
|
|
546
|
+
setTimeout(() => {
|
|
547
|
+
const toast = this.toasts.find((entry) => entry.id === id)
|
|
548
|
+
if (toast) toast.visible = false
|
|
549
|
+
setTimeout(() => { this.toasts = this.toasts.filter((entry) => entry.id !== id) }, 300)
|
|
550
|
+
}, 3000)
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
// ---- the one HTTP seam -------------------------------------------------
|
|
554
|
+
// REJECTS ON A SERVER REFUSAL, which neither fetcher underneath does:
|
|
555
|
+
// - window.fetch resolves on a 4xx or 5xx, so a bare catch sees a network
|
|
556
|
+
// failure and nothing else;
|
|
557
|
+
// - window.authedFetch (turf-monster's, used when the host defines it)
|
|
558
|
+
// resolves with the response for anything but a 401, and with null on
|
|
559
|
+
// an expired session or a rate-limited tier.
|
|
560
|
+
// Both are normalised here, so a caller ignores a refusal only by saying so
|
|
561
|
+
// in its own catch. The message is the server's own { error: "…" }, which
|
|
562
|
+
// every board endpoint writes for the operator to read.
|
|
563
|
+
request(url, method, body) {
|
|
564
|
+
const csrf = (doc.querySelector('meta[name="csrf-token"]') || {}).content || ""
|
|
565
|
+
const fetcher = win.authedFetch || win.fetch
|
|
566
|
+
return fetcher(url, {
|
|
567
|
+
method,
|
|
568
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
569
|
+
body: JSON.stringify(body || {})
|
|
570
|
+
}).then((response) => {
|
|
571
|
+
if (!response) throw new Error("Session expired — please sign in again.")
|
|
572
|
+
if (response.ok) return response
|
|
573
|
+
return response.json().catch(() => ({})).then((refusal) => {
|
|
574
|
+
throw new Error(refusal.error || ("Failed (" + response.status + ")"))
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
// The extension seam: an app listens for these on window.
|
|
580
|
+
emit(name, record, from, to, extra) {
|
|
581
|
+
try {
|
|
582
|
+
const detail = Object.assign({ record, from, to }, extra || {})
|
|
583
|
+
win.dispatchEvent(new win.CustomEvent("studio:" + name, { detail }))
|
|
584
|
+
} catch (error) {}
|
|
585
|
+
},
|
|
586
|
+
|
|
587
|
+
hook(hookName, detail) {
|
|
588
|
+
if (!hookName) return
|
|
589
|
+
const fn = win[hookName]
|
|
590
|
+
if (typeof fn === "function") { try { fn(detail) } catch (error) {} }
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|