phlex-reactive 0.9.4 → 0.10.0
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 +84 -0
- data/README.md +49 -44
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +202 -15
- 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/phlex/reactive/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +37 -0
- data/lib/phlex/reactive/component/helpers.rb +183 -86
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +23 -9
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +62 -50
- data/lib/phlex/reactive/test_helpers.rb +9 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42d923475e8b2eb3ea48a0ef076358a10dbfd7760c34709dc54af5910805ad01
|
|
4
|
+
data.tar.gz: a6822d8caa3e24dc863284f7bb6eafde39bc69562d4c5d14504b99e489c1e302
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 234bcb0ca3b36e91f8f53cbbb2dd31f86f03b5a1043911e13652f3b4c2d31fe882b883035c4ab8ba2e2fc8a0f0755ac6302f2bf6d04c09d93aa2d659cd69e358
|
|
7
|
+
data.tar.gz: b63a02ff87733de83ad39dbea2f9754ef62fbfc79d7cf8f1d4a98e22891dae50e215bbca11b5faecefe9a454a2b0794c3fbbf870ee4da2ca8ec025aea69185b7
|
data/CHANGELOG.md
CHANGED
|
@@ -6,8 +6,77 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`exclude:`/`visible_to:` now actually reach pgbus — actor-echo suppression works over SSE (#187).**
|
|
12
|
+
`broadcast_*_to`/`broadcast_*_to_each` passed `exclude:`/`visible_to:` as keyword
|
|
13
|
+
arguments to `Turbo::StreamsChannel.broadcast_*_to`, but turbo-rails swallows
|
|
14
|
+
unknown kwargs into its render locals — so on the pgbus transport the options were
|
|
15
|
+
**silently dropped** and `exclude: reactive_connection_id` never suppressed the
|
|
16
|
+
actor's own broadcast echo. They are now threaded through the thread-locals pgbus
|
|
17
|
+
reads (`Thread.current[:pgbus_broadcast_exclude]`/`_visible_to`), capability-gated
|
|
18
|
+
on `pgbus_streams?` so the Action Cable path is unchanged. Found by the new
|
|
19
|
+
end-to-end pgbus transport suite (below) — the prior unit doubles only proved the
|
|
20
|
+
option was *forwarded to the method*, never that it *reached pgbus*.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **CI transport verification matrix — the browser suite runs on Action Cable AND pgbus (#187).**
|
|
25
|
+
The system job is now `server × transport` (Puma/Falcon × cable/pgbus). The pgbus
|
|
26
|
+
cells add a plain `postgres:18` (pgbus vendors the PGMQ schema via its own
|
|
27
|
+
migrations — no extension image) and set `TRANSPORT=pgbus`, so the existing browser
|
|
28
|
+
suite proves the reactive round trip is transport-agnostic, and new `:pgbus`-tagged
|
|
29
|
+
specs prove real cross-tab broadcast delivery + actor-echo exclusion over live
|
|
30
|
+
Postgres SSE. `rake spec:system_matrix` runs the 2×2 locally (pgbus cells skip with
|
|
31
|
+
a note when Postgres isn't reachable); `rake pgbus:prepare_test_db` sets up the DB.
|
|
32
|
+
pgbus remains an optional, non-gemspec dependency — it is now a first-class *tested*
|
|
33
|
+
transport, not a required one.
|
|
34
|
+
|
|
9
35
|
### Changed
|
|
10
36
|
|
|
37
|
+
- **BREAKING: `reactive_show` speaks ONE conditions language — `if:`/`if_any:`/`unless:` (#180).**
|
|
38
|
+
The four accreted dialects (the positional-field predicate kwargs
|
|
39
|
+
`equals:`/`not:`/`in:`/`gte:`/`gt:`/`lte:`/`lt:`, the `all:`/`any:` term
|
|
40
|
+
arrays, and the `{ equals: … }` target predicate hashes) are **removed** and
|
|
41
|
+
replaced by a single Ruby-native language: a **Hash is an AND**, an **Array is
|
|
42
|
+
membership**, a **Range is a threshold**, `if_any:` is OR-of-AND (one level of
|
|
43
|
+
disjunction — the distributive-law killer), and `unless:` negates. Each removed
|
|
44
|
+
form raises a **guided `ArgumentError`** printing the exact rewrite. Migration:
|
|
45
|
+
|
|
46
|
+
| Before (removed) | After |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `reactive_show(:mode, not: "off")` | `reactive_show(unless: { mode: "off" })` |
|
|
49
|
+
| `reactive_show(:gift, equals: true)` | `reactive_show(if: { gift: true })` |
|
|
50
|
+
| `reactive_show(:size, in: %w[l xl])` | `reactive_show(if: { size: %w[l xl] })` |
|
|
51
|
+
| `reactive_show(:qty, gte: 10)` | `reactive_show(if: { qty: 10.. })` |
|
|
52
|
+
| `reactive_show(:amt, gt: 5000)` | `reactive_show(unless: { amt: ..5000 })` |
|
|
53
|
+
| `reactive_show(all: [{ field: :a, equals: "x" }, …])` | `reactive_show(if: { a: "x", … })` |
|
|
54
|
+
| `reactive_show(any: [{ field: :a, equals: "x" }, …])` | `reactive_show(if_any: [{ a: "x" }, …])` |
|
|
55
|
+
| `reactive_show_targets(:m, "#id" => { equals: "x" })` | `reactive_show_targets(:m, "#id" => "x")` |
|
|
56
|
+
|
|
57
|
+
New alongside it: **`reactive_values`** computes each binding's first-paint
|
|
58
|
+
`hidden:` server-side (no per-section mirror method, no flash);
|
|
59
|
+
**`reactive_scope :form`** lets bindings use bare field symbols
|
|
60
|
+
(`[name="form[field]"]` on the client); **`disable: true`** disables a hidden
|
|
61
|
+
section's own controls so a switched-away value never submits. The wire is now
|
|
62
|
+
ONE DNF shape (`data-reactive-show='{"any":[[term,…],…]}'`); the client keeps
|
|
63
|
+
legacy read arms for one minor (deploy overlap), removed in 0.11.
|
|
64
|
+
|
|
65
|
+
- **`Phlex::Reactive::ClientBindings` — a blessed client-only include (#180).** A
|
|
66
|
+
view that only shows/hides, filters, or computes client-side (no server
|
|
67
|
+
actions, no token) includes this instead of the full `Component`. It does NOT
|
|
68
|
+
pull in `Streamable` (so it never clobbers an app's own `replace`/`to_stream_*`
|
|
69
|
+
concern) or the token machinery — a token-less `reactive_root` with no `#id`
|
|
70
|
+
requirement, invisible to `phlex_reactive:doctor`. The full `Component`
|
|
71
|
+
includes it (one implementation), then layers Streamable + Identity on top.
|
|
72
|
+
|
|
73
|
+
- **Automatic token refresh — `reply.with` and `reply.streams` converge (#180).**
|
|
74
|
+
The action endpoint now appends a `reactive:token` refresh stream automatically
|
|
75
|
+
whenever a reply does not re-render the component's root, instead of prepending
|
|
76
|
+
a full self-replace that could clobber a live input. Picking `.streams` vs
|
|
77
|
+
`.with` to keep the signed token fresh is no longer a correctness decision — a
|
|
78
|
+
companion-only reply refreshes the token either way.
|
|
79
|
+
|
|
11
80
|
- **BREAKING: `verify_authorized` is ON by default (#168).** A reactive action
|
|
12
81
|
that completes **without any authorization call now raises**
|
|
13
82
|
`Phlex::Reactive::AuthorizationNotVerified` — **rolling back the transaction**
|
|
@@ -31,6 +100,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
31
100
|
|
|
32
101
|
### Added
|
|
33
102
|
|
|
103
|
+
- **Compound & numeric `reactive_show` predicates — `all:`/`any:` and
|
|
104
|
+
`gte:`/`gt:`/`lte:`/`lt:` (#176).** Value-conditional visibility now spans
|
|
105
|
+
**more than one field** and **numeric thresholds**, staying inside the
|
|
106
|
+
eval-free "declared literal predicate" contract. `all:` / `any:` fold a list of
|
|
107
|
+
per-field terms (`{ field:, equals:/not:/in:/gte:/… }`) with one fixed
|
|
108
|
+
connective — AND vs OR over the same literal vocabulary, no expression surface;
|
|
109
|
+
one flat binding replaces wrapper-div nesting and is the only way to express OR.
|
|
110
|
+
`gte:`/`gt:`/`lte:`/`lt:` compare `Number(value)` against a literal number baked
|
|
111
|
+
into the binding (the RHS must be a real `Numeric` — a typo fails at render); a
|
|
112
|
+
non-numeric field value is `NaN` → hidden, the safe reveal-on-threshold default.
|
|
113
|
+
Numeric predicates work standalone, as a compound term, and inside a
|
|
114
|
+
`reactive_show_targets` map. Malformed terms fold **false** (fail-closed:
|
|
115
|
+
default-deny). The single-field `reactive_show(:field, equals:)` form is
|
|
116
|
+
unchanged; the additions are backwards compatible.
|
|
117
|
+
|
|
34
118
|
- **Installable Claude debugging skill + `rails g phlex:reactive:claude` (#168).**
|
|
35
119
|
The gem ships a `phlex-reactive-debugging` skill (the doctor → inventory → find
|
|
36
120
|
→ browser `report()` → MCP workflow + a failure table) under `lib/`, and the
|
data/README.md
CHANGED
|
@@ -387,8 +387,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
387
387
|
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
|
|
388
388
|
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
389
389
|
| `reactive_text(:name, initial)` | Mirror a compute output (or a declared input) into a **text node** — a live preview heading, a character counter, `"Hello, {name}"` — via `textContent` (XSS-safe). The text sibling of `reactive_field`; carries no `name`, so it's never POSTed. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
|
|
390
|
-
| `reactive_show(
|
|
391
|
-
| `reactive_show_targets(:field, "#id" =>
|
|
390
|
+
| `reactive_show(if:/if_any:/unless:)` | **Value-conditional visibility** (the `x-show`/`data-show` case): spread onto the element to show/hide — it toggles `hidden` from the fields' **current values**, client-only, zero round trip. One conditions language: a **Hash is an AND**, an **Array is membership**, a **Range is a threshold**, `if_any:` is OR-of-AND, `unless:` negates. `reactive_values` computes first paint; `disable:` disables a hidden section's controls. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
391
|
+
| `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
392
392
|
| `reactive_filter(input:, option:, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root — typing in the named input shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. Optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
|
|
393
393
|
| `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
|
|
394
394
|
| `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric. |
|
|
@@ -892,52 +892,58 @@ free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
|
|
|
892
892
|
|
|
893
893
|
`on_client` covers the *unconditional* client-only interactions; the last gap
|
|
894
894
|
was **show/hide from a form field's current value** — the Alpine `x-show` /
|
|
895
|
-
Datastar `data-show` / Livewire `wire:show` case
|
|
896
|
-
|
|
897
|
-
`
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
toggles the `hidden` attribute from the field's current value on every
|
|
901
|
-
`input`/`change`. Client-only, **no token, no POST, ever**:
|
|
895
|
+
Datastar `data-show` / Livewire `wire:show` case. `reactive_show` closes it with
|
|
896
|
+
ONE Ruby-native conditions language: spread it onto the element to show/hide and
|
|
897
|
+
declare an `if:` / `if_any:` / `unless:` condition with `where`-style values —
|
|
898
|
+
the generic controller toggles the `hidden` attribute from the fields' current
|
|
899
|
+
values on every `input`/`change`. Client-only, **no token, no POST, ever**:
|
|
902
900
|
|
|
903
901
|
```ruby
|
|
904
902
|
def view_template
|
|
905
903
|
div(**reactive_root) do
|
|
906
904
|
select(name: "mode") { shipping_options }
|
|
907
|
-
div(**reactive_show(:mode
|
|
905
|
+
div(**reactive_show(unless: { mode: "off" })) { shipping_details }
|
|
908
906
|
|
|
909
907
|
input(type: "checkbox", name: "gift")
|
|
910
|
-
div(**reactive_show(:gift
|
|
908
|
+
div(**reactive_show(if: { gift: true })) { gift_message_field }
|
|
911
909
|
|
|
912
910
|
select(name: "size") { size_options }
|
|
913
|
-
div(**reactive_show(:size
|
|
911
|
+
div(**reactive_show(if: { size: %w[l xl] })) { surcharge_note } # membership
|
|
912
|
+
|
|
913
|
+
input(type: "number", name: "quantity")
|
|
914
|
+
div(**reactive_show(if: { quantity: 10.. })) { bulk_note } # threshold
|
|
914
915
|
end
|
|
915
916
|
end
|
|
916
917
|
```
|
|
917
918
|
|
|
918
|
-
- **
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
server
|
|
932
|
-
|
|
933
|
-
-
|
|
934
|
-
|
|
935
|
-
|
|
919
|
+
- **The value language**: a **Hash is an AND** (multiple keys ANDed), an
|
|
920
|
+
**Array is membership**, a **Range is a threshold** (`10..` ≥ 10, `..10` ≤ 10,
|
|
921
|
+
`...10` < 10, `10..20` between), `true`/`false` compare a checkbox's checked
|
|
922
|
+
state, `nil` matches blank. `unless:` **negates** and composes with `if:`.
|
|
923
|
+
Never an expression — every term is a declared literal, so there is no eval
|
|
924
|
+
surface. A blank/non-numeric value fails a numeric term **closed** (hidden).
|
|
925
|
+
- **OR-of-AND** — `if_any:` takes an array of AND-hashes (`if_any: [{ director:
|
|
926
|
+
true }, { shareholder: true, role: "individual" }]`), so
|
|
927
|
+
`director || (shareholder && individual)` is **one flat binding**, no nested
|
|
928
|
+
wrapper divs, no hand-applied distributive law.
|
|
929
|
+
- **First paint is computed for you** — declare `reactive_values` once
|
|
930
|
+
(`def reactive_values = { mode: @order.mode, gift: @order.gift? }`) and every
|
|
931
|
+
binding whose fields are all provided renders the correct initial `hidden:`
|
|
932
|
+
server-side. No per-section mirror method, no flash. An explicit `hidden:`
|
|
933
|
+
always wins; a per-call `values:` override merges over `reactive_values`.
|
|
934
|
+
- **`reactive_scope :form`** lets bindings and `reactive_values` use bare
|
|
935
|
+
symbols while the client resolves `[name="form[field]"]`.
|
|
936
|
+
- **`disable: true`** disables a hidden section's own controls so a
|
|
937
|
+
switched-away value never submits (the stale-value fix).
|
|
938
|
+
- **Field reads follow the collection rules**: a checkbox compares its *checked*
|
|
939
|
+
state; a radio group reads the **checked** radio's value; everything else
|
|
940
|
+
reads `.value`. Ownership is the usual rule — a nested reactive component's
|
|
941
|
+
fields and bindings belong to the nested component.
|
|
936
942
|
- **Composes with computes**: a `reactive_compute` output write dispatches a
|
|
937
943
|
real `input` event, so a *derived* value can drive visibility too.
|
|
938
|
-
- Presentational only, strictly weaker than the js ops: it reads
|
|
939
|
-
|
|
940
|
-
attribute freedom. Cross-root writes take the
|
|
944
|
+
- Presentational only, strictly weaker than the js ops: it reads owned fields
|
|
945
|
+
and toggles `hidden` (+ optionally `disabled`) on owned elements — no
|
|
946
|
+
`innerHTML`, no attribute freedom. Cross-root writes take the escape below.
|
|
941
947
|
|
|
942
948
|
**Cross-root targets (`reactive_show_targets`).** A plain `reactive_show` is
|
|
943
949
|
root-scoped by design — but "a mode selector reveals dependent sections
|
|
@@ -946,13 +952,13 @@ sidebar note) routinely puts the dependents **outside** the control's root.
|
|
|
946
952
|
`reactive_show_targets` is the declared escape, the visibility parallel of the
|
|
947
953
|
[cross-root text mirror](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root):
|
|
948
954
|
the component that **owns** the field declares which outside ids it governs,
|
|
949
|
-
spread on the **root
|
|
955
|
+
spread on the **root**, using the same `where`-style values:
|
|
950
956
|
|
|
951
957
|
```ruby
|
|
952
958
|
div(**mix(reactive_root, reactive_show_targets(:mode,
|
|
953
|
-
"#advanced-tab" =>
|
|
954
|
-
"#advanced-panel" =>
|
|
955
|
-
"#
|
|
959
|
+
"#advanced-tab" => "advanced", # equals
|
|
960
|
+
"#advanced-panel" => "advanced",
|
|
961
|
+
"#premium-note" => %w[gold platinum]))) do # membership
|
|
956
962
|
select(name: "mode") { mode_options }
|
|
957
963
|
# …
|
|
958
964
|
end
|
|
@@ -961,11 +967,10 @@ end
|
|
|
961
967
|
Same posture as `mirror:`: **opt-in and declared, never implicit** — a plain
|
|
962
968
|
`reactive_show` stays root-isolated; targets are **single id selectors only**
|
|
963
969
|
(a class/compound selector raises at render AND is warn-and-skipped by the
|
|
964
|
-
client — two-sided default-deny);
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
unrendered tab pane is fine.
|
|
970
|
+
client — two-sided default-deny); values use the same vocabulary; and the
|
|
971
|
+
toggle is `hidden` only. The field read stays **owned** — you can only drive
|
|
972
|
+
outside visibility from a field the declaring root owns. A target id not on the
|
|
973
|
+
page is silently skipped, so a target inside an unrendered tab pane is fine.
|
|
969
974
|
|
|
970
975
|
**One call per root.** Phlex `mix` space-joins duplicate string `data:`
|
|
971
976
|
values, so a *second* `reactive_show_targets` call on the same root would
|
|
@@ -973,8 +978,8 @@ concatenate two JSON payloads into an unparseable attribute (the client warns
|
|
|
973
978
|
and ignores it). Several fields go in **one call** via the hash form:
|
|
974
979
|
|
|
975
980
|
```ruby
|
|
976
|
-
reactive_show_targets(mode: { "#advanced-tab" =>
|
|
977
|
-
kind: { "#premium-note" =>
|
|
981
|
+
reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
982
|
+
kind: { "#premium-note" => %w[gold platinum] })
|
|
978
983
|
```
|
|
979
984
|
|
|
980
985
|
### Client-side computes (`reactive_compute` + `reactive_text`)
|
|
@@ -328,8 +328,21 @@ module Phlex
|
|
|
328
328
|
# ground-truth flag (rx_carries_token?), a raw string from a substring
|
|
329
329
|
# scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
|
|
330
330
|
# this guard would regress update/morph of self on an aliased id.
|
|
331
|
+
#
|
|
332
|
+
# A self-render reply (Response.replace/update/morph — subject_component
|
|
333
|
+
# set) whose streams somehow carry no token gets the full self-replace
|
|
334
|
+
# (defensive, unchanged). A companion-only reply.with (NO subject, NO
|
|
335
|
+
# token_component) that doesn't re-render the root gets a token-ONLY
|
|
336
|
+
# refresh instead — issue #180 automatic token refresh: reply.with and
|
|
337
|
+
# reply.streams converge, the author never picks a verb to keep the token
|
|
338
|
+
# fresh, and a live input is never clobbered by a forced replace.
|
|
331
339
|
elsif result.render_self? && streams.none? { stream_carries_token?(it) }
|
|
332
|
-
streams =
|
|
340
|
+
streams =
|
|
341
|
+
if result.subject_component
|
|
342
|
+
[component.to_stream_replace, *streams]
|
|
343
|
+
else
|
|
344
|
+
[*streams, component.to_stream_token]
|
|
345
|
+
end
|
|
333
346
|
end
|
|
334
347
|
|
|
335
348
|
append_deferred_streams(streams, result)
|
|
@@ -766,10 +766,59 @@ function showBindingMatches(el, value) {
|
|
|
766
766
|
console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
|
|
767
767
|
return null
|
|
768
768
|
}
|
|
769
|
+
// Numeric threshold predicates (issue #176 part B): gte/gt/lte/lt read the
|
|
770
|
+
// literal off its own flat attr and compare Number(value) against it. Any
|
|
771
|
+
// present numeric attr decides the binding — a non-numeric field value (NaN)
|
|
772
|
+
// is false (hidden), and a non-numeric LITERAL warn-skips (null).
|
|
773
|
+
for (const key of SHOW_NUMERIC_KEYS) {
|
|
774
|
+
const raw = el.getAttribute(`data-reactive-show-${key}`)
|
|
775
|
+
if (raw !== null) return numericPredicateMatches(key, raw, value)
|
|
776
|
+
}
|
|
769
777
|
console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
|
|
770
778
|
return null
|
|
771
779
|
}
|
|
772
780
|
|
|
781
|
+
// The numeric threshold keys (issue #176 part B) — the client half of the Ruby
|
|
782
|
+
// SHOW_NUMERIC_KEYS. Order-independent; the evaluator reads the one that's
|
|
783
|
+
// present. Each coerces BOTH sides to Number and compares.
|
|
784
|
+
const SHOW_NUMERIC_KEYS = ["gte", "gt", "lte", "lt"]
|
|
785
|
+
|
|
786
|
+
// Evaluate one numeric threshold predicate against a field value. Returns
|
|
787
|
+
// true/false for a decidable comparison, or null when the LITERAL itself is
|
|
788
|
+
// non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric
|
|
789
|
+
// FIELD value (empty/blank/garbage) is treated as NaN → false: the
|
|
790
|
+
// reveal-on-threshold notice stays hidden, the safe default. Shared by the
|
|
791
|
+
// owned-binding evaluator (raw string literal off an attr) and the
|
|
792
|
+
// cross-root/compound evaluator (a literal that arrived as a JSON number or
|
|
793
|
+
// string).
|
|
794
|
+
function numericPredicateMatches(key, literal, value) {
|
|
795
|
+
const rhs = Number(literal)
|
|
796
|
+
if (Number.isNaN(rhs)) {
|
|
797
|
+
console.warn(`[phlex-reactive] reactive_show ${key}: needs a numeric literal, got ${JSON.stringify(literal)} — skipped`)
|
|
798
|
+
return null
|
|
799
|
+
}
|
|
800
|
+
// A blank/whitespace field value must fail closed. Number("") and
|
|
801
|
+
// Number(" ") are 0 (NOT NaN), so a bare Number()+isNaN check would wrongly
|
|
802
|
+
// reveal a `lte:`/`lt:`/`gte: 0` binding on an EMPTY field. Force the
|
|
803
|
+
// empty/blank case to NaN so the "blank → hidden" contract holds for every
|
|
804
|
+
// operator, not just the ones where 0 happens to fail the comparison.
|
|
805
|
+
const trimmed = value == null ? "" : String(value).trim()
|
|
806
|
+
const n = trimmed === "" ? NaN : Number(trimmed)
|
|
807
|
+
if (Number.isNaN(n)) return false
|
|
808
|
+
switch (key) {
|
|
809
|
+
case "gte":
|
|
810
|
+
return n >= rhs
|
|
811
|
+
case "gt":
|
|
812
|
+
return n > rhs
|
|
813
|
+
case "lte":
|
|
814
|
+
return n <= rhs
|
|
815
|
+
case "lt":
|
|
816
|
+
return n < rhs
|
|
817
|
+
default:
|
|
818
|
+
return null
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
773
822
|
// Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
|
|
774
823
|
// reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
|
|
775
824
|
// unlike showBindingMatches there are no attrs to read or re-parse. The same
|
|
@@ -781,9 +830,89 @@ function showPredicateMatches(pred, value) {
|
|
|
781
830
|
if (typeof pred.equals === "string") return value === pred.equals
|
|
782
831
|
if (typeof pred.not === "string") return value !== pred.not
|
|
783
832
|
if (Array.isArray(pred.in)) return pred.in.includes(value)
|
|
833
|
+
// Numeric threshold predicates (issue #176 part B): the literal arrives as a
|
|
834
|
+
// JSON number (or a numeric string) embedded in the predicate object — one
|
|
835
|
+
// shared numericPredicateMatches with the owned-binding evaluator.
|
|
836
|
+
for (const key of SHOW_NUMERIC_KEYS) {
|
|
837
|
+
if (key in pred) return numericPredicateMatches(key, pred[key], value)
|
|
838
|
+
}
|
|
784
839
|
return null
|
|
785
840
|
}
|
|
786
841
|
|
|
842
|
+
// The selector matching every OWNED-element show binding: single-field
|
|
843
|
+
// (data-reactive-show-field, issue #161) OR compound all:/any:
|
|
844
|
+
// (data-reactive-show, issue #176). Both the connect() gate and the sync walk
|
|
845
|
+
// use it so a compound-only root still enables the sync.
|
|
846
|
+
const SHOW_BINDING_SELECTOR = "[data-reactive-show-field], [data-reactive-show]"
|
|
847
|
+
|
|
848
|
+
// Parse a compound show binding's JSON payload (issue #176 part A). Malformed
|
|
849
|
+
// JSON degrades to null WITH a warn — a bad binding must never throw or blank
|
|
850
|
+
// the page (client-side default-deny), but a collision (two bindings' JSON
|
|
851
|
+
// mix-joined) is worth surfacing.
|
|
852
|
+
function parseShowCompound(raw) {
|
|
853
|
+
try {
|
|
854
|
+
const parsed = JSON.parse(raw)
|
|
855
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
|
|
856
|
+
} catch {
|
|
857
|
+
// fall through to the warn
|
|
858
|
+
}
|
|
859
|
+
console.warn(`[phlex-reactive] malformed compound reactive_show payload ${JSON.stringify(raw)} — skipped`)
|
|
860
|
+
return null
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Evaluate one DNF TERM against a resolved field value (issue #180). A missing
|
|
864
|
+
// field (null) or a malformed/unknown predicate folds to FALSE — fail-closed
|
|
865
|
+
// (default-deny): a broken AND term can't pass, a broken OR term can't reveal.
|
|
866
|
+
function dnfTermMatches(term, fieldValue) {
|
|
867
|
+
if (!term || typeof term !== "object" || typeof term.field !== "string") return false
|
|
868
|
+
// An absent owned field reads as "" — identical to the server evaluator
|
|
869
|
+
// (ShowConditions.match? treats a missing field as blank). This keeps the
|
|
870
|
+
// Ruby first-paint and the client live-toggle in exact agreement (the shared
|
|
871
|
+
// fixture proves it). A malformed predicate still folds to false.
|
|
872
|
+
const value = fieldValue(term.field) ?? ""
|
|
873
|
+
return showPredicateMatches(term, value) === true
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Evaluate a DNF show payload (issue #180): { any: [group, …] } where each
|
|
877
|
+
// GROUP is an array of terms (terms AND within a group, groups OR). Returns
|
|
878
|
+
// true/false for a decidable payload, or null for a malformed one (no groups)
|
|
879
|
+
// so the caller warn-skips and leaves visibility alone. This is the ONE shape
|
|
880
|
+
// the 0.10 wire emits; showPayloadMatches routes the legacy shapes here or to
|
|
881
|
+
// the compatibility arm below.
|
|
882
|
+
function anyOfAllsMatches(groups, fieldValue) {
|
|
883
|
+
if (!Array.isArray(groups) || groups.length === 0) return null
|
|
884
|
+
// groups OR; within a group, terms AND (an empty group can't decide → false).
|
|
885
|
+
return groups.some((group) => Array.isArray(group) && group.length > 0 &&
|
|
886
|
+
group.every((term) => dnfTermMatches(term, fieldValue)))
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Route a parsed data-reactive-show payload to the right evaluator. The 0.10
|
|
890
|
+
// wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
|
|
891
|
+
// still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
|
|
892
|
+
// shape { all: [term,…] } / { any: [term,…] } where the values are flat TERM
|
|
893
|
+
// OBJECTS, not arrays. The nesting distinguishes them: DNF's any[0] is an Array.
|
|
894
|
+
// DELETE the legacy arm in 0.11.
|
|
895
|
+
function showPayloadMatches(payload, fieldValue) {
|
|
896
|
+
if (!payload || typeof payload !== "object") return null
|
|
897
|
+
const any = payload.any
|
|
898
|
+
if (Array.isArray(any) && (any.length === 0 || Array.isArray(any[0]))) {
|
|
899
|
+
return anyOfAllsMatches(any, fieldValue)
|
|
900
|
+
}
|
|
901
|
+
return legacyCompoundShowMatches(payload, fieldValue)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// LEGACY (0.9.5, deploy-overlap only — DELETE in 0.11): the flat all:/any:
|
|
905
|
+
// compound fold, where terms are objects (not groups). Preserved so a morph of
|
|
906
|
+
// stale pre-0.10 HTML doesn't go dead.
|
|
907
|
+
function legacyCompoundShowMatches(payload, fieldValue) {
|
|
908
|
+
const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
|
|
909
|
+
if (!connective) return null
|
|
910
|
+
const terms = payload[connective]
|
|
911
|
+
if (terms.length === 0) return null
|
|
912
|
+
const results = terms.map((term) => dnfTermMatches(term, fieldValue))
|
|
913
|
+
return connective === "all" ? results.every(Boolean) : results.some(Boolean)
|
|
914
|
+
}
|
|
915
|
+
|
|
787
916
|
// A cross-root show target must be a single ID selector (issue #164) — the
|
|
788
917
|
// SAME shape the #159 mirror enforces (one shared regex), with its own warn so
|
|
789
918
|
// a refused show target is distinguishable in the console. The client half of
|
|
@@ -2222,7 +2351,10 @@ export default class extends Controller {
|
|
|
2222
2351
|
// getAttribute, cheaper than the binding walk.
|
|
2223
2352
|
#showSyncEnabled() {
|
|
2224
2353
|
if (this.element.getAttribute?.("data-reactive-show-targets")) return true
|
|
2225
|
-
|
|
2354
|
+
// Single-field bindings carry -field; compound all:/any: bindings (issue
|
|
2355
|
+
// #176) carry data-reactive-show and have NO single controlling field, so
|
|
2356
|
+
// both selectors gate the sync.
|
|
2357
|
+
const nodes = this.element.querySelectorAll?.(SHOW_BINDING_SELECTOR) ?? []
|
|
2226
2358
|
for (const el of nodes) if (this.#ownsField(el)) return true
|
|
2227
2359
|
return false
|
|
2228
2360
|
}
|
|
@@ -2239,22 +2371,57 @@ export default class extends Controller {
|
|
|
2239
2371
|
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2240
2372
|
|
|
2241
2373
|
const owns = this.#ownershipFilter()
|
|
2374
|
+
const scope = this.element.getAttribute?.("data-reactive-scope") || null
|
|
2242
2375
|
const values = new Map()
|
|
2243
|
-
|
|
2376
|
+
// A memoized resolver shared by every binding in this pass — a field driving
|
|
2377
|
+
// several bindings (and several DNF terms) reads exactly once. Scope-aware:
|
|
2378
|
+
// a bare field `director` resolves as `[name="scope[director]"]` (issue #180).
|
|
2379
|
+
const fieldValue = (name) => {
|
|
2380
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2381
|
+
return values.get(name)
|
|
2382
|
+
}
|
|
2383
|
+
for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
|
|
2244
2384
|
if (!owns(el)) continue // a nested root's binding is its own controller's job
|
|
2385
|
+
|
|
2386
|
+
// The 0.10 DNF payload (issue #180): data-reactive-show carries
|
|
2387
|
+
// { any: [ [term,…], … ] }. The legacy flat-attr and 0.9.5-compound read
|
|
2388
|
+
// arms live in showPayloadMatches/showBindingMatches for deploy overlap.
|
|
2389
|
+
const payloadRaw = el.getAttribute("data-reactive-show")
|
|
2390
|
+
if (payloadRaw !== null) {
|
|
2391
|
+
const match = showPayloadMatches(parseShowCompound(payloadRaw), fieldValue)
|
|
2392
|
+
if (match !== null) this.#applyShowVisibility(el, match, owns, scope)
|
|
2393
|
+
continue
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// LEGACY flat-attr binding (pre-0.10, deploy overlap — removed in 0.11).
|
|
2245
2397
|
const name = el.getAttribute("data-reactive-show-field")
|
|
2246
2398
|
if (!name) continue
|
|
2247
|
-
|
|
2248
|
-
const value = values.get(name)
|
|
2399
|
+
const value = fieldValue(name)
|
|
2249
2400
|
if (value === null) continue // no owned field with that name — leave it be
|
|
2250
2401
|
const match = showBindingMatches(el, value)
|
|
2251
2402
|
if (match === null) continue // malformed predicate — warned + skipped
|
|
2252
|
-
el
|
|
2403
|
+
this.#applyShowVisibility(el, match, owns, scope)
|
|
2253
2404
|
}
|
|
2254
2405
|
|
|
2255
2406
|
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2256
2407
|
// field driving both an owned binding and an outside target reads once.
|
|
2257
|
-
this.#syncShowTargets(owns, values)
|
|
2408
|
+
this.#syncShowTargets(owns, values, scope)
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
|
|
2412
|
+
// the `disabled` of every owned named control inside it) from a match result
|
|
2413
|
+
// (issue #180). Disabling a hidden section's controls stops them submitting —
|
|
2414
|
+
// the stale-value fix. A visible section re-enables them. Controls a nested
|
|
2415
|
+
// reactive root owns are left alone (#15 ownership).
|
|
2416
|
+
#applyShowVisibility(el, match, owns, scope) {
|
|
2417
|
+
el.hidden = !match
|
|
2418
|
+
if (el.getAttribute("data-reactive-show-disable") !== "true") return
|
|
2419
|
+
if (typeof el.querySelectorAll !== "function") return
|
|
2420
|
+
for (const control of el.querySelectorAll("input[name], select[name], textarea[name]")) {
|
|
2421
|
+
if (owns(control)) control.disabled = !match
|
|
2422
|
+
}
|
|
2423
|
+
// The element itself may be a named control (a bare field with a binding).
|
|
2424
|
+
if (el.name && owns(el)) el.disabled = !match
|
|
2258
2425
|
}
|
|
2259
2426
|
|
|
2260
2427
|
// Apply the declared cross-root show targets (issue #164) — the visibility
|
|
@@ -2267,19 +2434,35 @@ export default class extends Controller {
|
|
|
2267
2434
|
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2268
2435
|
// target while siblings still apply. With no map declared this is one
|
|
2269
2436
|
// getAttribute and out.
|
|
2270
|
-
#syncShowTargets(owns, values) {
|
|
2437
|
+
#syncShowTargets(owns, values, scope) {
|
|
2271
2438
|
const map = this.#parseShowTargets()
|
|
2272
2439
|
for (const [name, targets] of Object.entries(map)) {
|
|
2273
2440
|
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2274
|
-
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2441
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2275
2442
|
const value = values.get(name)
|
|
2276
2443
|
if (value === null) continue // no owned field with that name — leave them be
|
|
2277
|
-
|
|
2444
|
+
// Every target's terms share this one field, so a constant resolver folds
|
|
2445
|
+
// the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
|
|
2446
|
+
const resolve = () => value
|
|
2447
|
+
for (const [selector, group] of Object.entries(targets)) {
|
|
2278
2448
|
if (!guardShowTargetSelector(selector)) continue
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2449
|
+
// 0.10 wire: the value is a DNF GROUP (an array of terms, ANDed).
|
|
2450
|
+
// LEGACY (0.9.5, deploy overlap — DELETE in 0.11): a flat predicate
|
|
2451
|
+
// OBJECT ({ equals/not/in/gte… }) routed through showPredicateMatches.
|
|
2452
|
+
let match
|
|
2453
|
+
if (Array.isArray(group)) {
|
|
2454
|
+
if (group.length === 0) {
|
|
2455
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets group for ${selector} — skipped`)
|
|
2456
|
+
continue
|
|
2457
|
+
}
|
|
2458
|
+
match = group.every((term) => dnfTermMatches(term, resolve))
|
|
2459
|
+
} else {
|
|
2460
|
+
const legacy = showPredicateMatches(group, value)
|
|
2461
|
+
if (legacy === null) {
|
|
2462
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
|
|
2463
|
+
continue
|
|
2464
|
+
}
|
|
2465
|
+
match = legacy
|
|
2283
2466
|
}
|
|
2284
2467
|
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
2285
2468
|
}
|
|
@@ -2317,10 +2500,14 @@ export default class extends Controller {
|
|
|
2317
2500
|
// Rails pairs with it); a radio group reports the CHECKED radio's value (""
|
|
2318
2501
|
// when none is); anything else reports .value first-wins. Returns null when
|
|
2319
2502
|
// no owned field carries the name — the caller then leaves visibility alone.
|
|
2320
|
-
#showFieldValue(name, owns) {
|
|
2503
|
+
#showFieldValue(name, owns, scope) {
|
|
2504
|
+
// Scope (issue #180): a bare field `director` under `data-reactive-scope=
|
|
2505
|
+
// "form"` resolves as `[name="form[director]"]`. A name already carrying a
|
|
2506
|
+
// bracket (a raw wire name the author passed) is used verbatim.
|
|
2507
|
+
const domName = scope && !name.includes("[") ? `${scope}[${name}]` : name
|
|
2321
2508
|
let sawRadio = false
|
|
2322
2509
|
let first = null
|
|
2323
|
-
for (const el of this.element.querySelectorAll(`[name="${
|
|
2510
|
+
for (const el of this.element.querySelectorAll(`[name="${domName}"]`)) {
|
|
2324
2511
|
if (!owns(el)) continue
|
|
2325
2512
|
if (el.type === "checkbox") return el.checked ? "true" : "false"
|
|
2326
2513
|
if (el.type === "radio") {
|