phlex-reactive 0.9.3 → 0.9.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 +212 -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 +659 -10
- 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 +348 -21
- 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
|
|
@@ -424,27 +441,152 @@ module Phlex
|
|
|
424
441
|
# after a morph; render the initial `hidden:` yourself (from the same
|
|
425
442
|
# server state that renders the field) to avoid a first-paint flash.
|
|
426
443
|
# Extra attrs deep-merge over the binding (mix), like reactive_field.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
444
|
+
# Compound forms (issue #176 part A) — visibility that depends on MORE
|
|
445
|
+
# THAN ONE field, or a threshold, staying inside the eval-free literal
|
|
446
|
+
# contract. `all:`/`any:` fold a list of per-field terms (each the same
|
|
447
|
+
# equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
|
|
448
|
+
# connective; the compound has no single controlling field, so it
|
|
449
|
+
# serializes as ONE JSON attr (data-reactive-show) like
|
|
450
|
+
# reactive_show_targets rather than the flat data-reactive-show-* attrs:
|
|
451
|
+
#
|
|
452
|
+
# # visible while type == "individual" AND country != "domestic"
|
|
453
|
+
# div(**reactive_show(all: [
|
|
454
|
+
# { field: :type, equals: "individual" },
|
|
455
|
+
# { field: :country, not: "domestic" }
|
|
456
|
+
# ]))
|
|
457
|
+
# # visible while director OR shareholder is checked
|
|
458
|
+
# div(**reactive_show(any: [
|
|
459
|
+
# { field: :director, equals: true },
|
|
460
|
+
# { field: :shareholder, equals: true }
|
|
461
|
+
# ]))
|
|
462
|
+
# # a numeric threshold, single field or as a compound term
|
|
463
|
+
# div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
|
|
464
|
+
# div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
|
|
465
|
+
#
|
|
466
|
+
# `all:`/`any:` are additive and mutually exclusive with the single-field
|
|
467
|
+
# form and with each other — enforced loudly at render, like the
|
|
468
|
+
# one-predicate rule. A single-field `all:` with one term degrades to the
|
|
469
|
+
# flat form's behaviour client-side.
|
|
470
|
+
def reactive_show(field = nil, **options)
|
|
471
|
+
connectives = options.slice(*SHOW_CONNECTIVE_KEYS)
|
|
472
|
+
return reactive_show_compound(field, connectives, options) if connectives.any?
|
|
473
|
+
|
|
474
|
+
reactive_show_single(field, options)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Client-side option filtering for the searchable combobox (issue #163)
|
|
478
|
+
# — the "preload + type to narrow" half of #72's keyboard nav, entirely
|
|
479
|
+
# client-side. Spread onto the ROOT (mix with reactive_root); it names
|
|
480
|
+
# the input whose value drives the filter and the option elements to
|
|
481
|
+
# show/hide, and the generic controller toggles `hidden` on every
|
|
482
|
+
# keystroke by substring-matching each option's haystack — no round
|
|
483
|
+
# trip, no token, no bespoke per-feature controller:
|
|
484
|
+
#
|
|
485
|
+
# div(**mix(reactive_root, reactive_filter(
|
|
486
|
+
# input: "#exercise-search",
|
|
487
|
+
# option: "[role=option]",
|
|
488
|
+
# group: "[data-filter-group]", # optional: collapse empty group headers
|
|
489
|
+
# empty: "#no-matches" # optional: reveal when 0 match
|
|
490
|
+
# ))) { … }
|
|
491
|
+
#
|
|
492
|
+
# Each option's haystack is its `data-reactive-filter-text` attribute
|
|
493
|
+
# (server-rendered — pack in synonyms/categories), falling back to the
|
|
494
|
+
# option's own text. Matching is a case-folded substring test — a
|
|
495
|
+
# DECLARED literal match, never an expression (no eval surface, the
|
|
496
|
+
# reactive_show posture). `group:` hides any group element whose every
|
|
497
|
+
# contained option is hidden; `empty:` reveals the no-matches node when
|
|
498
|
+
# 0 options are visible. The client seeds at connect and re-syncs after
|
|
499
|
+
# a morph; selectors resolve WITHIN this root only (#15 ownership).
|
|
500
|
+
#
|
|
501
|
+
# Filtering composes with reactive_listnav (Arrow/Enter/Escape skip
|
|
502
|
+
# hidden options) and each option's own on(:select, …) trigger —
|
|
503
|
+
# selection still round-trips as a signed action; only FILTERING is
|
|
504
|
+
# local. Blank selectors raise: a dead binding must fail at render.
|
|
505
|
+
def reactive_filter(input:, option:, group: nil, empty: nil)
|
|
506
|
+
data = {
|
|
507
|
+
reactive_filter_input: filter_selector!(:input, input),
|
|
508
|
+
reactive_filter_option: filter_selector!(:option, option)
|
|
509
|
+
}
|
|
510
|
+
data[:reactive_filter_group] = filter_selector!(:group, group) if group
|
|
511
|
+
data[:reactive_filter_empty] = filter_selector!(:empty, empty) if empty
|
|
512
|
+
|
|
513
|
+
{ data: }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# STANDALONE combobox keyboard navigation (issue #163) — the same
|
|
517
|
+
# Arrow/Enter/Escape wiring `on(…, listnav:)` appends, without the
|
|
518
|
+
# dispatch descriptor. A preload-and-filter combobox input fires NO
|
|
519
|
+
# action (filtering is pure client), so it can't carry on(); spread
|
|
520
|
+
# this onto the input instead:
|
|
521
|
+
#
|
|
522
|
+
# input(id: "search", type: "search", **reactive_listnav("[role=option]"))
|
|
523
|
+
#
|
|
524
|
+
# Arrow keys move the client-side highlight among the (visible) options,
|
|
525
|
+
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
526
|
+
# (selection stays a signed action), Escape clears. Combine with other
|
|
527
|
+
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
528
|
+
def reactive_listnav(option_selector)
|
|
529
|
+
{
|
|
530
|
+
data: {
|
|
531
|
+
action: LISTNAV_ACTIONS.join(" "),
|
|
532
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# CROSS-ROOT value-conditional visibility (issue #164) — the visibility
|
|
538
|
+
# parallel to reactive_compute's `mirror:` (#159). A plain reactive_show
|
|
539
|
+
# is root-scoped by design (#15), so it can't express "this control
|
|
540
|
+
# drives elements ELSEWHERE on the page" — a nav tab, a panel in another
|
|
541
|
+
# tab pane, a sidebar note. reactive_show_targets is the declared,
|
|
542
|
+
# id-allowlisted escape: the component that OWNS the field declares
|
|
543
|
+
# which outside ids it governs. Spread it on the ROOT (mix alongside
|
|
544
|
+
# reactive_root — the client reads it off the controller element):
|
|
545
|
+
#
|
|
546
|
+
# div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
547
|
+
# "#advanced-tab" => { equals: "advanced" },
|
|
548
|
+
# "#advanced-panel" => { equals: "advanced" },
|
|
549
|
+
# "#basic-note" => { not: "advanced" })))
|
|
550
|
+
#
|
|
551
|
+
# Same posture as mirror: — opt-in and declared, never implicit (a plain
|
|
552
|
+
# reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
|
|
553
|
+
# only, enforced here at declare time AND warn-and-skipped by the client
|
|
554
|
+
# interpreter (two-sided default-deny); the predicate is the same
|
|
555
|
+
# literal-only reactive_show vocabulary (exactly one of equals:/not:/
|
|
556
|
+
# in:, no expressions); and the toggle is `hidden` only — no innerHTML,
|
|
557
|
+
# no attribute freedom. The FIELD read stays owned (#15): you can only
|
|
558
|
+
# drive outside visibility from a field this root owns. A target id not
|
|
559
|
+
# on the page is silently skipped (an unrendered tab pane is normal).
|
|
560
|
+
#
|
|
561
|
+
# ONE call per root. Phlex `mix` space-joins duplicate STRING data
|
|
562
|
+
# values, so a second call's JSON would concatenate into an unparseable
|
|
563
|
+
# attr and the client would drop BOTH maps (it warns when that
|
|
564
|
+
# happens). Several fields therefore go in ONE call via the hash form:
|
|
565
|
+
#
|
|
566
|
+
# reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
|
|
567
|
+
# kind: { "#premium-note" => { not: "basic" } })
|
|
568
|
+
def reactive_show_targets(field, targets = nil)
|
|
569
|
+
field_maps = targets.nil? ? field : { field => targets }
|
|
570
|
+
unless field_maps.is_a?(Hash) && field_maps.any?
|
|
431
571
|
raise ArgumentError,
|
|
432
|
-
"
|
|
433
|
-
"in:
|
|
572
|
+
"reactive_show_targets needs at least one target " \
|
|
573
|
+
"(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
|
|
434
574
|
end
|
|
435
575
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
576
|
+
normalized = field_maps.to_h do |name, map|
|
|
577
|
+
# Catch the forgotten-field-name misuse — reactive_show_targets(
|
|
578
|
+
# "#id" => {…}) — before the per-target validation turns it into a
|
|
579
|
+
# baffling "predicate" error.
|
|
580
|
+
if name.to_s.start_with?("#")
|
|
581
|
+
raise ArgumentError,
|
|
582
|
+
"reactive_show_targets: #{name.inspect} looks like a target selector, not a field " \
|
|
583
|
+
"name — call reactive_show_targets(:field, #{name.inspect} => { ... })"
|
|
584
|
+
end
|
|
441
585
|
|
|
442
|
-
|
|
443
|
-
else
|
|
444
|
-
data[:"reactive_show_#{key}"] = value.to_s
|
|
586
|
+
[name.to_s, normalize_show_target_map(name, map)]
|
|
445
587
|
end
|
|
446
588
|
|
|
447
|
-
|
|
589
|
+
{ data: { reactive_show_targets: normalized.to_json } }
|
|
448
590
|
end
|
|
449
591
|
|
|
450
592
|
# Scoped busy indicator (issue #99). Marks an element so the generic
|
|
@@ -533,12 +675,33 @@ module Phlex
|
|
|
533
675
|
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
534
676
|
end
|
|
535
677
|
|
|
536
|
-
# The declared reactive_show predicates
|
|
537
|
-
#
|
|
538
|
-
#
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
|
|
678
|
+
# The declared reactive_show predicates: a literal match on the
|
|
679
|
+
# controlling field's current value. Exactly one per binding — enforced
|
|
680
|
+
# loudly at render (a dead binding must not no-op in the browser).
|
|
681
|
+
# `not`/`in` are Ruby keywords, so they arrive via **options rather than
|
|
682
|
+
# named kwargs. Issue #161 shipped the string-literal trio; issue #176
|
|
683
|
+
# adds the numeric-threshold quartet below.
|
|
684
|
+
SHOW_LITERAL_KEYS = %i[equals not in].freeze
|
|
685
|
+
|
|
686
|
+
# The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt
|
|
687
|
+
# compare the field value coerced to a Number against a literal number.
|
|
688
|
+
# Still a declared literal — the RHS is a number baked into the
|
|
689
|
+
# attribute, never an expression — the only new capability is ordered
|
|
690
|
+
# comparison. The RHS must be an actual Numeric in Ruby (stricter, so a
|
|
691
|
+
# typo like gte: "10" fails at render, not silently in the browser).
|
|
692
|
+
SHOW_NUMERIC_KEYS = %i[gte gt lte lt].freeze
|
|
693
|
+
|
|
694
|
+
# Every predicate key a reactive_show / reactive_show_targets binding may
|
|
695
|
+
# declare — the literal trio plus the numeric quartet. Exactly one of
|
|
696
|
+
# these decides a single binding; a compound term declares one too.
|
|
697
|
+
SHOW_PREDICATE_KEYS = (SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
|
|
698
|
+
|
|
699
|
+
# The compound connectives (issue #176 part A): fold a list of per-field
|
|
700
|
+
# literal/numeric terms with ONE fixed keyword. Not an expression surface
|
|
701
|
+
# — the connective is one of two fixed keywords, each term is the same
|
|
702
|
+
# declared predicate vocabulary. Mutually exclusive with a single field
|
|
703
|
+
# and with each other (enforced loudly at render).
|
|
704
|
+
SHOW_CONNECTIVE_KEYS = %i[all any].freeze
|
|
542
705
|
|
|
543
706
|
# The declared optimistic-hint class ops (issue #98): the cosmetic class
|
|
544
707
|
# vocabulary the client applies instantly and reverts on failure. Enforced
|
|
@@ -549,6 +712,170 @@ module Phlex
|
|
|
549
712
|
|
|
550
713
|
private
|
|
551
714
|
|
|
715
|
+
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
716
|
+
# stringified (issue #163). A blank selector is a dead binding — the
|
|
717
|
+
# client would silently match nothing — so it fails loudly at render,
|
|
718
|
+
# like reactive_show's predicate validation.
|
|
719
|
+
def filter_selector!(name, value)
|
|
720
|
+
selector = value.to_s
|
|
721
|
+
if selector.strip.empty?
|
|
722
|
+
raise ArgumentError,
|
|
723
|
+
"reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
selector
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Normalize + validate ONE field's target map (issue #164): each key a
|
|
730
|
+
# single id selector (loud raise — the declare-time half of the
|
|
731
|
+
# two-sided default-deny), each value one literal predicate. Shared by
|
|
732
|
+
# both reactive_show_targets call forms.
|
|
733
|
+
def normalize_show_target_map(field, targets)
|
|
734
|
+
unless targets.is_a?(Hash) && targets.any?
|
|
735
|
+
raise ArgumentError,
|
|
736
|
+
"reactive_show_targets(#{field.inspect}) needs at least one target " \
|
|
737
|
+
"(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
targets.to_h do |selector, predicate|
|
|
741
|
+
selector = selector.to_s
|
|
742
|
+
unless selector.match?(DSL::MIRROR_ID_SELECTOR)
|
|
743
|
+
raise ArgumentError,
|
|
744
|
+
"reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
|
|
745
|
+
"ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
|
|
749
|
+
[selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Validate ONE declared show predicate (issues #161/#164/#176) and
|
|
754
|
+
# return its wire form — { "equals" => "v" } / { "not" => "v" } /
|
|
755
|
+
# { "in" => ["a", …] } / { "gte" => 10 } (a numeric RHS stays a real
|
|
756
|
+
# Number so the wire carries a JSON number, not a string). reactive_show
|
|
757
|
+
# JSON-encodes an in: array into its own attr; the reactive_show_targets
|
|
758
|
+
# map and a compound term embed the predicate directly. Shared by every
|
|
759
|
+
# helper so the vocabulary and the loud validation can never drift.
|
|
760
|
+
# `context` names the call site in the error.
|
|
761
|
+
def normalize_show_predicate(predicates, context)
|
|
762
|
+
unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
|
|
763
|
+
raise ArgumentError,
|
|
764
|
+
"#{context} needs exactly one predicate — equals:, not:, in:, or " \
|
|
765
|
+
"gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
key, value = predicates.first
|
|
769
|
+
return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
|
|
770
|
+
return { key.to_s => value.to_s } unless key == :in
|
|
771
|
+
|
|
772
|
+
list = Array(value).map(&:to_s)
|
|
773
|
+
raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
|
|
774
|
+
|
|
775
|
+
{ "in" => list }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# Validate a numeric threshold predicate (issue #176 part B): the RHS
|
|
779
|
+
# must be an actual Numeric (a String "10" is a typo caught here, not a
|
|
780
|
+
# silent NaN in the browser). It rides the wire as a JSON number so the
|
|
781
|
+
# client compares Number(value) against it directly.
|
|
782
|
+
def normalize_show_numeric(key, value, context)
|
|
783
|
+
unless value.is_a?(Numeric)
|
|
784
|
+
raise ArgumentError,
|
|
785
|
+
"#{context} #{key}: needs a number (an ordered comparison against a literal), " \
|
|
786
|
+
"got #{value.inspect}"
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
{ key.to_s => value }
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
# The single-field reactive_show form (issue #161, extended for numeric
|
|
793
|
+
# thresholds in #176): one owned field + one predicate → flat
|
|
794
|
+
# data-reactive-show-* attrs. An in: list JSON-encodes into its own attr;
|
|
795
|
+
# a numeric threshold or a literal stringifies into the flat attr and the
|
|
796
|
+
# client Number()-coerces the numeric case back on read.
|
|
797
|
+
def reactive_show_single(field, options)
|
|
798
|
+
if field.nil?
|
|
799
|
+
raise ArgumentError,
|
|
800
|
+
"reactive_show needs a field (reactive_show(:mode, equals: …)) or a compound " \
|
|
801
|
+
"connective (reactive_show(all: [...]) / any:) — got neither"
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
predicates = options.slice(*SHOW_PREDICATE_KEYS)
|
|
805
|
+
attrs = options.except(*SHOW_PREDICATE_KEYS)
|
|
806
|
+
unless predicates.size == 1
|
|
807
|
+
raise ArgumentError,
|
|
808
|
+
"reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, in:, " \
|
|
809
|
+
"or gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
|
|
813
|
+
data = { reactive_show_field: field.to_s }
|
|
814
|
+
# in: → JSON array; a numeric threshold or a literal → the value as-is.
|
|
815
|
+
# Phlex stringifies it into the flat attr; the client re-reads via
|
|
816
|
+
# getAttribute (always a string) and Number()-coerces the numeric case.
|
|
817
|
+
data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value.to_s
|
|
818
|
+
|
|
819
|
+
mix({ data: }, attrs)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# The compound reactive_show form (issue #176 part A): `all:`/`any:` fold
|
|
823
|
+
# a list of per-field terms with one fixed connective, serialized as one
|
|
824
|
+
# JSON attr (data-reactive-show). A field alongside a connective, or two
|
|
825
|
+
# connectives, is a contradiction — raise. Each term is validated through
|
|
826
|
+
# the SAME predicate normalizer, so a term's vocabulary never drifts from
|
|
827
|
+
# the single-field form.
|
|
828
|
+
def reactive_show_compound(field, connectives, options)
|
|
829
|
+
unless field.nil?
|
|
830
|
+
raise ArgumentError,
|
|
831
|
+
"reactive_show got a field AND all:/any: — the compound and single-field forms are " \
|
|
832
|
+
"mutually exclusive; use one flat binding OR a compound list, not both"
|
|
833
|
+
end
|
|
834
|
+
unless connectives.size == 1
|
|
835
|
+
raise ArgumentError,
|
|
836
|
+
"reactive_show needs exactly one of all:/any: (one fixed connective), " \
|
|
837
|
+
"got #{connectives.keys.inspect}"
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
connective, terms = connectives.first
|
|
841
|
+
# A top-level predicate alongside a connective (reactive_show(all: [...],
|
|
842
|
+
# equals: "x")) is a misuse — predicates belong INSIDE terms. Catch it
|
|
843
|
+
# loudly at render rather than leaking `equals="x"` as a bogus HTML attr
|
|
844
|
+
# (the mix at the tail treats every leftover kwarg as a literal
|
|
845
|
+
# attribute). Same default-deny posture as the single-field path.
|
|
846
|
+
if (stray = options.slice(*SHOW_PREDICATE_KEYS)).any?
|
|
847
|
+
raise ArgumentError,
|
|
848
|
+
"reactive_show #{connective}: got a top-level predicate #{stray.keys.inspect} — " \
|
|
849
|
+
"predicates belong INSIDE each term ({ field: …, #{stray.keys.first}: … }), not beside the connective"
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
attrs = options.except(*SHOW_CONNECTIVE_KEYS)
|
|
853
|
+
unless terms.is_a?(Array) && terms.any?
|
|
854
|
+
raise ArgumentError,
|
|
855
|
+
"reactive_show #{connective}: needs at least one term " \
|
|
856
|
+
"({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
normalized = terms.map { normalize_show_term(connective, it) }
|
|
860
|
+
mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Normalize + validate ONE compound term (issue #176 part A): a Hash with
|
|
864
|
+
# a :field and exactly one predicate. The predicate goes through the
|
|
865
|
+
# shared normalizer; the field is stringified into the term. Returns
|
|
866
|
+
# { "field" => name, <predicate> } so the wire is uniform with the
|
|
867
|
+
# reactive_show_targets embedded-predicate shape.
|
|
868
|
+
def normalize_show_term(connective, term)
|
|
869
|
+
context = "reactive_show #{connective}: term"
|
|
870
|
+
unless term.is_a?(Hash) && term.key?(:field)
|
|
871
|
+
raise ArgumentError, "#{context} — each term needs a field (got #{term.inspect})"
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
field = term[:field]
|
|
875
|
+
predicate = normalize_show_predicate(term.except(:field), "#{context} (field #{field.inspect})")
|
|
876
|
+
{ "field" => field.to_s }.merge(predicate)
|
|
877
|
+
end
|
|
878
|
+
|
|
552
879
|
# True when the hint declares checked: :keep — the click-bound
|
|
553
880
|
# checkbox/radio case that must SKIP the forced type="button" so the native
|
|
554
881
|
# 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
|