phlex-reactive 0.11.3 → 0.11.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.
@@ -35,6 +35,26 @@
35
35
  # flag only controls the gem's own log lines. See the README Observability section.
36
36
  # Phlex::Reactive.log_events = true
37
37
 
38
+ # Turnkey APM integration. Set to :appsignal / :sentry / :datadog and each
39
+ # reactive action shows in your APM as its OWN transaction ("Counter#increment"),
40
+ # not one blurry ActionsController#create — and an action-body crash is reported
41
+ # to the tracker WITH component/action tags (plus a flash to the user if you set
42
+ # error_flash below). The SDK is runtime-detected: if it isn't loaded, this logs
43
+ # one warning and no-ops (no gem dependency is added). Pass a custom object
44
+ # responding to record_action(payload, duration_ms) / record_error(error, payload)
45
+ # to integrate any other tool.
46
+ # Phlex::Reactive.apm = :appsignal
47
+ #
48
+ # For a tracker with no built-in adapter, report errors yourself — this fires on
49
+ # any previously-uncaught action-body error, with the name-only context:
50
+ # Phlex::Reactive.on_action_error do |error, ctx|
51
+ # Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
52
+ # end
53
+ #
54
+ # Show the user a flash when an action crashes (500) — the SAME hook the 4xx
55
+ # errors already use; `kind` is :error for a crash:
56
+ # Phlex::Reactive.error_flash = ->(kind) { "Something went wrong — please retry." }
57
+
38
58
  # Client debug mode (devtools-lite). When on, every reactive root carries
39
59
  # data-reactive-debug="true" and the browser console.groups EVERY dispatch —
