phlex-reactive 0.10.0 → 0.11.0
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 +246 -0
- data/README.md +127 -78
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +370 -179
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +122 -34
- data/lib/phlex/reactive/component/helpers.rb +307 -165
- data/lib/phlex/reactive/component/registry.rb +6 -1
- data/lib/phlex/reactive/component.rb +3 -3
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/streamable.rb +229 -199
- data/lib/phlex/reactive/test_helpers.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +4 -1
|
@@ -47,7 +47,7 @@ module Phlex
|
|
|
47
47
|
|
|
48
48
|
# Subject-bound reply builder — the preferred way to control an action's
|
|
49
49
|
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
50
|
-
# `
|
|
50
|
+
# `reply.replace.flash(:error, msg)`: the
|
|
51
51
|
# component is the implicit subject (no `self` to thread) and there's no
|
|
52
52
|
# constant to qualify (reply is a method, so a namespaced component needs
|
|
53
53
|
# no `Response = …` alias). It returns the same immutable Response the
|
|
@@ -110,31 +110,49 @@ module Phlex
|
|
|
110
110
|
# (an explicit override wins as a clean replace, not a `mix` string-concat —
|
|
111
111
|
# mix would join two String ids into "default override").
|
|
112
112
|
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
# client's param reader sees as "" → falsy).
|
|
113
|
+
# Dirty tracking (issue #103, #184) is now a CLASS-LEVEL reactive_dirty
|
|
114
|
+
# declaration, not a reactive_root kwarg. reactive_root reads
|
|
115
|
+
# self.class.reactive_dirty_config and emits the SAME DOM as before: the
|
|
116
|
+
# trackDirty descriptor on the root's data-action (mix token-joins, so a
|
|
117
|
+
# caller's own data-action survives) UNLESS `only:` scoped tracking to named
|
|
118
|
+
# fields, and — for warn_unsaved: true — the navigate-away marker (STRING
|
|
119
|
+
# "true", since a boolean-true attr renders valueless → "" → falsy client-
|
|
120
|
+
# side). The removed track_dirty:/warn_unsaved: kwargs raise a guided error.
|
|
122
121
|
def reactive_root(**overrides)
|
|
123
122
|
# A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
|
|
124
123
|
# there's no token to self-match by id. Use an explicit override, else
|
|
125
124
|
# #id when the component defines one, else nothing (no id attr).
|
|
125
|
+
reject_removed_dirty_kwargs!(overrides)
|
|
126
126
|
root_id = overrides.delete(:id)
|
|
127
127
|
root_id = id if root_id.nil? && respond_to?(:id)
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
# Issue #183: bind a client-side compute AT THE ROOT — the descriptors +
|
|
129
|
+
# the recompute delegation ride here so no field needs per-field wiring.
|
|
130
|
+
# nil (the conditional-binding collapse) emits nothing.
|
|
131
|
+
compute = overrides.delete(:compute)
|
|
132
|
+
# Issue #184: dirty tracking is a class-level reactive_dirty declaration.
|
|
133
|
+
dirty = self.class.reactive_dirty_config if self.class.respond_to?(:reactive_dirty_config)
|
|
130
134
|
|
|
131
135
|
attrs = mix({ **reactive_attrs }, overrides)
|
|
132
136
|
attrs = mix(attrs, { id: root_id }) unless root_id.nil?
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
# Root-level delegation tracks the whole subtree UNLESS only: scoped it to
|
|
138
|
+
# named fields (those carry their own descriptor via reactive_field).
|
|
139
|
+
attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if dirty && dirty[:only].nil?
|
|
140
|
+
attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if dirty&.dig(:warn_unsaved)
|
|
141
|
+
attrs = mix(attrs, compute_binding(compute)) if compute
|
|
135
142
|
attrs
|
|
136
143
|
end
|
|
137
144
|
|
|
145
|
+
# Issue #184: track_dirty:/warn_unsaved: on reactive_root are removed in
|
|
146
|
+
# favor of the class-level reactive_dirty macro. Guided error naming it.
|
|
147
|
+
def reject_removed_dirty_kwargs!(overrides)
|
|
148
|
+
removed = overrides.keys & %i[track_dirty warn_unsaved]
|
|
149
|
+
return if removed.empty?
|
|
150
|
+
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
"reactive_root(#{removed.first}:) was removed in issue #184 — declare " \
|
|
153
|
+
"`reactive_dirty warn_unsaved: true` (class-level) instead."
|
|
154
|
+
end
|
|
155
|
+
|
|
138
156
|
# Attributes for an element that triggers an action.
|
|
139
157
|
# button(**on(:toggle)) { "○" }
|
|
140
158
|
# form(**on(:save, event: "submit")) { ... }
|
|
@@ -168,14 +186,10 @@ module Phlex
|
|
|
168
186
|
# input(**on(:add, event: "keydown.enter")) # Enter submits
|
|
169
187
|
# button(**on(:cancel, event: "keydown.esc")) # Escape cancels
|
|
170
188
|
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
# (clicking its own reactive trigger — so selection stays a signed action),
|
|
176
|
-
# and Escape clears — all with NO server round trip for the highlight (the
|
|
177
|
-
# controller's listnav* handlers, like #recompute). Omit it for no nav.
|
|
178
|
-
# input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
|
|
189
|
+
# Combobox keyboard navigation is the standalone reactive_listnav (issue
|
|
190
|
+
# #181 removed the `on(…, listnav:)` kwarg — it duplicated reactive_listnav
|
|
191
|
+
# while skipping its blank-selector validation). Compose it via mix:
|
|
192
|
+
# input(**mix(on(:search, event: "input", debounce: 300), reactive_listnav))
|
|
179
193
|
# The verbatim JSON for an empty explicit-params payload. The common
|
|
180
194
|
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
181
195
|
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
@@ -238,30 +252,31 @@ module Phlex
|
|
|
238
252
|
# input(type: "checkbox", checked: @todo.done,
|
|
239
253
|
# **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
|
|
240
254
|
# button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
|
|
241
|
-
# `
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
246
|
-
#
|
|
255
|
+
# `busy:` (issue #181) — declarative per-trigger pending states, Livewire's
|
|
256
|
+
# wire:loading + phx-disable-with without a Stimulus controller. It shares
|
|
257
|
+
# optimistic:'s key vocabulary and normalizer; the ONLY difference is the
|
|
258
|
+
# lifecycle: a busy: hint applies the moment the request is ENQUEUED
|
|
259
|
+
# (covering the queue wait, not just the fetch) and reverts on SETTLE (any
|
|
260
|
+
# completion), where optimistic: reverts only on FAILURE. `busy:` is a
|
|
261
|
+
# String or a Hash:
|
|
262
|
+
# * "Saving…" — String shorthand for { disable: true, text: … }
|
|
247
263
|
# * disable: true — disable the trigger while pending
|
|
248
|
-
# *
|
|
249
|
-
# (or a `to:` selector scoped to the root)
|
|
250
|
-
# *
|
|
251
|
-
# *
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
# pending window regardless of these hints, so an app styles a spinner with
|
|
258
|
-
# `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
|
|
264
|
+
# * add_class:/remove_class:/toggle_class: "…" / [ … ] — class op on the
|
|
265
|
+
# trigger (or a `to:` selector scoped to the root)
|
|
266
|
+
# * hide:/show: true — hide/show the target while pending
|
|
267
|
+
# * text: "Saving…" — swap the trigger's innerHTML while pending
|
|
268
|
+
# * to: :root / "sel" — target the ops at the root or a selector
|
|
269
|
+
# checked: :keep is optimistic-ONLY (a native flip has no settle-revert
|
|
270
|
+
# meaning). The trigger/root also always carry `data-reactive-busy` for the
|
|
271
|
+
# whole pending window regardless of these hints, so an app styles a spinner
|
|
272
|
+
# with `[data-reactive-busy] .spinner { display: block }` and zero Ruby; see
|
|
259
273
|
# busy_on for scoped indicators.
|
|
260
|
-
# button(**on(:save,
|
|
261
|
-
# button(**on(:destroy, confirm: "Sure?",
|
|
262
|
-
def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
|
|
263
|
-
window: false, once: false, outside: false, optimistic: nil,
|
|
274
|
+
# button(**on(:save, busy: "Saving…")) { "Save" }
|
|
275
|
+
# button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
|
|
276
|
+
def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
|
|
277
|
+
window: false, once: false, outside: false, optimistic: nil, busy: nil,
|
|
264
278
|
**params)
|
|
279
|
+
reject_removed_on_kwargs!(action_name, params)
|
|
265
280
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
266
281
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
267
282
|
# verbose_errors (dev + test), fail loudly at RENDER time instead —
|
|
@@ -298,7 +313,6 @@ module Phlex
|
|
|
298
313
|
|
|
299
314
|
window_bound = window || outside
|
|
300
315
|
action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
|
|
301
|
-
action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
|
|
302
316
|
attrs = {
|
|
303
317
|
data: {
|
|
304
318
|
action:,
|
|
@@ -308,12 +322,9 @@ module Phlex
|
|
|
308
322
|
}
|
|
309
323
|
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
310
324
|
attrs[:data][:reactive_throttle_param] = throttle if throttle
|
|
311
|
-
attrs[:data]
|
|
312
|
-
attrs[:data][:
|
|
313
|
-
attrs[:data][:
|
|
314
|
-
if (loading_hint = loading_hint_json(loading, disable_with, action_name))
|
|
315
|
-
attrs[:data][:reactive_loading_param] = loading_hint
|
|
316
|
-
end
|
|
325
|
+
apply_confirm!(attrs[:data], confirm) if confirm
|
|
326
|
+
attrs[:data][:reactive_optimistic_param] = pending_hint_json(optimistic, action_name, :optimistic) if optimistic
|
|
327
|
+
attrs[:data][:reactive_busy_param] = pending_hint_json(busy, action_name, :busy) if busy
|
|
317
328
|
# STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
|
|
318
329
|
# (data-reactive-outside-param), which Stimulus's param reader sees as ""
|
|
319
330
|
# — falsy in JS, so the guard silently never fires. The explicit ="true"
|
|
@@ -354,7 +365,7 @@ module Phlex
|
|
|
354
365
|
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
355
366
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
356
367
|
# browser.
|
|
357
|
-
def on_client(event, ops, window: false, once: false, outside: false)
|
|
368
|
+
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil)
|
|
358
369
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
359
370
|
raise ArgumentError,
|
|
360
371
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -374,6 +385,14 @@ module Phlex
|
|
|
374
385
|
# on()'s flags above.
|
|
375
386
|
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
376
387
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
388
|
+
# Issue #178: confirm: gates the client-op chain behind the SAME
|
|
389
|
+
# overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
|
|
390
|
+
# the identical data-reactive-confirm-param; the client's runOps prompts
|
|
391
|
+
# via confirmResolver BEFORE applying the ops (a falsy resolve cancels
|
|
392
|
+
# the chain), so a destructive client op gets the themed dialog with no
|
|
393
|
+
# round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
|
|
394
|
+
# apply_confirm! branches String vs Hash for both on and on_client.
|
|
395
|
+
apply_confirm!(attrs[:data], confirm) if confirm
|
|
377
396
|
attrs[:type] = "button" if event == "click" && !window_bound
|
|
378
397
|
attrs
|
|
379
398
|
end
|
|
@@ -388,27 +407,67 @@ module Phlex
|
|
|
388
407
|
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
389
408
|
# focusing the input doesn't dispatch and collapse edit mode.
|
|
390
409
|
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
#
|
|
395
|
-
#
|
|
396
|
-
#
|
|
397
|
-
#
|
|
398
|
-
#
|
|
399
|
-
#
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
410
|
+
# Dirty tracking is class-level now (issue #184): the per-field `dirty:`
|
|
411
|
+
# kwarg is REMOVED (reject_removed_field_dirty! raises a guided error). A
|
|
412
|
+
# field carries the trackDirty descriptor only when `reactive_dirty only:`
|
|
413
|
+
# names it (field_dirty_tracked?) — otherwise the root delegates for the
|
|
414
|
+
# whole subtree via reactive_root. The client behavior is unchanged (issue
|
|
415
|
+
# #103): a change re-scans this root's owned fields and marks the changed
|
|
416
|
+
# ones `data-reactive-dirty` (the root gets a count); NO client state ships —
|
|
417
|
+
# the baseline is the DOM's own `defaultValue`/`defaultChecked`/
|
|
418
|
+
# `defaultSelected` from the last server render (dirty = current ≠ default).
|
|
419
|
+
# The descriptor deep-merges via mix, so a caller's own data-action is
|
|
420
|
+
# token-joined, not clobbered (CLAUDE.md Never-Do #8).
|
|
421
|
+
def reactive_field(param, **attrs)
|
|
422
|
+
# Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
|
|
423
|
+
# print the reactive_dirty rewrite.
|
|
424
|
+
reject_removed_field_dirty!(attrs)
|
|
425
|
+
# Under reactive_scope, emit the SCOPED wire name (name="invoice[date]")
|
|
426
|
+
# so the POST arrives bracketed (the endpoint unwraps one level) AND the
|
|
427
|
+
# field matches the client show/compute resolvers, which already query
|
|
428
|
+
# [name="scope[x]"]. An explicit name: in attrs still wins via the spread
|
|
429
|
+
# (a third-party wire name, never re-scoped) — the escape hatch.
|
|
430
|
+
binding_attrs = { name: scoped_field_name(param), **attrs }
|
|
431
|
+
# Per-field dirty descriptor when reactive_dirty only: names this field
|
|
432
|
+
# (issue #184) — otherwise the root delegates for the whole subtree.
|
|
433
|
+
return binding_attrs unless field_dirty_tracked?(param)
|
|
403
434
|
|
|
404
435
|
mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
|
|
405
436
|
end
|
|
406
437
|
|
|
407
|
-
#
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
438
|
+
# The removed reactive_field(dirty:) kwarg (issue #184) — now a guided error.
|
|
439
|
+
def reject_removed_field_dirty!(attrs)
|
|
440
|
+
return unless attrs.key?(:dirty)
|
|
441
|
+
|
|
442
|
+
raise ArgumentError,
|
|
443
|
+
"reactive_field(dirty:) was removed in issue #184 — declare " \
|
|
444
|
+
"`reactive_dirty only: %i[...]` (class-level) instead."
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# True when reactive_dirty only: names this field, so it carries its own
|
|
448
|
+
# trackDirty descriptor (issue #184).
|
|
449
|
+
def field_dirty_tracked?(param)
|
|
450
|
+
return false unless self.class.respond_to?(:reactive_dirty_config)
|
|
451
|
+
|
|
452
|
+
only = self.class.reactive_dirty_config&.dig(:only)
|
|
453
|
+
only&.include?(param.to_sym) || false
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The wire name for a bare field param: `scope[param]` when the component
|
|
457
|
+
# declares reactive_scope, else the bare param. Read self.class.reactive_scope
|
|
458
|
+
# the way reactive_attrs does (helpers.rb) so scoped + unscoped stay aligned.
|
|
459
|
+
def scoped_field_name(param)
|
|
460
|
+
scope = self.class.reactive_scope if self.class.respond_to?(:reactive_scope)
|
|
461
|
+
scope ? "#{scope}[#{param}]" : param.to_s
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# REMOVED in issue #184 — one binding helper (reactive_field); the element
|
|
465
|
+
# is the caller's: input(**reactive_field(:value, value: @record.name)).
|
|
466
|
+
# Raises a guided error printing that rewrite.
|
|
467
|
+
def reactive_input(param, **)
|
|
468
|
+
raise ArgumentError,
|
|
469
|
+
"reactive_input was removed in issue #184 — use " \
|
|
470
|
+
"input(**reactive_field(#{param.inspect}, …)) (one binding helper; the element is yours)."
|
|
412
471
|
end
|
|
413
472
|
|
|
414
473
|
# Mirror a compute output (or a declared input) into a TEXT NODE — a live
|
|
@@ -426,9 +485,26 @@ module Phlex
|
|
|
426
485
|
# reducer would, or a morph repaints stale text (same reconcile contract
|
|
427
486
|
# reactive_compute documents). Extra attrs merge over the binding.
|
|
428
487
|
def reactive_text(name, initial = nil, **attrs)
|
|
488
|
+
# Issue #183: with no explicit initial, seed the first paint from
|
|
489
|
+
# reactive_values (Phase A) when the component declares it and covers this
|
|
490
|
+
# name — so the server render matches what the reducer would paint (the
|
|
491
|
+
# same no-flash reconcile contract reactive_show's first paint uses).
|
|
492
|
+
initial = reactive_text_seed(name) if initial.nil?
|
|
429
493
|
span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
|
|
430
494
|
end
|
|
431
495
|
|
|
496
|
+
# The reactive_values first-paint seed for a reactive_text name, stringified
|
|
497
|
+
# the way the client reports a field (via show_value_string), or nil when
|
|
498
|
+
# the component declares no reactive_values or doesn't cover the name.
|
|
499
|
+
def reactive_text_seed(name)
|
|
500
|
+
return nil unless respond_to?(:reactive_values)
|
|
501
|
+
|
|
502
|
+
values = reactive_values
|
|
503
|
+
return nil unless values.is_a?(Hash) && values.key?(name.to_sym)
|
|
504
|
+
|
|
505
|
+
show_value_string(values[name.to_sym])
|
|
506
|
+
end
|
|
507
|
+
|
|
432
508
|
# Value-conditional visibility (issue #180) — the x-show / data-show /
|
|
433
509
|
# wire:show equivalent, entirely client-side, in ONE Ruby-native
|
|
434
510
|
# conditions language. Spread onto the element to show/hide; declare an
|
|
@@ -495,12 +571,15 @@ module Phlex
|
|
|
495
571
|
# keystroke by substring-matching each option's haystack — no round
|
|
496
572
|
# trip, no token, no bespoke per-feature controller:
|
|
497
573
|
#
|
|
498
|
-
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
# )))
|
|
574
|
+
# Issue #186: name the FIELD that drives the filter — reactive_filter(:q)
|
|
575
|
+
# compiles :q to [name="q"] (scope-aware) and defaults option to the
|
|
576
|
+
# [role=option] convention. group:/empty: stay opt-in; any selector kwarg
|
|
577
|
+
# overrides a convention:
|
|
578
|
+
#
|
|
579
|
+
# div(**mix(reactive_root, reactive_filter(:q, empty: "#no-matches"))) do
|
|
580
|
+
# input(name: "q", type: "search", **reactive_listnav) # listnav → [role=option]
|
|
581
|
+
# …
|
|
582
|
+
# end
|
|
504
583
|
#
|
|
505
584
|
# Each option's haystack is its `data-reactive-filter-text` attribute
|
|
506
585
|
# (server-rendered — pack in synonyms/categories), falling back to the
|
|
@@ -515,11 +594,25 @@ module Phlex
|
|
|
515
594
|
# hidden options) and each option's own on(:select, …) trigger —
|
|
516
595
|
# selection still round-trips as a signed action; only FILTERING is
|
|
517
596
|
# local. Blank selectors raise: a dead binding must fail at render.
|
|
518
|
-
def reactive_filter(input
|
|
597
|
+
def reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil)
|
|
598
|
+
# Issue #186: the four-selector kwarg form is removed — name the FIELD that
|
|
599
|
+
# drives the filter instead. A leftover input: means the old call shape.
|
|
600
|
+
unless input == :__removed
|
|
601
|
+
raise ArgumentError,
|
|
602
|
+
"reactive_filter(input:) was removed in issue #186 — name the driving field: " \
|
|
603
|
+
"reactive_filter(:q) (compiles to [name=\"q\"], scope-aware; option defaults to [role=option])."
|
|
604
|
+
end
|
|
605
|
+
raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?
|
|
606
|
+
|
|
519
607
|
data = {
|
|
520
|
-
|
|
521
|
-
|
|
608
|
+
# Compile the field to a scoped [name="…"] selector (same scope convention
|
|
609
|
+
# reactive_field uses, so the filter input aligns with its own field).
|
|
610
|
+
reactive_filter_input: %([name="#{scoped_field_name(field)}"]),
|
|
611
|
+
# option defaults to the [role=option] convention; a kwarg overrides it.
|
|
612
|
+
reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
|
|
522
613
|
}
|
|
614
|
+
# group/empty stay OPT-IN (no convention default — a default would change the
|
|
615
|
+
# byte-stable wire and always-emit an attribute the client would then query).
|
|
523
616
|
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
524
617
|
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
525
618
|
|
|
@@ -538,7 +631,7 @@ module Phlex
|
|
|
538
631
|
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
539
632
|
# (selection stays a signed action), Escape clears. Combine with other
|
|
540
633
|
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
541
|
-
def reactive_listnav(option_selector)
|
|
634
|
+
def reactive_listnav(option_selector = "[role=option]")
|
|
542
635
|
{
|
|
543
636
|
data: {
|
|
544
637
|
action: LISTNAV_ACTIONS.join(" "),
|
|
@@ -614,20 +707,27 @@ module Phlex
|
|
|
614
707
|
{ data: { reactive_busy_on: action.to_s } }
|
|
615
708
|
end
|
|
616
709
|
|
|
617
|
-
#
|
|
618
|
-
#
|
|
619
|
-
#
|
|
620
|
-
# div(**
|
|
621
|
-
#
|
|
622
|
-
# It emits the reducer key plus the input/output field names as JSON so the
|
|
623
|
-
# client runs the reducer on `input`, writes the outputs with no round trip,
|
|
624
|
-
# then the debounced POST reconciles from the server reply. Raises for an
|
|
625
|
-
# undeclared compute — a silent no-op would leave the field wiring dead.
|
|
710
|
+
# REMOVED in issue #183 — the compute binding moved to
|
|
711
|
+
# reactive_root(compute: :name), which emits the descriptors AND the
|
|
712
|
+
# recompute delegation at the root, so no field carries per-field wiring:
|
|
713
|
+
# div(**reactive_root(compute: :payment_split)) { … }
|
|
714
|
+
# This helper now raises a guided ArgumentError printing that rewrite.
|
|
626
715
|
def reactive_compute_attrs(name)
|
|
627
|
-
|
|
716
|
+
raise ArgumentError,
|
|
717
|
+
"reactive_compute_attrs(#{name.inspect}) was removed in issue #183 — " \
|
|
718
|
+
"pass reactive_root(compute: #{name.inspect}) instead (bind + listen at the root)"
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# The root's compute descriptors + the recompute delegation (issue #183).
|
|
722
|
+
# Emits the same data-reactive-compute-* attrs as before PLUS the
|
|
723
|
+
# input->reactive#recompute action, all on the root element. Raises for an
|
|
724
|
+
# undeclared name (fail fast, not a silent no-op).
|
|
725
|
+
def compute_binding(name)
|
|
726
|
+
definition = self.class.reactive_computes[name.to_sym]
|
|
628
727
|
raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
|
|
629
728
|
|
|
630
729
|
data = {
|
|
730
|
+
action: "input->reactive#recompute",
|
|
631
731
|
reactive_compute_reducer_param: definition.reducer,
|
|
632
732
|
reactive_compute_inputs_param: compute_inputs_param(definition),
|
|
633
733
|
reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
|
|
@@ -658,12 +758,13 @@ module Phlex
|
|
|
658
758
|
definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
|
|
659
759
|
end
|
|
660
760
|
|
|
661
|
-
#
|
|
662
|
-
# is the
|
|
663
|
-
#
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
761
|
+
# REMOVED in issue #184 — one binding helper (reactive_field); the element
|
|
762
|
+
# is the caller's: select(**reactive_field(:status)) { status_options }.
|
|
763
|
+
# Raises a guided error printing that rewrite.
|
|
764
|
+
def reactive_select(param, **, &)
|
|
765
|
+
raise ArgumentError,
|
|
766
|
+
"reactive_select was removed in issue #184 — use " \
|
|
767
|
+
"select(**reactive_field(#{param.inspect})) { … } (one binding helper; the element is yours)."
|
|
667
768
|
end
|
|
668
769
|
|
|
669
770
|
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
@@ -702,13 +803,6 @@ module Phlex
|
|
|
702
803
|
LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
|
|
703
804
|
LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
704
805
|
|
|
705
|
-
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
706
|
-
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
707
|
-
# at build time in optimistic_hint_json (default-deny — a dead hint fails
|
|
708
|
-
# at render, not silently in the browser). Each carries a class string or
|
|
709
|
-
# array; hide/checked are flags with a fixed shape.
|
|
710
|
-
OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
|
|
711
|
-
|
|
712
806
|
private
|
|
713
807
|
|
|
714
808
|
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
@@ -725,6 +819,53 @@ module Phlex
|
|
|
725
819
|
selector
|
|
726
820
|
end
|
|
727
821
|
|
|
822
|
+
# Issue #179: apply a confirm: gate to a trigger's data hash. A String is
|
|
823
|
+
# the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
|
|
824
|
+
# is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
|
|
825
|
+
# only when field values look suspect:
|
|
826
|
+
# { when: { total: 0 }, message: } — reactive_show conditions language
|
|
827
|
+
# (scalar=equals, Range=threshold, Array=set); prompts when it MATCHES.
|
|
828
|
+
# { predicate: "name", message: } — a JS fn registered with
|
|
829
|
+
# setConfirmPredicate, evaluated over collected fields (multi-field logic).
|
|
830
|
+
# Shared by on and on_client so both paths speak the same three forms. The
|
|
831
|
+
# predicate is soft-validation UX, NOT authorization — a user can bypass it;
|
|
832
|
+
# the action still hits the endpoint's real authorize/default-deny.
|
|
833
|
+
def apply_confirm!(data, confirm)
|
|
834
|
+
case confirm
|
|
835
|
+
when String
|
|
836
|
+
data[:reactive_confirm_param] = confirm
|
|
837
|
+
when Hash
|
|
838
|
+
data[:reactive_confirm_when_param] = compile_conditional_confirm(confirm).to_json
|
|
839
|
+
else
|
|
840
|
+
raise ArgumentError,
|
|
841
|
+
"confirm: takes a String (static) or a Hash ({ when: {…}, message: } / " \
|
|
842
|
+
"{ predicate: \"name\", message: }), got #{confirm.class}"
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# Validate + compile a Hash confirm: into its wire payload. Exactly one of
|
|
847
|
+
# when:/predicate:, message: required — a dead binding fails loudly at
|
|
848
|
+
# render (the reactive_show posture), never silently in the browser.
|
|
849
|
+
def compile_conditional_confirm(confirm)
|
|
850
|
+
message = confirm[:message]
|
|
851
|
+
has_when = confirm.key?(:when)
|
|
852
|
+
has_predicate = confirm.key?(:predicate)
|
|
853
|
+
|
|
854
|
+
if has_when == has_predicate
|
|
855
|
+
raise ArgumentError,
|
|
856
|
+
"confirm: needs exactly one of when: (a conditions hash) or predicate: " \
|
|
857
|
+
"(a registered name), not both/neither — got #{confirm.except(:message).inspect}"
|
|
858
|
+
end
|
|
859
|
+
raise ArgumentError, "confirm: Hash needs a message: to show when it fires" if message.nil?
|
|
860
|
+
|
|
861
|
+
if has_predicate
|
|
862
|
+
{ "predicate" => confirm[:predicate].to_s, "message" => message }
|
|
863
|
+
else
|
|
864
|
+
groups = Phlex::Reactive::ShowConditions.normalize(if: confirm[:when])
|
|
865
|
+
{ "groups" => { "any" => groups }, "message" => message }
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
|
|
728
869
|
# Normalize + validate ONE field's target map (issue #180): each key a
|
|
729
870
|
# single id selector (loud raise — the declare-time half of the two-sided
|
|
730
871
|
# default-deny), each value a where-style condition value (scalar/Array/
|
|
@@ -842,89 +983,90 @@ module Phlex
|
|
|
842
983
|
value.to_s == "keep"
|
|
843
984
|
end
|
|
844
985
|
|
|
845
|
-
#
|
|
846
|
-
#
|
|
847
|
-
#
|
|
848
|
-
#
|
|
849
|
-
#
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
hint["checked"] = "keep"
|
|
871
|
-
when "to"
|
|
872
|
-
hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
|
|
873
|
-
else
|
|
874
|
-
raise ArgumentError,
|
|
875
|
-
"on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
|
|
876
|
-
"supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
|
|
877
|
-
end
|
|
878
|
-
end
|
|
879
|
-
|
|
880
|
-
hint.to_json
|
|
986
|
+
# The removed on() pending-state kwargs (issue #181). loading:/disable_with:
|
|
987
|
+
# collapsed into busy:; listnav: into the standalone reactive_listnav.
|
|
988
|
+
# They now land in **params, so catch them there and raise a guided error
|
|
989
|
+
# showing the rewrite — a clean break (pre-1.0), not a silent shim that
|
|
990
|
+
# would ship a stale call to the browser wrong.
|
|
991
|
+
REMOVED_ON_KWARGS = {
|
|
992
|
+
disable_with: 'busy: "…" (String shorthand for { disable: true, text: "…" })',
|
|
993
|
+
loading: "busy: { disable:, add_class:, text:, to: } (same keys as optimistic:)",
|
|
994
|
+
listnav: "reactive_listnav — mix(on(:search, event: \"input\"), reactive_listnav)"
|
|
995
|
+
}.freeze
|
|
996
|
+
|
|
997
|
+
# The unified pending-state hint vocabulary (issue #181): the js.* op names
|
|
998
|
+
# shared by optimistic: and busy:, plus disable:/to:. checked: :keep is
|
|
999
|
+
# optimistic-ONLY (a native control flip has no settle-revert meaning).
|
|
1000
|
+
PENDING_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
|
|
1001
|
+
|
|
1002
|
+
private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
|
|
1003
|
+
|
|
1004
|
+
def reject_removed_on_kwargs!(action_name, params)
|
|
1005
|
+
removed = params.keys & REMOVED_ON_KWARGS.keys
|
|
1006
|
+
return if removed.empty?
|
|
1007
|
+
|
|
1008
|
+
key = removed.first
|
|
1009
|
+
raise ArgumentError,
|
|
1010
|
+
"on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
|
|
881
1011
|
end
|
|
882
1012
|
|
|
883
|
-
# Normalize + validate
|
|
884
|
-
# form
|
|
885
|
-
#
|
|
886
|
-
#
|
|
887
|
-
#
|
|
888
|
-
# the
|
|
889
|
-
#
|
|
890
|
-
#
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
source = loading || {}
|
|
1013
|
+
# Normalize + validate a pending-state hint (issue #181), returning its
|
|
1014
|
+
# JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
|
|
1015
|
+
# :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
|
|
1016
|
+
# SETTLE); they differ only in client lifecycle, not in shape. The String
|
|
1017
|
+
# shorthand `busy: "Saving…"` expands to { disable: true, text: … }. `to:`
|
|
1018
|
+
# resolves through the SAME JS.normalize_target the js op builder uses (a
|
|
1019
|
+
# :root sentinel or a verbatim CSS selector). checked: :keep is accepted
|
|
1020
|
+
# only for :optimistic. An unknown key, a bad value, or the wrong type
|
|
1021
|
+
# raises — a dead hint fails loudly at render, never silently on the client.
|
|
1022
|
+
def pending_hint_json(source, action_name, kind)
|
|
1023
|
+
source = { disable: true, text: source } if kind == :busy && source.is_a?(String)
|
|
895
1024
|
unless source.is_a?(Hash)
|
|
896
1025
|
raise ArgumentError,
|
|
897
|
-
"on(#{action_name.inspect})
|
|
898
|
-
"(e.g. { disable: true,
|
|
1026
|
+
"on(#{action_name.inspect}) #{kind}: must be a #{"String or " if kind == :busy}Hash of visual " \
|
|
1027
|
+
"hints (e.g. { disable: true, text: \"Saving…\" }), got #{source.class}"
|
|
899
1028
|
end
|
|
900
1029
|
|
|
901
1030
|
hint = {}
|
|
902
1031
|
source.each do |key, value|
|
|
903
1032
|
case key.to_s
|
|
904
|
-
when
|
|
905
|
-
hint[
|
|
906
|
-
when "disable"
|
|
907
|
-
hint[
|
|
1033
|
+
when *PENDING_CLASS_OPS
|
|
1034
|
+
hint[key.to_s] = Array(value).map(&:to_s)
|
|
1035
|
+
when "hide", "show", "disable"
|
|
1036
|
+
hint[key.to_s] = value ? true : false
|
|
908
1037
|
when "text"
|
|
909
1038
|
hint["text"] = value.to_s
|
|
910
1039
|
when "to"
|
|
911
|
-
hint["to"] =
|
|
1040
|
+
hint["to"] = Phlex::Reactive::JS.normalize_target(value)
|
|
1041
|
+
when "checked"
|
|
1042
|
+
hint["checked"] = pending_checked!(value, action_name, kind)
|
|
912
1043
|
else
|
|
913
1044
|
raise ArgumentError,
|
|
914
|
-
"on(#{action_name.inspect}) got an unknown
|
|
915
|
-
"
|
|
1045
|
+
"on(#{action_name.inspect}) got an unknown #{kind} hint #{key.inspect} — supported: " \
|
|
1046
|
+
"add_class/remove_class/toggle_class, hide:, show:, disable:, text:, to:" \
|
|
1047
|
+
"#{", checked: :keep" if kind == :optimistic}"
|
|
916
1048
|
end
|
|
917
1049
|
end
|
|
918
1050
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1051
|
+
hint.to_json
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
# checked: :keep flips a native checkbox/radio and reverts on failure — it
|
|
1055
|
+
# only makes sense for optimistic: (a settle-revert busy: hint has no native
|
|
1056
|
+
# control to keep). Reject it for :busy, and reject any value but :keep.
|
|
1057
|
+
def pending_checked!(value, action_name, kind)
|
|
1058
|
+
if kind != :optimistic
|
|
1059
|
+
raise ArgumentError,
|
|
1060
|
+
"on(#{action_name.inspect}) #{kind}: does not support checked: — the native-control " \
|
|
1061
|
+
"flip is an optimistic-only hint (it reverts on failure, not on settle)"
|
|
1062
|
+
end
|
|
1063
|
+
unless value.to_s == "keep"
|
|
1064
|
+
raise ArgumentError,
|
|
1065
|
+
"on(#{action_name.inspect}) optimistic checked: only supports :keep " \
|
|
1066
|
+
"(flip the native control, revert on failure), got #{value.inspect}"
|
|
925
1067
|
end
|
|
926
1068
|
|
|
927
|
-
|
|
1069
|
+
"keep"
|
|
928
1070
|
end
|
|
929
1071
|
|
|
930
1072
|
# The component's record, for the nested-attributes helpers. Requires a
|