phlex-reactive 0.9.5 → 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 +69 -0
- data/README.md +49 -44
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +105 -39
- 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 +174 -219
- 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**
|
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)
|
|
@@ -860,30 +860,56 @@ function parseShowCompound(raw) {
|
|
|
860
860
|
return null
|
|
861
861
|
}
|
|
862
862
|
|
|
863
|
-
// Evaluate a
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
|
|
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) {
|
|
873
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) {
|
|
874
908
|
const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
|
|
875
909
|
if (!connective) return null
|
|
876
910
|
const terms = payload[connective]
|
|
877
|
-
if (terms.length === 0) return null
|
|
878
|
-
|
|
879
|
-
const results = terms.map((term) => {
|
|
880
|
-
if (!term || typeof term !== "object" || typeof term.field !== "string") return false
|
|
881
|
-
const value = fieldValue(term.field)
|
|
882
|
-
if (value === null) return false // no owned field with that name → fail-closed
|
|
883
|
-
const match = showPredicateMatches(term, value)
|
|
884
|
-
return match === true // null (malformed term) or false both fold to false
|
|
885
|
-
})
|
|
886
|
-
|
|
911
|
+
if (terms.length === 0) return null
|
|
912
|
+
const results = terms.map((term) => dnfTermMatches(term, fieldValue))
|
|
887
913
|
return connective === "all" ? results.every(Boolean) : results.some(Boolean)
|
|
888
914
|
}
|
|
889
915
|
|
|
@@ -2345,37 +2371,57 @@ export default class extends Controller {
|
|
|
2345
2371
|
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2346
2372
|
|
|
2347
2373
|
const owns = this.#ownershipFilter()
|
|
2374
|
+
const scope = this.element.getAttribute?.("data-reactive-scope") || null
|
|
2348
2375
|
const values = new Map()
|
|
2349
2376
|
// A memoized resolver shared by every binding in this pass — a field driving
|
|
2350
|
-
// several bindings (and several terms
|
|
2377
|
+
// several bindings (and several DNF terms) reads exactly once. Scope-aware:
|
|
2378
|
+
// a bare field `director` resolves as `[name="scope[director]"]` (issue #180).
|
|
2351
2379
|
const fieldValue = (name) => {
|
|
2352
|
-
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2380
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2353
2381
|
return values.get(name)
|
|
2354
2382
|
}
|
|
2355
2383
|
for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
|
|
2356
2384
|
if (!owns(el)) continue // a nested root's binding is its own controller's job
|
|
2357
2385
|
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
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)
|
|
2364
2393
|
continue
|
|
2365
2394
|
}
|
|
2366
2395
|
|
|
2396
|
+
// LEGACY flat-attr binding (pre-0.10, deploy overlap — removed in 0.11).
|
|
2367
2397
|
const name = el.getAttribute("data-reactive-show-field")
|
|
2368
2398
|
if (!name) continue
|
|
2369
2399
|
const value = fieldValue(name)
|
|
2370
2400
|
if (value === null) continue // no owned field with that name — leave it be
|
|
2371
2401
|
const match = showBindingMatches(el, value)
|
|
2372
2402
|
if (match === null) continue // malformed predicate — warned + skipped
|
|
2373
|
-
el
|
|
2403
|
+
this.#applyShowVisibility(el, match, owns, scope)
|
|
2374
2404
|
}
|
|
2375
2405
|
|
|
2376
2406
|
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2377
2407
|
// field driving both an owned binding and an outside target reads once.
|
|
2378
|
-
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
|
|
2379
2425
|
}
|
|
2380
2426
|
|
|
2381
2427
|
// Apply the declared cross-root show targets (issue #164) — the visibility
|
|
@@ -2388,19 +2434,35 @@ export default class extends Controller {
|
|
|
2388
2434
|
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2389
2435
|
// target while siblings still apply. With no map declared this is one
|
|
2390
2436
|
// getAttribute and out.
|
|
2391
|
-
#syncShowTargets(owns, values) {
|
|
2437
|
+
#syncShowTargets(owns, values, scope) {
|
|
2392
2438
|
const map = this.#parseShowTargets()
|
|
2393
2439
|
for (const [name, targets] of Object.entries(map)) {
|
|
2394
2440
|
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2395
|
-
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2441
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns, scope))
|
|
2396
2442
|
const value = values.get(name)
|
|
2397
2443
|
if (value === null) continue // no owned field with that name — leave them be
|
|
2398
|
-
|
|
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)) {
|
|
2399
2448
|
if (!guardShowTargetSelector(selector)) continue
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
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
|
|
2404
2466
|
}
|
|
2405
2467
|
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
2406
2468
|
}
|
|
@@ -2438,10 +2500,14 @@ export default class extends Controller {
|
|
|
2438
2500
|
// Rails pairs with it); a radio group reports the CHECKED radio's value (""
|
|
2439
2501
|
// when none is); anything else reports .value first-wins. Returns null when
|
|
2440
2502
|
// no owned field carries the name — the caller then leaves visibility alone.
|
|
2441
|
-
#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
|
|
2442
2508
|
let sawRadio = false
|
|
2443
2509
|
let first = null
|
|
2444
|
-
for (const el of this.element.querySelectorAll(`[name="${
|
|
2510
|
+
for (const el of this.element.querySelectorAll(`[name="${domName}"]`)) {
|
|
2445
2511
|
if (!owns(el)) continue
|
|
2446
2512
|
if (el.type === "checkbox") return el.checked ? "true" : "false"
|
|
2447
2513
|
if (el.type === "radio") {
|