40
60
  # action, param/collected field NAMES (never values), request encoding, HTTP
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # The adapter contract (issue #207). A custom APM integration need only be an
7
+ # object responding to these two methods — it does NOT have to subclass this;
8
+ # the class documents the interface and gives the built-in vendor adapters a
9
+ # shared home for the small helpers they all use.
10
+ #
11
+ # record_action(payload, duration_ms)
12
+ # payload is the name-only action/defer event payload
13
+ # ({ component:, action:, outcome: } — action:/nil for defer). Name the
14
+ # APM transaction and tag the outcome. Called on EVERY reactive action.
15
+ #
16
+ # record_error(error, payload)
17
+ # error is the raised exception; payload is the same name-only hash. Report
18
+ # the exception to the tracker WITH component/action tags. Called only on a
19
+ # previously-uncaught action-body error (a 500), just before the re-raise.
20
+ #
21
+ # A built-in adapter also answers `.available?` (a runtime `defined?(SDK)`
22
+ # probe) so the resolver can no-op when the SDK isn't loaded.
23
+ class Adapter
24
+ # The APM transaction name for a payload: "Component#action", or just the
25
+ # component when there's no action (a defer event), or nil when there's no
26
+ # trusted component (an invalid_token event carries none).
27
+ def transaction_name(payload)
28
+ component = payload[:component]
29
+ return nil unless component
30
+
31
+ action = payload[:action]
32
+ action ? "#{component}##{action}" : component
33
+ end
34
+
35
+ # Present but not enforced — a bare Adapter is not a usable APM. The vendor
36
+ # subclasses override all three.
37
+ def self.available? = false
38
+ def record_action(_payload, _duration_ms) = nil
39
+ def record_error(_error, _payload) = nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # AppSignal adapter (issue #207). Activates only when `::Appsignal` is loaded
7
+ # — never a gemspec dependency. Names the current transaction `Component#action`
8
+ # (so reactive traffic stops rolling into one ActionsController#create blob) and
9
+ # tags component/action/outcome; reports an action-body error with those tags.
10
+ class Appsignal < Adapter
11
+ def self.available? = defined?(::Appsignal) ? true : false
12
+
13
+ def record_action(payload, _duration_ms)
14
+ name = transaction_name(payload)
15
+ return unless name
16
+
17
+ ::Appsignal.set_action(name)
18
+ ::Appsignal.add_tags(tags(payload))
19
+ end
20
+
21
+ # Report the error with tags. AppSignal 4.x removed the positional
22
+ # tags/namespace args from set_error and requires the BLOCK form
23
+ # (set_error(error) { add_tags(...) }); 3.x accepts the positional hash.
24
+ # Branch on the method's arity so the adapter works on both majors without
25
+ # pinning a version — the capability-detection posture applied within a gem.
26
+ def record_error(error, payload)
27
+ if set_error_takes_tags?
28
+ ::Appsignal.set_error(error, tags(payload))
29
+ else
30
+ ::Appsignal.set_error(error) { ::Appsignal.add_tags(tags(payload)) }
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # True on AppSignal 3.x, where set_error accepts a positional tags arg
37
+ # (arity != 1). On 4.x set_error takes only the error (arity 1) + a block.
38
+ def set_error_takes_tags?
39
+ ::Appsignal.method(:set_error).arity != 1
40
+ end
41
+
42
+ def tags(payload)
43
+ {
44
+ "reactive_component" => payload[:component],
45
+ "reactive_action" => payload[:action],
46
+ "reactive_outcome" => payload[:outcome]&.to_s
47
+ }.compact
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # Datadog adapter (issue #207, dd-trace-rb). Activates only when
7
+ # `::Datadog::Tracing` is loaded. Renames the ACTIVE span `Component#action`
8
+ # + tags it, so the reactive action shows as its own resource in the trace;
9
+ # marks the active span errored on an action-body crash. No-ops with no
10
+ # active span (never opens one itself — that's the app's tracer's job).
11
+ class Datadog < Adapter
12
+ def self.available?
13
+ return false unless defined?(::Datadog::Tracing)
14
+
15
+ ::Datadog.respond_to?(:configuration)
16
+ end
17
+
18
+ def record_action(payload, _duration_ms)
19
+ span = active_span
20
+ return unless span
21
+
22
+ name = transaction_name(payload)
23
+ span.resource = name if name
24
+ span.set_tag("reactive.component", payload[:component]) if payload[:component]
25
+ span.set_tag("reactive.action", payload[:action]) if payload[:action]
26
+ span.set_tag("reactive.outcome", payload[:outcome].to_s) if payload[:outcome]
27
+ end
28
+
29
+ def record_error(error, payload)
30
+ span = active_span
31
+ return unless span
32
+
33
+ span.set_error(error)
34
+ span.set_tag("reactive.component", payload[:component]) if payload[:component]
35
+ span.set_tag("reactive.action", payload[:action]) if payload[:action]
36
+ end
37
+
38
+ private
39
+
40
+ def active_span = ::Datadog::Tracing.active_span
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # Sentry adapter (issue #207). Activates only when `::Sentry` is loaded AND
7
+ # initialized. Sentry's chief value here is error reporting: an action-body
8
+ # crash is captured WITH component/action tags. record_action names the
9
+ # transaction on the current scope for the successful path.
10
+ class Sentry < Adapter
11
+ def self.available?
12
+ return false unless defined?(::Sentry) && ::Sentry.respond_to?(:initialized?)
13
+
14
+ ::Sentry.initialized?
15
+ end
16
+
17
+ def record_action(payload, _duration_ms)
18
+ name = transaction_name(payload)
19
+ return unless name
20
+
21
+ ::Sentry.configure_scope { it.set_transaction_name(name) }
22
+ end
23
+
24
+ def record_error(error, payload)
25
+ ::Sentry.capture_exception(error, tags: tags(payload))
26
+ end
27
+
28
+ private
29
+
30
+ def tags(payload)
31
+ {
32
+ reactive_component: payload[:component],
33
+ reactive_action: payload[:action],
34
+ reactive_outcome: payload[:outcome]&.to_s
35
+ }.compact
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module APM
6
+ # The ASN-driven half of the APM integration (issue #207). Subscribes to
7
+ # action.phlex_reactive / defer.phlex_reactive and hands each event's
8
+ # name-only payload + duration to the resolved adapter's record_action —
9
+ # so the APM names the transaction `Component#action` and tags the outcome.
10
+ #
11
+ # A plain ActiveSupport::Notifications subscriber (NOT a LogSubscriber): a
12
+ # LogSubscriber dispatches by the FULL event method, but we want the SAME
13
+ # record_action for both action and defer events and to hold a reference to
14
+ # the adapter instance. install/uninstall manage exactly one subscription so
15
+ # a re-attach with the same adapter never double-reports.
16
+ class Subscriber
17
+ # The events whose duration+outcome we forward to the APM as a named
18
+ # transaction. render/broadcast are left to the app (a Datadog child-span
19
+ # adapter can subscribe to them itself) — the action IS the transaction.
20
+ EVENTS = /\A(?:action|defer)\.phlex_reactive\z/
21
+
22
+ class << self
23
+ # Install a single bus subscription for `adapter`. Idempotent for the
24
+ # SAME adapter: a repeat call is a no-op. A DIFFERENT adapter replaces
25
+ # the previous subscription (an app that swaps apm at boot wins last).
26
+ def install(adapter)
27
+ return if @adapter.equal?(adapter) && @subscription
28
+
29
+ uninstall
30
+ @adapter = adapter
31
+ subscriber = new(adapter)
32
+ @subscription = ::ActiveSupport::Notifications.subscribe(EVENTS) do |*args|
33
+ subscriber.call(::ActiveSupport::Notifications::Event.new(*args))
34
+ end
35
+ end
36
+
37
+ def uninstall
38
+ ::ActiveSupport::Notifications.unsubscribe(@subscription) if @subscription
39
+ @subscription = nil
40
+ @adapter = nil
41
+ end
42
+
43
+ def installed? = !@subscription.nil?
44
+ end
45
+
46
+ def initialize(adapter)
47
+ @adapter = adapter
48
+ end
49
+
50
+ # Route an already-built Event to record_action. Named after the event's
51
+ # leading segment (action/defer) so the synthesized-event unit spec can
52
+ # call subscriber.action(event) exactly like the LogSubscriber spec.
53
+ def call(event)
54
+ @adapter.record_action(event.payload, event.duration)
55
+ end
56
+ alias action call
57
+ alias defer call
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # Turnkey APM integration (issue #207). The gem already emits `*.phlex_reactive`
6
+ # ActiveSupport::Notifications events (issue #107); this namespace turns them
7
+ # into per-component visibility in AppSignal / Sentry / Datadog with ONE config
8
+ # line — `Phlex::Reactive.apm = :appsignal` — instead of every app hand-writing
9
+ # the subscribe block and each vendor's transaction-naming / error-tagging API.
10
+ #
11
+ # The vendor adapters are RUNTIME capability-detected (`defined?(::Appsignal)`
12
+ # etc.), never a gemspec dependency — the same optionality invariant as pgbus.
13
+ # A set-but-absent SDK logs one warning and no-ops.
14
+ module APM
15
+ # Symbol => built-in adapter class. Resolved lazily (at attach time), so the
16
+ # adapter class is only referenced when an app actually opts into it.
17
+ BUILT_INS = {
18
+ appsignal: "Phlex::Reactive::APM::Appsignal",
19
+ sentry: "Phlex::Reactive::APM::Sentry",
20
+ datadog: "Phlex::Reactive::APM::Datadog"
21
+ }.freeze
22
+
23
+ class << self
24
+ # Resolve `Phlex::Reactive.apm` to a live adapter instance, or nil.
25
+ # * nil -> nil (off; no warning)
26
+ # * an object -> returned verbatim (a custom adapter)
27
+ # * a known Symbol -> the built-in adapter instance IF its SDK is loaded,
28
+ # else nil + one warning
29
+ # * an unknown Symbol -> nil + one warning
30
+ def detect(apm)
31
+ return nil if apm.nil?
32
+ return apm unless apm.is_a?(Symbol)
33
+
34
+ klass_name = BUILT_INS[apm]
35
+ unless klass_name
36
+ known = BUILT_INS.keys.map(&:inspect).join(", ")
37
+ return warn_and_nil("apm = #{apm.inspect} — unknown APM flavour (known: #{known})")
38
+ end
39
+
40
+ klass = klass_name.constantize
41
+ # Memoize the instance PER SYMBOL so repeated detect/attach! calls return
42
+ # the SAME object — Subscriber.install keys idempotency on adapter
43
+ # identity (equal?), so a fresh klass.new each time would double-subscribe.
44
+ return (built_in_instances[apm] ||= klass.new) if klass.available?
45
+
46
+ warn_and_nil("apm = #{apm.inspect} set but #{apm} is not loaded — no-op. " \
47
+ "Require the SDK (or remove the setting).")
48
+ end
49
+
50
+ # Attach the APM Subscriber to the notification bus if an adapter resolves.
51
+ # Called once from the engine's after_initialize when Phlex::Reactive.apm
52
+ # is set. Idempotent within a boot: a second call with the SAME adapter is
53
+ # a no-op. Returns the attached adapter (or nil when nothing resolved).
54
+ def attach!(apm = Phlex::Reactive.apm)
55
+ adapter = detect(apm)
56
+ return nil unless adapter
57
+
58
+ Subscriber.install(adapter)
59
+ # Hold the adapter so the endpoint's error seam (report_error) can reach
60
+ # record_error without re-running detection per request.
61
+ Phlex::Reactive.resolved_apm_adapter = adapter
62
+ adapter
63
+ end
64
+
65
+ # Drop the installed subscriber + adapter AND the memoized built-in
66
+ # instances. Tests only (so a stubbed SDK doesn't leak a stale adapter into
67
+ # the next example).
68
+ def reset!
69
+ Subscriber.uninstall
70
+ Phlex::Reactive.resolved_apm_adapter = nil
71
+ @built_in_instances = nil
72
+ end
73
+
74
+ private
75
+
76
+ # Symbol => memoized built-in adapter instance. One instance per flavour for
77
+ # the life of the process (cleared only by reset! in tests), so detect is
78
+ # identity-stable and attach! stays idempotent.
79
+ def built_in_instances
80
+ @built_in_instances ||= {}
81
+ end
82
+
83
+ def warn_and_nil(message)
84
+ logger = Phlex::Reactive.send(:default_logger)
85
+ logger&.warn("[phlex-reactive] #{message}")
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -475,10 +475,19 @@ module Phlex
475
475
  kwargs = {}
