phlex-reactive 0.14.1 → 0.14.2

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.
@@ -846,8 +846,8 @@ function parseOps(raw) {
846
846
  }
847
847
 
848
848
  // --- on_client binding records (issue #271) -----------------------------------
849
- // Each on_client call emits ONE record ({on, ops, window?, outside?, confirm?,
850
- // confirmWhen?}); mix space-joins several onto one element (spaces inside a
849
+ // Each on_client call emits ONE record ({on, ops, window?, outside?, once?,
850
+ // detail?, confirm?, confirmWhen?}); mix space-joins several onto one element (spaces inside a
851
851
  // record ride as \u0020, so the join is unambiguous). Stimulus hands every
852
852
  // runOps descriptor on an element the SAME event.params, so runOps selects the
853
853
  // record(s) whose descriptor matches the firing event: event.type, the key
@@ -940,6 +940,21 @@ function bindingMatches(record, event, keyMappings) {
940
940
  return dot < 0 || keyMappings === null || keyFilterMatches(on.slice(dot + 1), event, keyMappings)
941
941
  }
942
942
 
943
+ // Issue #346: a record's `detail` lists top-level keys of event.detail and the
944
+ // JSON scalar each must strictly equal — on_client("turbo:submit-end", ops,
945
+ // detail: { success: true }) runs only after a successful submit. A missing or
946
+ // non-object event.detail, or a missing key, never matches (default-deny); a
947
+ // record without `detail` matches whatever the event carries.
948
+ function detailMatches(record, event) {
949
+ const expected = record.detail
950
+ if (expected === undefined) return true
951
+ if (expected === null || typeof expected !== "object" || Array.isArray(expected)) return false
952
+ const detail = event.detail
953
+ if (detail === null || typeof detail !== "object") return false
954
+ // A missing key reads undefined, which no JSON scalar (null included) equals.
955
+ return Object.keys(expected).every((key) => detail[key] === expected[key])
956
+ }
957
+
943
958
  // A `once: true` record (PR #272) is spent after its first run. Stimulus
944
959
  // removes only that binding's own listener; a regular same-event sibling on
945
960
  // the element keeps calling runOps with BOTH records, so the spent one is
@@ -2364,6 +2379,9 @@ export default class extends Controller {
2364
2379
  // An early replay never runs an outside: record (issue #303: an outside
2365
2380
  // click before connect had nothing to close).
2366
2381
  if (record.outside && event[EARLY_KEY]) continue
2382
+ // A detail miss (issue #346) is a complete no-op — checked before the
2383
+ // once record is spent, preventDefault and the confirm gate.
2384
+ if (!detailMatches(record, event)) continue
2367
2385
  if (onceBindingSpent(this, event, record)) continue
2368
2386
  this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2369
2387
  }
@@ -417,6 +417,16 @@ module Phlex
417
417
  # on_client(:click, js.hide("#menu"), outside: true),
418
418
  # on_client("keydown.esc", js.hide("#menu"))))
419
419
  #
420
+ # Issue #346: `detail:` gates the binding on the event's payload — a Hash
421
+ # of top-level event.detail keys to the JSON scalar each must strictly
422
+ # equal. The ops run after a SUCCESSFUL submit only, and a failed one
423
+ # keeps the selection for the retry:
424
+ # form(**on_client("turbo:submit-end", js.check_group("ids[]", false),
425
+ # detail: { success: true }))
426
+ # A missing event.detail or key never matches; a miss is a complete
427
+ # no-op (no confirm, no preventDefault). Not Turbo-specific: any
428
+ # CustomEvent whose detail carries a flag works.
429
+ #
420
430
  # Ops are EPHEMERAL UI: any server re-render of the component (an action
421
431
  # reply, a broadcast, a morph) rebuilds from server state and resets
422
432
  # whatever they toggled — by design (the LiveView JS-commands caveat). Use
@@ -425,7 +435,7 @@ module Phlex
425
435
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
426
436
  # accepted — a dead trigger should fail at render, not no-op in the
427
437
  # browser.
428
- def on_client(event, ops, window: false, once: false, outside: false, confirm: nil)
438
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil)
429
439
  unless ops.is_a?(Phlex::Reactive::JS)
430
440
  raise ArgumentError,
431
441
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -443,7 +453,7 @@ module Phlex
443
453
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
444
454
  end
445
455
  window_bound = window || outside
446
- record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
456
+ record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
447
457
  attrs = {
448
458
  data: {
449
459
  action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
@@ -1440,13 +1450,14 @@ module Phlex
1440
1450
  # overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
1441
1451
  # client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
1442
1452
  # CONDITIONAL, compiled by the shared compile_conditional_confirm.
1443
- def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
1453
+ def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
1444
1454
  record = { "on" => event }
1445
1455
  record["window"] = true if window_bound
1446
1456
  record["outside"] = true if outside
1447
1457
  # A mix-ed regular sibling keeps calling runOps after Stimulus drops
1448
1458
  # this binding's :once listener — the client skips a spent once record.
1449
1459
  record["once"] = true if once
1460
+ record["detail"] = client_binding_detail(detail, once:) unless detail.nil?
1450
1461
  case confirm
1451
1462
  when nil, false then nil
1452
1463
  when String then record["confirm"] = confirm
@@ -1457,6 +1468,44 @@ module Phlex
1457
1468
  record
1458
1469
  end
1459
1470
 
1471
+ # Issue #346: validate + stringify on_client's detail: filter. Loud at
1472
+ # render, like the other on_client validations — a dead filter fails
1473
+ # here, never silently in the browser.
1474
+ def client_binding_detail(detail, once:)
1475
+ unless detail.is_a?(Hash) && !detail.empty?
1476
+ raise ArgumentError,
1477
+ "on_client detail: takes a non-empty Hash of event.detail keys to values " \
1478
+ "(e.g. detail: { success: true }), got #{detail.inspect}"
1479
+ end
1480
+ if once
1481
+ raise ArgumentError,
1482
+ "on_client detail: cannot combine with once: — Stimulus removes a :once listener on its " \
1483
+ "first event, so a first non-matching event would leave the binding dead"
1484
+ end
1485
+ normalized = detail.to_h do |key, value|
1486
+ unless client_detail_scalar?(value)
1487
+ raise ArgumentError,
1488
+ "on_client detail: values must be JSON scalars (true/false/nil, a String or a finite " \
1489
+ "number), got #{value.inspect} for #{key.inspect}"
1490
+ end
1491
+ [key.to_s, value]
1492
+ end
1493
+ # :success and "success" would collapse into one key, dropping a condition.
1494
+ if normalized.size < detail.size
1495
+ raise ArgumentError, "on_client detail: keys must be unique as strings, got #{detail.keys.inspect}"
1496
+ end
1497
+
1498
+ normalized
1499
+ end
1500
+
1501
+ def client_detail_scalar?(value)
1502
+ case value
1503
+ when true, false, nil, String, Integer then true
1504
+ when Float then value.finite?
1505
+ else false
1506
+ end
1507
+ end
1508
+
1460
1509
  def apply_confirm!(data, confirm)
1461
1510
  case confirm
1462
1511
  when String
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.14.1"
5
+ VERSION = "0.14.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.1
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson