studio-engine 0.76.2 → 0.76.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67f19e731d05c8bba0ba8a2b7d0aaf9aef71f26705e19014bc5532c6fc5f3917
4
- data.tar.gz: 511695ff90c382eab81c9860e882bcff9407c68a08abc9094cf87c605a25b6c3
3
+ metadata.gz: 2c352381b0778909cd1798ba2d81abf435f1c2e668198ac38fdae33310d65d8c
4
+ data.tar.gz: 6a80ed76a9748b246df94016572aa1b1b470360c8938390acaef92342a4d1350
5
5
  SHA512:
6
- metadata.gz: 358ae5bb8a8f101cf7713218de92d1e6b08b1e2f4acf772b4f110abdf997bc5c1a2979f83beeaa6861af6af84869680b4f9351d512186986e7fb1f6250c2f726
7
- data.tar.gz: 33173de4aa44555b127b772b1a10f84405809d10d08cc7b0aa64d8a6286bd5ea1d0816eb97ac7f0270b24fc987abe4670ba65715df6c3473d03e59501e3363d4
6
+ metadata.gz: b57498e85f26bfe1ba051381c0411f0f5161513726ed9fbd6aa4769e0bd3767ec962d0f007ac09ad2326e9a97fb05d73e17e122ef5b779f6cc8e48dd923c3c24
7
+ data.tar.gz: 0a17dcfaa72bd771c57cacb04ee9f2f3b82baf5839bc9b739216bc1d6fc1e96b456bc68b645b0865bca9569d64b1abd5327a4e779ff74c6af4cf740776f8f14e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,55 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.76.3 — 2026-09-23
8
+
9
+ ### Fixed
10
+
11
+ - **A board drag that the server refuses now tells you.** The board primitive's
12
+ drag-reorder POSTed the new order and discarded the answer: `saveOrder` ended in
13
+ `.catch(function () {})` — an EMPTY handler, on a `fetch` that *resolves* on a
14
+ 4xx and so never reached it. Two layers of silence over one request. The
15
+ endpoints on the other end had already written the operator a reason (turf-monster's
16
+ week board answers a drifted card set with *"That order does not match this week's
17
+ games — reload and try again"*, and `Studio::Board::Reorderable` renders
18
+ `{ error: … }` beside every 422 it raises) and that sentence was unreachable by
19
+ construction, while the board's CSS counter renumbered the cards as though the save
20
+ had landed. A refused save now toasts the server's own words — or `window.alert`s
21
+ them on a `toasts: false` board — and resolves `false`.
22
+ - **Fixed at the seam, so all five boards get it.** The check lives in the
23
+ factory's `request()`, which now REJECTS on a refusal rather than handing back a
24
+ resolved 4xx. That is the semantics both callers already assumed by writing
25
+ `.catch(…)`, and it is what neither underlying fetcher provides. Every board
26
+ riding this factory is covered by the one change: turf-monster's NFL week order,
27
+ and mcritchie-studio's tasks (the DevOps board itself), depth charts, content and
28
+ news. No consumer change is needed beyond the gem bump.
29
+ - **An expired session is no longer a silently lost drag either.** A host that
30
+ defines `window.authedFetch` (turf-monster does) gets `null` back from it on a
31
+ 401 or a rate-limited 429. `saveOrder` never checked for that, and `applyMove`
32
+ would have thrown a `TypeError` off `resp.ok`. `request()` now names it:
33
+ *"Session expired — please sign in again."*
34
+ - **A refused reorder is not auto-reverted, deliberately.** Unlike a single-card
35
+ move there is no one card to snap back: the whole column re-ranked, and SortableJS
36
+ hands the drop over with the pre-drop order already gone. Every reorder endpoint's
37
+ refusal tells the operator to reload, which restores the stored order exactly; the
38
+ board's job is to make sure that instruction is read. `optimistic:` governs moves
39
+ only, and `studio/board/_board` now says so.
40
+ - **`saveOrder` returns a promise** (resolving `true`/`false`, never rejecting), so
41
+ a caller can wait for a save to land instead of assuming it did.
42
+
43
+ ### Changed
44
+
45
+ - **The board factory is now executed under test, not grepped.**
46
+ `test/views/board_factory_save_behavior_test.rb` extracts
47
+ `studio/_board_assets`'s `<script>` and runs it under node with stub
48
+ `window`/`document`/`fetch`, asserting `saveOrder`, `applyMove` and `request` by
49
+ CALLING them — nine scenarios covering the refusal text, the `null` session, an
50
+ unparseable error body, the silent success, the `toasts: false` fallback, the
51
+ unchanged event/hook seam and both move outcomes. The bug above survived every
52
+ source-substring assertion in `board_primitive_test.rb`, because the broken build
53
+ contained the word `catch` too; that section now says where behavioral coverage
54
+ belongs.
55
+
7
56
  ## 0.76.2 — 2026-09-22