476
476
 
477
477
  if reactive_record_key
478
- record = GlobalID::Locator.locate(payload.fetch("gid"))
479
- raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
480
-
481
- kwargs[reactive_record_key] = record
478
+ if (gid = payload["gid"])
479
+ record = GlobalID::Locator.locate(gid)
480
+ raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
481
+
482
+ kwargs[reactive_record_key] = record
483
+ else
484
+ # A DRAFT token (issue #208): Component::Identity signs no gid
485
+ # for an unsaved (or nil) record. Omit the kwarg — the
486
+ # component's initialize default seeds a fresh draft, mirroring
487
+ # first render — so a draft parent can run real server actions
488
+ # (its transient identity rides in the signed state instead).
489
+ ensure_draft_default!
490
+ end
482
491
  end
483
492
 
484
493
  if reactive_state_keys.any?
@@ -496,6 +505,20 @@ module Phlex
496
505
 
497
506
  new(**kwargs)
498
507
  end
508
+
509
+ # A draft token can only rebuild through the record kwarg's initialize
510
+ # default (issue #208). A component whose initialize REQUIRES the
511
+ # record can't render drafts — teach the fix instead of exploding
512
+ # with a bare missing-keyword ArgumentError deep in new(**kwargs).
513
+ def ensure_draft_default!
514
+ return unless instance_method(:initialize).parameters.include?([:keyreq, reactive_record_key])
515
+
516
+ raise Phlex::Reactive::Error,
517
+ "#{self}: the action token carries no record gid (the #{reactive_record_key} was unsaved " \
518
+ "when rendered — a draft), but initialize requires `#{reactive_record_key}:`. Give the " \
519
+ "kwarg a default (e.g. `#{reactive_record_key}: nil` or a fresh record) so the draft " \
520
+ "can rebuild through it."
521
+ end
499
522
  end
500
523
  end
501
524
  end
@@ -640,6 +640,197 @@ module Phlex
640
640
  }
641
641
  end
642
642
 
643
+ # Tag-chip input (issue #203) — the composed combobox/tags primitive.
644
+ # Spread onto the ROOT (mix with reactive_root); it names the hidden
645
+ # field that stores the COMMA-JOINED value, and the generic controller
646
+ # maintains that field + the chip list entirely client-side. The value
647
+ # is FORM state (like text in an input), never component state — so
648
+ # add/remove round-trips nothing; the surrounding form submit carries
649
+ # the joined value and the server splits it (`tags.split(",")`).
650
+ #
651
+ # div(**mix(reactive_root, reactive_tags(:tags), reactive_filter(:tag_query))) do
652
+ # input(type: :hidden, **reactive_field(:tags), value: @tags.join(","))
653
+ # div(data: { reactive_tags_list: true }) { } # chips render here
654
+ # template(data: { reactive_tags_template: true }) do # the chip markup (server-owned)
655
+ # span(class: "chip") do
656
+ # span(data: { reactive_tag_text: true }) # the client writes the tag here (textContent)
657
+ # button(**reactive_tags_remove) { "×" } # the client fills the tag param per chip
658
+ # end
659
+ # end
660
+ # input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
661
+ # button(**reactive_tags_option("Ruby")) { "Ruby" } # preloaded options, filter narrows them
662
+ # end
663
+ #
664
+ # The chip list is a CLIENT PROJECTION of the hidden field: every sync
665
+ # rebuilds the chips by cloning the <template> (textContent writes only
666
+ # — never innerHTML), so the hidden value is the single source of truth
667
+ # and a server re-render/morph re-seeds cleanly. An option whose tag is
668
+ # already selected is hidden and marked data-reactive-tags-selected
669
+ # (reactive_filter keeps it hidden through re-filters). Tags dedupe
670
+ # case-insensitively, keeping the first casing.
671
+ #
672
+ # Composes with reactive_filter (type to narrow — same driving input)
673
+ # and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted
674
+ # option via its own tagsPick trigger, and reactive_tags_add only adds
675
+ # the TYPED text when nothing is highlighted — no double add).
676
+ def reactive_tags(field = nil)
677
+ if field.nil? || field.to_s.strip.empty?
678
+ raise ArgumentError, "reactive_tags needs a field name — reactive_tags(:tags)"
679
+ end
680
+
681
+ # Compile the field to a scoped [name="…"] selector, the reactive_filter
682
+ # convention — so the hidden field written via reactive_field(:tags)
683
+ # resolves under reactive_scope too.
684
+ { data: { reactive_tags_field: %([name="#{scoped_field_name(field)}"]) } }
685
+ end
686
+
687
+ # The Enter-to-add trigger for the tags query input (issue #203) — a
688
+ # CLIENT-ONLY keyboard action (no dispatch descriptor, no POST). Mix it
689
+ # AFTER reactive_listnav so Enter prefers the highlighted option
690
+ # (listnavPick preventDefaults; tagsAdd then skips), and free text adds
691
+ # only when nothing is highlighted:
692
+ # input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
693
+ # Enter never submits the enclosing form — the client preventDefaults.
694
+ def reactive_tags_add
695
+ { data: { action: "keydown.enter->reactive#tagsAdd" } }
696
+ end
697
+
698
+ # A preloaded option row that ADDS its tag on click (issue #203) — the
699
+ # tags sibling of the combobox's on(:select) option, but CLIENT-ONLY
700
+ # (form state, no POST). Emits the [role=option] convention (so
701
+ # reactive_filter/reactive_listnav see it), the forced type="button" (a
702
+ # bare button inside a <form> would submit it), and the tag value the
703
+ # client reads. Compose extra attrs (the filter haystack, a testid) via
704
+ # mix. The tag can't contain a comma — it would corrupt the joined value.
705
+ def reactive_tags_option(tag)
706
+ {
707
+ type: "button",
708
+ role: "option",
709
+ data: {
710
+ action: "click->reactive#tagsPick",
711
+ reactive_tag_param: tags_tag!(:reactive_tags_option, tag)
712
+ }
713
+ }
714
+ end
715
+
716
+ # A chip's remove button (issue #203) — client-only, no POST. Two forms:
717
+ # inside the <template data-reactive-tags-template> chip, call it with NO
718
+ # tag (the client fills data-reactive-tag-param per cloned chip); on a
719
+ # server-rendered initial chip, pass the tag explicitly:
720
+ # button(**reactive_tags_remove(tag)) { "×" }
721
+ def reactive_tags_remove(tag = nil)
722
+ attrs = { type: "button", data: { action: "click->reactive#tagsRemove" } }
723
+ attrs[:data][:reactive_tag_param] = tags_tag!(:reactive_tags_remove, tag) unless tag.nil?
724
+ attrs
725
+ end
726
+
727
+ # Draft nested-attribute rows (issue #208) — the "new parent + child
728
+ # rows" primitive. A form that builds child rows BEFORE the parent
729
+ # exists (a new order accumulating line items) can't be a reactive
730
+ # collection: an unsaved parent has no gid to sign, so there is nothing
731
+ # to round-trip. These helpers run that pre-save window entirely
732
+ # CLIENT-SIDE, the reactive_tags posture: the rows are FORM state (like
733
+ # text in an input), never component state — add/remove round-trips
734
+ # nothing, and the surrounding REAL form submit carries Rails'
735
+ # accepts_nested_attributes_for names so the server reconciles parent +
736
+ # rows in ONE create.
737
+ #
738
+ # The wiring (all inside one reactive root, itself inside the real
739
+ # <form> that will POST the parent):
740
+ #
741
+ # div(**reactive_root) do
742
+ # div(**reactive_nested_list(:line_items)) { } # rows land here
743
+ # template(**reactive_nested_template(:line_items)) do # ONE row's markup (server-owned)
744
+ # div(**reactive_nested_row) do
745
+ # input(name: nested_field_name(:line_items, :quantity)) # …[NEW_ROW][quantity]
746
+ # button(**reactive_nested_remove) { "×" }
747
+ # end
748
+ # end
749
+ # button(**reactive_nested_add(:line_items)) { "Add row" }
750
+ # end
751
+ #
752
+ # Clicking add clones the template and swaps every NEW_ROW in the
753
+ # clone's name/id/for attributes for a fresh unique index, so each row
754
+ # posts as its own `…_attributes[<index>][field]` group. Remove on a
755
+ # draft row deletes it from the DOM; remove on a row carrying a hidden
756
+ # `[_destroy]` input (a persisted row in an edit form, rendered with
757
+ # nested_field_name(index: item_index)) marks it "1" and hides the row
758
+ # — Rails destroys it on save. The DOM is the single source of truth;
759
+ # a server re-render of the root REPLACES the rows, so keep replace-
760
+ # shaped actions out of a root holding unsent draft rows.
761
+ #
762
+ # Several collections can share one root — every marker/trigger is
763
+ # keyed by the association name. Nesting a collection INSIDE another's
764
+ # template is not supported (the placeholder swap would hit both).
765
+ # Once the parent is saved, the persisted flow takes over: the same row
766
+ # markup renders with real indexes, or graduates to a reactive
767
+ # collection (reactive_collection + reply.append/remove).
768
+
769
+ # The index placeholder a template row carries in its field names; the
770
+ # client swaps it for a fresh unique index on every add. Referenced by
771
+ # both sides of the wire — change it nowhere.
772
+ NESTED_NEW_ROW = "NEW_ROW"
773
+
774
+ # The Rails nested-attributes wire name for one row field — the name
775
+ # accepts_nested_attributes_for expects. Defaults to the template
776
+ # placeholder; pass index: for a server-rendered row. Scope-aware: the
777
+ # `<association>_attributes` base goes through reactive_scope FIRST, so
778
+ # a `reactive_scope :order` component emits
779
+ # order[line_items_attributes][3][quantity] (never a nested-bracket
780
+ # corruption of the scope wrap).
781
+ def nested_field_name(association, field, index: NESTED_NEW_ROW)
782
+ nested_identifier!(:nested_field_name, :association, association)
783
+ nested_identifier!(:nested_field_name, :field, field)
784
+ unless index.to_s == NESTED_NEW_ROW || index.to_s.match?(/\A\d+\z/)
785
+ raise ArgumentError,
786
+ "nested_field_name index: must be an integer or the NEW_ROW placeholder, " \
787
+ "got #{index.inspect} — anything else corrupts the bracketed wire name"
788
+ end
789
+
790
+ "#{scoped_field_name(:"#{association}_attributes")}[#{index}][#{field}]"
791
+ end
792
+
793
+ # The container cloned rows land in — one per association, inside the
794
+ # root. Server-rendered rows (an edit form's persisted children) render
795
+ # inside it too.
796
+ def reactive_nested_list(association)
797
+ { data: { reactive_nested_list: nested_identifier!(:reactive_nested_list, :association, association) } }
798
+ end
799
+
800
+ # The <template> holding ONE row's markup (server-owned, inert until
801
+ # cloned). Field names inside use nested_field_name's placeholder form.
802
+ def reactive_nested_template(association)
803
+ { data: { reactive_nested_template: nested_identifier!(:reactive_nested_template, :association,
804
+ association) } }
805
+ end
806
+
807
+ # The row wrapper marker — what nestedRemove resolves from its trigger
808
+ # (closest). Spread on the template row's outermost element AND on
809
+ # server-rendered rows, so both remove the same way.
810
+ def reactive_nested_row
811
+ { data: { reactive_nested_row: true } }
812
+ end
813
+
814
+ # The add-a-row trigger — CLIENT-ONLY (no dispatch descriptor, no
815
+ # POST). Forced type="button": a bare button inside the surrounding
816
+ # 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
+ }
824
+ }
825
+ end
826
+
827
+ # A row's remove trigger — client-only. Draft rows leave the DOM;
828
+ # persisted rows (a hidden [_destroy] input present) are marked and
829
+ # hidden instead, so Rails destroys them on save.
830
+ def reactive_nested_remove
831
+ { type: "button", data: { action: "click->reactive#nestedRemove" } }
832
+ end
833
+
643
834
  # CROSS-ROOT value-conditional visibility (issue #164) — the visibility
