phlex-reactive 0.9.4 → 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 +84 -0
- data/README.md +49 -44
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +202 -15
- 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 +183 -86
- 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,45 +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
|
-
#
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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" }
|
|
454
|
+
# ]))
|
|
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.
|
|
474
|
+
def reactive_show(field = nil, **options)
|
|
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
|
|
485
|
+
|
|
486
|
+
result = mix({ data: }, attrs)
|
|
487
|
+
apply_first_paint_hidden(result, groups, values_override)
|
|
458
488
|
end
|
|
459
489
|
|
|
460
490
|
# Client-side option filtering for the searchable combobox (issue #163)
|
|
@@ -527,33 +557,35 @@ module Phlex
|
|
|
527
557
|
# reactive_root — the client reads it off the controller element):
|
|
528
558
|
#
|
|
529
559
|
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
530
|
-
# "#advanced-tab" =>
|
|
531
|
-
# "#advanced-panel" =>
|
|
532
|
-
# "#
|
|
560
|
+
# "#advanced-tab" => "advanced", # equals
|
|
561
|
+
# "#advanced-panel" => "advanced",
|
|
562
|
+
# "#premium-note" => %w[gold platinum]))) # membership
|
|
533
563
|
#
|
|
534
564
|
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
535
565
|
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
536
566
|
# only, enforced here at declare time AND warn-and-skipped by the client
|
|
537
|
-
# interpreter (two-sided default-deny); the
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
#
|
|
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.
|
|
543
575
|
#
|
|
544
576
|
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
545
577
|
# values, so a second call's JSON would concatenate into an unparseable
|
|
546
578
|
# attr and the client would drop BOTH maps (it warns when that
|
|
547
579
|
# happens). Several fields therefore go in ONE call via the hash form:
|
|
548
580
|
#
|
|
549
|
-
# reactive_show_targets(mode: { "#advanced-tab" =>
|
|
550
|
-
# kind: { "#premium-note" =>
|
|
581
|
+
# reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
582
|
+
# kind: { "#premium-note" => %w[gold platinum] })
|
|
551
583
|
def reactive_show_targets(field, targets = nil)
|
|
552
584
|
field_maps = targets.nil? ? field : { field => targets }
|
|
553
585
|
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
554
586
|
raise ArgumentError,
|
|
555
587
|
"reactive_show_targets needs at least one target " \
|
|
556
|
-
"(:field, \"#id\" =>
|
|
588
|
+
"(:field, \"#id\" => value), got #{field_maps.inspect}"
|
|
557
589
|
end
|
|
558
590
|
|
|
559
591
|
normalized = field_maps.to_h do |name, map|
|
|
@@ -658,12 +690,17 @@ module Phlex
|
|
|
658
690
|
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
659
691
|
end
|
|
660
692
|
|
|
661
|
-
# The
|
|
662
|
-
#
|
|
663
|
-
#
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
667
704
|
|
|
668
705
|
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
669
706
|
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
@@ -688,50 +725,110 @@ module Phlex
|
|
|
688
725
|
selector
|
|
689
726
|
end
|
|
690
727
|
|
|
691
|
-
# Normalize + validate ONE field's target map (issue #
|
|
692
|
-
# single id selector (loud raise — the declare-time half of the
|
|
693
|
-
#
|
|
694
|
-
#
|
|
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.
|
|
695
733
|
def normalize_show_target_map(field, targets)
|
|
696
734
|
unless targets.is_a?(Hash) && targets.any?
|
|
697
735
|
raise ArgumentError,
|
|
698
736
|
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
699
|
-
"(\"#id\" =>
|
|
737
|
+
"(\"#id\" => value), got #{targets.inspect}"
|
|
700
738
|
end
|
|
701
739
|
|
|
702
|
-
targets.to_h do |selector,
|
|
740
|
+
targets.to_h do |selector, value|
|
|
703
741
|
selector = selector.to_s
|
|
704
742
|
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
705
743
|
raise ArgumentError,
|
|
706
744
|
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
707
745
|
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
708
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
|
|
709
753
|
|
|
710
|
-
|
|
711
|
-
|
|
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]
|
|
712
757
|
end
|
|
713
758
|
end
|
|
714
759
|
|
|
715
|
-
#
|
|
716
|
-
#
|
|
717
|
-
#
|
|
718
|
-
#
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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)
|
|
765
|
+
unless field.nil?
|
|
766
|
+
raise ArgumentError,
|
|
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."
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
|
|
773
|
+
raise ArgumentError,
|
|
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)."
|
|
777
|
+
end
|
|
778
|
+
if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
|
|
779
|
+
replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
|
|
723
780
|
raise ArgumentError,
|
|
724
|
-
"#{
|
|
725
|
-
"
|
|
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."
|
|
726
784
|
end
|
|
785
|
+
end
|
|
786
|
+
|
|
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)
|
|
727
795
|
|
|
728
|
-
|
|
729
|
-
return
|
|
796
|
+
provided = show_values(values_override)
|
|
797
|
+
return attrs if provided.nil?
|
|
730
798
|
|
|
731
|
-
|
|
732
|
-
|
|
799
|
+
referenced = Phlex::Reactive::ShowConditions.fields(groups)
|
|
800
|
+
return attrs unless referenced.all? { provided.key?(it) }
|
|
733
801
|
|
|
734
|
-
|
|
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
|
|
831
|
+
end
|
|
735
832
|
end
|
|
736
833
|
|
|
737
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
|
|
@@ -57,13 +57,24 @@ module Phlex
|
|
|
57
57
|
# end
|
|
58
58
|
# end
|
|
59
59
|
#
|
|
60
|
-
# Assembled from
|
|
61
|
-
# public API change:
|
|
62
|
-
# *
|
|
63
|
-
#
|
|
64
|
-
# *
|
|
65
|
-
#
|
|
66
|
-
#
|
|
60
|
+
# Assembled from cohesive concerns (issue #115, restructured in #180) — one
|
|
61
|
+
# include, zero public API change. The include stack, in order:
|
|
62
|
+
# * Phlex::Reactive::Streamable — the render/broadcast/#id surface (mixed in
|
|
63
|
+
# first, so its methods sit below the component's own).
|
|
64
|
+
# * Phlex::Reactive::ClientBindings — the CLIENT-ONLY surface (issue #180),
|
|
65
|
+
# itself Component::DSL (the declaration registries via Component::Registry
|
|
66
|
+
# + from_identity) + Component::Helpers (reply/js, reactive_attrs/root,
|
|
67
|
+
# on/on_client, the field/select/text bindings, reactive_show/filter/
|
|
68
|
+
# compute, and the nested-attributes helpers). ClientBindings is the ONE
|
|
69
|
+
# implementation of that surface, shared with the standalone client-only
|
|
70
|
+
# include; it carries NO token machinery.
|
|
71
|
+
# * Component::Identity — reactive_token + the hot-path ivar precomputation.
|
|
72
|
+
# Its presence is what makes the server-action macros (action/
|
|
73
|
+
# reactive_record/reactive_state) legal here — they raise on a
|
|
74
|
+
# ClientBindings-only class that lacks it.
|
|
75
|
+
# * Component::Lazy — reactive_lazy (deferred initial mount, issue #165).
|
|
76
|
+
# So a full, token-bearing reactive component is a SUPERSET of a client-only
|
|
77
|
+
# one, not a fork.
|
|
67
78
|
module Component
|
|
68
79
|
extend ActiveSupport::Concern
|
|
69
80
|
include Phlex::Reactive::Streamable
|
|
@@ -111,9 +122,12 @@ module Phlex
|
|
|
111
122
|
end
|
|
112
123
|
end
|
|
113
124
|
|
|
114
|
-
|
|
125
|
+
# ClientBindings (issue #180) is the client-only surface (DSL + Helpers),
|
|
126
|
+
# tokenless and Streamable-free. Component includes it as the ONE
|
|
127
|
+
# implementation, then layers Identity + Lazy (and Streamable, above) on
|
|
128
|
+
# top — a token-bearing root is a superset of the client-only one.
|
|
129
|
+
include ClientBindings
|
|
115
130
|
include Identity
|
|
116
|
-
include Helpers
|
|
117
131
|
include Lazy
|
|
118
132
|
end
|
|
119
133
|
end
|