phlex-reactive 0.12.1 → 0.12.2
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 +20 -0
- data/README.md +41 -5
- data/lib/phlex/reactive/component/helpers.rb +139 -22
- 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: 753ece18695afa9629ee3bec0fd51722f83618adcb2488265a873ff93bf89dd0
|
|
4
|
+
data.tar.gz: 55fd2590040f0a9c0831e622981d50b71145fbcb289157237787c59b13ec6426
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ba6bd8917f55a95d2039f8f86ac393c23c564da694575bb1978b0eeb5f1d5667a0531b0ac0c2a4f8f8e23e03199461eeba0d2dc3decb866b377b404ddb44665
|
|
7
|
+
data.tar.gz: 4f46e011c63a7500e6262fb3b24fceaf4c13c64b99d1555c0d7fe130326791ba388245c4493a8716231f5510f6d336e91492985a193bfaa5851c9475f3b4b173
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Instance-dynamic wire names — keyword escape hatches on the
|
|
12
|
+
field-compiling helpers (#224).** A form builder's wire name is computed per
|
|
13
|
+
instance (`user[tags]`), which the class-level `reactive_scope` compile can't
|
|
14
|
+
express — the gap that forced phlex-forms' `tag_field` draft
|
|
15
|
+
(mhenrixon/phlex-forms#6) onto raw data attrs. Every helper that compiles a
|
|
16
|
+
field name now takes a verbatim keyword escape hatch (never re-scoped,
|
|
17
|
+
validated at render, mutually exclusive with the blessed field form):
|
|
18
|
+
`reactive_tags(name: "user[tags]")`, `reactive_filter(input: "#tags_query")`
|
|
19
|
+
(a raw CSS selector — the deliberately **name-less** query input inside a
|
|
20
|
+
real form, targeted by id, so it can never submit a stray param),
|
|
21
|
+
`nested_field_name(:items, :qty, scope: "order")` (a per-call parent prefix
|
|
22
|
+
that wins over `reactive_scope`), and
|
|
23
|
+
`reactive_nested_list(:items, as: :json, name: "order[items]")` (the hidden
|
|
24
|
+
JSON sync field, JSON mode only). Server-side sugar only — the client already
|
|
25
|
+
resolves these attributes as arbitrary root-scoped selectors; the shipped
|
|
26
|
+
wire for existing calls is byte-identical. Note: `reactive_filter(input:)`
|
|
27
|
+
deliberately re-blesses the kwarg removed in #186 — the field form stays the
|
|
28
|
+
blessed default, and a pre-0.10 `input:`/`option:` call shape is valid again
|
|
29
|
+
with identical semantics instead of raising the removal error.
|
|
30
|
+
|
|
11
31
|
- **Project board — the kanban flagship demo (#216).** A live three-lane board
|
|
12
32
|
on the docs site (`/docs/example-project-board`, `/demos/project-board`)
|
|
13
33
|
composing the toolkit end to end: each card is its own nested reactive root
|
data/README.md
CHANGED
|
@@ -390,17 +390,17 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
390
390
|
| `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). |
|
|
391
391
|
| `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). |
|
|
392
392
|
| `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). |
|
|
393
|
-
| `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). |
|
|
393
|
+
| `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. `input:` is the escape hatch — a raw CSS selector for a **name-less** driving input (`input: "#tags_query"`), the form-builder case. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
|
|
394
394
|
| `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. |
|
|
395
|
-
| `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). |
|
|
395
|
+
| `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). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
|
|
396
396
|
| `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
|
|
397
397
|
| `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; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
|
|
398
398
|
| `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). |
|
|
399
399
|
| `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). |
|
|
400
400
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
401
|
-
| `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_). |
|
|
401
|
+
| `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; `name:` names that hidden field **verbatim** (`name: "order[todos]"`, never re-scoped — the form-builder escape hatch, JSON mode only). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
402
402
|
| `reactive_nested_add(:assoc, from:, clear:)` / `reactive_nested_remove(confirm:)` | 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`. **Confirm on remove**: `reactive_nested_remove(confirm: "Really delete this row?")` gates the remove behind the same overridable `confirmResolver` as `on`/`on_client` (a per-row string, or the conditional `{ when:, message: }` Hash). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
403
|
-
| `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
|
|
403
|
+
| `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`; `scope:` overrides it **per call** (`scope: "order"`, verbatim — the form-builder escape hatch). |
|
|
404
404
|
| `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). |
|
|
405
405
|
| `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). |
|
|
406
406
|
| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
|
|
@@ -1218,6 +1218,17 @@ stays its own signed `on(:select)` trigger. Only *filtering* is client-side —
|
|
|
1218
1218
|
selection still round-trips as a real signed action. Blank selectors raise at
|
|
1219
1219
|
render: a dead binding must fail loudly, not no-op in the browser.
|
|
1220
1220
|
|
|
1221
|
+
**The escape hatch — a name-less input, targeted by id.** Inside a real
|
|
1222
|
+
POST/GET form, a *named* query input submits a stray param alongside your
|
|
1223
|
+
value. A form builder (phlex-forms' `tag_field`) therefore renders the query
|
|
1224
|
+
input with an **id and no `name`** — which the field form can't express. Pass
|
|
1225
|
+
`input:` (a raw CSS selector, verbatim — never re-scoped) instead of the field;
|
|
1226
|
+
exactly one of the two per call:
|
|
1227
|
+
|
|
1228
|
+
```ruby
|
|
1229
|
+
reactive_filter(input: "#user_tags_query") # the input carries id, NO name
|
|
1230
|
+
```
|
|
1231
|
+
|
|
1221
1232
|
### Tag-chip input (`reactive_tags`)
|
|
1222
1233
|
|
|
1223
1234
|
The composed combobox/tags widget: preload suggestions, type to narrow, Enter or
|
|
@@ -1272,7 +1283,23 @@ dispatches a real `input` event on the hidden field, so `reactive_dirty`,
|
|
|
1272
1283
|
|
|
1273
1284
|
The styled, form-builder-integrated version of this widget (label/errors/
|
|
1274
1285
|
theming) belongs in your form layer — these helpers are deliberately the
|
|
1275
|
-
unstyled primitives, like `reactive_filter` before them.
|
|
1286
|
+
unstyled primitives, like `reactive_filter` before them. That form layer is
|
|
1287
|
+
exactly who needs the **escape hatches**: a form builder's wire name is
|
|
1288
|
+
computed **per instance** (`user[tags]`, from the builder's object name), which
|
|
1289
|
+
the class-level `reactive_scope` compile can't express. `name:` takes the wire
|
|
1290
|
+
name **verbatim** (never re-scoped), and the query input goes name-less,
|
|
1291
|
+
targeted by id via `reactive_filter(input:)` — so a real form submit carries
|
|
1292
|
+
`user[tags]` and nothing else:
|
|
1293
|
+
|
|
1294
|
+
```ruby
|
|
1295
|
+
div(**mix(reactive_root(id: "user_tags_widget"),
|
|
1296
|
+
reactive_tags(name: "user[tags]"), # verbatim — never re-scoped
|
|
1297
|
+
reactive_filter(input: "#user_tags_query"))) do # id-targeted, name-less input
|
|
1298
|
+
input(type: :hidden, name: "user[tags]", id: "user_tags", value: @tags.join(","))
|
|
1299
|
+
# …chips/template/suggestions as above…
|
|
1300
|
+
input(id: "user_tags_query", type: "search", **mix(reactive_listnav, reactive_tags_add))
|
|
1301
|
+
end
|
|
1302
|
+
```
|
|
1276
1303
|
|
|
1277
1304
|
### Draft rows for a new parent (`reactive_nested_*`)
|
|
1278
1305
|
|
|
@@ -1346,6 +1373,12 @@ it on save. Several collections can share one root (everything is keyed by the
|
|
|
1346
1373
|
association name); nesting a collection inside another's template is not
|
|
1347
1374
|
supported.
|
|
1348
1375
|
|
|
1376
|
+
`nested_field_name` is scope-aware (`reactive_scope :order` → the
|
|
1377
|
+
`order[…]` wrap above). When the parent prefix is **per-instance** — a form
|
|
1378
|
+
builder's object name, possibly itself bracketed (`"user[profile]"`) — pass
|
|
1379
|
+
`scope:` per call instead; it's used verbatim and wins over the class-level
|
|
1380
|
+
scope: `nested_field_name(:line_items, :quantity, scope: "order")`.
|
|
1381
|
+
|
|
1349
1382
|
Two boundaries to respect: the DOM is the single source of truth for unsent
|
|
1350
1383
|
draft rows, so a **server re-render of the root replaces them** — keep
|
|
1351
1384
|
replace-shaped actions out of a root holding unsent rows. And once the parent
|
|
@@ -1363,6 +1396,9 @@ path exactly as it is:
|
|
|
1363
1396
|
div(**reactive_nested_list(:line_items, as: :json)) { } # + a hidden field to sync
|
|
1364
1397
|
# the hidden field the client keeps in sync (seed "[]" so an empty submit posts one):
|
|
1365
1398
|
input(type: "hidden", **reactive_field(:line_items), value: "[]")
|
|
1399
|
+
|
|
1400
|
+
# Form-builder escape hatch: name the hidden field VERBATIM (never re-scoped):
|
|
1401
|
+
div(**reactive_nested_list(:line_items, as: :json, name: "order[line_items]")) { }
|
|
1366
1402
|
```
|
|
1367
1403
|
|
|
1368
1404
|
```ruby
|
|
@@ -601,20 +601,35 @@ module Phlex
|
|
|
601
601
|
# hidden options) and each option's own on(:select, …) trigger —
|
|
602
602
|
# selection still round-trips as a signed action; only FILTERING is
|
|
603
603
|
# local. Blank selectors raise: a dead binding must fail at render.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
604
|
+
#
|
|
605
|
+
# Issue #224: `input:` is the ESCAPE HATCH — a raw CSS selector for the
|
|
606
|
+
# one driving input the field form can't express: a deliberately
|
|
607
|
+
# NAME-LESS query input inside a real POST form (a named input would
|
|
608
|
+
# submit a stray param), targeted by id. A form builder (phlex-forms'
|
|
609
|
+
# tag_field) computes that id per instance. Verbatim, never re-scoped;
|
|
610
|
+
# exactly one of field/input: per call — the field form stays the
|
|
611
|
+
# blessed default:
|
|
612
|
+
# reactive_filter(input: "#user_tags_query")
|
|
613
|
+
def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil)
|
|
614
|
+
# nil-presence, NOT truthiness: `input: cond && "#sel"` with cond false
|
|
615
|
+
# must fail loudly in filter_selector! below (a boolean is never a
|
|
616
|
+
# selector), never slip into the field branch and emit a dead binding.
|
|
617
|
+
if field && !input.nil?
|
|
618
|
+
raise ArgumentError,
|
|
619
|
+
"reactive_filter takes ONE driving-input form — a field name (reactive_filter(:q), " \
|
|
620
|
+
"scope-aware) OR input: (a raw CSS selector for a name-less input), not both"
|
|
621
|
+
end
|
|
622
|
+
if field.nil? && input.nil?
|
|
608
623
|
raise ArgumentError,
|
|
609
|
-
"reactive_filter
|
|
610
|
-
"
|
|
624
|
+
"reactive_filter needs a field name — reactive_filter(:q) — or the input: " \
|
|
625
|
+
"escape hatch (a raw CSS selector, e.g. input: \"#tags_query\")"
|
|
611
626
|
end
|
|
612
|
-
raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?
|
|
613
627
|
|
|
614
628
|
data = {
|
|
615
629
|
# Compile the field to a scoped [name="…"] selector (same scope convention
|
|
616
|
-
# reactive_field uses, so the filter input aligns with its own field)
|
|
617
|
-
|
|
630
|
+
# reactive_field uses, so the filter input aligns with its own field) — or
|
|
631
|
+
# take the input: selector verbatim (issue #224).
|
|
632
|
+
reactive_filter_input: input.nil? ? %([name="#{scoped_field_name(field)}"]) : filter_selector!(:input, input),
|
|
618
633
|
# option defaults to the [role=option] convention; a kwarg overrides it.
|
|
619
634
|
reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
|
|
620
635
|
}
|
|
@@ -680,9 +695,30 @@ module Phlex
|
|
|
680
695
|
# and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted
|
|
681
696
|
# option via its own tagsPick trigger, and reactive_tags_add only adds
|
|
682
697
|
# the TYPED text when nothing is highlighted — no double add).
|
|
683
|
-
|
|
698
|
+
#
|
|
699
|
+
# Issue #224: `name:` is the ESCAPE HATCH for an instance-dynamic wire
|
|
700
|
+
# name — a form builder (phlex-forms' tag_field) computes "user[tags]"
|
|
701
|
+
# per instance, which the class-level reactive_scope compile can't
|
|
702
|
+
# express. Verbatim, NEVER re-scoped (reactive_field's explicit-name
|
|
703
|
+
# precedent); exactly one of field/name: per call:
|
|
704
|
+
# reactive_tags(name: "user[tags]")
|
|
705
|
+
def reactive_tags(field = nil, name: nil)
|
|
706
|
+
# nil-presence, NOT truthiness: `name: cond && "user[tags]"` with cond
|
|
707
|
+
# false must fail loudly in verbatim_name_selector!, never silently
|
|
708
|
+
# fall through to the field branch.
|
|
709
|
+
unless name.nil?
|
|
710
|
+
if field
|
|
711
|
+
raise ArgumentError,
|
|
712
|
+
"reactive_tags takes ONE field form — a field name (reactive_tags(:tags), scope-aware) " \
|
|
713
|
+
"OR name: (a verbatim wire name, never re-scoped), not both"
|
|
714
|
+
end
|
|
715
|
+
return { data: { reactive_tags_field: verbatim_name_selector!(:reactive_tags, name) } }
|
|
716
|
+
end
|
|
717
|
+
|
|
684
718
|
if field.nil? || field.to_s.strip.empty?
|
|
685
|
-
raise ArgumentError,
|
|
719
|
+
raise ArgumentError,
|
|
720
|
+
"reactive_tags needs a field name — reactive_tags(:tags) — or the name: " \
|
|
721
|
+
"escape hatch (a verbatim wire name, e.g. name: \"user[tags]\")"
|
|
686
722
|
end
|
|
687
723
|
|
|
688
724
|
# Compile the field to a scoped [name="…"] selector, the reactive_filter
|
|
@@ -785,7 +821,15 @@ module Phlex
|
|
|
785
821
|
# a `reactive_scope :order` component emits
|
|
786
822
|
# order[line_items_attributes][3][quantity] (never a nested-bracket
|
|
787
823
|
# corruption of the scope wrap).
|
|
788
|
-
|
|
824
|
+
#
|
|
825
|
+
# Issue #224: `scope:` is the per-call ESCAPE HATCH — a form builder's
|
|
826
|
+
# object name is per-instance ("order", or itself bracketed for a
|
|
827
|
+
# nested fieldset: "user[profile]"), which the class-level
|
|
828
|
+
# reactive_scope can't express. Used verbatim as the wrap and WINS over
|
|
829
|
+
# reactive_scope:
|
|
830
|
+
# nested_field_name(:line_items, :quantity, scope: "order")
|
|
831
|
+
# # => order[line_items_attributes][NEW_ROW][quantity]
|
|
832
|
+
def nested_field_name(association, field, index: NESTED_NEW_ROW, scope: nil)
|
|
789
833
|
nested_identifier!(:nested_field_name, :association, association)
|
|
790
834
|
nested_identifier!(:nested_field_name, :field, field)
|
|
791
835
|
unless index.to_s == NESTED_NEW_ROW || index.to_s.match?(/\A\d+\z/)
|
|
@@ -794,7 +838,9 @@ module Phlex
|
|
|
794
838
|
"got #{index.inspect} — anything else corrupts the bracketed wire name"
|
|
795
839
|
end
|
|
796
840
|
|
|
797
|
-
|
|
841
|
+
base = :"#{association}_attributes"
|
|
842
|
+
prefix = scope.nil? ? scoped_field_name(base) : "#{nested_scope!(scope)}[#{base}]"
|
|
843
|
+
"#{prefix}[#{index}][#{field}]"
|
|
798
844
|
end
|
|
799
845
|
|
|
800
846
|
# The container cloned rows land in — one per association, inside the
|
|
@@ -810,10 +856,23 @@ module Phlex
|
|
|
810
856
|
# naming the hidden field the client mirrors the rows into on every
|
|
811
857
|
# add/remove/input. The default (:attributes) is unchanged — the plain
|
|
812
858
|
# accepts_nested_attributes_for wire.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
859
|
+
#
|
|
860
|
+
# Issue #224: `name:` is the verbatim ESCAPE HATCH for that hidden
|
|
861
|
+
# field's wire name — a form builder's "order[todos]" the class-level
|
|
862
|
+
# reactive_scope compile can't express. JSON-mode only (the
|
|
863
|
+
# :attributes mode has no field to name); never re-scoped:
|
|
864
|
+
# reactive_nested_list(:todos, as: :json, name: "order[todos]")
|
|
865
|
+
def reactive_nested_list(association, as: :attributes, name: nil)
|
|
866
|
+
assoc = nested_identifier!(:reactive_nested_list, :association, association)
|
|
867
|
+
data = { reactive_nested_list: assoc }
|
|
868
|
+
if as == :attributes
|
|
869
|
+
unless name.nil?
|
|
870
|
+
raise ArgumentError,
|
|
871
|
+
"reactive_nested_list(name:) only applies to as: :json — it names the hidden JSON " \
|
|
872
|
+
"sync field; the :attributes mode has no field to name"
|
|
873
|
+
end
|
|
874
|
+
return { data: }
|
|
875
|
+
end
|
|
817
876
|
|
|
818
877
|
unless as == :json
|
|
819
878
|
raise ArgumentError,
|
|
@@ -824,9 +883,17 @@ module Phlex
|
|
|
824
883
|
# JSON mode: mark the container and name the hidden field the client
|
|
825
884
|
# keeps in sync — a scope-aware [name="…"] selector, the same
|
|
826
885
|
# convention reactive_tags/reactive_filter use so the field resolves
|
|
827
|
-
# under reactive_scope too.
|
|
828
|
-
|
|
829
|
-
data[:
|
|
886
|
+
# under reactive_scope too. name: takes the wire name verbatim
|
|
887
|
+
# (issue #224).
|
|
888
|
+
data[:reactive_nested_json] = assoc
|
|
889
|
+
# nil-presence, NOT truthiness (the reactive_tags rationale): a falsy
|
|
890
|
+
# non-nil name: must fail loudly in verbatim_name_selector!.
|
|
891
|
+
data[:reactive_nested_json_field] =
|
|
892
|
+
if name.nil?
|
|
893
|
+
%([name="#{scoped_field_name(association)}"])
|
|
894
|
+
else
|
|
895
|
+
verbatim_name_selector!(:reactive_nested_list, name)
|
|
896
|
+
end
|
|
830
897
|
{ data: }
|
|
831
898
|
end
|
|
832
899
|
|
|
@@ -1109,10 +1176,12 @@ module Phlex
|
|
|
1109
1176
|
# A reactive_filter/reactive_listnav selector, validated non-blank and
|
|
1110
1177
|
# stringified (issue #163). A blank selector is a dead binding — the
|
|
1111
1178
|
# client would silently match nothing — so it fails loudly at render,
|
|
1112
|
-
# like reactive_show's predicate validation.
|
|
1179
|
+
# like reactive_show's predicate validation. A boolean is rejected too
|
|
1180
|
+
# (issue #224): `input: cond && "#sel"` with cond false would otherwise
|
|
1181
|
+
# stringify to the plausible-looking dead selector "false".
|
|
1113
1182
|
def filter_selector!(name, value)
|
|
1114
1183
|
selector = value.to_s
|
|
1115
|
-
if selector.strip.empty?
|
|
1184
|
+
if value == true || value == false || selector.strip.empty?
|
|
1116
1185
|
raise ArgumentError,
|
|
1117
1186
|
"reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
|
|
1118
1187
|
end
|
|
@@ -1135,6 +1204,54 @@ module Phlex
|
|
|
1135
1204
|
value
|
|
1136
1205
|
end
|
|
1137
1206
|
|
|
1207
|
+
# A verbatim wire name for the name:-form escape hatches (issue #224) —
|
|
1208
|
+
# an instance-dynamic name (a form builder's "user[tags]") used exactly
|
|
1209
|
+
# as given, never re-scoped. The name is interpolated into a
|
|
1210
|
+
# double-quoted [name="…"] attribute selector the CLIENT passes to
|
|
1211
|
+
# querySelectorAll, so anything that breaks a CSS string breaks the
|
|
1212
|
+
# binding IN THE BROWSER: a `"` ends the string early, a `\` CSS-escapes
|
|
1213
|
+
# (a trailing one swallows the closing quote — the selector silently
|
|
1214
|
+
# matches the wrong name), and a raw control character (newline) makes
|
|
1215
|
+
# querySelectorAll THROW, aborting the controller's connect. All fail
|
|
1216
|
+
# loudly here at render instead. Booleans are rejected with the blank
|
|
1217
|
+
# check: `name: cond && "user[tags]"` with cond false must never
|
|
1218
|
+
# compile the plausible-looking [name="false"].
|
|
1219
|
+
def verbatim_name_selector!(helper, name)
|
|
1220
|
+
value = name.to_s
|
|
1221
|
+
if name == true || name == false || value.strip.empty?
|
|
1222
|
+
raise ArgumentError,
|
|
1223
|
+
"#{helper} name: needs a non-blank wire name (e.g. name: \"user[tags]\"), got #{name.inspect}"
|
|
1224
|
+
end
|
|
1225
|
+
if value.match?(/["\\\x00-\x1f]/)
|
|
1226
|
+
raise ArgumentError,
|
|
1227
|
+
"#{helper} name: can't contain a double quote, backslash, or control character — " \
|
|
1228
|
+
"the name is compiled into a [name=\"…\"] selector the client queries with, " \
|
|
1229
|
+
"got #{name.inspect}"
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
%([name="#{value}"])
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
# The per-call scope: override for nested_field_name (issue #224) — a
|
|
1236
|
+
# form builder's parent prefix, possibly itself bracketed
|
|
1237
|
+
# ("user[profile]"), validated non-blank. Used verbatim as the wrap.
|
|
1238
|
+
# String/Symbol only: `scope: cond && "order"` with cond false would
|
|
1239
|
+
# otherwise stringify to the silently-corrupting "false[…]" wire name.
|
|
1240
|
+
def nested_scope!(scope)
|
|
1241
|
+
unless scope.is_a?(String) || scope.is_a?(Symbol)
|
|
1242
|
+
raise ArgumentError,
|
|
1243
|
+
"nested_field_name scope: needs a String or Symbol parent prefix " \
|
|
1244
|
+
"(e.g. scope: \"order\"), got #{scope.inspect}"
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
value = scope.to_s
|
|
1248
|
+
return value unless value.strip.empty?
|
|
1249
|
+
|
|
1250
|
+
raise ArgumentError,
|
|
1251
|
+
"nested_field_name scope: needs a non-blank parent prefix (e.g. scope: \"order\"), " \
|
|
1252
|
+
"got #{scope.inspect}"
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1138
1255
|
# An association/field name for the nested-rows wire (issue #208),
|
|
1139
1256
|
# validated to a plain Ruby identifier at render — it becomes an
|
|
1140
1257
|
# attribute value, a CSS selector fragment, AND a bracketed param name,
|