644
835
  # parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
645
836
  # is root-scoped by design (#15), so it can't express "this control
@@ -825,6 +1016,33 @@ module Phlex
825
1016
  selector
826
1017
  end
827
1018
 
1019
+ # A declared tag value for reactive_tags_option/reactive_tags_remove
1020
+ # (issue #203), validated non-blank and comma-free at render — the hidden
1021
+ # field is comma-joined, so a declared tag containing a comma would
1022
+ # corrupt the stored value (and a blank tag is a dead trigger).
1023
+ def tags_tag!(helper, tag)
1024
+ value = tag.to_s.strip
1025
+ raise ArgumentError, "#{helper} needs a non-blank tag, got #{tag.inspect}" if value.empty?
1026
+ if value.include?(",")
1027
+ raise ArgumentError,
1028
+ "#{helper}(#{tag.inspect}): a tag can't contain a comma — the hidden field is comma-joined"
1029
+ end
1030
+
1031
+ value
1032
+ end
1033
+
1034
+ # An association/field name for the nested-rows wire (issue #208),
1035
+ # validated to a plain Ruby identifier at render — it becomes an
1036
+ # attribute value, a CSS selector fragment, AND a bracketed param name,
1037
+ # so anything else is a dead (or corrupting) binding.
1038
+ def nested_identifier!(helper, kind, name)
1039
+ value = name.to_s
1040
+ return value if value.match?(/\A[a-z_][a-z0-9_]*\z/)
1041
+
1042
+ raise ArgumentError,
1043
+ "#{helper} needs a plain #{kind} name (e.g. :line_items), got #{name.inspect}"
1044
+ end
1045
+
828
1046
  # Issue #179: apply a confirm: gate to a trigger's data hash. A String is
829
1047
  # the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
830
1048
  # is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
@@ -122,6 +122,12 @@ module Phlex
122
122
  # once per boot; the events fire for APMs regardless of this flag.
123
123
  Phlex::Reactive::LogSubscriber.attach_to(:phlex_reactive) if Phlex::Reactive.log_events
124
124
 
125
+ # Attach the turnkey APM adapter (issue #207) when an app set
126
+ # Phlex::Reactive.apm. Resolution is deferred to HERE — after initializers
127
+ # ran, so the vendor SDK (if any) is loaded — and no-ops with one warning
128
+ # when the named SDK is absent (the pgbus optionality invariant). Idempotent.
129
+ Phlex::Reactive::APM.attach! if Phlex::Reactive.apm
130
+
125
131
  # Freeze the param-type registry (issue #109): custom types register in
126
132
  # an initializer, which has run by now, so no further registration is
127
133
  # accepted. A schema referencing a type is validated at declaration; the
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.11.3"
5
+ VERSION = "0.11.5"
6
6
  end
7
7
  end