phlex-reactive 0.11.5 → 0.11.7
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 +51 -0
- data/README.md +79 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +282 -6
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +133 -17
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce274b76a614b8510a9fc365e717d0b5391b815743a9cd0e9f3ffa716428c0ec
|
|
4
|
+
data.tar.gz: 9be84dd96ad24ef556935b8b68a60ecdbf48fabe902e1541584d6af6f22e17cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e87ae21d988550c315b9ac27a721d5e65b7fdf354ddbaa487cd0e2db7e062ad854198f0a6893ab8e9d0531b6ff698829f7421bd1d8a4fbd3c533992a4485a51
|
|
7
|
+
data.tar.gz: 107d841025bc3d6d2fecb4b30027e417607773edd12c45503a5615ecee40d9cd5994d42c5ddd40a3ec53c354fa49ef482953844b2ee2ee01311e27c0bbf987c2
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,57 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Fill-then-add for draft nested rows — `reactive_nested_add(:assoc, from:, clear:)` (#208).**
|
|
12
|
+
The draft-rows primitive's add is *inline-edit* — it clones the template and
|
|
13
|
+
focuses the new row's first field, so you type INTO the row. A common
|
|
14
|
+
alternative puts the add controls OUTSIDE the row (a preset `<select>`, a
|
|
15
|
+
typeahead, plain inputs) and "Add" **snapshots** those values into a new row,
|
|
16
|
+
then clears them for the next entry. `from: { row_field => "#source-selector" }`
|
|
17
|
+
seeds each cloned-row field from its source control's current value (matching
|
|
18
|
+
the field by the same trailing-bracket-segment key inference JSON mode uses),
|
|
19
|
+
keeps focus on the sources rather than stealing it into the row, and `clear:
|
|
20
|
+
true` resets the sources (each via the set-value + dispatch contract, so dirty
|
|
21
|
+
tracking and compute observe the reset). `from:` values are raw CSS selectors
|
|
22
|
+
resolved within the reactive root (#15 ownership); an unresolved source or an
|
|
23
|
+
unmatched row-field key is silently skipped (the row still adds). It composes
|
|
24
|
+
with BOTH wire modes: the seeded values ride the renumbered `_attributes` names
|
|
25
|
+
on submit (`:attributes`) and the end-of-add JSON sync serializes them
|
|
26
|
+
(`as: :json`), with no extra wiring. The no-`from:` inline-edit default is
|
|
27
|
+
unchanged. See the "Draft rows for a new parent" README section and the
|
|
28
|
+
`/docs/example-draft-rows` page.
|
|
29
|
+
|
|
30
|
+
- **Multi-field cross-root targets — `reactive_show_targets` speaks the full
|
|
31
|
+
conditions language (#209).** A `"#id"` KEY now takes an `if:`/`if_any:`/
|
|
32
|
+
`unless:` conditions Hash, so an OUTSIDE element can show/hide from a
|
|
33
|
+
COMBINATION of owned fields — `reactive_show_targets("#trade-warning" =>
|
|
34
|
+
{ if: { type: "trade", price: ..0 } })` — the last case that forced a
|
|
35
|
+
bespoke two-field JS listener on otherwise-declarative forms. The client
|
|
36
|
+
folds the target's DNF payload with the SAME per-term owned-field reads as
|
|
37
|
+
an in-root `reactive_show` (missing owned field reads blank, fail-closed);
|
|
38
|
+
a target whose referenced fields are ALL unowned is left alone (the
|
|
39
|
+
single-field skip, generalized). Target-keyed and field-keyed entries mix
|
|
40
|
+
in the ONE call per root (a field name may never start with `#`, so the
|
|
41
|
+
key routes unambiguously); existing field-keyed calls emit a byte-identical
|
|
42
|
+
wire. Declare-time validation stays loud: a bare value, unknown keys, or an
|
|
43
|
+
empty conditions Hash under a `"#id"` key raises a guided `ArgumentError`.
|
|
44
|
+
|
|
45
|
+
- **JSON mode for draft nested rows — `reactive_nested_list(:assoc, as: :json)` (#208).**
|
|
46
|
+
The draft-rows primitive (#208) posts standard Rails
|
|
47
|
+
`accepts_nested_attributes_for` names. But many apps persist a "new parent +
|
|
48
|
+
child rows" form by serializing the rows into ONE hidden field and
|
|
49
|
+
`JSON.parse`-ing it in the controller — adopting the primitive meant rewiring
|
|
50
|
+
that persistence path. `as: :json` removes the choice: the list keeps the same
|
|
51
|
+
`<template>` row, `nested_field_name`, and add/remove triggers, but the generic
|
|
52
|
+
controller now mirrors the surviving rows into a single hidden field as a JSON
|
|
53
|
+
array on every add / remove / keystroke (the set-value + dispatch contract, so
|
|
54
|
+
dirty tracking and compute still see it), inferring each JSON key from the
|
|
55
|
+
trailing bracket segment of a row input's name
|
|
56
|
+
(`order[line_items_attributes][3][quantity]` → `"quantity"`). A removed row
|
|
57
|
+
leaves the array (an absent row IS the removal — JSON has no `_destroy`
|
|
58
|
+
marker). The default (`:attributes`) is unchanged, so existing draft forms are
|
|
59
|
+
byte-identical. See the "Draft rows for a new parent" README section and the
|
|
60
|
+
`/docs/example-draft-rows` page.
|
|
61
|
+
|
|
11
62
|
- **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
|
|
12
63
|
already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
|
|
13
64
|
every reactive action still POSTs to the same endpoint, so an APM rolled all
|
data/README.md
CHANGED
|
@@ -387,7 +387,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
387
387
|
| `reactive_field(:param, **attrs)` | The attribute hash that binds a control to an action param (no magic `name:`) — spread onto any control: `input(**reactive_field(:value, value: @record.name))`, `select(**reactive_field(:status)) { … }`. |
|
|
388
388
|
| `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). |
|
|
389
389
|
| `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). |
|
|
390
|
-
| `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). |
|
|
390
|
+
| `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..`); a `"#id"` **key** takes a full conditions Hash for a **multi-field** predicate (`"#warn" => { if: { type: "trade", price: ..0 } }`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
391
391
|
| `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; 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). |
|
|
392
392
|
| `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. |
|
|
393
393
|
| `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). See [Tag-chip input](#tag-chip-input-reactive_tags). |
|
|
@@ -396,8 +396,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
396
396
|
| `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
|
|
397
397
|
| `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
|
|
398
398
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
399
|
-
| `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
400
|
-
| `reactive_nested_add(:assoc)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). |
|
|
399
|
+
| `reactive_nested_list(:assoc, as: :attributes \| :json)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). `as: :json` (default `:attributes`) serializes the rows into ONE hidden JSON field instead of posting `accepts_nested_attributes_for` names — for an app whose controller `JSON.parse`s a serialized param. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
400
|
+
| `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). **Fill-then-add**: `from: { row_field => "#source-selector" }` seeds the new row from add controls that live OUTSIDE it (a preset select, a typeahead), and `clear: true` resets them — composes with `as: :attributes` AND `as: :json`. See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
401
401
|
| `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
|
|
402
402
|
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
|
|
403
403
|
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
@@ -1019,6 +1019,23 @@ reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
|
|
|
1019
1019
|
kind: { "#premium-note" => %w[gold platinum] })
|
|
1020
1020
|
```
|
|
1021
1021
|
|
|
1022
|
+
**Multi-field targets.** A `"#id"` *key* takes a full `if:`/`if_any:`/`unless:`
|
|
1023
|
+
conditions Hash — the same language `reactive_show` speaks — so a cross-root
|
|
1024
|
+
target can read a **combination** of owned fields (the case that used to force
|
|
1025
|
+
a bespoke two-field JS listener):
|
|
1026
|
+
|
|
1027
|
+
```ruby
|
|
1028
|
+
reactive_show_targets(
|
|
1029
|
+
"#trade-warning" => { if: { type: "trade", price: ..0 } }, # type == "trade" AND price <= 0
|
|
1030
|
+
mode: { "#advanced-tab" => "advanced" } # field-keyed entries mix in the ONE call
|
|
1031
|
+
)
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
The fold is identical to an in-root `reactive_show` (each term reads its own
|
|
1035
|
+
field; a missing owned field reads as blank — fail-closed). Every referenced
|
|
1036
|
+
field must be owned by the declaring root; a target whose fields are all
|
|
1037
|
+
unowned is left alone, like the single-field skip.
|
|
1038
|
+
|
|
1022
1039
|
### Client-side computes (`reactive_compute` + `reactive_text`)
|
|
1023
1040
|
|
|
1024
1041
|
Some math should feel instant with **no round trip** — a NEW, unsaved record's
|
|
@@ -1334,6 +1351,65 @@ is saved, the persisted flow takes over: the same row markup renders with real
|
|
|
1334
1351
|
indexes, or the list graduates to a [reactive collection](#reactive-collections-addremove-rows--count--empty-state)
|
|
1335
1352
|
(`reactive_collection` + `reply.append`/`reply.remove`).
|
|
1336
1353
|
|
|
1354
|
+
**JSON mode — one hidden field instead of `accepts_nested_attributes_for`.**
|
|
1355
|
+
If your controller already parses a **serialized JSON param** (the app-rolled
|
|
1356
|
+
"stuff the rows into a hidden field, `JSON.parse` on submit" pattern) rather
|
|
1357
|
+
than nested attributes, opt the list into `as: :json` and keep your persistence
|
|
1358
|
+
path exactly as it is:
|
|
1359
|
+
|
|
1360
|
+
```ruby
|
|
1361
|
+
div(**reactive_nested_list(:line_items, as: :json)) { } # + a hidden field to sync
|
|
1362
|
+
# the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
|
|
1363
|
+
input(type: "hidden", **reactive_field(:line_items), value: "[]")
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
```ruby
|
|
1367
|
+
# The controller stays hand-rolled — NO accepts_nested_attributes_for:
|
|
1368
|
+
rows = JSON.parse(params.require(:order).permit(:line_items)[:line_items].presence || "[]")
|
|
1369
|
+
order = Order.create!(total: params[:order][:total])
|
|
1370
|
+
rows.each { order.line_items.create!(quantity: it["quantity"], price: it["price"]) }
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
Everything else is identical — the same `<template>` row, the same
|
|
1374
|
+
`nested_field_name`, the same add/remove triggers. In JSON mode the client
|
|
1375
|
+
mirrors the surviving rows into that one hidden field as a JSON array on every
|
|
1376
|
+
add / remove / keystroke (the set-value + dispatch contract, so dirty tracking
|
|
1377
|
+
and compute still see it), inferring each JSON key from the **trailing bracket
|
|
1378
|
+
segment** of a row input's name (`order[line_items_attributes][3][quantity]` →
|
|
1379
|
+
`"quantity"`). A removed row simply leaves the array (JSON carries no `_destroy`
|
|
1380
|
+
marker — an absent row *is* the removal). The per-row `_attributes` names still
|
|
1381
|
+
render but are ignored by a controller that doesn't permit them; the JSON field
|
|
1382
|
+
is the single source of truth.
|
|
1383
|
+
|
|
1384
|
+
**Fill-then-add — snapshot add controls that live OUTSIDE the row.** The default
|
|
1385
|
+
`reactive_nested_add` is *inline-edit*: it clones the template and focuses the
|
|
1386
|
+
new row's first field, so you type INTO the row. A common alternative puts the
|
|
1387
|
+
add controls outside the row — a preset `<select>`, a typeahead, plain inputs —
|
|
1388
|
+
and "Add" **snapshots** those values into a new row, then clears them for the
|
|
1389
|
+
next entry. Pass `from:` (a map of row-field → source-control selector) and
|
|
1390
|
+
optionally `clear:`:
|
|
1391
|
+
|
|
1392
|
+
```ruby
|
|
1393
|
+
input(id: "item-name", type: "text") # the add controls, OUTSIDE the row
|
|
1394
|
+
input(id: "item-qty", type: "number")
|
|
1395
|
+
|
|
1396
|
+
button(**reactive_nested_add(:line_items,
|
|
1397
|
+
from: { name: "#item-name", quantity: "#item-qty" }, # row field => source selector
|
|
1398
|
+
clear: true)) { "Add item" }
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
On click the client clones the template, fills each cloned-row field from its
|
|
1402
|
+
source control's current value (matching the field by the **same** trailing
|
|
1403
|
+
bracket-segment key inference JSON mode uses), keeps focus on the sources (so
|
|
1404
|
+
you keep entering the next item — it does *not* steal focus into the row), and,
|
|
1405
|
+
with `clear: true`, resets the sources (each via the set-value + dispatch
|
|
1406
|
+
contract, so dirty tracking and compute see it). The `from:` values are raw CSS
|
|
1407
|
+
selectors resolved within this root (`#15` ownership); a selector that resolves
|
|
1408
|
+
nothing, or a row-field key with no matching cloned field, is skipped (the row
|
|
1409
|
+
still adds — never a throw). It composes with **both** wire modes: the seeded
|
|
1410
|
+
values ride the renumbered `_attributes` names on submit (`:attributes`) and the
|
|
1411
|
+
end-of-add JSON sync serializes them (`as: :json`), with no extra wiring.
|
|
1412
|
+
|
|
1337
1413
|
Relatedly, a **draft parent can now run real server actions too** (issue #208):
|
|
1338
1414
|
an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
|
|
1339
1415
|
the component through the record kwarg's **initialize default** —
|
|
@@ -989,6 +989,23 @@ function anyOfAllsMatches(groups, fieldValue) {
|
|
|
989
989
|
group.every((term) => dnfTermMatches(term, fieldValue)))
|
|
990
990
|
}
|
|
991
991
|
|
|
992
|
+
// Every field a DNF payload's groups reference (issue #209) — drives the
|
|
993
|
+
// "leave the target alone when NO referenced field is owned" skip, the
|
|
994
|
+
// single-field-target skip generalized. Returns null for a malformed payload
|
|
995
|
+
// (no groups, or no term names a field) so the caller warn-skips instead of
|
|
996
|
+
// toggling on garbage (default-deny, like every other malformed-wire arm).
|
|
997
|
+
function dnfGroupFields(groups) {
|
|
998
|
+
if (!Array.isArray(groups) || groups.length === 0) return null
|
|
999
|
+
const fields = new Set()
|
|
1000
|
+
for (const group of groups) {
|
|
1001
|
+
if (!Array.isArray(group)) continue
|
|
1002
|
+
for (const term of group) {
|
|
1003
|
+
if (term && typeof term === "object" && typeof term.field === "string") fields.add(term.field)
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return fields.size > 0 ? [...fields] : null
|
|
1007
|
+
}
|
|
1008
|
+
|
|
992
1009
|
// Route a parsed data-reactive-show payload to the right evaluator. The 0.10
|
|
993
1010
|
// wire is { any: [ [term,…], … ] } (DNF — groups are ARRAYS). For a stale tab
|
|
994
1011
|
// still serving pre-0.10 HTML (deploy overlap), fall back to the 0.9.5 compound
|
|
@@ -1142,6 +1159,11 @@ export default class extends Controller {
|
|
|
1142
1159
|
// indexes) plus the once-only missing-list/template warning latch.
|
|
1143
1160
|
#nestedIndex = 0
|
|
1144
1161
|
#nestedWarned = false
|
|
1162
|
+
// JSON-mode nested rows (issue #208): the bound delegated input/change
|
|
1163
|
+
// handler and the bound morph re-seed, held so disconnect() removes exactly
|
|
1164
|
+
// them.
|
|
1165
|
+
#boundSyncNestedJson
|
|
1166
|
+
#boundSeedNestedJson
|
|
1145
1167
|
// Connect-time compute seed (issue #199): the bound re-seed attached to
|
|
1146
1168
|
// turbo:morph-element so an in-place morph re-runs the compute, held for teardown.
|
|
1147
1169
|
#boundSeedCompute
|
|
@@ -1266,6 +1288,24 @@ export default class extends Controller {
|
|
|
1266
1288
|
this.#syncTags()
|
|
1267
1289
|
}
|
|
1268
1290
|
|
|
1291
|
+
// JSON-mode nested rows (issue #208) — ONLY when the root owns a list with
|
|
1292
|
+
// `as: :json`, so a form without one pays a single probe (the show/filter/
|
|
1293
|
+
// tags gate precedent). ONE delegated input + change listener re-serializes
|
|
1294
|
+
// the rows into the hidden field on every owned edit (nestedAdd/Remove call
|
|
1295
|
+
// the sync directly; this covers typing into a row's fields). The connect
|
|
1296
|
+
// seed writes the initial array (a plain replace re-connects), and
|
|
1297
|
+
// turbo:morph-element re-seeds after an in-place morph (which keeps the
|
|
1298
|
+
// element connected, fires no Stimulus lifecycle, and may have rewritten
|
|
1299
|
+
// the rows to server truth while the hidden field kept its pre-morph value).
|
|
1300
|
+
if (this.#nestedJsonEnabled()) {
|
|
1301
|
+
this.#boundSyncNestedJson = (event) => this.syncNestedJson(event)
|
|
1302
|
+
this.#boundSeedNestedJson = () => this.#syncAllNestedJson()
|
|
1303
|
+
this.element.addEventListener?.("input", this.#boundSyncNestedJson)
|
|
1304
|
+
this.element.addEventListener?.("change", this.#boundSyncNestedJson)
|
|
1305
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSeedNestedJson)
|
|
1306
|
+
this.#syncAllNestedJson()
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1269
1309
|
// Connect-time compute seed (issue #199) — ONLY when the root carries a
|
|
1270
1310
|
// reactive_compute binding that opts in (data-reactive-compute-seed). A
|
|
1271
1311
|
// freshly-rendered compute root (a first paint, or a server validation-error
|
|
@@ -1318,6 +1358,7 @@ export default class extends Controller {
|
|
|
1318
1358
|
this.#teardownShowSync()
|
|
1319
1359
|
this.#teardownFilterSync()
|
|
1320
1360
|
this.#teardownTagsSync()
|
|
1361
|
+
this.#teardownNestedJsonSync()
|
|
1321
1362
|
this.#teardownComputeSeed()
|
|
1322
1363
|
if (this.#boundProbeLazyDefer) {
|
|
1323
1364
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
@@ -1828,8 +1869,87 @@ export default class extends Controller {
|
|
|
1828
1869
|
const row = proto.cloneNode(true)
|
|
1829
1870
|
this.#renumberNestedRow(row, this.#nextNestedIndex())
|
|
1830
1871
|
list.appendChild(row)
|
|
1831
|
-
|
|
1832
|
-
|
|
1872
|
+
|
|
1873
|
+
// Fill-then-add (issue #208 Scenario A): seed the cloned row from named
|
|
1874
|
+
// source controls OUTSIDE the row, then (optionally) clear the sources.
|
|
1875
|
+
// Runs AFTER renumber+append so a seeded field's name already carries its
|
|
1876
|
+
// final `[<index>][field]` form — the key match agrees with what JSON mode
|
|
1877
|
+
// reads. `seeded` is the FIRST source we cleared/read, so fill-then-add can
|
|
1878
|
+
// return focus to the sources instead of stealing it into the new row.
|
|
1879
|
+
const fromJson = trigger?.getAttribute?.("data-reactive-nested-from-param")
|
|
1880
|
+
const clear = trigger?.getAttribute?.("data-reactive-nested-clear-param") === "true"
|
|
1881
|
+
const firstSource = this.#seedNestedRow(row, fromJson, clear)
|
|
1882
|
+
|
|
1883
|
+
// Focus: inline-edit (no from:) focuses the new row's first field so you
|
|
1884
|
+
// type INTO it; fill-then-add keeps focus on the sources (the first one) so
|
|
1885
|
+
// you keep entering the next item — stealing focus would break that loop.
|
|
1886
|
+
if (fromJson) firstSource?.focus?.()
|
|
1887
|
+
else [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]?.focus?.()
|
|
1888
|
+
|
|
1889
|
+
// JSON mode (issue #208): a freshly-added row must land in the hidden field
|
|
1890
|
+
// immediately (seeded values included), so the serialized array reflects the
|
|
1891
|
+
// DOM even before the first keystroke. A no-op when not `as: :json`.
|
|
1892
|
+
if (list.getAttribute?.("data-reactive-nested-json") === assoc) this.#syncNestedJson(assoc)
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// Fill-then-add (issue #208): copy each source control's value into the
|
|
1896
|
+
// matching cloned-row field, keyed by the trailing bracket segment of the
|
|
1897
|
+
// field's name (#nestedJsonKey — the SAME inference JSON mode uses, so the
|
|
1898
|
+
// two features can't drift). Sources resolve root-scoped and owned (#15); an
|
|
1899
|
+
// unresolved source or an unmatched key is silently skipped (the row still
|
|
1900
|
+
// adds — a half-mapped binding must never throw on click). Returns the FIRST
|
|
1901
|
+
// source control read (for focus), or null. With `clear`, resets every source
|
|
1902
|
+
// it read via the set-value + dispatch contract (#183) so dirty/show/compute
|
|
1903
|
+
// observe the reset.
|
|
1904
|
+
#seedNestedRow(row, fromJson, clear) {
|
|
1905
|
+
if (!fromJson) return null
|
|
1906
|
+
let map
|
|
1907
|
+
try {
|
|
1908
|
+
map = JSON.parse(fromJson)
|
|
1909
|
+
} catch {
|
|
1910
|
+
return null
|
|
1911
|
+
}
|
|
1912
|
+
if (!map || typeof map !== "object") return null
|
|
1913
|
+
|
|
1914
|
+
const owns = this.#ownershipFilter()
|
|
1915
|
+
const rowFields = [...(row.querySelectorAll?.("input, select, textarea") ?? [])]
|
|
1916
|
+
const sources = []
|
|
1917
|
+
for (const [key, selector] of Object.entries(map)) {
|
|
1918
|
+
const source = [...(this.element.querySelectorAll?.(selector) ?? [])].find(owns)
|
|
1919
|
+
if (!source) continue
|
|
1920
|
+
const target = rowFields.find((field) => this.#nestedJsonKey(field.getAttribute?.("name")) === key)
|
|
1921
|
+
if (!target) continue
|
|
1922
|
+
this.#seedNestedField(target, source)
|
|
1923
|
+
sources.push(source)
|
|
1924
|
+
}
|
|
1925
|
+
if (clear) for (const source of sources) this.#clearNestedSource(source)
|
|
1926
|
+
return sources[0] ?? null
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Copy a source control's value into a cloned-row field, then dispatch a
|
|
1930
|
+
// bubbling `input` (the set-value + dispatch contract, #183). Checkbox ↔
|
|
1931
|
+
// checkbox copies the checked state; every other target takes the source's
|
|
1932
|
+
// submit-shaped value (#nestedFieldValue), so a checkbox source feeding a
|
|
1933
|
+
// text field lands "on"/"" exactly as a submit would.
|
|
1934
|
+
#seedNestedField(target, source) {
|
|
1935
|
+
if (target.type === "checkbox") {
|
|
1936
|
+
target.checked = source.type === "checkbox" ? !!source.checked : this.#nestedFieldValue(source) !== ""
|
|
1937
|
+
} else {
|
|
1938
|
+
target.value = this.#nestedFieldValue(source)
|
|
1939
|
+
}
|
|
1940
|
+
if (typeof target.dispatchEvent === "function") {
|
|
1941
|
+
target.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Reset a source control after a fill-then-add (issue #208), dispatching a
|
|
1946
|
+
// bubbling `input` so dirty tracking / reactive_show / compute see the reset.
|
|
1947
|
+
#clearNestedSource(source) {
|
|
1948
|
+
if (source.type === "checkbox") source.checked = false
|
|
1949
|
+
else source.value = ""
|
|
1950
|
+
if (typeof source.dispatchEvent === "function") {
|
|
1951
|
+
source.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1952
|
+
}
|
|
1833
1953
|
}
|
|
1834
1954
|
|
|
1835
1955
|
// Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
|
|
@@ -1857,6 +1977,108 @@ export default class extends Controller {
|
|
|
1857
1977
|
} else {
|
|
1858
1978
|
row.parentNode?.removeChild?.(row)
|
|
1859
1979
|
}
|
|
1980
|
+
|
|
1981
|
+
// JSON mode (issue #208): the removed (or _destroy-hidden) row must leave
|
|
1982
|
+
// the serialized array too — an absent row IS the removal. Re-sync every
|
|
1983
|
+
// owned JSON-mode list; a form without one iterates an empty set and exits.
|
|
1984
|
+
this.#syncAllNestedJson()
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// JSON-mode nested rows (issue #208) — the delegated input/change handler.
|
|
1988
|
+
// An app whose controller parses a serialized JSON param instead of Rails'
|
|
1989
|
+
// accepts_nested_attributes_for opts a list into `as: :json`; the client
|
|
1990
|
+
// then mirrors that list's rows into ONE hidden field as a JSON array on
|
|
1991
|
+
// every owned edit. Public so Stimulus can bind it; a no-op unless the
|
|
1992
|
+
// edited field belongs to a JSON-mode list this root owns.
|
|
1993
|
+
syncNestedJson(event) {
|
|
1994
|
+
const target = event?.target
|
|
1995
|
+
if (!target || !this.#ownsField(target)) return
|
|
1996
|
+
// Re-serialize every JSON-mode list (an edit could touch any of them; the
|
|
1997
|
+
// per-list owned-row scan is cheap and keeps this handler association-free).
|
|
1998
|
+
this.#syncAllNestedJson()
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Serialize every owned JSON-mode list into its hidden field. The connect
|
|
2002
|
+
// seed and the input/remove re-syncs funnel through here so one place owns
|
|
2003
|
+
// the "DOM rows → JSON field" projection.
|
|
2004
|
+
#syncAllNestedJson() {
|
|
2005
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2006
|
+
const owns = this.#ownershipFilter()
|
|
2007
|
+
for (const list of [...this.element.querySelectorAll("[data-reactive-nested-json]")].filter(owns)) {
|
|
2008
|
+
this.#syncNestedJson(list.getAttribute("data-reactive-nested-json"))
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Project ONE JSON-mode list's surviving rows into its hidden field. Each
|
|
2013
|
+
// row becomes an object keyed by the trailing bracket segment of its inputs'
|
|
2014
|
+
// names (…[title] → "title"); a hidden/_destroy-marked row is skipped (an
|
|
2015
|
+
// absent row IS the removal — JSON carries no destroy marker). The write
|
|
2016
|
+
// uses the set-value + dispatch contract (issue #183) so dirty tracking,
|
|
2017
|
+
// reactive_show, and compute see the change — but only when the value
|
|
2018
|
+
// actually changed, so a connect seed on an already-correct field is silent.
|
|
2019
|
+
#syncNestedJson(assoc) {
|
|
2020
|
+
const owns = this.#ownershipFilter()
|
|
2021
|
+
const list = [...this.element.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
|
|
2022
|
+
if (!list) return
|
|
2023
|
+
const field = this.#nestedJsonField(list)
|
|
2024
|
+
if (!field) return
|
|
2025
|
+
|
|
2026
|
+
const rows = []
|
|
2027
|
+
for (const row of [...(list.querySelectorAll?.("[data-reactive-nested-row]") ?? [])]) {
|
|
2028
|
+
if (!owns(row) || row.hidden) continue
|
|
2029
|
+
rows.push(this.#nestedRowObject(row))
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
const next = JSON.stringify(rows)
|
|
2033
|
+
if (field.value === next) return
|
|
2034
|
+
field.value = next
|
|
2035
|
+
if (typeof field.dispatchEvent === "function") {
|
|
2036
|
+
field.dispatchEvent(new Event("input", { bubbles: true }))
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// The hidden field a JSON-mode list mirrors into — resolved fresh (a morph
|
|
2041
|
+
// replaces nodes, never cache it) and OWNED by this root (#15). null when
|
|
2042
|
+
// the selector resolves nothing, so the caller no-ops (a half-built binding
|
|
2043
|
+
// must never break the page).
|
|
2044
|
+
#nestedJsonField(list) {
|
|
2045
|
+
const selector = list.getAttribute?.("data-reactive-nested-json-field")
|
|
2046
|
+
if (!selector) return null
|
|
2047
|
+
const owns = this.#ownershipFilter()
|
|
2048
|
+
return [...this.element.querySelectorAll(selector)].find(owns) ?? null
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// One row → { key: value } over its named form controls. The JSON key is the
|
|
2052
|
+
// trailing bracket segment of each control's name (order[todos_attributes]
|
|
2053
|
+
// [3][title] → "title"; a bare `title` → "title"), the "infer from input
|
|
2054
|
+
// names" contract. The [_destroy] control is dropped (JSON has no destroy
|
|
2055
|
+
// marker). Later inputs with the same key win (last-wins, the DOM order).
|
|
2056
|
+
#nestedRowObject(row) {
|
|
2057
|
+
const obj = {}
|
|
2058
|
+
for (const el of [...(row.querySelectorAll?.("input, select, textarea") ?? [])]) {
|
|
2059
|
+
const key = this.#nestedJsonKey(el.getAttribute?.("name"))
|
|
2060
|
+
if (key === null || key === "_destroy") continue
|
|
2061
|
+
obj[key] = this.#nestedFieldValue(el)
|
|
2062
|
+
}
|
|
2063
|
+
return obj
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// The trailing bracket segment of a field name (the inferred JSON key), or
|
|
2067
|
+
// the bare name when it carries no brackets. null for a nameless control
|
|
2068
|
+
// (a bare button, an unnamed helper input) — skipped by the caller.
|
|
2069
|
+
#nestedJsonKey(name) {
|
|
2070
|
+
if (!name) return null
|
|
2071
|
+
const match = name.match(/\[([^\][]+)\]$/)
|
|
2072
|
+
return match ? match[1] : name
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// A form control's submitted value: an unchecked checkbox contributes "" (it
|
|
2076
|
+
// wouldn't post at all), a checked one its value (default "on"); everything
|
|
2077
|
+
// else its .value. Keeps the JSON shape close to what a real form submit
|
|
2078
|
+
// would carry for the same control.
|
|
2079
|
+
#nestedFieldValue(el) {
|
|
2080
|
+
if (el.type === "checkbox") return el.checked ? (el.value || "on") : ""
|
|
2081
|
+
return el.value ?? ""
|
|
1860
2082
|
}
|
|
1861
2083
|
|
|
1862
2084
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
@@ -2876,7 +3098,7 @@ export default class extends Controller {
|
|
|
2876
3098
|
|
|
2877
3099
|
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2878
3100
|
// field driving both an owned binding and an outside target reads once.
|
|
2879
|
-
this.#syncShowTargets(
|
|
3101
|
+
this.#syncShowTargets(fieldValue)
|
|
2880
3102
|
}
|
|
2881
3103
|
|
|
2882
3104
|
// Toggle `hidden` (and, when the binding declares data-reactive-show-disable,
|
|
@@ -2905,12 +3127,20 @@ export default class extends Controller {
|
|
|
2905
3127
|
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2906
3128
|
// target while siblings still apply. With no map declared this is one
|
|
2907
3129
|
// getAttribute and out.
|
|
2908
|
-
|
|
3130
|
+
//
|
|
3131
|
+
// A "#id" KEY (issue #209) is a TARGET-KEYED entry instead: its value is the
|
|
3132
|
+
// same DNF payload data-reactive-show holds, folded with per-term owned-field
|
|
3133
|
+
// reads — the multi-field cross-root case. The "#" prefix routes unambiguously
|
|
3134
|
+
// (a field name may never start with "#"; the Ruby helper raises).
|
|
3135
|
+
#syncShowTargets(fieldValue) {
|
|
2909
3136
|
const map = this.#parseShowTargets()
|
|
2910
3137
|
for (const [name, targets] of Object.entries(map)) {
|
|
3138
|
+
if (name.startsWith("#")) {
|
|
3139
|
+
this.#applyConditionsTarget(name, targets, fieldValue)
|
|
3140
|
+
continue
|
|
3141
|
+
}
|
|
2911
3142
|
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2912
|
-
|
|
2913
|
-
const value = values.get(name)
|
|
3143
|
+
const value = fieldValue(name)
|
|
2914
3144
|
if (value === null) continue // no owned field with that name — leave them be
|
|
2915
3145
|
// Every target's terms share this one field, so a constant resolver folds
|
|
2916
3146
|
// the group (issue #180): a target's value is a DNF GROUP (terms ANDed).
|
|
@@ -2940,6 +3170,28 @@ export default class extends Controller {
|
|
|
2940
3170
|
}
|
|
2941
3171
|
}
|
|
2942
3172
|
|
|
3173
|
+
// Apply ONE target-keyed conditions entry (issue #209): "#id" → the DNF
|
|
3174
|
+
// payload { any: [[term,…],…] }, folded by the SAME anyOfAllsMatches as an
|
|
3175
|
+
// in-root reactive_show — each term reads its OWN owned field, a missing
|
|
3176
|
+
// owned field reads as blank (fail-closed, the shared-fixture contract). A
|
|
3177
|
+
// target whose referenced fields are ALL unowned is left alone — the
|
|
3178
|
+
// single-field skip generalized (this root has nothing to evaluate with). A
|
|
3179
|
+
// malformed payload warn-skips its one target while siblings still apply;
|
|
3180
|
+
// the selector guard is the same id-only allowlist as every cross-root arm.
|
|
3181
|
+
#applyConditionsTarget(selector, payload, fieldValue) {
|
|
3182
|
+
if (!guardShowTargetSelector(selector)) return
|
|
3183
|
+
const groups = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.any : null
|
|
3184
|
+
const fields = dnfGroupFields(groups)
|
|
3185
|
+
if (fields === null) {
|
|
3186
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets conditions for ${selector} — skipped`)
|
|
3187
|
+
return
|
|
3188
|
+
}
|
|
3189
|
+
if (fields.every((name) => fieldValue(name) === null)) return // no owned field — leave it be
|
|
3190
|
+
const match = anyOfAllsMatches(groups, fieldValue)
|
|
3191
|
+
if (match === null) return // unreachable after dnfGroupFields, kept fail-closed
|
|
3192
|
+
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
3193
|
+
}
|
|
3194
|
+
|
|
2943
3195
|
// The declared cross-root show-target map (issue #164): a JSON object of
|
|
2944
3196
|
// { field: { "#id": predicate } } from data-reactive-show-targets (emitted
|
|
2945
3197
|
// by reactive_show_targets on the root). Absent degrades to {}; malformed
|
|
@@ -3099,6 +3351,15 @@ export default class extends Controller {
|
|
|
3099
3351
|
return !!this.element.getAttribute?.("data-reactive-tags-field")
|
|
3100
3352
|
}
|
|
3101
3353
|
|
|
3354
|
+
// Whether this root owns at least one JSON-mode nested list (issue #208) —
|
|
3355
|
+
// the connect() gate. A cheap descendant probe: a form without one wires no
|
|
3356
|
+
// input/change listeners. Ownership is re-checked per sync, so a stray match
|
|
3357
|
+
// in a nested reactive root here is harmless (it just arms the listeners).
|
|
3358
|
+
#nestedJsonEnabled() {
|
|
3359
|
+
if (typeof this.element?.querySelector !== "function") return false
|
|
3360
|
+
return !!this.element.querySelector("[data-reactive-nested-json]")
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3102
3363
|
// The hidden input storing the comma-joined value, resolved fresh per use
|
|
3103
3364
|
// (a morph replaces nodes — never cache it) and OWNED by this root (issue
|
|
3104
3365
|
// #15). null when the selector resolves nothing — every caller then no-ops:
|
|
@@ -3287,6 +3548,21 @@ export default class extends Controller {
|
|
|
3287
3548
|
this.#boundSyncTags = undefined
|
|
3288
3549
|
}
|
|
3289
3550
|
|
|
3551
|
+
// Remove the JSON-mode nested-rows listeners on disconnect (issue #208), so a
|
|
3552
|
+
// stray input/change/morph after the element leaves the DOM never re-syncs
|
|
3553
|
+
// against a detached root.
|
|
3554
|
+
#teardownNestedJsonSync() {
|
|
3555
|
+
if (this.#boundSyncNestedJson) {
|
|
3556
|
+
this.element.removeEventListener?.("input", this.#boundSyncNestedJson)
|
|
3557
|
+
this.element.removeEventListener?.("change", this.#boundSyncNestedJson)
|
|
3558
|
+
this.#boundSyncNestedJson = undefined
|
|
3559
|
+
}
|
|
3560
|
+
if (this.#boundSeedNestedJson) {
|
|
3561
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSeedNestedJson)
|
|
3562
|
+
this.#boundSeedNestedJson = undefined
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3290
3566
|
// Remove the compute seed morph listener on disconnect (issue #199), so a
|
|
3291
3567
|
// stray turbo:morph-element after the element leaves the DOM never re-seeds
|
|
3292
3568
|
// against a detached root.
|