phlex-reactive 0.9.5 → 0.10.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 +69 -0
- data/README.md +49 -44
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +105 -39
- 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 +37 -0
- data/lib/phlex/reactive/component/helpers.rb +174 -219
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +23 -9
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +62 -50
- data/lib/phlex/reactive/test_helpers.rb +9 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The blessed CLIENT-ONLY include (issue #180). A view that only shows/hides
|
|
6
|
+
# (reactive_show), filters (reactive_filter), computes client-side
|
|
7
|
+
# (reactive_compute), or runs zero-round-trip ops (on_client/js) — with NO
|
|
8
|
+
# server actions and NO signed token — includes this instead of the full
|
|
9
|
+
# Phlex::Reactive::Component.
|
|
10
|
+
#
|
|
11
|
+
# Why it exists: including the full Component pulls in Streamable, whose
|
|
12
|
+
# class/instance methods (replace/update/append/prepend/remove,
|
|
13
|
+
# to_stream_*) collide by NAME with an app's own Turbo-stream concern — so
|
|
14
|
+
# adopters cherry-picked an internal module and hand-rolled a token-less root
|
|
15
|
+
# (and defined a dummy #id "never read at render" just to keep
|
|
16
|
+
# phlex_reactive:doctor quiet). ClientBindings is that missing seam, blessed:
|
|
17
|
+
#
|
|
18
|
+
# class ExerciseFilter < ApplicationComponent
|
|
19
|
+
# include Phlex::Reactive::ClientBindings
|
|
20
|
+
#
|
|
21
|
+
# reactive_scope :form
|
|
22
|
+
# def reactive_values = { role: @role }
|
|
23
|
+
#
|
|
24
|
+
# def view_template
|
|
25
|
+
# div(**reactive_root) do # token-less: no #id required
|
|
26
|
+
# select(name: "form[role]") { role_options }
|
|
27
|
+
# div(**reactive_show(if: { role: "individual" })) { demographics }
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# It includes the DECLARATION DSL and the view Helpers, but NOT Streamable
|
|
33
|
+
# (nothing to clobber) or Identity (no token). So a ClientBindings class never
|
|
34
|
+
# enters Streamable.registered_classes and is invisible to
|
|
35
|
+
# phlex_reactive:doctor's #id check — no dummy #id, no nag.
|
|
36
|
+
#
|
|
37
|
+
# The client-only DSL macros (reactive_scope, reactive_compute) work; the
|
|
38
|
+
# SERVER-ACTION macros (action, reactive_record, reactive_state) RAISE at
|
|
39
|
+
# class-definition time — without Identity they could only silently no-op
|
|
40
|
+
# (sign nothing, dispatch nowhere), so the guard fails loudly (the guard lives
|
|
41
|
+
# in Component::DSL#require_server_actions!, keyed on Identity presence).
|
|
42
|
+
#
|
|
43
|
+
# Helpers is token-tolerant (issue #180): reactive_attrs emits a token only
|
|
44
|
+
# when reactive_token is available, and reactive_root uses #id only when
|
|
45
|
+
# defined — so the client-only view helpers work with no Identity/Streamable.
|
|
46
|
+
#
|
|
47
|
+
# The full Component includes ClientBindings too (ONE implementation of the
|
|
48
|
+
# client-only surface), then layers Streamable + Identity on top — so a
|
|
49
|
+
# token-bearing reactive_root is a SUPERSET, not a fork.
|
|
50
|
+
module ClientBindings
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
include Component::DSL
|
|
54
|
+
include Component::Helpers
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -29,10 +29,31 @@ module Phlex
|
|
|
29
29
|
MIRROR_ID_SELECTOR = /\A#[A-Za-z_][\w-]*\z/
|
|
30
30
|
|
|
31
31
|
class_methods do
|
|
32
|
+
# Guard the three SERVER-ACTION macros (issue #180). ClientBindings
|
|
33
|
+
# includes this DSL for its client-only macros (reactive_scope,
|
|
34
|
+
# reactive_compute) but does NOT include Identity/Streamable — so a
|
|
35
|
+
# signed token is never minted and no action endpoint dispatches to
|
|
36
|
+
# this class. Declaring action/reactive_record/reactive_state there
|
|
37
|
+
# would silently no-op (an action that can never fire, a record that
|
|
38
|
+
# signs nothing). Fail LOUDLY at class-definition time instead — the
|
|
39
|
+
# default-deny posture. Keyed on Identity presence: a full
|
|
40
|
+
# Phlex::Reactive::Component includes it (passes); a ClientBindings-only
|
|
41
|
+
# class does not (raises).
|
|
42
|
+
def require_server_actions!(macro)
|
|
43
|
+
return if include?(Phlex::Reactive::Component::Identity)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"#{self}: `#{macro}` needs the full Phlex::Reactive::Component — it signs a token / " \
|
|
47
|
+
"dispatches a server action, which Phlex::Reactive::ClientBindings deliberately omits " \
|
|
48
|
+
"(client-only: no token, no endpoint). Include Phlex::Reactive::Component for actions, " \
|
|
49
|
+
"or drop `#{macro}` and use the client-only helpers (reactive_show/filter/compute, on_client)."
|
|
50
|
+
end
|
|
51
|
+
|
|
32
52
|
# Declare the ActiveRecord (GlobalID-able) record this component is
|
|
33
53
|
# rebuilt from. The signed token carries its GlobalID; the server
|
|
34
54
|
# re-finds it on each action. State lives in the DB.
|
|
35
55
|
def reactive_record(name)
|
|
56
|
+
require_server_actions!(:reactive_record)
|
|
36
57
|
Registry.write_scalar(self, :record_key, name.to_sym)
|
|
37
58
|
end
|
|
38
59
|
|
|
@@ -44,9 +65,24 @@ module Phlex
|
|
|
44
65
|
Registry.resolve_scalar(self, :record_key, :reactive_record_key)
|
|
45
66
|
end
|
|
46
67
|
|
|
68
|
+
# Declare (with a name) OR read (bare) a form-field NAMESPACE for this
|
|
69
|
+
# component's bindings (issue #180). With `reactive_scope :form`,
|
|
70
|
+
# reactive_show / reactive_values use bare symbols (`:director`) and the
|
|
71
|
+
# client resolves the owned field as `[name="form[director]"]`; the root
|
|
72
|
+
# emits `data-reactive-scope="form"`. Bare `reactive_scope` reads the
|
|
73
|
+
# nearest declared scope up the ancestry (nil when none) — the
|
|
74
|
+
# dual-purpose form matching reactive_compute's getter/setter. A
|
|
75
|
+
# per-binding raw string field name still passes through unscoped.
|
|
76
|
+
def reactive_scope(name = nil)
|
|
77
|
+
return Registry.resolve_scalar(self, :scope, :reactive_scope) if name.nil?
|
|
78
|
+
|
|
79
|
+
Registry.write_scalar(self, :scope, name.to_sym)
|
|
80
|
+
end
|
|
81
|
+
|
|
47
82
|
# Opt into signed STATE for record-less components only.
|
|
48
83
|
# reactive_state :count, :open
|
|
49
84
|
def reactive_state(*names)
|
|
85
|
+
require_server_actions!(:reactive_state)
|
|
50
86
|
Registry.append(self, :state_keys, names.map(&:to_sym))
|
|
51
87
|
end
|
|
52
88
|
|
|
@@ -112,6 +148,7 @@ module Phlex
|
|
|
112
148
|
# Array params accept BOTH a JSON array and a Rails-style index hash
|
|
113
149
|
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
114
150
|
def action(name, params: {})
|
|
151
|
+
require_server_actions!(:action)
|
|
115
152
|
Registry.write_entry(
|
|
116
153
|
self, :actions, name.to_sym,
|
|
117
154
|
Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
|
|
@@ -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
|
|
|
@@ -112,11 +120,16 @@ module Phlex
|
|
|
112
120
|
# guard (STRING "true" — a boolean-true attr renders valueless, which the
|
|
113
121
|
# client's param reader sees as "" → falsy).
|
|
114
122
|
def reactive_root(**overrides)
|
|
115
|
-
|
|
123
|
+
# A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
|
|
124
|
+
# there's no token to self-match by id. Use an explicit override, else
|
|
125
|
+
# #id when the component defines one, else nothing (no id attr).
|
|
126
|
+
root_id = overrides.delete(:id)
|
|
127
|
+
root_id = id if root_id.nil? && respond_to?(:id)
|
|
116
128
|
track_dirty = overrides.delete(:track_dirty)
|
|
117
129
|
warn_unsaved = overrides.delete(:warn_unsaved)
|
|
118
130
|
|
|
119
|
-
attrs = mix({ **reactive_attrs }, overrides
|
|
131
|
+
attrs = mix({ **reactive_attrs }, overrides)
|
|
132
|
+
attrs = mix(attrs, { id: root_id }) unless root_id.nil?
|
|
120
133
|
attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
|
|
121
134
|
attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
|
|
122
135
|
attrs
|
|
@@ -416,62 +429,62 @@ module Phlex
|
|
|
416
429
|
span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
|
|
417
430
|
end
|
|
418
431
|
|
|
419
|
-
# Value-conditional visibility (issue #
|
|
420
|
-
# wire:show equivalent, entirely client-side
|
|
421
|
-
#
|
|
422
|
-
#
|
|
423
|
-
# from the
|
|
424
|
-
# no token, no bespoke Stimulus controller
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
#
|
|
428
|
-
#
|
|
429
|
-
#
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
# after a morph; render the initial `hidden:` yourself (from the same
|
|
442
|
-
# server state that renders the field) to avoid a first-paint flash.
|
|
443
|
-
# Extra attrs deep-merge over the binding (mix), like reactive_field.
|
|
444
|
-
# Compound forms (issue #176 part A) — visibility that depends on MORE
|
|
445
|
-
# THAN ONE field, or a threshold, staying inside the eval-free literal
|
|
446
|
-
# contract. `all:`/`any:` fold a list of per-field terms (each the same
|
|
447
|
-
# equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
|
|
448
|
-
# connective; the compound has no single controlling field, so it
|
|
449
|
-
# serializes as ONE JSON attr (data-reactive-show) like
|
|
450
|
-
# reactive_show_targets rather than the flat data-reactive-show-* attrs:
|
|
451
|
-
#
|
|
452
|
-
# # visible while type == "individual" AND country != "domestic"
|
|
453
|
-
# div(**reactive_show(all: [
|
|
454
|
-
# { field: :type, equals: "individual" },
|
|
455
|
-
# { field: :country, not: "domestic" }
|
|
432
|
+
# Value-conditional visibility (issue #180) — the x-show / data-show /
|
|
433
|
+
# wire:show equivalent, entirely client-side, in ONE Ruby-native
|
|
434
|
+
# conditions language. Spread onto the element to show/hide; declare an
|
|
435
|
+
# if:/if_any:/unless: condition with where-style values, and the generic
|
|
436
|
+
# controller toggles `hidden` from the fields' CURRENT values on every
|
|
437
|
+
# input/change — no round trip, no token, no bespoke Stimulus controller.
|
|
438
|
+
#
|
|
439
|
+
# THE VALUE LANGUAGE (Phlex::Reactive::ShowConditions):
|
|
440
|
+
# Hash = AND (multiple keys ANDed) if: { a: "x", b: "y" }
|
|
441
|
+
# Array = membership if: { size: %w[l xl] }
|
|
442
|
+
# Range = threshold (10.. / ..10 / ...10 / 10..20) if: { qty: 10.. }
|
|
443
|
+
# true/false = checkbox checked-state if: { gift: true }
|
|
444
|
+
# nil = blank if: { note: nil }
|
|
445
|
+
# unless: = negation (composes with if:/if_any:)
|
|
446
|
+
#
|
|
447
|
+
# div(**reactive_show(unless: { mode: "off" })) { "details" }
|
|
448
|
+
# div(**reactive_show(if: { size: %w[l xl] })) { "surcharge" }
|
|
449
|
+
# div(**reactive_show(if: { qty: 10.. })) { "bulk note" }
|
|
450
|
+
# # OR-of-AND — director OR (shareholder AND role == "individual"):
|
|
451
|
+
# div(**reactive_show(if_any: [
|
|
452
|
+
# { director: true },
|
|
453
|
+
# { shareholder: true, role: "individual" }
|
|
456
454
|
# ]))
|
|
457
|
-
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
#
|
|
466
|
-
# `
|
|
467
|
-
#
|
|
468
|
-
#
|
|
469
|
-
#
|
|
455
|
+
#
|
|
456
|
+
# There is no expression surface — every term is a declared literal, so
|
|
457
|
+
# the same default-deny posture as before. Everything normalizes to ONE
|
|
458
|
+
# DNF wire attr (data-reactive-show='{"any":[[term,…],…]}').
|
|
459
|
+
#
|
|
460
|
+
# FIRST PAINT is computed for you: declare reactive_values (an instance
|
|
461
|
+
# method returning { field => value }) and every binding whose fields are
|
|
462
|
+
# all provided renders the correct initial `hidden:` server-side — no
|
|
463
|
+
# per-section mirror method, no flash. An explicit `hidden:` always wins;
|
|
464
|
+
# a per-call `values:` override merges over reactive_values.
|
|
465
|
+
#
|
|
466
|
+
# `disable: true` disables the section's OWNED controls while it is hidden
|
|
467
|
+
# so a switched-away value never submits. `reactive_scope :form` lets
|
|
468
|
+
# bindings use bare field symbols ([name="form[field]"] on the client).
|
|
469
|
+
#
|
|
470
|
+
# Scope: presentational only, strictly less powerful than the js ops — it
|
|
471
|
+
# reads owned fields (#15 ownership) and toggles `hidden` (+ optionally
|
|
472
|
+
# `disabled`) on owned elements. Extra attrs deep-merge over the binding
|
|
473
|
+
# (mix), like reactive_field.
|
|
470
474
|
def reactive_show(field = nil, **options)
|
|
471
|
-
|
|
472
|
-
|
|
475
|
+
reject_legacy_show_surface!(field, options)
|
|
476
|
+
|
|
477
|
+
conditions = options.slice(*SHOW_CONDITION_KEYS)
|
|
478
|
+
disable = options.delete(:disable)
|
|
479
|
+
values_override = options.delete(:values)
|
|
480
|
+
attrs = options.except(*SHOW_CONDITION_KEYS)
|
|
481
|
+
|
|
482
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
483
|
+
data = { reactive_show: { "any" => groups }.to_json }
|
|
484
|
+
data[:reactive_show_disable] = "true" if disable
|
|
473
485
|
|
|
474
|
-
|
|
486
|
+
result = mix({ data: }, attrs)
|
|
487
|
+
apply_first_paint_hidden(result, groups, values_override)
|
|
475
488
|
end
|
|
476
489
|
|
|
477
490
|
# Client-side option filtering for the searchable combobox (issue #163)
|
|
@@ -544,33 +557,35 @@ module Phlex
|
|
|
544
557
|
# reactive_root — the client reads it off the controller element):
|
|
545
558
|
#
|
|
546
559
|
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
547
|
-
# "#advanced-tab" =>
|
|
548
|
-
# "#advanced-panel" =>
|
|
549
|
-
# "#
|
|
560
|
+
# "#advanced-tab" => "advanced", # equals
|
|
561
|
+
# "#advanced-panel" => "advanced",
|
|
562
|
+
# "#premium-note" => %w[gold platinum]))) # membership
|
|
550
563
|
#
|
|
551
564
|
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
552
565
|
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
553
566
|
# 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
|
-
#
|
|
567
|
+
# interpreter (two-sided default-deny); the value uses the same where-
|
|
568
|
+
# style conditions vocabulary (scalar/Array/Range, no expressions); and
|
|
569
|
+
# the toggle is `hidden` only — no innerHTML, no attribute freedom. The
|
|
570
|
+
# FIELD read stays owned (#15): you can only drive outside visibility from
|
|
571
|
+
# a field this root owns. A target id not on the page is silently skipped
|
|
572
|
+
# (an unrendered tab pane is normal). A target value is positive-only (no
|
|
573
|
+
# per-target unless:) — express "not X" as a membership Array over the
|
|
574
|
+
# remaining options.
|
|
560
575
|
#
|
|
561
576
|
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
562
577
|
# values, so a second call's JSON would concatenate into an unparseable
|
|
563
578
|
# attr and the client would drop BOTH maps (it warns when that
|
|
564
579
|
# happens). Several fields therefore go in ONE call via the hash form:
|
|
565
580
|
#
|
|
566
|
-
# reactive_show_targets(mode: { "#advanced-tab" =>
|
|
567
|
-
# kind: { "#premium-note" =>
|
|
581
|
+
# reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
582
|
+
# kind: { "#premium-note" => %w[gold platinum] })
|
|
568
583
|
def reactive_show_targets(field, targets = nil)
|
|
569
584
|
field_maps = targets.nil? ? field : { field => targets }
|
|
570
585
|
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
571
586
|
raise ArgumentError,
|
|
572
587
|
"reactive_show_targets needs at least one target " \
|
|
573
|
-
"(:field, \"#id\" =>
|
|
588
|
+
"(:field, \"#id\" => value), got #{field_maps.inspect}"
|
|
574
589
|
end
|
|
575
590
|
|
|
576
591
|
normalized = field_maps.to_h do |name, map|
|
|
@@ -675,33 +690,17 @@ module Phlex
|
|
|
675
690
|
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
676
691
|
end
|
|
677
692
|
|
|
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
|
|
693
|
+
# The conditions-language kwargs (issue #180): if:/if_any:/unless: —
|
|
694
|
+
# compiled by Phlex::Reactive::ShowConditions into the DNF wire. The ONE
|
|
695
|
+
# vocabulary; there are no predicate kwargs any more.
|
|
696
|
+
SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
|
|
697
|
+
|
|
698
|
+
# The removed 0.9.5 surface (issue #180 clean break): each of these
|
|
699
|
+
# kwargs — and a positional field — now raises a GUIDED error printing
|
|
700
|
+
# the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
|
|
701
|
+
# shape; nothing here reaches the wire.
|
|
702
|
+
LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
|
|
703
|
+
LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
705
704
|
|
|
706
705
|
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
707
706
|
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
@@ -726,154 +725,110 @@ module Phlex
|
|
|
726
725
|
selector
|
|
727
726
|
end
|
|
728
727
|
|
|
729
|
-
# Normalize + validate ONE field's target map (issue #
|
|
730
|
-
# single id selector (loud raise — the declare-time half of the
|
|
731
|
-
#
|
|
732
|
-
#
|
|
728
|
+
# Normalize + validate ONE field's target map (issue #180): each key a
|
|
729
|
+
# single id selector (loud raise — the declare-time half of the two-sided
|
|
730
|
+
# default-deny), each value a where-style condition value (scalar/Array/
|
|
731
|
+
# Range) that compiles to ONE DNF group (terms ANDed). Shared by both
|
|
732
|
+
# reactive_show_targets call forms.
|
|
733
733
|
def normalize_show_target_map(field, targets)
|
|
734
734
|
unless targets.is_a?(Hash) && targets.any?
|
|
735
735
|
raise ArgumentError,
|
|
736
736
|
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
737
|
-
"(\"#id\" =>
|
|
737
|
+
"(\"#id\" => value), got #{targets.inspect}"
|
|
738
738
|
end
|
|
739
739
|
|
|
740
|
-
targets.to_h do |selector,
|
|
740
|
+
targets.to_h do |selector, value|
|
|
741
741
|
selector = selector.to_s
|
|
742
742
|
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
743
743
|
raise ArgumentError,
|
|
744
744
|
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
745
745
|
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
746
746
|
end
|
|
747
|
+
if value.is_a?(Hash)
|
|
748
|
+
raise ArgumentError,
|
|
749
|
+
"reactive_show_targets(#{field.inspect}) target #{selector.inspect}: the { equals: ... } " \
|
|
750
|
+
"predicate form was removed — pass a bare value (#{selector.inspect} => \"advanced\", " \
|
|
751
|
+
"=> %w[a b] for a set, => 10.. for a threshold)"
|
|
752
|
+
end
|
|
747
753
|
|
|
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}"
|
|
766
|
-
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
|
-
end
|
|
777
|
-
|
|
778
|
-
# Validate a numeric threshold predicate (issue #176 part B): the RHS
|
|
779
|
-
# must be an actual Numeric (a String "10" is a typo caught here, not a
|
|
780
|
-
# silent NaN in the browser). It rides the wire as a JSON number so the
|
|
781
|
-
# client compares Number(value) against it directly.
|
|
782
|
-
def normalize_show_numeric(key, value, context)
|
|
783
|
-
unless value.is_a?(Numeric)
|
|
784
|
-
raise ArgumentError,
|
|
785
|
-
"#{context} #{key}: needs a number (an ordered comparison against a literal), " \
|
|
786
|
-
"got #{value.inspect}"
|
|
787
|
-
end
|
|
788
|
-
|
|
789
|
-
{ key.to_s => value }
|
|
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?
|
|
799
|
-
raise ArgumentError,
|
|
800
|
-
"reactive_show needs a field (reactive_show(:mode, equals: …)) or a compound " \
|
|
801
|
-
"connective (reactive_show(all: [...]) / any:) — got neither"
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
predicates = options.slice(*SHOW_PREDICATE_KEYS)
|
|
805
|
-
attrs = options.except(*SHOW_PREDICATE_KEYS)
|
|
806
|
-
unless predicates.size == 1
|
|
807
|
-
raise ArgumentError,
|
|
808
|
-
"reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, in:, " \
|
|
809
|
-
"or gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
|
|
754
|
+
# A target compiles to ONE group: the field-vs-value condition.
|
|
755
|
+
groups = Phlex::Reactive::ShowConditions.normalize(if: { field => value })
|
|
756
|
+
[selector, groups.first]
|
|
810
757
|
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
758
|
end
|
|
821
759
|
|
|
822
|
-
#
|
|
823
|
-
# a
|
|
824
|
-
#
|
|
825
|
-
#
|
|
826
|
-
|
|
827
|
-
# the single-field form.
|
|
828
|
-
def reactive_show_compound(field, connectives, options)
|
|
760
|
+
# Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
|
|
761
|
+
# with a GUIDED error printing the if:/if_any:/unless: rewrite. A
|
|
762
|
+
# positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
|
|
763
|
+
# connective (all:/any:) all land here before any conditions parsing.
|
|
764
|
+
def reject_legacy_show_surface!(field, options)
|
|
829
765
|
unless field.nil?
|
|
830
766
|
raise ArgumentError,
|
|
831
|
-
"reactive_show
|
|
832
|
-
"
|
|
833
|
-
|
|
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}"
|
|
767
|
+
"reactive_show no longer takes a positional field — the conditions language is " \
|
|
768
|
+
"keyword-only: reactive_show(if: { #{field}: <value> }) (a Range is a threshold, " \
|
|
769
|
+
"an Array is a set, unless: negates). See the 0.10 upgrade notes."
|
|
838
770
|
end
|
|
839
771
|
|
|
840
|
-
|
|
841
|
-
# A top-level predicate alongside a connective (reactive_show(all: [...],
|
|
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?
|
|
772
|
+
if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
|
|
847
773
|
raise ArgumentError,
|
|
848
|
-
"reactive_show
|
|
849
|
-
"
|
|
774
|
+
"reactive_show(#{pred.first}: ...) was removed — use the conditions language: " \
|
|
775
|
+
"reactive_show(if: { field: value }). equals:/not: → if:/unless:, in: → an Array value, " \
|
|
776
|
+
"gte:/gt:/lte:/lt: → a Range value (10.., ..10, ...10)."
|
|
850
777
|
end
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
unless terms.is_a?(Array) && terms.any?
|
|
778
|
+
if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
|
|
779
|
+
replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
|
|
854
780
|
raise ArgumentError,
|
|
855
|
-
"reactive_show
|
|
856
|
-
"(
|
|
781
|
+
"reactive_show(#{conn.first}: [...]) was removed — use #{replacement}. " \
|
|
782
|
+
"all: → if: (one AND group); any: → if_any: (OR of AND groups). Terms are now " \
|
|
783
|
+
"field => value pairs, not { field:, equals: } hashes."
|
|
857
784
|
end
|
|
858
|
-
|
|
859
|
-
normalized = terms.map { normalize_show_term(connective, it) }
|
|
860
|
-
mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
|
|
861
785
|
end
|
|
862
786
|
|
|
863
|
-
#
|
|
864
|
-
#
|
|
865
|
-
#
|
|
866
|
-
#
|
|
867
|
-
#
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
787
|
+
# Compute the first-paint `hidden:` from reactive_values (issue #180) so
|
|
788
|
+
# the author never restates the predicate as a Ruby mirror method. Fires
|
|
789
|
+
# only when EVERY field the binding references is provided (by
|
|
790
|
+
# reactive_values, merged under a per-call `values:` override); otherwise
|
|
791
|
+
# the attrs are returned untouched. An explicit `hidden:` in the caller's
|
|
792
|
+
# attrs always wins (it survives the mix, so this is a no-op then).
|
|
793
|
+
def apply_first_paint_hidden(attrs, groups, values_override)
|
|
794
|
+
return attrs if attrs.key?(:hidden)
|
|
795
|
+
|
|
796
|
+
provided = show_values(values_override)
|
|
797
|
+
return attrs if provided.nil?
|
|
798
|
+
|
|
799
|
+
referenced = Phlex::Reactive::ShowConditions.fields(groups)
|
|
800
|
+
return attrs unless referenced.all? { provided.key?(it) }
|
|
801
|
+
|
|
802
|
+
visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
|
|
803
|
+
attrs.merge(hidden: !visible)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# The { field => current-string-value } map the first-paint evaluator
|
|
807
|
+
# reads: reactive_values (if the component declares it) merged under a
|
|
808
|
+
# per-call values: override, both stringified the way the client reads a
|
|
809
|
+
# field (checkbox → "true"/"false", nil → ""). nil when neither source
|
|
810
|
+
# exists — first paint then no-ops (no flash guarantee is the author's,
|
|
811
|
+
# exactly as before).
|
|
812
|
+
def show_values(values_override)
|
|
813
|
+
base = respond_to?(:reactive_values) ? reactive_values : nil
|
|
814
|
+
return nil if base.nil? && values_override.nil?
|
|
815
|
+
|
|
816
|
+
merged = {}
|
|
817
|
+
merged.merge!(base) if base
|
|
818
|
+
merged.merge!(values_override) if values_override
|
|
819
|
+
merged.to_h { |name, value| [name.to_s, show_value_string(value)] }
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Stringify a reactive_values entry the way the client's #showFieldValue
|
|
823
|
+
# reports the live field: a boolean is the checkbox checked-state string,
|
|
824
|
+
# nil is blank, everything else is to_s.
|
|
825
|
+
def show_value_string(value)
|
|
826
|
+
case value
|
|
827
|
+
when true then "true"
|
|
828
|
+
when false then "false"
|
|
829
|
+
when nil then ""
|
|
830
|
+
else value.to_s
|
|
872
831
|
end
|
|
873
|
-
|
|
874
|
-
field = term[:field]
|
|
875
|
-
predicate = normalize_show_predicate(term.except(:field), "#{context} (field #{field.inspect})")
|
|
876
|
-
{ "field" => field.to_s }.merge(predicate)
|
|
877
832
|
end
|
|
878
833
|
|
|
879
834
|
# True when the hint declares checked: :keep — the click-bound
|
|
@@ -51,6 +51,8 @@ module Phlex
|
|
|
51
51
|
collections: :@reactive_own_collections,
|
|
52
52
|
computes: :@reactive_own_computes,
|
|
53
53
|
record_key: :@reactive_own_record_key,
|
|
54
|
+
# Form-field namespace — reactive_scope (issue #180).
|
|
55
|
+
scope: :@reactive_own_scope,
|
|
54
56
|
# Deferred reply segments — reactive_lazy (issue #165).
|
|
55
57
|
lazy: :@reactive_own_lazy,
|
|
56
58
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|