phlex-reactive 0.12.0 → 0.12.2

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.
@@ -601,20 +601,35 @@ module Phlex
601
601
  # hidden options) and each option's own on(:select, …) trigger —
602
602
  # selection still round-trips as a signed action; only FILTERING is
603
603
  # local. Blank selectors raise: a dead binding must fail at render.
604
- def reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil)
605
- # Issue #186: the four-selector kwarg form is removed name the FIELD that
606
- # drives the filter instead. A leftover input: means the old call shape.
607
- unless input == :__removed
604
+ #
605
+ # Issue #224: `input:` is the ESCAPE HATCH a raw CSS selector for the
606
+ # one driving input the field form can't express: a deliberately
607
+ # NAME-LESS query input inside a real POST form (a named input would
608
+ # submit a stray param), targeted by id. A form builder (phlex-forms'
609
+ # tag_field) computes that id per instance. Verbatim, never re-scoped;
610
+ # exactly one of field/input: per call — the field form stays the
611
+ # blessed default:
612
+ # reactive_filter(input: "#user_tags_query")
613
+ def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil)
614
+ # nil-presence, NOT truthiness: `input: cond && "#sel"` with cond false
615
+ # must fail loudly in filter_selector! below (a boolean is never a
616
+ # selector), never slip into the field branch and emit a dead binding.
617
+ if field && !input.nil?
618
+ raise ArgumentError,
619
+ "reactive_filter takes ONE driving-input form — a field name (reactive_filter(:q), " \
620
+ "scope-aware) OR input: (a raw CSS selector for a name-less input), not both"
621
+ end
622
+ if field.nil? && input.nil?
608
623
  raise ArgumentError,
609
- "reactive_filter(input:) was removed in issue #186name the driving field: " \
610
- "reactive_filter(:q) (compiles to [name=\"q\"], scope-aware; option defaults to [role=option])."
624
+ "reactive_filter needs a field name reactive_filter(:q) or the input: " \
625
+ "escape hatch (a raw CSS selector, e.g. input: \"#tags_query\")"
611
626
  end
612
- raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?
613
627
 
614
628
  data = {
615
629
  # Compile the field to a scoped [name="…"] selector (same scope convention
616
- # reactive_field uses, so the filter input aligns with its own field).
617
- reactive_filter_input: %([name="#{scoped_field_name(field)}"]),
630
+ # reactive_field uses, so the filter input aligns with its own field) — or
631
+ # take the input: selector verbatim (issue #224).
632
+ reactive_filter_input: input.nil? ? %([name="#{scoped_field_name(field)}"]) : filter_selector!(:input, input),
618
633
  # option defaults to the [role=option] convention; a kwarg overrides it.
619
634
  reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
620
635
  }
@@ -680,9 +695,30 @@ module Phlex
680
695
  # and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted
681
696
  # option via its own tagsPick trigger, and reactive_tags_add only adds
682
697
  # the TYPED text when nothing is highlighted — no double add).
683
- def reactive_tags(field = nil)
698
+ #
699
+ # Issue #224: `name:` is the ESCAPE HATCH for an instance-dynamic wire
700
+ # name — a form builder (phlex-forms' tag_field) computes "user[tags]"
701
+ # per instance, which the class-level reactive_scope compile can't
702
+ # express. Verbatim, NEVER re-scoped (reactive_field's explicit-name
703
+ # precedent); exactly one of field/name: per call:
704
+ # reactive_tags(name: "user[tags]")
705
+ def reactive_tags(field = nil, name: nil)
706
+ # nil-presence, NOT truthiness: `name: cond && "user[tags]"` with cond
707
+ # false must fail loudly in verbatim_name_selector!, never silently
708
+ # fall through to the field branch.
709
+ unless name.nil?
710
+ if field
711
+ raise ArgumentError,
712
+ "reactive_tags takes ONE field form — a field name (reactive_tags(:tags), scope-aware) " \
713
+ "OR name: (a verbatim wire name, never re-scoped), not both"
714
+ end
715
+ return { data: { reactive_tags_field: verbatim_name_selector!(:reactive_tags, name) } }
716
+ end
717
+
684
718
  if field.nil? || field.to_s.strip.empty?
685
- raise ArgumentError, "reactive_tags needs a field name — reactive_tags(:tags)"
719
+ raise ArgumentError,
720
+ "reactive_tags needs a field name — reactive_tags(:tags) — or the name: " \
721
+ "escape hatch (a verbatim wire name, e.g. name: \"user[tags]\")"
686
722
  end
687
723
 
688
724
  # Compile the field to a scoped [name="…"] selector, the reactive_filter
@@ -785,7 +821,15 @@ module Phlex
785
821
  # a `reactive_scope :order` component emits
786
822
  # order[line_items_attributes][3][quantity] (never a nested-bracket
787
823
  # corruption of the scope wrap).
788
- def nested_field_name(association, field, index: NESTED_NEW_ROW)
824
+ #
825
+ # Issue #224: `scope:` is the per-call ESCAPE HATCH — a form builder's
826
+ # object name is per-instance ("order", or itself bracketed for a
827
+ # nested fieldset: "user[profile]"), which the class-level
828
+ # reactive_scope can't express. Used verbatim as the wrap and WINS over
829
+ # reactive_scope:
830
+ # nested_field_name(:line_items, :quantity, scope: "order")
831
+ # # => order[line_items_attributes][NEW_ROW][quantity]
832
+ def nested_field_name(association, field, index: NESTED_NEW_ROW, scope: nil)
789
833
  nested_identifier!(:nested_field_name, :association, association)
790
834
  nested_identifier!(:nested_field_name, :field, field)
791
835
  unless index.to_s == NESTED_NEW_ROW || index.to_s.match?(/\A\d+\z/)
@@ -794,7 +838,9 @@ module Phlex
794
838
  "got #{index.inspect} — anything else corrupts the bracketed wire name"
795
839
  end
796
840
 
797
- "#{scoped_field_name(:"#{association}_attributes")}[#{index}][#{field}]"
841
+ base = :"#{association}_attributes"
842
+ prefix = scope.nil? ? scoped_field_name(base) : "#{nested_scope!(scope)}[#{base}]"
843
+ "#{prefix}[#{index}][#{field}]"
798
844
  end
799
845
 
800
846
  # The container cloned rows land in — one per association, inside the
@@ -810,10 +856,23 @@ module Phlex
810
856
  # naming the hidden field the client mirrors the rows into on every
811
857
  # add/remove/input. The default (:attributes) is unchanged — the plain
812
858
  # accepts_nested_attributes_for wire.
813
- def reactive_nested_list(association, as: :attributes)
814
- name = nested_identifier!(:reactive_nested_list, :association, association)
815
- data = { reactive_nested_list: name }
816
- return { data: } if as == :attributes
859
+ #
860
+ # Issue #224: `name:` is the verbatim ESCAPE HATCH for that hidden
861
+ # field's wire name — a form builder's "order[todos]" the class-level
862
+ # reactive_scope compile can't express. JSON-mode only (the
863
+ # :attributes mode has no field to name); never re-scoped:
864
+ # reactive_nested_list(:todos, as: :json, name: "order[todos]")
865
+ def reactive_nested_list(association, as: :attributes, name: nil)
866
+ assoc = nested_identifier!(:reactive_nested_list, :association, association)
867
+ data = { reactive_nested_list: assoc }
868
+ if as == :attributes
869
+ unless name.nil?
870
+ raise ArgumentError,
871
+ "reactive_nested_list(name:) only applies to as: :json — it names the hidden JSON " \
872
+ "sync field; the :attributes mode has no field to name"
873
+ end
874
+ return { data: }
875
+ end
817
876
 
818
877
  unless as == :json
819
878
  raise ArgumentError,
@@ -824,9 +883,17 @@ module Phlex
824
883
  # JSON mode: mark the container and name the hidden field the client
825
884
  # keeps in sync — a scope-aware [name="…"] selector, the same
826
885
  # convention reactive_tags/reactive_filter use so the field resolves
827
- # under reactive_scope too.
828
- data[:reactive_nested_json] = name
829
- data[:reactive_nested_json_field] = %([name="#{scoped_field_name(association)}"])
886
+ # under reactive_scope too. name: takes the wire name verbatim
887
+ # (issue #224).
888
+ data[:reactive_nested_json] = assoc
889
+ # nil-presence, NOT truthiness (the reactive_tags rationale): a falsy
890
+ # non-nil name: must fail loudly in verbatim_name_selector!.
891
+ data[:reactive_nested_json_field] =
892
+ if name.nil?
893
+ %([name="#{scoped_field_name(association)}"])
894
+ else
895
+ verbatim_name_selector!(:reactive_nested_list, name)
896
+ end
830
897
  { data: }
