phlex-reactive 0.9.4 → 0.9.5

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.
@@ -441,20 +441,37 @@ module Phlex
441
441
  # after a morph; render the initial `hidden:` yourself (from the same
442
442
  # server state that renders the field) to avoid a first-paint flash.
443
443
  # Extra attrs deep-merge over the binding (mix), like reactive_field.
444
- def reactive_show(field, **options)
445
- predicates = options.slice(*SHOW_PREDICATE_KEYS)
446
- attrs = options.except(*SHOW_PREDICATE_KEYS)
447
- unless predicates.size == 1
448
- raise ArgumentError,
449
- "reactive_show(#{field.inspect}) needs exactly one predicate equals:, not:, or " \
450
- "in: got #{predicates.keys.inspect}"
451
- end
452
-
453
- key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
454
- data = { reactive_show_field: field.to_s }
455
- data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value
456
-
457
- mix({ data: }, attrs)
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" }
456
+ # ]))
457
+ # # visible while director OR shareholder is checked
458
+ # div(**reactive_show(any: [
459
+ # { field: :director, equals: true },
460
+ # { field: :shareholder, equals: true }
461
+ # ]))
462
+ # # a numeric threshold, single field or as a compound term
463
+ # div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
464
+ # div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
465
+ #
466
+ # `all:`/`any:` are additive and mutually exclusive with the single-field
467
+ # form and with each other — enforced loudly at render, like the
468
+ # one-predicate rule. A single-field `all:` with one term degrades to the
469
+ # flat form's behaviour client-side.
470
+ def reactive_show(field = nil, **options)
471
+ connectives = options.slice(*SHOW_CONNECTIVE_KEYS)
472
+ return reactive_show_compound(field, connectives, options) if connectives.any?
473
+
474
+ reactive_show_single(field, options)
458
475
  end
459
476
 
460
477
  # Client-side option filtering for the searchable combobox (issue #163)
@@ -658,12 +675,33 @@ module Phlex
658
675
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
659
676
  end
660
677
 
661
- # The declared reactive_show predicates (issue #161): a literal match on
662
- # the controlling field's current value. Exactly one per binding —
663
- # enforced loudly at render (a dead binding must not no-op in the
664
- # browser). `not`/`in` are Ruby keywords, so they arrive via **options
665
- # rather than named kwargs.
666
- SHOW_PREDICATE_KEYS = %i[equals not in].freeze
678
+ # The declared reactive_show predicates: a literal match on the
679
+ # controlling field's current value. Exactly one per binding — enforced
680
+ # loudly at render (a dead binding must not no-op in the browser).
681
+ # `not`/`in` are Ruby keywords, so they arrive via **options rather than
682
+ # named kwargs. Issue #161 shipped the string-literal trio; issue #176
683
+ # adds the numeric-threshold quartet below.
684
+ SHOW_LITERAL_KEYS = %i[equals not in].freeze
685
+
686
+ # The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt
687
+ # compare the field value coerced to a Number against a literal number.
688
+ # Still a declared literal — the RHS is a number baked into the
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
667
705
 
668
706
  # The declared optimistic-hint class ops (issue #98): the cosmetic class
669
707
  # vocabulary the client applies instantly and reverts on failure. Enforced
@@ -712,20 +750,23 @@ module Phlex
712
750
  end
713
751
  end
714
752
 
715
- # Validate ONE declared literal show predicate (issues #161/#164) and
753
+ # Validate ONE declared show predicate (issues #161/#164/#176) and
716
754
  # return its wire form — { "equals" => "v" } / { "not" => "v" } /
717
- # { "in" => ["a", …] } (a real array: reactive_show JSON-encodes it into
718
- # its own attr; the reactive_show_targets map embeds it directly).
719
- # Shared by both helpers so the vocabulary and the loud validation can
720
- # never drift. `context` names the call site in the error.
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.
721
761
  def normalize_show_predicate(predicates, context)
722
762
  unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
723
763
  raise ArgumentError,
724
- "#{context} needs exactly one predicate — equals:, not:, or in: " \
725
- "got #{predicates.keys.inspect}"
764
+ "#{context} needs exactly one predicate — equals:, not:, in:, or " \
765
+ "gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
726
766
  end
727
767
 
728
768
  key, value = predicates.first
769
+ return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
729
770
  return { key.to_s => value.to_s } unless key == :in
730
771
 
731
772
  list = Array(value).map(&:to_s)
@@ -734,6 +775,107 @@ module Phlex
734
775
  { "in" => list }
735
776
  end
736
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}"
810
+ 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
+ end
821
+
822
+ # The compound reactive_show form (issue #176 part A): `all:`/`any:` fold
823
+ # a list of per-field terms with one fixed connective, serialized as one
824
+ # JSON attr (data-reactive-show). A field alongside a connective, or two
825
+ # connectives, is a contradiction — raise. Each term is validated through
826
+ # the SAME predicate normalizer, so a term's vocabulary never drifts from
827
+ # the single-field form.
828
+ def reactive_show_compound(field, connectives, options)
829
+ unless field.nil?
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
839
+
840
+ connective, terms = connectives.first
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?
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
851
+
852
+ attrs = options.except(*SHOW_CONNECTIVE_KEYS)
853
+ unless terms.is_a?(Array) && terms.any?
854
+ raise ArgumentError,
855
+ "reactive_show #{connective}: needs at least one term " \
856
+ "({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
857
+ end
858
+
859
+ normalized = terms.map { normalize_show_term(connective, it) }
860
+ mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
861
+ end
862
+
863
+ # Normalize + validate ONE compound term (issue #176 part A): a Hash with
864
+ # a :field and exactly one predicate. The predicate goes through the
865
+ # shared normalizer; the field is stringified into the term. Returns
866
+ # { "field" => name, <predicate> } so the wire is uniform with the
867
+ # reactive_show_targets embedded-predicate shape.
868
+ def normalize_show_term(connective, term)
869
+ context = "reactive_show #{connective}: term"
870
+ unless term.is_a?(Hash) && term.key?(:field)
871
+ raise ArgumentError, "#{context} — each term needs a field (got #{term.inspect})"
872
+ 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
+ end
878
+
737
879
  # True when the hint declares checked: :keep — the click-bound
738
880
  # checkbox/radio case that must SKIP the forced type="button" so the native
739
881
  # control (and its native flip) survives (issue #98). Accepts symbol or
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.9.4"
5
+ VERSION = "0.9.5"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.4
4
+ version: 0.9.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson