phlex-reactive 0.10.0 → 0.11.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 +246 -0
- data/README.md +127 -78
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +370 -179
- 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/dsl.rb +122 -34
- data/lib/phlex/reactive/component/helpers.rb +307 -165
- data/lib/phlex/reactive/component/registry.rb +6 -1
- data/lib/phlex/reactive/component.rb +3 -3
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/streamable.rb +229 -199
- data/lib/phlex/reactive/test_helpers.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +4 -1
data/README.md
CHANGED
|
@@ -233,7 +233,7 @@ rails g phlex:reactive:claude
|
|
|
233
233
|
└──────── Turbo applies it in ◀──────────────────────────────┘
|
|
234
234
|
|
|
235
235
|
...and for OTHER tabs/users:
|
|
236
|
-
model change → Component.
|
|
236
|
+
model change → Component.broadcast_to(stream, replace: model) → pgbus SSE → same morph
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
Client actions and server broadcasts **converge on one re-render unit**: the
|
|
@@ -326,7 +326,7 @@ The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inl
|
|
|
326
326
|
| [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
|
|
327
327
|
| [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/archive, optimistic toggle + delete, Enter-to-add, broadcast on change |
|
|
328
328
|
| [Inline edit + dirty tracking](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit toggle plus an "Unsaved" badge + leave-guard, with zero shipped state |
|
|
329
|
-
| [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `
|
|
329
|
+
| [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast — a background event pushes a re-render, plus a `broadcast_to(js:)` cross-tab pulse |
|
|
330
330
|
| [Reactive collections](https://phlex-reactive.zoolutions.llc/docs/example-collections) | Add/remove rows + a running count + an empty state, declared **once** with `reactive_collection`, optimistic dismiss |
|
|
331
331
|
| [Loading states](https://phlex-reactive.zoolutions.llc/docs/example-loading-states) | `disable_with:` + `busy_on` + `aria-busy`, with a latency toggle to make the pending window visible |
|
|
332
332
|
| [Client-only ops](https://phlex-reactive.zoolutions.llc/docs/example-client-ops) | `on_client` tabs / outside-close menu / accessible drawer — zero fetches, zero custom JS |
|
|
@@ -350,11 +350,11 @@ flagship — every feature composed into one believable UI.
|
|
|
350
350
|
| `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
|
|
351
351
|
| `.update(model = nil, morph: false, **opts)` | `<turbo-stream action=update target=id>` (inner-HTML update); `morph: true` adds `method="morph"` so Turbo morphs the inner HTML in place (issue #113) |
|
|
352
352
|
| `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
353
|
-
| `.
|
|
354
|
-
| `.
|
|
355
|
-
| `.
|
|
356
|
-
| `.
|
|
357
|
-
| `#to_stream_replace
|
|
353
|
+
| `.broadcast_to(*streamables, replace: model, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
|
|
354
|
+
| `.broadcast_to(*streamables, update: model, morph: false)` | Broadcast an inner-HTML update; `morph: true` morphs in place, so a peer editing the component keeps its focus/caret (issue #113) |
|
|
355
|
+
| `.broadcast_to(*streamables, append: model, target:)` / `prepend:` / `remove:` | The other broadcast verbs — the verb is a kwarg (exactly one of `replace:`/`update:`/`append:`/`prepend:`/`remove:`/`js:`) |
|
|
356
|
+
| `.broadcast_to(each: stream_keys, replace: model, morph: false, exclude:, visible_to:)` | **Render once, fan out** the *same* payload to K different stream keys — a per-tenant loop. Pass `each:` instead of `*streamables`. K renders + K HMACs collapse to 1 + K cheap channel calls (~9.5× at K=10). Each key is a `[record, :symbol]` pair (or a bare string). Transport opts + `morph:` forward per key. Per-viewer `visible_to:` content stays render-per-call. See [Broadcasting](https://phlex-reactive.zoolutions.llc/docs/broadcasting). |
|
|
357
|
+
| `#to_stream_replace(morph: false)` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `morph: true` morphs in place |
|
|
358
358
|
| `#to_stream_update(morph: false)` | Inner-HTML update of the *already-built* instance; `morph: true` morphs in place (issue #113) |
|
|
359
359
|
|
|
360
360
|
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
@@ -368,7 +368,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
368
368
|
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
369
369
|
| `mark_authorized!` | Inside an action: satisfy the `verify_authorized` guard after a bespoke check the interceptor can't see (a hand-rolled policy). Call it only after your check passes. |
|
|
370
370
|
| `skip_verify_authorized [ :a, :b ]` | Opt a component (bare) or specific actions out of the default-ON `verify_authorized` guard — for a genuinely public component (a counter, a client-only filter). |
|
|
371
|
-
| `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. |
|
|
371
|
+
| `reactive_root(**overrides)` | Spread onto the root element: emits the component `id` **and** `reactive_attrs` together, so the controller root always carries `#id`. Preferred over `id:` + `reactive_attrs`. `**overrides` (`class:`/`data:`) deep-merge. `compute: :name` binds a client-side compute **at the root** — descriptors plus the `input->reactive#recompute` delegation, so no field needs its own wiring; `nil` collapses to no binding. See [Client-side computes](#client-side-computes-reactive_compute--reactive_text). |
|
|
372
372
|
| `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
|
|
373
373
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
374
374
|
| `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
|
|
@@ -384,16 +384,15 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
384
384
|
| `on(:action, once: true)` | Fire at most once, then unbind (Stimulus's native `:once`). |
|
|
385
385
|
| `on_client(:click, js.toggle("#menu"))` | **Client-only** trigger: applies declared DOM ops with ZERO round trip — no token, no POST, ever. Takes the same `window:`/`once:`/`outside:` modifiers. See [Client-only ops](#client-only-ops-on_client--js--zero-round-trips). |
|
|
386
386
|
| `js` | The immutable op builder behind `on_client`: `show`/`hide`/`toggle` (the `hidden` attribute, with an optional `transition:`), `add_class`/`remove_class`/`toggle_class`, `set_attr`/`remove_attr`/`toggle_attr` (allowlisted names), `focus`/`focus_first`, `text` (set `textContent` — XSS-safe), and `dispatch` — chainable. |
|
|
387
|
-
| `
|
|
388
|
-
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
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)) { … }`. |
|
|
389
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). |
|
|
390
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). |
|
|
391
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). |
|
|
392
|
-
| `reactive_filter(
|
|
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). |
|
|
393
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. |
|
|
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. |
|
|
393
|
+
| `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). |
|
|
395
394
|
| `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). |
|
|
396
|
-
| `
|
|
395
|
+
| `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). |
|
|
397
396
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
398
397
|
| `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). |
|
|
399
398
|
| `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). |
|
|
@@ -428,6 +427,23 @@ Register during boot only: the registry is **frozen after initialization**, so a
|
|
|
428
427
|
runtime `param_type` call raises. A schema referencing a registered type is
|
|
429
428
|
validated at declaration like any built-in.
|
|
430
429
|
|
|
430
|
+
**Named param schemas (`Phlex::Reactive.param_schema`).** Register a reusable
|
|
431
|
+
schema once so sibling components stop duplicating a verbatim params Hash that
|
|
432
|
+
drifts — follows the same boot-only contract as `param_type`:
|
|
433
|
+
|
|
434
|
+
```ruby
|
|
435
|
+
# config/initializers/phlex_reactive.rb
|
|
436
|
+
Phlex::Reactive.param_schema :todo, title: :string, done: :boolean
|
|
437
|
+
|
|
438
|
+
# then, in any component:
|
|
439
|
+
action :save, params: :todo
|
|
440
|
+
|
|
441
|
+
# or compose it into a bigger schema:
|
|
442
|
+
action :bulk, params: { **Phlex::Reactive.param_schema(:todo), note: :string }
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Reading an unregistered name raises, listing the ones that are registered.
|
|
446
|
+
|
|
431
447
|
**File uploads (`:file`).** Declare `:file` (or `[:file]` for multiple) to accept
|
|
432
448
|
an uploaded file in a reactive action — attach a document/receipt/image to the
|
|
433
449
|
record without dropping out to a bespoke controller. When the reactive root holds
|
|
@@ -686,7 +702,7 @@ is not disabled mid-typing. On settle the text is restored only if it still
|
|
|
686
702
|
matches what was swapped in; a morph that rendered a **new** server label is left
|
|
687
703
|
alone.
|
|
688
704
|
|
|
689
|
-
### Dirty-field tracking (`
|
|
705
|
+
### Dirty-field tracking (`reactive_dirty`)
|
|
690
706
|
|
|
691
707
|
Show "unsaved changes", enable **Save** only when something changed, or warn
|
|
692
708
|
before navigating away — Livewire's `wire:dirty` — **without shipping any client
|
|
@@ -696,17 +712,32 @@ zero extra bytes. `input.defaultValue` / `defaultChecked` / `option.defaultSelec
|
|
|
696
712
|
phlex-reactive reads that baseline straight from the DOM — nothing to snapshot,
|
|
697
713
|
nothing to sign.
|
|
698
714
|
|
|
715
|
+
`reactive_dirty` is declared **once, at the class level**, alongside your other
|
|
716
|
+
macros (`reactive_record`/`reactive_state`/`action`) — not as a `reactive_root` or
|
|
717
|
+
`reactive_field` keyword:
|
|
718
|
+
|
|
699
719
|
```ruby
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
720
|
+
class TodoForm < ApplicationComponent
|
|
721
|
+
include Phlex::Reactive::Component
|
|
722
|
+
|
|
723
|
+
reactive_record :todo
|
|
724
|
+
reactive_dirty warn_unsaved: true
|
|
725
|
+
action :save, params: { title: :string }
|
|
726
|
+
|
|
727
|
+
def view_template
|
|
728
|
+
div(**reactive_root) do
|
|
729
|
+
input(**reactive_field(:title, value: @todo.title))
|
|
730
|
+
span(class: "unsaved-badge") { "Unsaved" } # shown via [data-reactive-dirty] CSS
|
|
731
|
+
button(**on(:save, disable_with: "Saving…")) { "Save" }
|
|
732
|
+
end
|
|
733
|
+
end
|
|
704
734
|
end
|
|
705
735
|
```
|
|
706
736
|
|
|
707
|
-
- **`
|
|
708
|
-
re-scan on change. **`
|
|
709
|
-
|
|
737
|
+
- **`reactive_dirty`** (no args) wires every input under the root to a full
|
|
738
|
+
re-scan on change. **`reactive_dirty only: %i[title]`** scopes tracking to the
|
|
739
|
+
named fields only — each carries its own descriptor instead of the root
|
|
740
|
+
delegating for the whole subtree (use it when only some fields should count).
|
|
710
741
|
- On each change the client re-scans **every field the root owns** in one pass and
|
|
711
742
|
marks:
|
|
712
743
|
- each changed field with **`data-reactive-dirty="true"`**, and
|
|
@@ -739,15 +770,16 @@ and the badge clears with no reload. (Turbo 8 morph preserves a focused field's
|
|
|
739
770
|
in-progress value while writing the fresh defaults — the post-morph re-scan is
|
|
740
771
|
what keeps the root count honest in that state.)
|
|
741
772
|
|
|
742
|
-
**`warn_unsaved: true`** arms a navigate-away guard gated on the
|
|
743
|
-
count: `beforeunload` (a real browser unload) and
|
|
744
|
-
in-app navigation). A clean form never blocks.
|
|
745
|
-
own generic `beforeunload` copy (your message
|
|
746
|
-
`turbo:before-visit` **does not fire on
|
|
747
|
-
documented Turbo gap, not a
|
|
773
|
+
**`reactive_dirty warn_unsaved: true`** arms a navigate-away guard gated on the
|
|
774
|
+
**live** dirty count: `beforeunload` (a real browser unload) and
|
|
775
|
+
`turbo:before-visit` (a Turbo in-app navigation). A clean form never blocks.
|
|
776
|
+
**Caveats:** browsers show their own generic `beforeunload` copy (your message
|
|
777
|
+
string is legacy and ignored), and `turbo:before-visit` **does not fire on
|
|
778
|
+
restoration visits** (Back/Forward) — a documented Turbo gap, not a
|
|
779
|
+
phlex-reactive one.
|
|
748
780
|
|
|
749
781
|
**Out of scope (v1):** rich-text / `contenteditable` editors have no `default*`
|
|
750
|
-
baseline and are **not** tracked. Combining `reactive_field
|
|
782
|
+
baseline and are **not** tracked. Combining `reactive_field` with your own
|
|
751
783
|
`data:`/`on(...)` still needs `mix(...)` (a bare merge clobbers the `data-action`
|
|
752
784
|
the descriptor rides on — the same rule as everywhere else).
|
|
753
785
|
|
|
@@ -788,7 +820,7 @@ the same chain covers the rest of the client-only vocabulary:
|
|
|
788
820
|
|
|
789
821
|
```ruby
|
|
790
822
|
button(**on_client(:click, js
|
|
791
|
-
.toggle("#menu", transition:
|
|
823
|
+
.toggle("#menu", transition: { during: "transition-opacity", from: "opacity-0", to: "opacity-100" })
|
|
792
824
|
.toggle_attr(:root, "aria-expanded") # accessible disclosure state
|
|
793
825
|
.focus_first("#menu") # move focus into the opened menu
|
|
794
826
|
.dispatch("app:menu-toggled", detail: { open: true }))) { "Menu" }
|
|
@@ -814,7 +846,7 @@ button(**on_client(:click, js
|
|
|
814
846
|
another component or a plain Stimulus controller can react to a client-only
|
|
815
847
|
interaction — `to:` picks the element (default: the component root), `detail:`
|
|
816
848
|
is the payload.
|
|
817
|
-
- **`transition:
|
|
849
|
+
- **`transition: { during:, from:, to: }`** on `show`/`hide`/`toggle` animates the
|
|
818
850
|
visibility flip: `during`+`from` are applied, then `from`→`to` swaps on the next
|
|
819
851
|
frame, and the helper classes are cleaned up on `animationend` (with a timeout
|
|
820
852
|
fallback, so an element with no animation never leaves them stuck). The op chain
|
|
@@ -996,9 +1028,8 @@ reactive_compute :preview,
|
|
|
996
1028
|
inputs: { title: :string, qty: :number }, # typed: :string raw, :number → Number
|
|
997
1029
|
outputs: %i[title_preview char_count] # written with no round trip
|
|
998
1030
|
|
|
999
|
-
div(**
|
|
1000
|
-
input(**
|
|
1001
|
-
data: { action: "input->reactive#recompute" }))
|
|
1031
|
+
div(**reactive_root(compute: :preview)) do
|
|
1032
|
+
input(**reactive_field(:title, value: @post.title))
|
|
1002
1033
|
h2 { reactive_text(:title_preview, @post.title) } # a text-node output
|
|
1003
1034
|
small { reactive_text(:char_count) } # another text-node output
|
|
1004
1035
|
end
|
|
@@ -1012,18 +1043,31 @@ setComputeReducer("preview", ({ title }) => ({
|
|
|
1012
1043
|
}))
|
|
1013
1044
|
```
|
|
1014
1045
|
|
|
1046
|
+
- **`reactive_root(compute: :name)` binds AND listens at the root.** It emits the
|
|
1047
|
+
compute descriptors plus the `input->reactive#recompute` delegation on the root
|
|
1048
|
+
element, so no field needs any per-field compute wiring. `nil` (e.g.
|
|
1049
|
+
`reactive_root(compute: (:preview unless @post.persisted?))`) collapses to no
|
|
1050
|
+
binding at all — one expression for conditional compute.
|
|
1015
1051
|
- **Typed inputs.** `inputs:` takes a **hash** to type each input: a `:number` is
|
|
1016
1052
|
coerced through `Number` (blank/NaN → 0 — the array-form default), a `:string`
|
|
1017
1053
|
reaches the reducer **raw** so a live text preview reads real text. The **array
|
|
1018
|
-
form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire.
|
|
1054
|
+
form** (`inputs: %i[a b]`) stays all-numeric and byte-identical on the wire. The
|
|
1055
|
+
**permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
|
|
1056
|
+
declaration — bare symbols default to `:number`, a trailing hash types the
|
|
1057
|
+
exceptions.
|
|
1019
1058
|
- **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
|
|
1020
|
-
`textContent` (XSS-safe by construction).
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
`
|
|
1024
|
-
|
|
1025
|
-
`
|
|
1026
|
-
|
|
1059
|
+
`textContent` (XSS-safe by construction). Every reducer-result key paints any
|
|
1060
|
+
matching sink: an owned **field** if declared in `outputs:`, any owned
|
|
1061
|
+
**`reactive_text`** node by presence, or a declared **`mirror:`** id — so an
|
|
1062
|
+
`outputs:` entry that exists only to reach a text node is redundant (existing
|
|
1063
|
+
declarations keep working; it's a widening, not a breaking change). It carries
|
|
1064
|
+
**no `name`**, so it's never collected or POSTed as a param.
|
|
1065
|
+
- **Reducer-less mirrors, and reactive_values seeding.** A declared **input** also
|
|
1066
|
+
mirrors into its own `reactive_text(:same_name)` node on every keystroke with
|
|
1067
|
+
**no reducer at all** — so `reactive_text(:title)` is a live field echo out of
|
|
1068
|
+
the box. And `reactive_text(:name)` called with **no explicit `initial`** seeds
|
|
1069
|
+
its first paint from `reactive_values` when the component declares one and
|
|
1070
|
+
covers that name — the same no-flash first-paint contract `reactive_show` uses.
|
|
1027
1071
|
- **Seed the server render.** Your `view_template` must seed each mirror with the
|
|
1028
1072
|
same derived value the reducer would (`reactive_text(:char_count, "5/80")`), or
|
|
1029
1073
|
a later morph repaints stale text — the same reconcile contract the whole
|
|
@@ -1109,13 +1153,13 @@ root instead:
|
|
|
1109
1153
|
|
|
1110
1154
|
```ruby
|
|
1111
1155
|
div(**mix(reactive_root, reactive_filter(
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
empty: "#no-matches" # optional: reveal when 0 options match
|
|
1156
|
+
:q, # the FIELD that drives the filter → [name="q"] (scope-aware)
|
|
1157
|
+
group: "[data-filter-group]", # optional: collapse a header when all its options hide
|
|
1158
|
+
empty: "#no-matches" # optional: reveal when 0 options match
|
|
1116
1159
|
))) do
|
|
1117
1160
|
# STANDALONE keyboard nav — no action on the input, so typing never POSTs.
|
|
1118
|
-
|
|
1161
|
+
# name: "q" is the field reactive_filter(:q) binds to; option: defaults to [role=option].
|
|
1162
|
+
input(name: "q", type: "search", **reactive_listnav("[role=option]"))
|
|
1119
1163
|
|
|
1120
1164
|
categories.each do |category, exercises|
|
|
1121
1165
|
div(data: { filter_group: "" }) do
|
|
@@ -1178,17 +1222,17 @@ button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
|
1178
1222
|
**Binding inputs to action params (drop the magic `name:`).** A field's value
|
|
1179
1223
|
travels with an action only if its `name` equals the param. Hand-writing
|
|
1180
1224
|
`name: "value"` on every input is easy to forget — the action then silently gets
|
|
1181
|
-
nothing. `
|
|
1182
|
-
stays on the button, so focusing the
|
|
1183
|
-
mode):
|
|
1225
|
+
nothing. `reactive_field` returns the attribute hash that carries the binding —
|
|
1226
|
+
spread it onto any control (the trigger stays on the button, so focusing the
|
|
1227
|
+
field doesn't dispatch and collapse edit mode):
|
|
1184
1228
|
|
|
1185
1229
|
```ruby
|
|
1186
1230
|
action :save, params: { value: :string, status: :string }
|
|
1187
1231
|
|
|
1188
1232
|
def view_template
|
|
1189
1233
|
span(**reactive_root) do
|
|
1190
|
-
|
|
1191
|
-
|
|
1234
|
+
input(**reactive_field(:value, value: @record.name)) # <input name="value" …>
|
|
1235
|
+
select(**reactive_field(:status)) do # <select name="status">…</select>
|
|
1192
1236
|
%w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
|
|
1193
1237
|
end
|
|
1194
1238
|
button(**mix(on(:save), data: { testid: "save" })) { "Save" }
|
|
@@ -1252,7 +1296,7 @@ gains a seam.
|
|
|
1252
1296
|
By default an action re-renders its component in place. To do more, **return**
|
|
1253
1297
|
`reply.<verb>` — a subject-bound builder available in every component. It governs
|
|
1254
1298
|
only the actor's HTTP reply (cross-tab updates still use
|
|
1255
|
-
`
|
|
1299
|
+
`broadcast_to(..., exclude: reactive_connection_id)`). Returning anything else
|
|
1256
1300
|
keeps the default, so existing actions are unaffected.
|
|
1257
1301
|
|
|
1258
1302
|
`reply` reads cleanly: the component is the implicit subject (no `self` to
|
|
@@ -1276,7 +1320,7 @@ def add(item:) = reply.replace.stream(Totals.update(@order)) # mul
|
|
|
1276
1320
|
def update(name:) = (@row.update!(name:); reply.morph)
|
|
1277
1321
|
|
|
1278
1322
|
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
|
|
1279
|
-
def rename(value:) = (@account.update!(name: value); reply.replace.
|
|
1323
|
+
def rename(value:) = (@account.update!(name: value); reply.replace.also(page_heading: @account.name))
|
|
1280
1324
|
|
|
1281
1325
|
# Update ONLY part of the component (issue #30): re-stream just the total cell,
|
|
1282
1326
|
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
|
|
@@ -1289,8 +1333,8 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
|
|
|
1289
1333
|
|---|---|
|
|
1290
1334
|
| `reply.replace` / `reply.update(morph: false)` | re-render in place (default; `replace` swaps the whole element via outerHTML, `update` swaps only the inner HTML) |
|
|
1291
1335
|
| `reply.morph` / `reply.replace(morph: true)` / `reply.update(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (`replace` #28; `update` #113) |
|
|
1292
|
-
| `.
|
|
1293
|
-
| `.
|
|
1336
|
+
| `.also(target => content, …)` | **UPDATE** (inner HTML) companion elements by DOM id; `content` is a plain string (escaped) or a Phlex component. The argument *type* picks the action — pairs mean `update` |
|
|
1337
|
+
| `.also(component, morph: false)` | **REPLACE** another Streamable component at its own `#id` (`morph: true` morphs in place). A component argument means `replace` |
|
|
1294
1338
|
| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped, wrapped in a level-carrying `<div>` — see [Flash levels](#flash-levels)) or a Phlex component (rendered verbatim; off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
|
|
1295
1339
|
| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
1296
1340
|
| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
@@ -1299,7 +1343,7 @@ def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams
|
|
|
1299
1343
|
| `.defer(component, placeholder:, morph:)` | take an **expensive segment off the actor's critical path** (issue #165) — the reply returns immediately and the real render streams to the SAME actor when ready; see [Deferred segments](#deferred-segments-replydefer--reactive_lazy) |
|
|
1300
1344
|
| `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
|
|
1301
1345
|
|
|
1302
|
-
`.flash`/`.stream`/`.
|
|
1346
|
+
`.flash`/`.stream`/`.also` are additive on a self-replace, so the component's
|
|
1303
1347
|
signed token always refreshes. **`reply.streams`** is the exception that proves
|
|
1304
1348
|
the rule: it deliberately skips the full-self replace (so your hand-built streams
|
|
1305
1349
|
update only the targets you name) and refreshes the token via a tiny inert
|
|
@@ -1363,7 +1407,7 @@ Semantics you can rely on:
|
|
|
1363
1407
|
the action's transaction committed; a rollback or a denied action leaks no
|
|
1364
1408
|
deferred render (and enqueues no job).
|
|
1365
1409
|
- **Actor-scoped** — the deferred render reaches only the actor; peers keep
|
|
1366
|
-
getting updates via `
|
|
1410
|
+
getting updates via `broadcast_to` (use both when both need the value).
|
|
1367
1411
|
- **Superseding** — a newer action for the same target aborts the in-flight
|
|
1368
1412
|
deferred render; a fast typist never gets stale totals painted over fresh ones.
|
|
1369
1413
|
- **Interactive on arrival** — the streamed root carries a fresh action token.
|
|
@@ -1479,12 +1523,14 @@ Prefer your own markup? Two escape hatches:
|
|
|
1479
1523
|
# (you own the markup entirely, including the level styling):
|
|
1480
1524
|
reply.replace.flash(:error, Alert.new(level: :error, message: msg))
|
|
1481
1525
|
|
|
1482
|
-
# 2. Or configure a flash component ONCE — string flashes render through it
|
|
1483
|
-
#
|
|
1484
|
-
|
|
1526
|
+
# 2. Or configure a flash component ONCE — string flashes render through it;
|
|
1527
|
+
# component content still bypasses it. flash_component is an app-owned
|
|
1528
|
+
# CALLABLE (issue #182): it maps (level, content) to your component —
|
|
1529
|
+
# the gem never guesses kwargs:
|
|
1530
|
+
Phlex::Reactive.flash_component = ->(level, content) { MyFlash.new(level:, content:) } # default nil → the built-in wrapper
|
|
1485
1531
|
```
|
|
1486
1532
|
|
|
1487
|
-
#### Server-pushed client ops (`reply.js` + `
|
|
1533
|
+
#### Server-pushed client ops (`reply.js` + `broadcast_to(js:)`, issue #97)
|
|
1488
1534
|
|
|
1489
1535
|
Sometimes the server needs the client to do something other than swap HTML —
|
|
1490
1536
|
focus the next field after a save, dispatch an app event to a toast host, add an
|
|
@@ -1514,11 +1560,11 @@ transport (Action Cable **or** pgbus) — a background nudge to all viewers:
|
|
|
1514
1560
|
|
|
1515
1561
|
```ruby
|
|
1516
1562
|
# In a model/job: light up the bell in every viewer's tab, minus the actor's own.
|
|
1517
|
-
Notifications::Badge.
|
|
1518
|
-
js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
|
|
1563
|
+
Notifications::Badge.broadcast_to(user, :alerts,
|
|
1564
|
+
js: js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
|
|
1519
1565
|
```
|
|
1520
1566
|
|
|
1521
|
-
`
|
|
1567
|
+
`broadcast_to` with `js:` **refuses focus-class ops** (`focus`/`focus_first` raise
|
|
1522
1568
|
`ArgumentError`): broadcasting focus would steal it in every subscriber's tab, so
|
|
1523
1569
|
focus is an actor-reply concern only. Everything else is a fair broadcast (class
|
|
1524
1570
|
and attribute toggles, `dispatch`). As with `on_client`, the ops are
|
|
@@ -1538,7 +1584,7 @@ record can be there purely for **identity + authorization** while the action's
|
|
|
1538
1584
|
real job is to recompute **live, unsaved form values** the user is mid-edit. The
|
|
1539
1585
|
record is re-located and instantiated on each action (`from_identity`), never
|
|
1540
1586
|
auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
|
|
1541
|
-
opt-in (you call `record.update!` / `
|
|
1587
|
+
opt-in (you call `record.update!` / `broadcast_to` yourself). Pair that with
|
|
1542
1588
|
`reply.streams` and you get a first-class "authorize via the row, compute over
|
|
1543
1589
|
the params, stream a partial update, touch neither the DB nor peer tabs" action:
|
|
1544
1590
|
|
|
@@ -1565,9 +1611,10 @@ Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
|
|
|
1565
1611
|
aren't clobbered. Authorize the record as always — identity is never permission.
|
|
1566
1612
|
|
|
1567
1613
|
> **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
|
|
1568
|
-
> immutable value object the endpoint reads.
|
|
1569
|
-
> (`Phlex::Reactive::Response.replace(self)`
|
|
1570
|
-
>
|
|
1614
|
+
> immutable value object the endpoint reads. `reply` is the ONE documented door
|
|
1615
|
+
> (issue #182): the old `Phlex::Reactive::Response.replace(self)` class verbs
|
|
1616
|
+
> are removed and raise a guided `ArgumentError` naming the `reply.<verb>`
|
|
1617
|
+
> rewrite; treat `Response` as an internal detail.
|
|
1571
1618
|
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
1572
1619
|
> `html: @account.name` is safe even for user-supplied values. To emit intentional
|
|
1573
1620
|
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
|
|
@@ -1825,12 +1872,13 @@ class NotificationsList < ApplicationComponent
|
|
|
1825
1872
|
|
|
1826
1873
|
def add(title:)
|
|
1827
1874
|
todo = Todo.create!(title:)
|
|
1828
|
-
reply.append(:notifications
|
|
1875
|
+
reply.append(todo, to: :notifications) # append row + bump count + clear empty-state
|
|
1829
1876
|
end
|
|
1830
1877
|
|
|
1831
1878
|
def dismiss(id:)
|
|
1832
|
-
Todo.find(id)
|
|
1833
|
-
|
|
1879
|
+
todo = Todo.find(id)
|
|
1880
|
+
todo.destroy!
|
|
1881
|
+
reply.remove(todo, from: :notifications) # remove row + bump count + restore empty-state at 0
|
|
1834
1882
|
end
|
|
1835
1883
|
|
|
1836
1884
|
# view_template renders the count, the container <ul>, and the empty-state on
|
|
@@ -1840,9 +1888,9 @@ end
|
|
|
1840
1888
|
|
|
1841
1889
|
| Builder | Reply (one `Response`) |
|
|
1842
1890
|
|---|---|
|
|
1843
|
-
| `reply.append(
|
|
1844
|
-
| `reply.prepend(
|
|
1845
|
-
| `reply.remove(
|
|
1891
|
+
| `reply.append(model, to: name)` | append the row into the container + update the count + remove the empty-state when the list crosses 0→1 |
|
|
1892
|
+
| `reply.prepend(model, to: name)` | as `append`, but the row goes to the top |
|
|
1893
|
+
| `reply.remove(model, from: name)` | remove the row by its `dom_id` + update the count + append the empty-state back when the list crosses →0 |
|
|
1846
1894
|
|
|
1847
1895
|
- **`size:` is the source of truth** — it's *re-counted* server-side after the
|
|
1848
1896
|
mutation, so the badge and the empty-state are correct-by-construction (no
|
|
@@ -1854,11 +1902,11 @@ end
|
|
|
1854
1902
|
(correct on the first click, silently rejected after); the helper bakes the
|
|
1855
1903
|
refresh in so you never hit it.
|
|
1856
1904
|
- **`remove` takes the record or its `dom_id` string** — a just-destroyed
|
|
1857
|
-
ActiveRecord still answers `dom_id` correctly, so `reply.remove(:items
|
|
1905
|
+
ActiveRecord still answers `dom_id` correctly, so `reply.remove(todo, from: :items)`
|
|
1858
1906
|
works; pass the raw id only if your row `#id` matches `ActiveRecord::RecordIdentifier`.
|
|
1859
1907
|
- **Reply governs the actor's HTTP response only.** For a *cross-tab* live list
|
|
1860
1908
|
(other viewers see the row appear) keep broadcasting the row with
|
|
1861
|
-
`NotificationRow.
|
|
1909
|
+
`NotificationRow.broadcast_to(..., append: model, exclude: reactive_connection_id)` —
|
|
1862
1910
|
`reactive_collection` is the per-actor add/remove + count + empty-state wrapper,
|
|
1863
1911
|
not a replacement for the broadcast.
|
|
1864
1912
|
|
|
@@ -2126,7 +2174,7 @@ end
|
|
|
2126
2174
|
|
|
2127
2175
|
[pgbus](https://github.com/mhenrixon/pgbus) replaces Action Cable's transport
|
|
2128
2176
|
with Postgres SSE and fixes its reliability gaps. With it installed,
|
|
2129
|
-
`
|
|
2177
|
+
`broadcast_to` and `turbo_stream_from` route over pgbus automatically:
|
|
2130
2178
|
|
|
2131
2179
|
```ruby
|
|
2132
2180
|
class Message < ApplicationRecord
|
|
@@ -2158,7 +2206,8 @@ events, all under the `phlex_reactive` namespace:
|
|
|
2158
2206
|
|-------|-------|---------|
|
|
2159
2207
|
| `action.phlex_reactive` | once per request | `component`, `action`, `outcome` (`ok`/`denied_undeclared`/`invalid_token`/`not_found`/`unauthorized`/`unverified`) |
|
|
2160
2208
|
| `render.phlex_reactive` | per component render | `component`, `bytesize` |
|
|
2161
|
-
| `broadcast.phlex_reactive` | per `
|
|
2209
|
+
| `broadcast.phlex_reactive` | per `broadcast_to` call (Action Cable **and** pgbus) | `component`, `stream_action`, `streamables` |
|
|
2210
|
+
| `defer.phlex_reactive` | once per deferred render (the `reply.defer` pull/push endpoint) | `component`, `outcome` (`ok`/`no_content`/`invalid_token`/`not_found`/`unauthorized`) |
|
|
2162
2211
|
|
|
2163
2212
|
Payloads carry **names, the outcome, and sizes only** — never the token, params,
|
|
2164
2213
|
or state, so an event can't leak a secret. Subscribe from an initializer:
|
|
@@ -85,7 +85,7 @@ module Phlex
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
event[:outcome] = :ok
|
|
88
|
-
stream = payload["m"] == "morph"
|
|
88
|
+
stream = component.to_stream_replace(morph: payload["m"] == "morph")
|
|
89
89
|
render turbo_stream: stream
|
|
90
90
|
rescue Phlex::Reactive::InvalidToken => e
|
|
91
91
|
event[:outcome] = :invalid_token
|
|
@@ -119,7 +119,7 @@ module Phlex
|
|
|
119
119
|
payload = verified_payload
|
|
120
120
|
component_class = resolve_component(payload["c"])
|
|
121
121
|
event[:component] = component_class.name
|
|
122
|
-
action_def = component_class.
|
|
122
|
+
action_def = component_class.reactive_actions[reactive_action_name.to_sym]
|
|
123
123
|
|
|
124
124
|
# default-deny
|
|
125
125
|
unless action_def
|
|
@@ -189,7 +189,9 @@ module Phlex
|
|
|
189
189
|
return unless callable
|
|
190
190
|
|
|
191
191
|
message = callable.call(kind)
|
|
192
|
-
|
|
192
|
+
# flash_stream is a private Response builder (issue #182) — the endpoint's
|
|
193
|
+
# error path is an internal caller, so reach it via send.
|
|
194
|
+
Phlex::Reactive::Response.send(:flash_stream, :error, message, target: Phlex::Reactive.flash_target)
|
|
193
195
|
rescue => e # rubocop:disable Style/RescueStandardError
|
|
194
196
|
::Rails.logger&.warn("[phlex-reactive] error_flash raised: #{e.message}") if defined?(::Rails) &&
|
|
195
197
|
::Rails.respond_to?(:logger)
|
|
@@ -298,7 +300,7 @@ module Phlex
|
|
|
298
300
|
|
|
299
301
|
streams = result.streams
|
|
300
302
|
|
|
301
|
-
# Partial update (
|
|
303
|
+
# Partial update (reply.streams, issue #30): the action
|
|
302
304
|
# re-rendered only PART of the component and opted out of the full-self
|
|
303
305
|
# replace. Append a tiny token-only stream so the signed token still rolls
|
|
304
306
|
# forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
|
|
@@ -320,7 +322,7 @@ module Phlex
|
|
|
320
322
|
# for replace AND update of self (both re-render the root via
|
|
321
323
|
# render_component, carrying the token), and still adds the fallback
|
|
322
324
|
# replace when a hand-built `with(...)` stream omits it. Idempotent: a
|
|
323
|
-
#
|
|
325
|
+
# reply.replace/update already carries the token, so we
|
|
324
326
|
# don't double the self-render.
|
|
325
327
|
#
|
|
326
328
|
# GUARD 2 (issue #114): GLOBAL, un-scoped — "does ANY stream carry a
|
|
@@ -329,7 +331,7 @@ module Phlex
|
|
|
329
331
|
# scan. Deliberately NOT rx_refreshes_token_for? (target-scoped): scoping
|
|
330
332
|
# this guard would regress update/morph of self on an aliased id.
|
|
331
333
|
#
|
|
332
|
-
# A self-render reply (
|
|
334
|
+
# A self-render reply (reply.replace/update/morph — subject_component
|
|
333
335
|
# set) whose streams somehow carry no token gets the full self-replace
|
|
334
336
|
# (defensive, unchanged). A companion-only reply.with (NO subject, NO
|
|
335
337
|
# token_component) that doesn't re-render the root gets a token-ONLY
|
|
@@ -483,13 +485,31 @@ module Phlex
|
|
|
483
485
|
# skipped — zero extra work on the production path.
|
|
484
486
|
def coerce_params(action_def, component_class: nil, action_name: nil)
|
|
485
487
|
dropped = Phlex::Reactive.verbose_errors ? [] : nil
|
|
486
|
-
raw = params.fetch(:params, {})
|
|
488
|
+
raw = unwrap_scope(params.fetch(:params, {}), component_class)
|
|
487
489
|
|
|
488
490
|
coerced = action_def.schema.coerce(raw, dropped)
|
|
489
491
|
log_dropped_params(dropped, action_def.params, component_class, action_name)
|
|
490
492
|
coerced
|
|
491
493
|
end
|
|
492
494
|
|
|
495
|
+
# Issue #184: a scoped component's fields POST bracketed (todo[title]), which
|
|
496
|
+
# Rails expands to { "todo" => { "title" => … } } before the action runs. Peel
|
|
497
|
+
# exactly ONE scope level so the FLAT schema { title: :string } matches (the
|
|
498
|
+
# #67 bracket-drop footgun). Only when the component declares reactive_scope
|
|
499
|
+
# AND the raw params carry that single key mapping to a Hash — otherwise the
|
|
500
|
+
# raw params pass through untouched (unscoped components + nested_attributes
|
|
501
|
+
# shapes are unaffected).
|
|
502
|
+
def unwrap_scope(raw, component_class)
|
|
503
|
+
scope = component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
|
|
504
|
+
return raw unless scope
|
|
505
|
+
|
|
506
|
+
# At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
|
|
507
|
+
# too — NOT a Hash. Accept either shape (both answer to the schema's
|
|
508
|
+
# coerce): unwrap only when the scope key maps to a nested params/hash.
|
|
509
|
+
nested = raw[scope.to_s]
|
|
510
|
+
nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
|
|
511
|
+
end
|
|
512
|
+
|
|
493
513
|
# ---- verbose_errors dropped-param logging --------------------------
|
|
494
514
|
# ParamSchema collects the dropped entries; the controller formats the ONE
|
|
495
515
|
# warn line (with the #16/#21 shape hints). Everything below runs ONLY when
|
|
@@ -577,7 +597,7 @@ module Phlex
|
|
|
577
597
|
diagnostic: :unknown_class
|
|
578
598
|
)
|
|
579
599
|
end
|
|
580
|
-
unless klass.respond_to?(:
|
|
600
|
+
unless klass.respond_to?(:reactive_actions) && klass.include?(Phlex::Reactive::Component)
|
|
581
601
|
raise Phlex::Reactive::InvalidToken.new(
|
|
582
602
|
"#{name} resolved but does not include Phlex::Reactive::Component",
|
|
583
603
|
diagnostic: :not_reactive_class
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The client-side confirm-predicate registry — the multi-field escape hatch for
|
|
2
|
+
// conditional confirmation (issue #179).
|
|
3
|
+
//
|
|
4
|
+
// confirm: { when: { total: 0 }, message: } handles the single-field 80% case
|
|
5
|
+
// declaratively (the reactive_show conditions language), with NO JS. But some
|
|
6
|
+
// soft-validation is multi-field — "warn if the end date precedes the start",
|
|
7
|
+
// "warn if two related totals disagree" — which a single field=value condition
|
|
8
|
+
// can't express. This registry is the seam for that logic: name a pure function
|
|
9
|
+
// with `confirm: { predicate: "name", message: }` (Ruby) and register it here.
|
|
10
|
+
//
|
|
11
|
+
// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable
|
|
12
|
+
// registry with a lookup the controller calls. Register once at boot:
|
|
13
|
+
//
|
|
14
|
+
// import { setConfirmPredicate } from "phlex/reactive/confirm_predicate"
|
|
15
|
+
// setConfirmPredicate("end_before_start", ({ starts_at, ends_at }) =>
|
|
16
|
+
// ends_at !== "" && ends_at < starts_at)
|
|
17
|
+
//
|
|
18
|
+
// The predicate's signature is (fields) => boolean:
|
|
19
|
+
//
|
|
20
|
+
// fields — a plain object of { name: value } over the trigger root's collected
|
|
21
|
+
// controls (the SAME snapshot reactive_compute reads — #collectFields).
|
|
22
|
+
// Values are the raw control values (strings; a checkbox is a boolean).
|
|
23
|
+
//
|
|
24
|
+
// It returns truthy to WARN (the confirm dialog fires with the declared message)
|
|
25
|
+
// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT
|
|
26
|
+
// authorization: a user can bypass it (devtools, an unregistered name) and the
|
|
27
|
+
// action still hits the endpoint's real authorize/default-deny — never let a
|
|
28
|
+
// predicate stand in for a server-side check.
|
|
29
|
+
//
|
|
30
|
+
// A missing predicate (name never registered) makes the gate a NO-OP: the
|
|
31
|
+
// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's
|
|
32
|
+
// unknown-reducer posture — a stale/typo'd name must not break the page or
|
|
33
|
+
// (worse) block a legitimate action behind a dialog that can never resolve.
|
|
34
|
+
|
|
35
|
+
const predicates = new Map()
|
|
36
|
+
|
|
37
|
+
// Register (or replace) the predicate for `key`. `fn` is
|
|
38
|
+
// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.
|
|
39
|
+
export function setConfirmPredicate(key, fn) {
|
|
40
|
+
predicates.set(key, fn)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Look up a registered predicate; undefined when none — the controller then
|
|
44
|
+
// proceeds without a dialog (and warns) rather than throwing or blocking.
|
|
45
|
+
export function confirmPredicate(key) {
|
|
46
|
+
return predicates.get(key)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Test seam: clear the registry so a predicate registered in one test can't leak.
|
|
50
|
+
export function __resetConfirmPredicateRegistryForTest() {
|
|
51
|
+
predicates.clear()
|
|
52
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
var j=new Map;function u(b,q){j.set(b,q)}function v(b){return j.get(b)}function w(){j.clear()}export{u as setConfirmPredicate,v as confirmPredicate,w as __resetConfirmPredicateRegistryForTest};
|
|
2
|
+
|
|
3
|
+
//# debugId=2F392605F11792F264756E2164756E21
|
|
4
|
+
//# sourceMappingURL=confirm_predicate.min.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["confirm_predicate.js"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"// The client-side confirm-predicate registry — the multi-field escape hatch for\n// conditional confirmation (issue #179).\n//\n// confirm: { when: { total: 0 }, message: } handles the single-field 80% case\n// declaratively (the reactive_show conditions language), with NO JS. But some\n// soft-validation is multi-field — \"warn if the end date precedes the start\",\n// \"warn if two related totals disagree\" — which a single field=value condition\n// can't express. This registry is the seam for that logic: name a pure function\n// with `confirm: { predicate: \"name\", message: }` (Ruby) and register it here.\n//\n// The seam mirrors compute.js (setComputeReducer) and confirm.js: a settable\n// registry with a lookup the controller calls. Register once at boot:\n//\n// import { setConfirmPredicate } from \"phlex/reactive/confirm_predicate\"\n// setConfirmPredicate(\"end_before_start\", ({ starts_at, ends_at }) =>\n// ends_at !== \"\" && ends_at < starts_at)\n//\n// The predicate's signature is (fields) => boolean:\n//\n// fields — a plain object of { name: value } over the trigger root's collected\n// controls (the SAME snapshot reactive_compute reads — #collectFields).\n// Values are the raw control values (strings; a checkbox is a boolean).\n//\n// It returns truthy to WARN (the confirm dialog fires with the declared message)\n// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT\n// authorization: a user can bypass it (devtools, an unregistered name) and the\n// action still hits the endpoint's real authorize/default-deny — never let a\n// predicate stand in for a server-side check.\n//\n// A missing predicate (name never registered) makes the gate a NO-OP: the\n// controller proceeds WITHOUT a dialog and warns, exactly like compute.js's\n// unknown-reducer posture — a stale/typo'd name must not break the page or\n// (worse) block a legitimate action behind a dialog that can never resolve.\n\nconst predicates = new Map()\n\n// Register (or replace) the predicate for `key`. `fn` is\n// (fields: Record<string, unknown>) => boolean — truthy warns, falsy proceeds.\nexport function setConfirmPredicate(key, fn) {\n predicates.set(key, fn)\n}\n\n// Look up a registered predicate; undefined when none — the controller then\n// proceeds without a dialog (and warns) rather than throwing or blocking.\nexport function confirmPredicate(key) {\n return predicates.get(key)\n}\n\n// Test seam: clear the registry so a predicate registered in one test can't leak.\nexport function __resetConfirmPredicateRegistryForTest() {\n predicates.clear()\n}\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": "AAkCA,IAAM,EAAa,IAAI,IAIhB,SAAS,CAAmB,CAAC,EAAK,EAAI,CAC3C,EAAW,IAAI,EAAK,CAAE,EAKjB,SAAS,CAAgB,CAAC,EAAK,CACpC,OAAO,EAAW,IAAI,CAAG,EAIpB,SAAS,CAAsC,EAAG,CACvD,EAAW,MAAM",
|
|
8
|
+
"debugId": "2F392605F11792F264756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|