831
898
  end
832
899
 
@@ -1109,10 +1176,12 @@ module Phlex
1109
1176
  # A reactive_filter/reactive_listnav selector, validated non-blank and
1110
1177
  # stringified (issue #163). A blank selector is a dead binding — the
1111
1178
  # client would silently match nothing — so it fails loudly at render,
1112
- # like reactive_show's predicate validation.
1179
+ # like reactive_show's predicate validation. A boolean is rejected too
1180
+ # (issue #224): `input: cond && "#sel"` with cond false would otherwise
1181
+ # stringify to the plausible-looking dead selector "false".
1113
1182
  def filter_selector!(name, value)
1114
1183
  selector = value.to_s
1115
- if selector.strip.empty?
1184
+ if value == true || value == false || selector.strip.empty?
1116
1185
  raise ArgumentError,
1117
1186
  "reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
1118
1187
  end
@@ -1135,6 +1204,54 @@ module Phlex
1135
1204
  value
1136
1205
  end
1137
1206
 
1207
+ # A verbatim wire name for the name:-form escape hatches (issue #224) —
1208
+ # an instance-dynamic name (a form builder's "user[tags]") used exactly
1209
+ # as given, never re-scoped. The name is interpolated into a
1210
+ # double-quoted [name="…"] attribute selector the CLIENT passes to
1211
+ # querySelectorAll, so anything that breaks a CSS string breaks the
1212
+ # binding IN THE BROWSER: a `"` ends the string early, a `\` CSS-escapes
1213
+ # (a trailing one swallows the closing quote — the selector silently
1214
+ # matches the wrong name), and a raw control character (newline) makes
1215
+ # querySelectorAll THROW, aborting the controller's connect. All fail
1216
+ # loudly here at render instead. Booleans are rejected with the blank
1217
+ # check: `name: cond && "user[tags]"` with cond false must never
1218
+ # compile the plausible-looking [name="false"].
1219
+ def verbatim_name_selector!(helper, name)
1220
+ value = name.to_s
1221
+ if name == true || name == false || value.strip.empty?
1222
+ raise ArgumentError,
1223
+ "#{helper} name: needs a non-blank wire name (e.g. name: \"user[tags]\"), got #{name.inspect}"
1224
+ end
1225
+ if value.match?(/["\\\x00-\x1f]/)
1226
+ raise ArgumentError,
1227
+ "#{helper} name: can't contain a double quote, backslash, or control character — " \
1228
+ "the name is compiled into a [name=\"…\"] selector the client queries with, " \
1229
+ "got #{name.inspect}"
1230
+ end
1231
+
1232
+ %([name="#{value}"])
1233
+ end
1234
+
1235
+ # The per-call scope: override for nested_field_name (issue #224) — a
1236
+ # form builder's parent prefix, possibly itself bracketed
1237
+ # ("user[profile]"), validated non-blank. Used verbatim as the wrap.
1238
+ # String/Symbol only: `scope: cond && "order"` with cond false would
1239
+ # otherwise stringify to the silently-corrupting "false[…]" wire name.
1240
+ def nested_scope!(scope)
1241
+ unless scope.is_a?(String) || scope.is_a?(Symbol)
1242
+ raise ArgumentError,
1243
+ "nested_field_name scope: needs a String or Symbol parent prefix " \
1244
+ "(e.g. scope: \"order\"), got #{scope.inspect}"
1245
+ end
1246
+
1247
+ value = scope.to_s
1248
+ return value unless value.strip.empty?
1249
+
1250
+ raise ArgumentError,
1251
+ "nested_field_name scope: needs a non-blank parent prefix (e.g. scope: \"order\"), " \
1252
+ "got #{scope.inspect}"
1253
+ end
1254
+
1138
1255
  # An association/field name for the nested-rows wire (issue #208),
1139
1256
  # validated to a plain Ruby identifier at render — it becomes an
1140
1257
  # attribute value, a CSS selector fragment, AND a bracketed param name,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.0"
5
+ VERSION = "0.12.2"
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.12.0
4
+ version: 0.12.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson