phlex-reactive 0.11.4 → 0.11.6
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 +89 -0
- data/README.md +197 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +43 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +305 -4
- 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/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +20 -0
- data/lib/phlex/reactive/apm/adapter.rb +43 -0
- data/lib/phlex/reactive/apm/appsignal.rb +52 -0
- data/lib/phlex/reactive/apm/datadog.rb +44 -0
- data/lib/phlex/reactive/apm/sentry.rb +40 -0
- data/lib/phlex/reactive/apm/subscriber.rb +61 -0
- data/lib/phlex/reactive/apm.rb +91 -0
- data/lib/phlex/reactive/component/dsl.rb +27 -4
- data/lib/phlex/reactive/component/helpers.rb +199 -8
- data/lib/phlex/reactive/engine.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +89 -1
- metadata +7 -1
|
@@ -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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
@@ -724,6 +724,139 @@ module Phlex
|
|
|
724
724
|
attrs
|
|
725
725
|
end
|
|
726
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
|
+
#
|
|
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: }
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# The <template> holding ONE row's markup (server-owned, inert until
|
|
827
|
+
# cloned). Field names inside use nested_field_name's placeholder form.
|
|
828
|
+
def reactive_nested_template(association)
|
|
829
|
+
{ data: { reactive_nested_template: nested_identifier!(:reactive_nested_template, :association,
|
|
830
|
+
association) } }
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
# The row wrapper marker — what nestedRemove resolves from its trigger
|
|
834
|
+
# (closest). Spread on the template row's outermost element AND on
|
|
835
|
+
# server-rendered rows, so both remove the same way.
|
|
836
|
+
def reactive_nested_row
|
|
837
|
+
{ data: { reactive_nested_row: true } }
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# The add-a-row trigger — CLIENT-ONLY (no dispatch descriptor, no
|
|
841
|
+
# POST). Forced type="button": a bare button inside the surrounding
|
|
842
|
+
# real <form> would submit it.
|
|
843
|
+
def reactive_nested_add(association)
|
|
844
|
+
{
|
|
845
|
+
type: "button",
|
|
846
|
+
data: {
|
|
847
|
+
action: "click->reactive#nestedAdd",
|
|
848
|
+
reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
# A row's remove trigger — client-only. Draft rows leave the DOM;
|
|
854
|
+
# persisted rows (a hidden [_destroy] input present) are marked and
|
|
855
|
+
# hidden instead, so Rails destroys them on save.
|
|
856
|
+
def reactive_nested_remove
|
|
857
|
+
{ type: "button", data: { action: "click->reactive#nestedRemove" } }
|
|
858
|
+
end
|
|
859
|
+
|
|
727
860
|
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
728
861
|
# parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
|
|
729
862
|
# is root-scoped by design (#15), so it can't express "this control
|
|
@@ -757,6 +890,22 @@ module Phlex
|
|
|
757
890
|
#
|
|
758
891
|
# reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
759
892
|
# kind: { "#premium-note" => %w[gold platinum] })
|
|
893
|
+
#
|
|
894
|
+
# MULTI-FIELD targets (issue #209): a "#id" KEY takes a full
|
|
895
|
+
# if:/if_any:/unless: conditions Hash — the SAME language reactive_show
|
|
896
|
+
# speaks, so a cross-root target can finally read a COMBINATION of
|
|
897
|
+
# owned fields (the last case forcing a bespoke JS listener):
|
|
898
|
+
#
|
|
899
|
+
# reactive_show_targets("#trade-warning" => {
|
|
900
|
+
# if: { type: "trade", price: ..0 } # type == "trade" AND price <= 0
|
|
901
|
+
# })
|
|
902
|
+
#
|
|
903
|
+
# Target-keyed and field-keyed entries mix in the ONE call (a "#" key is
|
|
904
|
+
# unambiguous — a field name may never start with "#"). The client folds
|
|
905
|
+
# the target's DNF payload with the same per-term field reads as an
|
|
906
|
+
# in-root reactive_show: every referenced field must be OWNED by this
|
|
907
|
+
# root (a missing owned field reads as blank, fail-closed); a target
|
|
908
|
+
# whose fields are ALL unowned is left alone, like the single-field skip.
|
|
760
909
|
def reactive_show_targets(field, targets = nil)
|
|
761
910
|
field_maps = targets.nil? ? field : { field => targets }
|
|
762
911
|
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
@@ -766,16 +915,12 @@ module Phlex
|
|
|
766
915
|
end
|
|
767
916
|
|
|
768
917
|
normalized = field_maps.to_h do |name, map|
|
|
769
|
-
# Catch the forgotten-field-name misuse — reactive_show_targets(
|
|
770
|
-
# "#id" => {…}) — before the per-target validation turns it into a
|
|
771
|
-
# baffling "predicate" error.
|
|
772
918
|
if name.to_s.start_with?("#")
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
919
|
+
# Target-keyed conditions (issue #209): "#id" => { if:/if_any:/unless: }.
|
|
920
|
+
[name.to_s, normalize_show_target_conditions(name.to_s, map)]
|
|
921
|
+
else
|
|
922
|
+
[name.to_s, normalize_show_target_map(name, map)]
|
|
776
923
|
end
|
|
777
|
-
|
|
778
|
-
[name.to_s, normalize_show_target_map(name, map)]
|
|
779
924
|
end
|
|
780
925
|
|
|
781
926
|
{ data: { reactive_show_targets: normalized.to_json } }
|
|
@@ -924,6 +1069,18 @@ module Phlex
|
|
|
924
1069
|
value
|
|
925
1070
|
end
|
|
926
1071
|
|
|
1072
|
+
# An association/field name for the nested-rows wire (issue #208),
|
|
1073
|
+
# validated to a plain Ruby identifier at render — it becomes an
|
|
1074
|
+
# attribute value, a CSS selector fragment, AND a bracketed param name,
|
|
1075
|
+
# so anything else is a dead (or corrupting) binding.
|
|
1076
|
+
def nested_identifier!(helper, kind, name)
|
|
1077
|
+
value = name.to_s
|
|
1078
|
+
return value if value.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
1079
|
+
|
|
1080
|
+
raise ArgumentError,
|
|
1081
|
+
"#{helper} needs a plain #{kind} name (e.g. :line_items), got #{name.inspect}"
|
|
1082
|
+
end
|
|
1083
|
+
|
|
927
1084
|
# Issue #179: apply a confirm: gate to a trigger's data hash. A String is
|
|
928
1085
|
# the static #52/#55 form (data-reactive-confirm-param, unchanged). A Hash
|
|
929
1086
|
# is the CONDITIONAL form (data-reactive-confirm-when-param, JSON) — warn
|
|
@@ -1003,6 +1160,40 @@ module Phlex
|
|
|
1003
1160
|
end
|
|
1004
1161
|
end
|
|
1005
1162
|
|
|
1163
|
+
# Normalize + validate ONE target-keyed entry (issue #209): the "#id"
|
|
1164
|
+
# key is a single id selector (the same declare-time guard as
|
|
1165
|
+
# field-keyed targets), the value a full if:/if_any:/unless: conditions
|
|
1166
|
+
# Hash compiled by ShowConditions into the SAME { "any" => groups } DNF
|
|
1167
|
+
# payload reactive_show emits. Anything else — a bare value, unknown
|
|
1168
|
+
# keys, empty conditions — raises a guided error at render (a dead
|
|
1169
|
+
# binding must never reach the browser).
|
|
1170
|
+
def normalize_show_target_conditions(selector, conditions)
|
|
1171
|
+
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
1172
|
+
raise ArgumentError,
|
|
1173
|
+
"reactive_show_targets target #{selector.inspect} must be a single " \
|
|
1174
|
+
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
1175
|
+
end
|
|
1176
|
+
unless conditions.is_a?(Hash) && conditions.any?
|
|
1177
|
+
raise ArgumentError,
|
|
1178
|
+
"reactive_show_targets: a target key takes a conditions Hash — " \
|
|
1179
|
+
"reactive_show_targets(#{selector.inspect} => { if: { field: value, ... } }); " \
|
|
1180
|
+
"to key by field instead: reactive_show_targets(:field, #{selector.inspect} => value). " \
|
|
1181
|
+
"Got #{selector.inspect} => #{conditions.inspect}"
|
|
1182
|
+
end
|
|
1183
|
+
# Unknown keys are reported BEFORE the presence check so { bogus: 1 }
|
|
1184
|
+
# names its offender instead of the generic shape message (specific
|
|
1185
|
+
# beats generic). A non-empty hash surviving this subtraction holds
|
|
1186
|
+
# only condition keys, so no separate presence check remains.
|
|
1187
|
+
if (unknown = conditions.keys - SHOW_CONDITION_KEYS).any?
|
|
1188
|
+
raise ArgumentError,
|
|
1189
|
+
"reactive_show_targets #{selector.inspect}: unknown conditions key(s) " \
|
|
1190
|
+
"#{unknown.map(&:inspect).join(", ")} — a target's conditions Hash takes only " \
|
|
1191
|
+
"if:/if_any:/unless: (the reactive_show language)"
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
{ "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1006
1197
|
# Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
|
|
1007
1198
|
# with a GUIDED error printing the if:/if_any:/unless: rewrite. A
|
|
1008
1199
|
# positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
|
|
@@ -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
|