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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +19 -1
- data/app/javascript/phlex/reactive/core.min.js +2 -2
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +20 -2
- data/lib/phlex/reactive/component/helpers.rb +52 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -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?,
|
|
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
|