phlex-reactive 0.9.3 → 0.9.4
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 +197 -0
- data/README.md +282 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +534 -6
- 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/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +193 -8
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
|
@@ -28,6 +28,23 @@ module Phlex
|
|
|
28
28
|
Phlex::Reactive.current_connection_id
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Manually satisfy the verify_authorized guard (issue #168) for a bespoke
|
|
32
|
+
# authorization check the interceptor can't see — a hand-rolled policy, a
|
|
33
|
+
# feature flag, an ownership comparison that doesn't go through one of
|
|
34
|
+
# Phlex::Reactive.authorization_methods:
|
|
35
|
+
#
|
|
36
|
+
# def publish
|
|
37
|
+
# raise NotAllowed unless @post.author == Current.user
|
|
38
|
+
# mark_authorized!
|
|
39
|
+
# @post.update!(published: true)
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# Call it only AFTER your check passes — it asserts "I have authorized
|
|
43
|
+
# this action." A no-op when verify_authorized is off.
|
|
44
|
+
def mark_authorized!
|
|
45
|
+
Phlex::Reactive::Authorization.mark!
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
# Subject-bound reply builder — the preferred way to control an action's
|
|
32
49
|
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
33
50
|
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
@@ -433,18 +450,126 @@ module Phlex
|
|
|
433
450
|
"in: — got #{predicates.keys.inspect}"
|
|
434
451
|
end
|
|
435
452
|
|
|
436
|
-
key, value = predicates.first
|
|
453
|
+
key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
|
|
437
454
|
data = { reactive_show_field: field.to_s }
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
455
|
+
data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value
|
|
456
|
+
|
|
457
|
+
mix({ data: }, attrs)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Client-side option filtering for the searchable combobox (issue #163)
|
|
461
|
+
# — the "preload + type to narrow" half of #72's keyboard nav, entirely
|
|
462
|
+
# client-side. Spread onto the ROOT (mix with reactive_root); it names
|
|
463
|
+
# the input whose value drives the filter and the option elements to
|
|
464
|
+
# show/hide, and the generic controller toggles `hidden` on every
|
|
465
|
+
# keystroke by substring-matching each option's haystack — no round
|
|
466
|
+
# trip, no token, no bespoke per-feature controller:
|
|
467
|
+
#
|
|
468
|
+
# div(**mix(reactive_root, reactive_filter(
|
|
469
|
+
# input: "#exercise-search",
|
|
470
|
+
# option: "[role=option]",
|
|
471
|
+
# group: "[data-filter-group]", # optional: collapse empty group headers
|
|
472
|
+
# empty: "#no-matches" # optional: reveal when 0 match
|
|
473
|
+
# ))) { … }
|
|
474
|
+
#
|
|
475
|
+
# Each option's haystack is its `data-reactive-filter-text` attribute
|
|
476
|
+
# (server-rendered — pack in synonyms/categories), falling back to the
|
|
477
|
+
# option's own text. Matching is a case-folded substring test — a
|
|
478
|
+
# DECLARED literal match, never an expression (no eval surface, the
|
|
479
|
+
# reactive_show posture). `group:` hides any group element whose every
|
|
480
|
+
# contained option is hidden; `empty:` reveals the no-matches node when
|
|
481
|
+
# 0 options are visible. The client seeds at connect and re-syncs after
|
|
482
|
+
# a morph; selectors resolve WITHIN this root only (#15 ownership).
|
|
483
|
+
#
|
|
484
|
+
# Filtering composes with reactive_listnav (Arrow/Enter/Escape skip
|
|
485
|
+
# hidden options) and each option's own on(:select, …) trigger —
|
|
486
|
+
# selection still round-trips as a signed action; only FILTERING is
|
|
487
|
+
# local. Blank selectors raise: a dead binding must fail at render.
|
|
488
|
+
def reactive_filter(input:, option:, group: nil, empty: nil)
|
|
489
|
+
data = {
|
|
490
|
+
reactive_filter_input: filter_selector!(:input, input),
|
|
491
|
+
reactive_filter_option: filter_selector!(:option, option)
|
|
492
|
+
}
|
|
493
|
+
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
494
|
+
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
495
|
+
|
|
496
|
+
{ data: }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# STANDALONE combobox keyboard navigation (issue #163) — the same
|
|
500
|
+
# Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the
|
|
501
|
+
# dispatch descriptor. A preload-and-filter combobox input fires NO
|
|
502
|
+
# action (filtering is pure client), so it can't carry on(); spread
|
|
503
|
+
# this onto the input instead:
|
|
504
|
+
#
|
|
505
|
+
# input(id: "search", type: "search", **reactive_listnav("[role=option]"))
|
|
506
|
+
#
|
|
507
|
+
# Arrow keys move the client-side highlight among the (visible) options,
|
|
508
|
+
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
509
|
+
# (selection stays a signed action), Escape clears. Combine with other
|
|
510
|
+
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
511
|
+
def reactive_listnav(option_selector)
|
|
512
|
+
{
|
|
513
|
+
data: {
|
|
514
|
+
action: LISTNAV_ACTIONS.join(" "),
|
|
515
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
521
|
+
# parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
|
|
522
|
+
# is root-scoped by design (#15), so it can't express "this control
|
|
523
|
+
# drives elements ELSEWHERE on the page" — a nav tab, a panel in another
|
|
524
|
+
# tab pane, a sidebar note. reactive_show_targets is the declared,
|
|
525
|
+
# id-allowlisted escape: the component that OWNS the field declares
|
|
526
|
+
# which outside ids it governs. Spread it on the ROOT (mix alongside
|
|
527
|
+
# reactive_root — the client reads it off the controller element):
|
|
528
|
+
#
|
|
529
|
+
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
530
|
+
# "#advanced-tab" => { equals: "advanced" },
|
|
531
|
+
# "#advanced-panel" => { equals: "advanced" },
|
|
532
|
+
# "#basic-note" => { not: "advanced" })))
|
|
533
|
+
#
|
|
534
|
+
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
535
|
+
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
536
|
+
# only, enforced here at declare time AND warn-and-skipped by the client
|
|
537
|
+
# interpreter (two-sided default-deny); the predicate is the same
|
|
538
|
+
# literal-only reactive_show vocabulary (exactly one of equals:/not:/
|
|
539
|
+
# in:, no expressions); and the toggle is `hidden` only — no innerHTML,
|
|
540
|
+
# no attribute freedom. The FIELD read stays owned (#15): you can only
|
|
541
|
+
# drive outside visibility from a field this root owns. A target id not
|
|
542
|
+
# on the page is silently skipped (an unrendered tab pane is normal).
|
|
543
|
+
#
|
|
544
|
+
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
545
|
+
# values, so a second call's JSON would concatenate into an unparseable
|
|
546
|
+
# attr and the client would drop BOTH maps (it warns when that
|
|
547
|
+
# happens). Several fields therefore go in ONE call via the hash form:
|
|
548
|
+
#
|
|
549
|
+
# reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
|
|
550
|
+
# kind: { "#premium-note" => { not: "basic" } })
|
|
551
|
+
def reactive_show_targets(field, targets = nil)
|
|
552
|
+
field_maps = targets.nil? ? field : { field => targets }
|
|
553
|
+
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
554
|
+
raise ArgumentError,
|
|
555
|
+
"reactive_show_targets needs at least one target " \
|
|
556
|
+
"(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
normalized = field_maps.to_h do |name, map|
|
|
560
|
+
# Catch the forgotten-field-name misuse — reactive_show_targets(
|
|
561
|
+
# "#id" => {…}) — before the per-target validation turns it into a
|
|
562
|
+
# baffling "predicate" error.
|
|
563
|
+
if name.to_s.start_with?("#")
|
|
564
|
+
raise ArgumentError,
|
|
565
|
+
"reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
|
|
566
|
+
"name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
|
|
567
|
+
end
|
|
441
568
|
|
|
442
|
-
|
|
443
|
-
else
|
|
444
|
-
data[:"reactive_show_#{key}"] = value.to_s
|
|
569
|
+
[name.to_s, normalize_show_target_map(name, map)]
|
|
445
570
|
end
|
|
446
571
|
|
|
447
|
-
|
|
572
|
+
{ data: { reactive_show_targets: normalized.to_json } }
|
|
448
573
|
end
|
|
449
574
|
|
|
450
575
|
# Scoped busy indicator (issue #99). Marks an element so the generic
|
|
@@ -549,6 +674,66 @@ module Phlex
|
|
|
549
674
|
|
|
550
675
|
private
|
|
551
676
|
|
|
677
|
+
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
678
|
+
# stringified (issue #163). A blank selector is a dead binding — the
|
|
679
|
+
# client would silently match nothing — so it fails loudly at render,
|
|
680
|
+
# like reactive_show's predicate validation.
|
|
681
|
+
def filter_selector!(name, value)
|
|
682
|
+
selector = value.to_s
|
|
683
|
+
if selector.strip.empty?
|
|
684
|
+
raise ArgumentError,
|
|
685
|
+
"reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
selector
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Normalize + validate ONE field's target map (issue #164): each key a
|
|
692
|
+
# single id selector (loud raise — the declare-time half of the
|
|
693
|
+
# two-sided default-deny), each value one literal predicate. Shared by
|
|
694
|
+
# both reactive_show_targets call forms.
|
|
695
|
+
def normalize_show_target_map(field, targets)
|
|
696
|
+
unless targets.is_a?(Hash) && targets.any?
|
|
697
|
+
raise ArgumentError,
|
|
698
|
+
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
699
|
+
"(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
targets.to_h do |selector, predicate|
|
|
703
|
+
selector = selector.to_s
|
|
704
|
+
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
705
|
+
raise ArgumentError,
|
|
706
|
+
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
707
|
+
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
|
|
711
|
+
[selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Validate ONE declared literal show predicate (issues #161/#164) and
|
|
716
|
+
# return its wire form — { "equals" => "v" } / { "not" => "v" } /
|
|
717
|
+
# { "in" => ["a", …] } (a real array: reactive_show JSON-encodes it into
|
|
718
|
+
# its own attr; the reactive_show_targets map embeds it directly).
|
|
719
|
+
# Shared by both helpers so the vocabulary and the loud validation can
|
|
720
|
+
# never drift. `context` names the call site in the error.
|
|
721
|
+
def normalize_show_predicate(predicates, context)
|
|
722
|
+
unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
|
|
723
|
+
raise ArgumentError,
|
|
724
|
+
"#{context} needs exactly one predicate — equals:, not:, or in: — " \
|
|
725
|
+
"got #{predicates.keys.inspect}"
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
key, value = predicates.first
|
|
729
|
+
return { key.to_s => value.to_s } unless key == :in
|
|
730
|
+
|
|
731
|
+
list = Array(value).map(&:to_s)
|
|
732
|
+
raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
|
|
733
|
+
|
|
734
|
+
{ "in" => list }
|
|
735
|
+
end
|
|
736
|
+
|
|
552
737
|
# True when the hint declares checked: :keep — the click-bound
|
|
553
738
|
# checkbox/radio case that must SKIP the forced type="button" so the native
|
|
554
739
|
# control (and its native flip) survives (issue #98). Accepts symbol or
|
|
@@ -55,6 +55,15 @@ module Phlex
|
|
|
55
55
|
# declared, the token carries just {c} — enough to mount, but with no
|
|
56
56
|
# identity to round-trip, so declare reactive_state for a draft you sync.
|
|
57
57
|
def reactive_token
|
|
58
|
+
Phlex::Reactive.sign(reactive_identity_payload)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The UNSIGNED identity payload — extracted from reactive_token (issue
|
|
62
|
+
# #165) so the defer machinery mints its purpose-scoped defer tokens
|
|
63
|
+
# from the exact same shape and the two token families can never drift.
|
|
64
|
+
# Still THE hot path (reactive_token calls it on every render); the
|
|
65
|
+
# split adds one method dispatch, verified flat by the token bench.
|
|
66
|
+
def reactive_identity_payload
|
|
58
67
|
klass = self.class
|
|
59
68
|
payload = { "c" => klass.name }
|
|
60
69
|
|
|
@@ -75,7 +84,7 @@ module Phlex
|
|
|
75
84
|
payload["s"] = state
|
|
76
85
|
end
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
payload
|
|
79
88
|
end
|
|
80
89
|
|
|
81
90
|
# A record's gid is signable unless it is an unsaved AR-like draft
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Lazy initial mount (issue #165): a `reactive_lazy` component's FIRST
|
|
7
|
+
# (page-embedded) render emits a placeholder SHELL — the root id, the
|
|
8
|
+
# generic controller, the pending markers, and a defer token ON the root
|
|
9
|
+
# — and the client fetches the real content through the same defer
|
|
10
|
+
# machinery on connect (the Livewire #[Lazy] shape).
|
|
11
|
+
#
|
|
12
|
+
# Lazy applies ONLY to the initial mount. Every render that goes through
|
|
13
|
+
# the reactive machinery — an action reply's self-replace, a broadcast,
|
|
14
|
+
# the defer endpoint/job, the class stream builders — runs inside
|
|
15
|
+
# Defer.with_real_render, so it renders the REAL template. Anything else
|
|
16
|
+
# would make an action on a lazy component cost two round trips.
|
|
17
|
+
module Lazy
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Phlex 2's render hook: the template runs inside the block. Overridden
|
|
23
|
+
# (not yield-then-decorate) so the lazy shell can REPLACE the template
|
|
24
|
+
# entirely on the initial mount. A component's own around_template
|
|
25
|
+
# override composes via normal method lookup as long as it calls super.
|
|
26
|
+
def around_template(&)
|
|
27
|
+
klass = self.class
|
|
28
|
+
if klass.respond_to?(:reactive_lazy?) && klass.reactive_lazy? && !Phlex::Reactive::Defer.real_render?
|
|
29
|
+
render_defer_shell
|
|
30
|
+
else
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The shell: owns the component's id (the arrival replaces it by that
|
|
36
|
+
# id), mounts the generic controller, and carries the defer token as a
|
|
37
|
+
# ROOT attribute — the controller's connect() probes it and enters the
|
|
38
|
+
# same module-level fetch path a reply directive uses. Pending markers
|
|
39
|
+
# + the .reactive-defer-placeholder class are the CSS hooks.
|
|
40
|
+
def render_defer_shell
|
|
41
|
+
public_send(
|
|
42
|
+
self.class.reactive_lazy_tag,
|
|
43
|
+
id:,
|
|
44
|
+
class: "reactive-defer-placeholder",
|
|
45
|
+
aria: { busy: "true" },
|
|
46
|
+
data: {
|
|
47
|
+
controller: "reactive",
|
|
48
|
+
reactive_defer_pending: "true",
|
|
49
|
+
# UNBOUND (issue #165 security): a lazy shell renders during the
|
|
50
|
+
# page render — on a fresh visit the session doesn't exist yet, so
|
|
51
|
+
# the token can't be actor-bound (it would 400 at the endpoint,
|
|
52
|
+
# which by then IS bound). It lives in the actor's own page (a
|
|
53
|
+
# small leak surface); the TTL + `authorize!` are its bound. Only
|
|
54
|
+
# reply.defer tokens (in action responses that can transit proxies)
|
|
55
|
+
# are actor-bound.
|
|
56
|
+
reactive_defer_token: Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true)
|
|
57
|
+
}
|
|
58
|
+
) { render_deferred_placeholder_content }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Same content contract as reply.defer's placeholder: true — the
|
|
62
|
+
# component's deferred_placeholder returns a Phlex component instance
|
|
63
|
+
# (rendered), an html_safe String (raw), or a plain String (escaped
|
|
64
|
+
# text — data, not markup). No method / nil → the empty shell.
|
|
65
|
+
def render_deferred_placeholder_content
|
|
66
|
+
return unless respond_to?(:deferred_placeholder, true)
|
|
67
|
+
|
|
68
|
+
value = send(:deferred_placeholder)
|
|
69
|
+
case value
|
|
70
|
+
when nil then nil
|
|
71
|
+
when ::Phlex::SGML then render(value)
|
|
72
|
+
else
|
|
73
|
+
value.html_safe? ? raw(value) : plain(value.to_s)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -50,7 +50,13 @@ module Phlex
|
|
|
50
50
|
state_keys: :@reactive_own_state_keys,
|
|
51
51
|
collections: :@reactive_own_collections,
|
|
52
52
|
computes: :@reactive_own_computes,
|
|
53
|
-
record_key: :@reactive_own_record_key
|
|
53
|
+
record_key: :@reactive_own_record_key,
|
|
54
|
+
# Deferred reply segments — reactive_lazy (issue #165).
|
|
55
|
+
lazy: :@reactive_own_lazy,
|
|
56
|
+
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
57
|
+
# WHOLE component) plus a list of named actions.
|
|
58
|
+
skip_all: :@reactive_own_skip_all,
|
|
59
|
+
skip_actions: :@reactive_own_skip_actions
|
|
54
60
|
}.freeze
|
|
55
61
|
|
|
56
62
|
# The identity-side hot-path memos swept by bump! (see the contract
|