phlex-reactive 0.11.5 → 0.11.7

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.
@@ -793,8 +793,34 @@ module Phlex
793
793
  # The container cloned rows land in — one per association, inside the
794
794
  # root. Server-rendered rows (an edit form's persisted children) render
795
795
  # inside it too.
796
- def reactive_nested_list(association)
797
- { data: { reactive_nested_list: nested_identifier!(:reactive_nested_list, :association, association) } }
796
+ #
797
+ # `as: :json` (issue #208) switches the SUBMIT wire from Rails'
798
+ # accepts_nested_attributes_for names to ONE hidden JSON field — for an
799
+ # app whose controller already parses a serialized JSON param
800
+ # (`JSON.parse(params[:order][:todos])`) instead of nested attributes.
801
+ # The container keeps its plain marker (nestedAdd/Remove still key on
802
+ # it), and gains data-reactive-nested-json plus a scope-aware selector
803
+ # naming the hidden field the client mirrors the rows into on every
804
+ # add/remove/input. The default (:attributes) is unchanged — the plain
805
+ # accepts_nested_attributes_for wire.
806
+ def reactive_nested_list(association, as: :attributes)
807
+ name = nested_identifier!(:reactive_nested_list, :association, association)
808
+ data = { reactive_nested_list: name }
809
+ return { data: } if as == :attributes
810
+
811
+ unless as == :json
812
+ raise ArgumentError,
813
+ "reactive_nested_list(as:) takes :attributes (the default, Rails nested-attribute names) " \
814
+ "or :json (serialize the rows into one hidden JSON field), got #{as.inspect}"
815
+ end
816
+
817
+ # JSON mode: mark the container and name the hidden field the client
818
+ # keeps in sync — a scope-aware [name="…"] selector, the same
819
+ # convention reactive_tags/reactive_filter use so the field resolves
820
+ # under reactive_scope too.
821
+ data[:reactive_nested_json] = name
822
+ data[:reactive_nested_json_field] = %([name="#{scoped_field_name(association)}"])
823
+ { data: }
798
824
  end
799
825
 
800
826
  # The <template> holding ONE row's markup (server-owned, inert until
@@ -814,14 +840,58 @@ module Phlex
814
840
  # The add-a-row trigger — CLIENT-ONLY (no dispatch descriptor, no
815
841
  # POST). Forced type="button": a bare button inside the surrounding
816
842
  # real <form> would submit it.
817
- def reactive_nested_add(association)
818
- {
819
- type: "button",
820
- data: {
821
- action: "click->reactive#nestedAdd",
822
- reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
823
- }
843
+ #
844
+ # FILL-THEN-ADD (issue #208 Scenario A). By default add clones the
845
+ # template and focuses the new row's first field — INLINE-EDIT (you
846
+ # type INTO the row). But a common form shape puts the add controls
847
+ # OUTSIDE the row (a preset <select>, a typeahead, plain inputs) and
848
+ # "Add" SNAPSHOTS those values into a new row, then clears them for the
849
+ # next entry. `from:` expresses that: a map of ROW FIELD name => SOURCE
850
+ # CONTROL selector. On click the client fills each cloned-row field
851
+ # from its source's current value (matching the field by the trailing
852
+ # bracket segment of its name — the SAME key inference JSON mode uses,
853
+ # so the two agree), keeps focus on the sources, and (with `clear:
854
+ # true`) resets the sources. It composes with BOTH wire modes: the
855
+ # seeded values ride the renumbered `…_attributes[i][field]` names on
856
+ # submit (:attributes), and the end-of-add JSON sync serializes them
857
+ # (as: :json) with no extra wiring.
858
+ #
859
+ # a(**reactive_nested_add(:items,
860
+ # from: { name: "#item-name", quantity: "#item-qty" }, clear: true))
861
+ #
862
+ # `from:` values are RAW CSS selectors (the escape-hatch posture of
863
+ # reactive_filter/reactive_tags) — the sources are author-owned markup,
864
+ # not reactive_field bindings — resolved root-scoped (#15 ownership).
865
+ def reactive_nested_add(association, from: nil, clear: false)
866
+ data = {
867
+ action: "click->reactive#nestedAdd",
868
+ reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
824
869
  }
870
+ unless from.nil?
871
+ data[:reactive_nested_from_param] = nested_from_param!(from)
872
+ # STRING "true", not boolean: a valueless boolean attr reads "" (falsy)
873
+ # client-side — the on()/tags precedent.
874
+ data[:reactive_nested_clear_param] = "true" if clear
875
+ end
876
+ { type: "button", data: }
877
+ end
878
+
879
+ # Validate + compile a fill-then-add `from:` map into its JSON wire
880
+ # (issue #208). Each key is a ROW FIELD name (plain identifier, like the
881
+ # association); each value is a SOURCE CONTROL selector (non-blank). An
882
+ # empty map is a dead binding — fail at render (the reactive_show_targets
883
+ # posture), never a silent no-op.
884
+ def nested_from_param!(from)
885
+ unless from.is_a?(Hash) && from.any?
886
+ raise ArgumentError,
887
+ "reactive_nested_add from: needs at least one row-field => source-selector pair " \
888
+ "(e.g. from: { quantity: \"#item-qty\" }), got #{from.inspect}"
889
+ end
890
+
891
+ from.to_h do |field, selector|
892
+ [nested_identifier!(:reactive_nested_add, :field, field),
893
+ filter_selector!(:reactive_nested_add_from, selector)]
894
+ end.to_json
825
895
  end
826
896
 
827
897
  # A row's remove trigger — client-only. Draft rows leave the DOM;
@@ -864,6 +934,22 @@ module Phlex
864
934
  #
865
935
  # reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
866
936
  # kind: { "#premium-note" => %w[gold platinum] })
937
+ #
938
+ # MULTI-FIELD targets (issue #209): a "#id" KEY takes a full
939
+ # if:/if_any:/unless: conditions Hash — the SAME language reactive_show
940
+ # speaks, so a cross-root target can finally read a COMBINATION of
941
+ # owned fields (the last case forcing a bespoke JS listener):
942
+ #
943
+ # reactive_show_targets("#trade-warning" => {
944
+ # if: { type: "trade", price: ..0 } # type == "trade" AND price <= 0
945
+ # })
946
+ #
947
+ # Target-keyed and field-keyed entries mix in the ONE call (a "#" key is
948
+ # unambiguous — a field name may never start with "#"). The client folds
949
+ # the target's DNF payload with the same per-term field reads as an
950
+ # in-root reactive_show: every referenced field must be OWNED by this
951
+ # root (a missing owned field reads as blank, fail-closed); a target
952
+ # whose fields are ALL unowned is left alone, like the single-field skip.
867
953
  def reactive_show_targets(field, targets = nil)
868
954
  field_maps = targets.nil? ? field : { field => targets }
869
955
  unless field_maps.is_a?(Hash) && field_maps.any?
@@ -873,16 +959,12 @@ module Phlex
873
959
  end
874
960
 
875
961
  normalized = field_maps.to_h do |name, map|
876
- # Catch the forgotten-field-name misuse — reactive_show_targets(
877
- # "#id" => {…}) — before the per-target validation turns it into a
878
- # baffling "predicate" error.
879
962
  if name.to_s.start_with?("#")
880
- raise ArgumentError,
881
- "reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
882
- "name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
963
+ # Target-keyed conditions (issue #209): "#id" => { if:/if_any:/unless: }.
964
+ [name.to_s, normalize_show_target_conditions(name.to_s, map)]
965
+ else
966
+ [name.to_s, normalize_show_target_map(name, map)]
883
967
  end
884
-
885
- [name.to_s, normalize_show_target_map(name, map)]
886
968
  end
887
969
 
888
970
  { data: { reactive_show_targets: normalized.to_json } }
@@ -1122,6 +1204,40 @@ module Phlex
1122
1204
  end
1123
1205
  end
1124
1206
 
1207
+ # Normalize + validate ONE target-keyed entry (issue #209): the "#id"
1208
+ # key is a single id selector (the same declare-time guard as
1209
+ # field-keyed targets), the value a full if:/if_any:/unless: conditions
1210
+ # Hash compiled by ShowConditions into the SAME { "any" => groups } DNF
1211
+ # payload reactive_show emits. Anything else — a bare value, unknown
1212
+ # keys, empty conditions — raises a guided error at render (a dead
1213
+ # binding must never reach the browser).
1214
+ def normalize_show_target_conditions(selector, conditions)
1215
+ unless selector.match?(DSL::MIRROR_ID_SELECTOR)
1216
+ raise ArgumentError,
1217
+ "reactive_show_targets target #{selector.inspect} must be a single " \
1218
+ "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
1219
+ end
1220
+ unless conditions.is_a?(Hash) && conditions.any?
1221
+ raise ArgumentError,
1222
+ "reactive_show_targets: a target key takes a conditions Hash — " \
1223
+ "reactive_show_targets(#{selector.inspect} => { if: { field: value, ... } }); " \
1224
+ "to key by field instead: reactive_show_targets(:field, #{selector.inspect} => value). " \
1225
+ "Got #{selector.inspect} => #{conditions.inspect}"
1226
+ end
1227
+ # Unknown keys are reported BEFORE the presence check so { bogus: 1 }
1228
+ # names its offender instead of the generic shape message (specific
1229
+ # beats generic). A non-empty hash surviving this subtraction holds
1230
+ # only condition keys, so no separate presence check remains.
1231
+ if (unknown = conditions.keys - SHOW_CONDITION_KEYS).any?
1232
+ raise ArgumentError,
1233
+ "reactive_show_targets #{selector.inspect}: unknown conditions key(s) " \
1234
+ "#{unknown.map(&:inspect).join(", ")} — a target's conditions Hash takes only " \
1235
+ "if:/if_any:/unless: (the reactive_show language)"
1236
+ end
1237
+
1238
+ { "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
1239
+ end
1240
+
1125
1241
  # Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
1126
1242
  # with a GUIDED error printing the if:/if_any:/unless: rewrite. A
1127
1243
  # positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.11.5"
5
+ VERSION = "0.11.7"
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.11.5
4
+ version: 0.11.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson