phlex-reactive 0.9.5 → 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 +315 -0
- data/README.md +176 -122
- data/app/controllers/phlex/reactive/actions_controller.rb +41 -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 +475 -218
- 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/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +158 -33
- data/lib/phlex/reactive/component/helpers.rb +478 -381
- data/lib/phlex/reactive/component/registry.rb +8 -1
- data/lib/phlex/reactive/component.rb +26 -12
- 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/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +244 -202
- data/lib/phlex/reactive/test_helpers.rb +10 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +6 -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
|
|
@@ -73,16 +73,24 @@ module Phlex
|
|
|
73
73
|
# signed identity token. Spread onto the root:
|
|
74
74
|
# div(id:, **reactive_attrs) { ... }
|
|
75
75
|
def reactive_attrs
|
|
76
|
-
data = {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
data = { controller: "reactive" }
|
|
77
|
+
# A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
|
|
78
|
+
# has no Identity, so no token — the root is tokenless (show/filter/
|
|
79
|
+
# compute need no signed round trip). reactive_token is private, so the
|
|
80
|
+
# include-private `respond_to?` mirrors to_stream_token's guard.
|
|
81
|
+
data[:reactive_token_value] = reactive_token if respond_to?(:reactive_token, true)
|
|
80
82
|
# Client debug mode (issue #108): stamp the flag so the generic controller
|
|
81
83
|
# console.groups every dispatch. STRING "true", not boolean — Phlex renders
|
|
82
84
|
# a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
|
|
83
85
|
# JS), so the client's attr check would never fire (the on()/warn_unsaved
|
|
84
86
|
# precedent). Off by default → no key, no string, zero client surface.
|
|
85
87
|
data[:reactive_debug] = "true" if Phlex::Reactive.debug
|
|
88
|
+
# Field-name scope (issue #180): the client prefixes bare binding field
|
|
89
|
+
# names with `scope[...]`. Omitted entirely when undeclared — byte-stable
|
|
90
|
+
# wire for unscoped components.
|
|
91
|
+
if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
|
|
92
|
+
data[:reactive_scope] = scope.to_s
|
|
93
|
+
end
|
|
86
94
|
{ data: }
|
|
87
95
|
end
|
|
88
96
|
|
|
@@ -102,26 +110,49 @@ module Phlex
|
|
|
102
110
|
# (an explicit override wins as a clean replace, not a `mix` string-concat —
|
|
103
111
|
# mix would join two String ids into "default override").
|
|
104
112
|
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# 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.
|
|
114
121
|
def reactive_root(**overrides)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
# A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
|
|
123
|
+
# there's no token to self-match by id. Use an explicit override, else
|
|
124
|
+
# #id when the component defines one, else nothing (no id attr).
|
|
125
|
+
reject_removed_dirty_kwargs!(overrides)
|
|
126
|
+
root_id = overrides.delete(:id)
|
|
127
|
+
root_id = id if root_id.nil? && respond_to?(:id)
|
|
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)
|
|
134
|
+
|
|
135
|
+
attrs = mix({ **reactive_attrs }, overrides)
|
|
136
|
+
attrs = mix(attrs, { id: root_id }) unless root_id.nil?
|
|
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
|
|
122
142
|
attrs
|
|
123
143
|
end
|
|
124
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
|
+
|
|
125
156
|
# Attributes for an element that triggers an action.
|
|
126
157
|
# button(**on(:toggle)) { "○" }
|
|
127
158
|
# form(**on(:save, event: "submit")) { ... }
|
|
@@ -155,14 +186,10 @@ module Phlex
|
|
|
155
186
|
# input(**on(:add, event: "keydown.enter")) # Enter submits
|
|
156
187
|
# button(**on(:cancel, event: "keydown.esc")) # Escape cancels
|
|
157
188
|
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
# (clicking its own reactive trigger — so selection stays a signed action),
|
|
163
|
-
# and Escape clears — all with NO server round trip for the highlight (the
|
|
164
|
-
# controller's listnav* handlers, like #recompute). Omit it for no nav.
|
|
165
|
-
# 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))
|
|
166
193
|
# The verbatim JSON for an empty explicit-params payload. The common
|
|
167
194
|
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
168
195
|
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
@@ -225,30 +252,31 @@ module Phlex
|
|
|
225
252
|
# input(type: "checkbox", checked: @todo.done,
|
|
226
253
|
# **mix(on(:toggle, event: "change", optimistic: { checked: :keep }), name: "done"))
|
|
227
254
|
# button(**on(:destroy, confirm: "Delete?", optimistic: { hide: true, to: :root })) { "Delete" }
|
|
228
|
-
# `
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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: … }
|
|
234
263
|
# * disable: true — disable the trigger while pending
|
|
235
|
-
# *
|
|
236
|
-
# (or a `to:` selector scoped to the root)
|
|
237
|
-
# *
|
|
238
|
-
# *
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
# pending window regardless of these hints, so an app styles a spinner with
|
|
245
|
-
# `[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
|
|
246
273
|
# busy_on for scoped indicators.
|
|
247
|
-
# button(**on(:save,
|
|
248
|
-
# button(**on(:destroy, confirm: "Sure?",
|
|
249
|
-
def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
|
|
250
|
-
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,
|
|
251
278
|
**params)
|
|
279
|
+
reject_removed_on_kwargs!(action_name, params)
|
|
252
280
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
253
281
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
254
282
|
# verbose_errors (dev + test), fail loudly at RENDER time instead —
|
|
@@ -285,7 +313,6 @@ module Phlex
|
|
|
285
313
|
|
|
286
314
|
window_bound = window || outside
|
|
287
315
|
action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
|
|
288
|
-
action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
|
|
289
316
|
attrs = {
|
|
290
317
|
data: {
|
|
291
318
|
action:,
|
|
@@ -295,12 +322,9 @@ module Phlex
|
|
|
295
322
|
}
|
|
296
323
|
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
297
324
|
attrs[:data][:reactive_throttle_param] = throttle if throttle
|
|
298
|
-
attrs[:data]
|
|
299
|
-
attrs[:data][:
|
|
300
|
-
attrs[:data][:
|
|
301
|
-
if (loading_hint = loading_hint_json(loading, disable_with, action_name))
|
|
302
|
-
attrs[:data][:reactive_loading_param] = loading_hint
|
|
303
|
-
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
|
|
304
328
|
# STRING "true", not boolean: Phlex renders a `true` attribute VALUELESS
|
|
305
329
|
# (data-reactive-outside-param), which Stimulus's param reader sees as ""
|
|
306
330
|
# — falsy in JS, so the guard silently never fires. The explicit ="true"
|
|
@@ -341,7 +365,7 @@ module Phlex
|
|
|
341
365
|
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
342
366
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
343
367
|
# browser.
|
|
344
|
-
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)
|
|
345
369
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
346
370
|
raise ArgumentError,
|
|
347
371
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -361,6 +385,14 @@ module Phlex
|
|
|
361
385
|
# on()'s flags above.
|
|
362
386
|
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
363
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
|
|
364
396
|
attrs[:type] = "button" if event == "click" && !window_bound
|
|
365
397
|
attrs
|
|
366
398
|
end
|
|
@@ -375,27 +407,67 @@ module Phlex
|
|
|
375
407
|
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
376
408
|
# focusing the input doesn't dispatch and collapse edit mode.
|
|
377
409
|
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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)
|
|
390
434
|
|
|
391
435
|
mix(binding_attrs, { data: { action: "input->reactive#trackDirty" } })
|
|
392
436
|
end
|
|
393
437
|
|
|
394
|
-
#
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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)."
|
|
399
471
|
end
|
|
400
472
|
|
|
401
473
|
# Mirror a compute output (or a declared input) into a TEXT NODE — a live
|
|
@@ -413,65 +485,82 @@ module Phlex
|
|
|
413
485
|
# reducer would, or a morph repaints stale text (same reconcile contract
|
|
414
486
|
# reactive_compute documents). Extra attrs merge over the binding.
|
|
415
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?
|
|
416
493
|
span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
|
|
417
494
|
end
|
|
418
495
|
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
#
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
# —
|
|
437
|
-
#
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
#
|
|
445
|
-
#
|
|
446
|
-
#
|
|
447
|
-
#
|
|
448
|
-
#
|
|
449
|
-
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
# div(**reactive_show(all: [
|
|
454
|
-
# { field: :type, equals: "individual" },
|
|
455
|
-
# { field: :country, not: "domestic" }
|
|
456
|
-
# ]))
|
|
457
|
-
# # visible while director OR shareholder is checked
|
|
458
|
-
# div(**reactive_show(any: [
|
|
459
|
-
# { field: :director, equals: true },
|
|
460
|
-
# { field: :shareholder, equals: true }
|
|
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
|
+
|
|
508
|
+
# Value-conditional visibility (issue #180) — the x-show / data-show /
|
|
509
|
+
# wire:show equivalent, entirely client-side, in ONE Ruby-native
|
|
510
|
+
# conditions language. Spread onto the element to show/hide; declare an
|
|
511
|
+
# if:/if_any:/unless: condition with where-style values, and the generic
|
|
512
|
+
# controller toggles `hidden` from the fields' CURRENT values on every
|
|
513
|
+
# input/change — no round trip, no token, no bespoke Stimulus controller.
|
|
514
|
+
#
|
|
515
|
+
# THE VALUE LANGUAGE (Phlex::Reactive::ShowConditions):
|
|
516
|
+
# Hash = AND (multiple keys ANDed) if: { a: "x", b: "y" }
|
|
517
|
+
# Array = membership if: { size: %w[l xl] }
|
|
518
|
+
# Range = threshold (10.. / ..10 / ...10 / 10..20) if: { qty: 10.. }
|
|
519
|
+
# true/false = checkbox checked-state if: { gift: true }
|
|
520
|
+
# nil = blank if: { note: nil }
|
|
521
|
+
# unless: = negation (composes with if:/if_any:)
|
|
522
|
+
#
|
|
523
|
+
# div(**reactive_show(unless: { mode: "off" })) { "details" }
|
|
524
|
+
# div(**reactive_show(if: { size: %w[l xl] })) { "surcharge" }
|
|
525
|
+
# div(**reactive_show(if: { qty: 10.. })) { "bulk note" }
|
|
526
|
+
# # OR-of-AND — director OR (shareholder AND role == "individual"):
|
|
527
|
+
# div(**reactive_show(if_any: [
|
|
528
|
+
# { director: true },
|
|
529
|
+
# { shareholder: true, role: "individual" }
|
|
461
530
|
# ]))
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
#
|
|
466
|
-
#
|
|
467
|
-
#
|
|
468
|
-
#
|
|
469
|
-
#
|
|
531
|
+
#
|
|
532
|
+
# There is no expression surface — every term is a declared literal, so
|
|
533
|
+
# the same default-deny posture as before. Everything normalizes to ONE
|
|
534
|
+
# DNF wire attr (data-reactive-show='{"any":[[term,…],…]}').
|
|
535
|
+
#
|
|
536
|
+
# FIRST PAINT is computed for you: declare reactive_values (an instance
|
|
537
|
+
# method returning { field => value }) and every binding whose fields are
|
|
538
|
+
# all provided renders the correct initial `hidden:` server-side — no
|
|
539
|
+
# per-section mirror method, no flash. An explicit `hidden:` always wins;
|
|
540
|
+
# a per-call `values:` override merges over reactive_values.
|
|
541
|
+
#
|
|
542
|
+
# `disable: true` disables the section's OWNED controls while it is hidden
|
|
543
|
+
# so a switched-away value never submits. `reactive_scope :form` lets
|
|
544
|
+
# bindings use bare field symbols ([name="form[field]"] on the client).
|
|
545
|
+
#
|
|
546
|
+
# Scope: presentational only, strictly less powerful than the js ops — it
|
|
547
|
+
# reads owned fields (#15 ownership) and toggles `hidden` (+ optionally
|
|
548
|
+
# `disabled`) on owned elements. Extra attrs deep-merge over the binding
|
|
549
|
+
# (mix), like reactive_field.
|
|
470
550
|
def reactive_show(field = nil, **options)
|
|
471
|
-
|
|
472
|
-
|
|
551
|
+
reject_legacy_show_surface!(field, options)
|
|
552
|
+
|
|
553
|
+
conditions = options.slice(*SHOW_CONDITION_KEYS)
|
|
554
|
+
disable = options.delete(:disable)
|
|
555
|
+
values_override = options.delete(:values)
|
|
556
|
+
attrs = options.except(*SHOW_CONDITION_KEYS)
|
|
557
|
+
|
|
558
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
559
|
+
data = { reactive_show: { "any" => groups }.to_json }
|
|
560
|
+
data[:reactive_show_disable] = "true" if disable
|
|
473
561
|
|
|
474
|
-
|
|
562
|
+
result = mix({ data: }, attrs)
|
|
563
|
+
apply_first_paint_hidden(result, groups, values_override)
|
|
475
564
|
end
|
|
476
565
|
|
|
477
566
|
# Client-side option filtering for the searchable combobox (issue #163)
|
|
@@ -482,12 +571,15 @@ module Phlex
|
|
|
482
571
|
# keystroke by substring-matching each option's haystack — no round
|
|
483
572
|
# trip, no token, no bespoke per-feature controller:
|
|
484
573
|
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
# )))
|
|
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
|
|
491
583
|
#
|
|
492
584
|
# Each option's haystack is its `data-reactive-filter-text` attribute
|
|
493
585
|
# (server-rendered — pack in synonyms/categories), falling back to the
|
|
@@ -502,11 +594,25 @@ module Phlex
|
|
|
502
594
|
# hidden options) and each option's own on(:select, …) trigger —
|
|
503
595
|
# selection still round-trips as a signed action; only FILTERING is
|
|
504
596
|
# local. Blank selectors raise: a dead binding must fail at render.
|
|
505
|
-
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
|
+
|
|
506
607
|
data = {
|
|
507
|
-
|
|
508
|
-
|
|
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]"
|
|
509
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).
|
|
510
616
|
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
511
617
|
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
512
618
|
|
|
@@ -525,7 +631,7 @@ module Phlex
|
|
|
525
631
|
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
526
632
|
# (selection stays a signed action), Escape clears. Combine with other
|
|
527
633
|
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
528
|
-
def reactive_listnav(option_selector)
|
|
634
|
+
def reactive_listnav(option_selector = "[role=option]")
|
|
529
635
|
{
|
|
530
636
|
data: {
|
|
531
637
|
action: LISTNAV_ACTIONS.join(" "),
|
|
@@ -544,33 +650,35 @@ module Phlex
|
|
|
544
650
|
# reactive_root — the client reads it off the controller element):
|
|
545
651
|
#
|
|
546
652
|
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
547
|
-
# "#advanced-tab" =>
|
|
548
|
-
# "#advanced-panel" =>
|
|
549
|
-
# "#
|
|
653
|
+
# "#advanced-tab" => "advanced", # equals
|
|
654
|
+
# "#advanced-panel" => "advanced",
|
|
655
|
+
# "#premium-note" => %w[gold platinum]))) # membership
|
|
550
656
|
#
|
|
551
657
|
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
552
658
|
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
553
659
|
# only, enforced here at declare time AND warn-and-skipped by the client
|
|
554
|
-
# interpreter (two-sided default-deny); the
|
|
555
|
-
#
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
#
|
|
559
|
-
#
|
|
660
|
+
# interpreter (two-sided default-deny); the value uses the same where-
|
|
661
|
+
# style conditions vocabulary (scalar/Array/Range, no expressions); and
|
|
662
|
+
# the toggle is `hidden` only — no innerHTML, no attribute freedom. The
|
|
663
|
+
# FIELD read stays owned (#15): you can only drive outside visibility from
|
|
664
|
+
# a field this root owns. A target id not on the page is silently skipped
|
|
665
|
+
# (an unrendered tab pane is normal). A target value is positive-only (no
|
|
666
|
+
# per-target unless:) — express "not X" as a membership Array over the
|
|
667
|
+
# remaining options.
|
|
560
668
|
#
|
|
561
669
|
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
562
670
|
# values, so a second call's JSON would concatenate into an unparseable
|
|
563
671
|
# attr and the client would drop BOTH maps (it warns when that
|
|
564
672
|
# happens). Several fields therefore go in ONE call via the hash form:
|
|
565
673
|
#
|
|
566
|
-
# reactive_show_targets(mode: { "#advanced-tab" =>
|
|
567
|
-
# kind: { "#premium-note" =>
|
|
674
|
+
# reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
675
|
+
# kind: { "#premium-note" => %w[gold platinum] })
|
|
568
676
|
def reactive_show_targets(field, targets = nil)
|
|
569
677
|
field_maps = targets.nil? ? field : { field => targets }
|
|
570
678
|
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
571
679
|
raise ArgumentError,
|
|
572
680
|
"reactive_show_targets needs at least one target " \
|
|
573
|
-
"(:field, \"#id\" =>
|
|
681
|
+
"(:field, \"#id\" => value), got #{field_maps.inspect}"
|
|
574
682
|
end
|
|
575
683
|
|
|
576
684
|
normalized = field_maps.to_h do |name, map|
|
|
@@ -599,20 +707,27 @@ module Phlex
|
|
|
599
707
|
{ data: { reactive_busy_on: action.to_s } }
|
|
600
708
|
end
|
|
601
709
|
|
|
602
|
-
#
|
|
603
|
-
#
|
|
604
|
-
#
|
|
605
|
-
# div(**
|
|
606
|
-
#
|
|
607
|
-
# It emits the reducer key plus the input/output field names as JSON so the
|
|
608
|
-
# client runs the reducer on `input`, writes the outputs with no round trip,
|
|
609
|
-
# then the debounced POST reconciles from the server reply. Raises for an
|
|
610
|
-
# 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.
|
|
611
715
|
def reactive_compute_attrs(name)
|
|
612
|
-
|
|
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]
|
|
613
727
|
raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
|
|
614
728
|
|
|
615
729
|
data = {
|
|
730
|
+
action: "input->reactive#recompute",
|
|
616
731
|
reactive_compute_reducer_param: definition.reducer,
|
|
617
732
|
reactive_compute_inputs_param: compute_inputs_param(definition),
|
|
618
733
|
reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
|
|
@@ -643,12 +758,13 @@ module Phlex
|
|
|
643
758
|
definition.inputs.to_h { [it.to_s, types[it].to_s] }.to_json
|
|
644
759
|
end
|
|
645
760
|
|
|
646
|
-
#
|
|
647
|
-
# is the
|
|
648
|
-
#
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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)."
|
|
652
768
|
end
|
|
653
769
|
|
|
654
770
|
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
@@ -675,40 +791,17 @@ module Phlex
|
|
|
675
791
|
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
676
792
|
end
|
|
677
793
|
|
|
678
|
-
# The
|
|
679
|
-
#
|
|
680
|
-
#
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
#
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
#
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
# attribute, never an expression — the only new capability is ordered
|
|
690
|
-
# comparison. The RHS must be an actual Numeric in Ruby (stricter, so a
|
|
691
|
-
# typo like gte: "10" fails at render, not silently in the browser).
|
|
692
|
-
SHOW_NUMERIC_KEYS = %i[gte gt lte lt].freeze
|
|
693
|
-
|
|
694
|
-
# Every predicate key a reactive_show / reactive_show_targets binding may
|
|
695
|
-
# declare — the literal trio plus the numeric quartet. Exactly one of
|
|
696
|
-
# these decides a single binding; a compound term declares one too.
|
|
697
|
-
SHOW_PREDICATE_KEYS = (SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
|
|
698
|
-
|
|
699
|
-
# The compound connectives (issue #176 part A): fold a list of per-field
|
|
700
|
-
# literal/numeric terms with ONE fixed keyword. Not an expression surface
|
|
701
|
-
# — the connective is one of two fixed keywords, each term is the same
|
|
702
|
-
# declared predicate vocabulary. Mutually exclusive with a single field
|
|
703
|
-
# and with each other (enforced loudly at render).
|
|
704
|
-
SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
705
|
-
|
|
706
|
-
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
707
|
-
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
708
|
-
# at build time in optimistic_hint_json (default-deny — a dead hint fails
|
|
709
|
-
# at render, not silently in the browser). Each carries a class string or
|
|
710
|
-
# array; hide/checked are flags with a fixed shape.
|
|
711
|
-
OPTIMISTIC_CLASS_OPS = %w[toggle_class add_class remove_class].freeze
|
|
794
|
+
# The conditions-language kwargs (issue #180): if:/if_any:/unless: —
|
|
795
|
+
# compiled by Phlex::Reactive::ShowConditions into the DNF wire. The ONE
|
|
796
|
+
# vocabulary; there are no predicate kwargs any more.
|
|
797
|
+
SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
|
|
798
|
+
|
|
799
|
+
# The removed 0.9.5 surface (issue #180 clean break): each of these
|
|
800
|
+
# kwargs — and a positional field — now raises a GUIDED error printing
|
|
801
|
+
# the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
|
|
802
|
+
# shape; nothing here reaches the wire.
|
|
803
|
+
LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
|
|
804
|
+
LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
712
805
|
|
|
713
806
|
private
|
|
714
807
|
|
|
@@ -726,154 +819,157 @@ module Phlex
|
|
|
726
819
|
selector
|
|
727
820
|
end
|
|
728
821
|
|
|
729
|
-
#
|
|
730
|
-
#
|
|
731
|
-
#
|
|
732
|
-
#
|
|
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
|
+
|
|
869
|
+
# Normalize + validate ONE field's target map (issue #180): each key a
|
|
870
|
+
# single id selector (loud raise — the declare-time half of the two-sided
|
|
871
|
+
# default-deny), each value a where-style condition value (scalar/Array/
|
|
872
|
+
# Range) that compiles to ONE DNF group (terms ANDed). Shared by both
|
|
873
|
+
# reactive_show_targets call forms.
|
|
733
874
|
def normalize_show_target_map(field, targets)
|
|
734
875
|
unless targets.is_a?(Hash) && targets.any?
|
|
735
876
|
raise ArgumentError,
|
|
736
877
|
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
737
|
-
"(\"#id\" =>
|
|
878
|
+
"(\"#id\" => value), got #{targets.inspect}"
|
|
738
879
|
end
|
|
739
880
|
|
|
740
|
-
targets.to_h do |selector,
|
|
881
|
+
targets.to_h do |selector, value|
|
|
741
882
|
selector = selector.to_s
|
|
742
883
|
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
743
884
|
raise ArgumentError,
|
|
744
885
|
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
745
886
|
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
746
887
|
end
|
|
888
|
+
if value.is_a?(Hash)
|
|
889
|
+
raise ArgumentError,
|
|
890
|
+
"reactive_show_targets(#{field.inspect}) target #{selector.inspect}: the { equals: ... } " \
|
|
891
|
+
"predicate form was removed — pass a bare value (#{selector.inspect} => \"advanced\", " \
|
|
892
|
+
"=> %w[a b] for a set, => 10.. for a threshold)"
|
|
893
|
+
end
|
|
747
894
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
end
|
|
752
|
-
|
|
753
|
-
# Validate ONE declared show predicate (issues #161/#164/#176) and
|
|
754
|
-
# return its wire form — { "equals" => "v" } / { "not" => "v" } /
|
|
755
|
-
# { "in" => ["a", …] } / { "gte" => 10 } (a numeric RHS stays a real
|
|
756
|
-
# Number so the wire carries a JSON number, not a string). reactive_show
|
|
757
|
-
# JSON-encodes an in: array into its own attr; the reactive_show_targets
|
|
758
|
-
# map and a compound term embed the predicate directly. Shared by every
|
|
759
|
-
# helper so the vocabulary and the loud validation can never drift.
|
|
760
|
-
# `context` names the call site in the error.
|
|
761
|
-
def normalize_show_predicate(predicates, context)
|
|
762
|
-
unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
|
|
763
|
-
raise ArgumentError,
|
|
764
|
-
"#{context} needs exactly one predicate — equals:, not:, in:, or " \
|
|
765
|
-
"gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
|
|
895
|
+
# A target compiles to ONE group: the field-vs-value condition.
|
|
896
|
+
groups = Phlex::Reactive::ShowConditions.normalize(if: { field => value })
|
|
897
|
+
[selector, groups.first]
|
|
766
898
|
end
|
|
767
|
-
|
|
768
|
-
key, value = predicates.first
|
|
769
|
-
return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
|
|
770
|
-
return { key.to_s => value.to_s } unless key == :in
|
|
771
|
-
|
|
772
|
-
list = Array(value).map(&:to_s)
|
|
773
|
-
raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
|
|
774
|
-
|
|
775
|
-
{ "in" => list }
|
|
776
899
|
end
|
|
777
900
|
|
|
778
|
-
#
|
|
779
|
-
#
|
|
780
|
-
#
|
|
781
|
-
#
|
|
782
|
-
def
|
|
783
|
-
unless
|
|
901
|
+
# Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
|
|
902
|
+
# with a GUIDED error printing the if:/if_any:/unless: rewrite. A
|
|
903
|
+
# positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
|
|
904
|
+
# connective (all:/any:) all land here before any conditions parsing.
|
|
905
|
+
def reject_legacy_show_surface!(field, options)
|
|
906
|
+
unless field.nil?
|
|
784
907
|
raise ArgumentError,
|
|
785
|
-
"
|
|
786
|
-
"
|
|
908
|
+
"reactive_show no longer takes a positional field — the conditions language is " \
|
|
909
|
+
"keyword-only: reactive_show(if: { #{field}: <value> }) (a Range is a threshold, " \
|
|
910
|
+
"an Array is a set, unless: negates). See the 0.10 upgrade notes."
|
|
787
911
|
end
|
|
788
912
|
|
|
789
|
-
|
|
790
|
-
end
|
|
791
|
-
|
|
792
|
-
# The single-field reactive_show form (issue #161, extended for numeric
|
|
793
|
-
# thresholds in #176): one owned field + one predicate → flat
|
|
794
|
-
# data-reactive-show-* attrs. An in: list JSON-encodes into its own attr;
|
|
795
|
-
# a numeric threshold or a literal stringifies into the flat attr and the
|
|
796
|
-
# client Number()-coerces the numeric case back on read.
|
|
797
|
-
def reactive_show_single(field, options)
|
|
798
|
-
if field.nil?
|
|
913
|
+
if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
|
|
799
914
|
raise ArgumentError,
|
|
800
|
-
"reactive_show
|
|
801
|
-
"
|
|
915
|
+
"reactive_show(#{pred.first}: ...) was removed — use the conditions language: " \
|
|
916
|
+
"reactive_show(if: { field: value }). equals:/not: → if:/unless:, in: → an Array value, " \
|
|
917
|
+
"gte:/gt:/lte:/lt: → a Range value (10.., ..10, ...10)."
|
|
802
918
|
end
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
attrs = options.except(*SHOW_PREDICATE_KEYS)
|
|
806
|
-
unless predicates.size == 1
|
|
919
|
+
if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
|
|
920
|
+
replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
|
|
807
921
|
raise ArgumentError,
|
|
808
|
-
"reactive_show(#{
|
|
809
|
-
"
|
|
922
|
+
"reactive_show(#{conn.first}: [...]) was removed — use #{replacement}. " \
|
|
923
|
+
"all: → if: (one AND group); any: → if_any: (OR of AND groups). Terms are now " \
|
|
924
|
+
"field => value pairs, not { field:, equals: } hashes."
|
|
810
925
|
end
|
|
811
|
-
|
|
812
|
-
key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
|
|
813
|
-
data = { reactive_show_field: field.to_s }
|
|
814
|
-
# in: → JSON array; a numeric threshold or a literal → the value as-is.
|
|
815
|
-
# Phlex stringifies it into the flat attr; the client re-reads via
|
|
816
|
-
# getAttribute (always a string) and Number()-coerces the numeric case.
|
|
817
|
-
data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value.to_s
|
|
818
|
-
|
|
819
|
-
mix({ data: }, attrs)
|
|
820
926
|
end
|
|
821
927
|
|
|
822
|
-
#
|
|
823
|
-
#
|
|
824
|
-
#
|
|
825
|
-
#
|
|
826
|
-
# the
|
|
827
|
-
# the
|
|
828
|
-
def
|
|
829
|
-
|
|
830
|
-
raise ArgumentError,
|
|
831
|
-
"reactive_show got a field AND all:/any: — the compound and single-field forms are " \
|
|
832
|
-
"mutually exclusive; use one flat binding OR a compound list, not both"
|
|
833
|
-
end
|
|
834
|
-
unless connectives.size == 1
|
|
835
|
-
raise ArgumentError,
|
|
836
|
-
"reactive_show needs exactly one of all:/any: (one fixed connective), " \
|
|
837
|
-
"got #{connectives.keys.inspect}"
|
|
838
|
-
end
|
|
928
|
+
# Compute the first-paint `hidden:` from reactive_values (issue #180) so
|
|
929
|
+
# the author never restates the predicate as a Ruby mirror method. Fires
|
|
930
|
+
# only when EVERY field the binding references is provided (by
|
|
931
|
+
# reactive_values, merged under a per-call `values:` override); otherwise
|
|
932
|
+
# the attrs are returned untouched. An explicit `hidden:` in the caller's
|
|
933
|
+
# attrs always wins (it survives the mix, so this is a no-op then).
|
|
934
|
+
def apply_first_paint_hidden(attrs, groups, values_override)
|
|
935
|
+
return attrs if attrs.key?(:hidden)
|
|
839
936
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
# equals: "x")) is a misuse — predicates belong INSIDE terms. Catch it
|
|
843
|
-
# loudly at render rather than leaking `equals="x"` as a bogus HTML attr
|
|
844
|
-
# (the mix at the tail treats every leftover kwarg as a literal
|
|
845
|
-
# attribute). Same default-deny posture as the single-field path.
|
|
846
|
-
if (stray = options.slice(*SHOW_PREDICATE_KEYS)).any?
|
|
847
|
-
raise ArgumentError,
|
|
848
|
-
"reactive_show #{connective}: got a top-level predicate #{stray.keys.inspect} — " \
|
|
849
|
-
"predicates belong INSIDE each term ({ field: …, #{stray.keys.first}: … }), not beside the connective"
|
|
850
|
-
end
|
|
937
|
+
provided = show_values(values_override)
|
|
938
|
+
return attrs if provided.nil?
|
|
851
939
|
|
|
852
|
-
|
|
853
|
-
unless
|
|
854
|
-
raise ArgumentError,
|
|
855
|
-
"reactive_show #{connective}: needs at least one term " \
|
|
856
|
-
"({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
|
|
857
|
-
end
|
|
940
|
+
referenced = Phlex::Reactive::ShowConditions.fields(groups)
|
|
941
|
+
return attrs unless referenced.all? { provided.key?(it) }
|
|
858
942
|
|
|
859
|
-
|
|
860
|
-
|
|
943
|
+
visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
|
|
944
|
+
attrs.merge(hidden: !visible)
|
|
861
945
|
end
|
|
862
946
|
|
|
863
|
-
#
|
|
864
|
-
#
|
|
865
|
-
#
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
947
|
+
# The { field => current-string-value } map the first-paint evaluator
|
|
948
|
+
# reads: reactive_values (if the component declares it) merged under a
|
|
949
|
+
# per-call values: override, both stringified the way the client reads a
|
|
950
|
+
# field (checkbox → "true"/"false", nil → ""). nil when neither source
|
|
951
|
+
# exists — first paint then no-ops (no flash guarantee is the author's,
|
|
952
|
+
# exactly as before).
|
|
953
|
+
def show_values(values_override)
|
|
954
|
+
base = respond_to?(:reactive_values) ? reactive_values : nil
|
|
955
|
+
return nil if base.nil? && values_override.nil?
|
|
956
|
+
|
|
957
|
+
merged = {}
|
|
958
|
+
merged.merge!(base) if base
|
|
959
|
+
merged.merge!(values_override) if values_override
|
|
960
|
+
merged.to_h { |name, value| [name.to_s, show_value_string(value)] }
|
|
961
|
+
end
|
|
873
962
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
963
|
+
# Stringify a reactive_values entry the way the client's #showFieldValue
|
|
964
|
+
# reports the live field: a boolean is the checkbox checked-state string,
|
|
965
|
+
# nil is blank, everything else is to_s.
|
|
966
|
+
def show_value_string(value)
|
|
967
|
+
case value
|
|
968
|
+
when true then "true"
|
|
969
|
+
when false then "false"
|
|
970
|
+
when nil then ""
|
|
971
|
+
else value.to_s
|
|
972
|
+
end
|
|
877
973
|
end
|
|
878
974
|
|
|
879
975
|
# True when the hint declares checked: :keep — the click-bound
|
|
@@ -887,89 +983,90 @@ module Phlex
|
|
|
887
983
|
value.to_s == "keep"
|
|
888
984
|
end
|
|
889
985
|
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
#
|
|
893
|
-
#
|
|
894
|
-
#
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
hint["checked"] = "keep"
|
|
916
|
-
when "to"
|
|
917
|
-
hint["to"] = value == :root ? Phlex::Reactive::JS::ROOT_SENTINEL : value.to_s
|
|
918
|
-
else
|
|
919
|
-
raise ArgumentError,
|
|
920
|
-
"on(#{action_name.inspect}) got an unknown optimistic hint #{key.inspect} — " \
|
|
921
|
-
"supported: toggle_class/add_class/remove_class, checked: :keep, hide: true, to:"
|
|
922
|
-
end
|
|
923
|
-
end
|
|
924
|
-
|
|
925
|
-
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]}"
|
|
926
1011
|
end
|
|
927
1012
|
|
|
928
|
-
# Normalize + validate
|
|
929
|
-
# form
|
|
930
|
-
#
|
|
931
|
-
#
|
|
932
|
-
#
|
|
933
|
-
# the
|
|
934
|
-
#
|
|
935
|
-
#
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
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)
|
|
940
1024
|
unless source.is_a?(Hash)
|
|
941
1025
|
raise ArgumentError,
|
|
942
|
-
"on(#{action_name.inspect})
|
|
943
|
-
"(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}"
|
|
944
1028
|
end
|
|
945
1029
|
|
|
946
1030
|
hint = {}
|
|
947
1031
|
source.each do |key, value|
|
|
948
1032
|
case key.to_s
|
|
949
|
-
when
|
|
950
|
-
hint[
|
|
951
|
-
when "disable"
|
|
952
|
-
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
|
|
953
1037
|
when "text"
|
|
954
1038
|
hint["text"] = value.to_s
|
|
955
1039
|
when "to"
|
|
956
|
-
hint["to"] =
|
|
1040
|
+
hint["to"] = Phlex::Reactive::JS.normalize_target(value)
|
|
1041
|
+
when "checked"
|
|
1042
|
+
hint["checked"] = pending_checked!(value, action_name, kind)
|
|
957
1043
|
else
|
|
958
1044
|
raise ArgumentError,
|
|
959
|
-
"on(#{action_name.inspect}) got an unknown
|
|
960
|
-
"
|
|
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}"
|
|
961
1048
|
end
|
|
962
1049
|
end
|
|
963
1050
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
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}"
|
|
970
1067
|
end
|
|
971
1068
|
|
|
972
|
-
|
|
1069
|
+
"keep"
|
|
973
1070
|
end
|
|
974
1071
|
|
|
975
1072
|
# The component's record, for the nested-attributes helpers. Requires a
|