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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +207 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +43 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +362 -1
- 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 +218 -0
- 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
|
|
@@ -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
|