8
57
 
9
58
  ## 0.76.1 — 2026-09-19
@@ -187,12 +187,11 @@
187
187
  body[this.moveParam.resource] = {};
188
188
  body[this.moveParam.resource][this.moveParam.attr] = newZone;
189
189
 
190
- return this.request(url, "PATCH", body).then(function (resp) {
191
- if (!resp.ok) {
192
- return resp.json().catch(function () { return {}; }).then(function (err) {
193
- throw new Error(err.error || ("Failed (" + resp.status + ")"));
194
- });
195
- }
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 () {
196
195
  self.setCardZone(card, newZone);
197
196
  self.toast("Moved to " + self.label(newZone), "success");
198
197
  self.emit("board-moved", id, fromKey, newZone);
@@ -208,6 +207,23 @@
208
207
 
209
208
  // Save the destination column's order. Reads the ordered id list and POSTs
210
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.
211
227
  saveOrder: function (toZone) {
212
228
  var self = this;
213
229
  var ids = Array.prototype.slice
@@ -216,12 +232,15 @@
216
232
  var zone = this.zoneKey(toZone);
217
233
  this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
218
234
  this.hook(this.onDropHook, { ids: ids, zone: zone });
219
- if (this.demo || !this.reorderUrl) return;
235
+ if (this.demo || !this.reorderUrl) return Promise.resolve(true);
220
236
  var payload = {};
221
237
  payload[this.reorderPayload] = ids;
222
238
  payload.zone = zone;
223
- this.request(this.reorderUrl, "POST", payload).catch(function () {
224
- // Order save failed — positions are stale but the board stays functional.
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;
225
244
  });
226
245
  },
227
246
 
@@ -377,6 +396,22 @@
377
396
  },
378
397
 
379
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`.
380
415
  request: function (url, method, body) {
381
416
  var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
382
417
  var fetcher = window.authedFetch || window.fetch;
@@ -384,6 +419,18 @@
384
419
  method: method,
385
420
  headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
386
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
+ });
387
434
  });
388
435
  },
389
436
 
@@ -59,7 +59,13 @@
59
59
  card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
60
60
  card_as: local the record binds to in card_partial. Default :card.
61
61
  card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
62
- reorder_url: POST endpoint the drag-reorder saves the new order to.
62
+ reorder_url: POST endpoint the drag-reorder saves the new order to. It is
63
+ expected to answer a refusal with a 4xx carrying
64
+ { error: "…" } (Studio::Board::Reorderable already does);
65
+ the factory TOASTS that sentence. A refused reorder is NOT
66
+ reverted — the whole column moved and the pre-drop order is
67
+ gone by then — so write that message as the instruction the
68
+ operator should follow, e.g. "… reload and try again".
63
69
  reorder_payload: :slugs | :ids — the key the ordered id list POSTs under.
64
70
  Default :slugs.
65
71
  move_url: PATCH template for a cross-column move, ":id" → the card's id
@@ -78,8 +84,10 @@
78
84
  empty_selector: the empty-state selector (revert anchor + count toggle).
79
85
  Default ".kanban-empty"; nil for a board with no empty state.
80
86
  live_channel: Turbo Stream channel for live updates (nil = static board).
81
- optimistic: revert + red-ring flash a failed move. Default true.
87
+ optimistic: revert + red-ring flash a failed MOVE. Default true. It does
88
+ not govern reorders, which never revert (see reorder_url).
82
89
  toasts: show the toast host (false = window.alert on error). Default true.
90
+ Either way a refused move or save reaches the operator.
83
91
  empty_label: empty-zone text. Default "Drop here".
84
92
  header_slot: raw HTML rendered above the columns (pass html_safe content).
85
93
  above_board_slot: raw HTML rendered between the header and the columns.
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.76.2"
2
+ VERSION = "0.76.3"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.76.2
4
+ version: 0.76.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-22 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails