phlex-reactive 0.4.7 → 0.4.8
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 +105 -0
- data/README.md +155 -24
- data/app/javascript/phlex/reactive/compute.js +48 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +128 -0
- data/lib/phlex/reactive/component.rb +104 -3
- data/lib/phlex/reactive/engine.rb +11 -0
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2858c45de31c08fa46914064af8c8177e0cecf507c7e908dc4239858f5c078b6
|
|
4
|
+
data.tar.gz: 540758b5f0c04562edfe8a41719b129c863fe5079475a6cd114818804d4c337f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 052426b076385bc6c23c04e9a5b9c0d15106c514ea7b7c640e4e407cd0517e8910b005038d62156121a43125babbc402806a7a7544b14be76da7b50993c3ab3e
|
|
7
|
+
data.tar.gz: 10ec149142e82a0e3627d060fea0ed267bcc8ff42f0799fbe22ae95d12119ff4a4f500eb6e6ad6ec05591adfb93dd5530b37a044949b614acdcac60fc589d74a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,76 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Combobox keyboard navigation — `on(:search, …, listnav: "[role=option]")` (#72).**
|
|
12
|
+
A search/combobox trigger can now declare client-side list navigation: Arrow
|
|
13
|
+
Up/Down move a highlight among the option elements IN-BROWSER (no round trip),
|
|
14
|
+
Enter picks the highlighted option by clicking its own `on(:select)` trigger
|
|
15
|
+
(so the selection stays a normal signed reactive action), and Escape clears —
|
|
16
|
+
all without a bespoke Stimulus controller. `listnav:` appends Stimulus's native
|
|
17
|
+
keyboard filters (`keydown.down/up/enter/esc->reactive#listnav*`) to the input's
|
|
18
|
+
`data-action` and marks the option selector; the generic controller's `listnav*`
|
|
19
|
+
handlers own the ephemeral highlight (a `data-reactive-highlighted` attribute,
|
|
20
|
+
never shipped as trusted state), mirroring `#recompute`. Only the highlight is
|
|
21
|
+
client-side — selection is still a default-deny, signed action. No new client
|
|
22
|
+
module (the handlers live in the existing controller); covered by unit (JS),
|
|
23
|
+
request, and real-browser system specs green under Puma AND Falcon.
|
|
24
|
+
|
|
25
|
+
- **Keyboard triggers on `on(...)` via `event:` — Enter-to-submit /
|
|
26
|
+
Escape-to-cancel with no client JavaScript.** `event:` is interpolated straight
|
|
27
|
+
into the Stimulus action descriptor, so **Stimulus's native keyboard filters
|
|
28
|
+
just work**: `on(:add, event: "keydown.enter")` emits
|
|
29
|
+
`keydown.enter->reactive#dispatch` and the action fires only on Enter, not on
|
|
30
|
+
every keypress. `event: "keydown.esc"` gives Escape-to-cancel. No new option to
|
|
31
|
+
learn (it's Stimulus's own filter syntax), no client change, no vendored-client
|
|
32
|
+
re-sync — and, deliberately, **no reserved `key:` keyword**, so `key` stays a
|
|
33
|
+
normal action-param name (`on(:switch, key: "pgbus")` keeps passing `key`
|
|
34
|
+
through as a param — no backward-incompatibility). Because a keyboard trigger
|
|
35
|
+
isn't a click, it does not get the `type="button"` a click trigger does. One
|
|
36
|
+
action per element still holds — bind Enter-save and Escape-cancel to separate
|
|
37
|
+
elements. README documents it under "Keyboard triggers".
|
|
38
|
+
- **`reactive_compute` — client-side data bindings (no round trip).** A component
|
|
39
|
+
can now declare a client-side computation that recomputes derived fields
|
|
40
|
+
IN-BROWSER on `input`, with NO server round trip — the "instant" half of a
|
|
41
|
+
new/unpersisted-record UX that previously required a hand-written Stimulus
|
|
42
|
+
controller (e.g. an order calculator that rebalances a payment split as you
|
|
43
|
+
type). Declare the binding in Ruby and register the matching reducer once in JS:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
reactive_compute :payment_split,
|
|
47
|
+
inputs: %i[allowance cash leasing total], # fields the reducer reads
|
|
48
|
+
outputs: %i[allowance cash leasing] # fields it writes (no POST)
|
|
49
|
+
# in the view: div(**mix(reactive_root, reactive_compute_attrs(:payment_split)))
|
|
50
|
+
# on the edited field: data-action="input->reactive#recompute"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { setComputeReducer } from "phlex/reactive/compute"
|
|
55
|
+
setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({
|
|
56
|
+
allowance, leasing, cash: total - allowance - leasing,
|
|
57
|
+
}))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The generic controller runs the named reducer on `input`, writes only the
|
|
61
|
+
declared outputs (leaving the edited field + caret alone), and fires `input` on
|
|
62
|
+
each field it sets so a chained summary repaints — matching the server's
|
|
63
|
+
`set_value` + `dispatch("input")` contract. A missing/unregistered reducer is a
|
|
64
|
+
no-op (a page never breaks because a binding wasn't wired up). When the same
|
|
65
|
+
component ALSO carries `on(...)` (a persisted record, or a draft you sync), that
|
|
66
|
+
debounced POST still fires and the server reply reconciles — so `reactive_compute`
|
|
67
|
+
is the optimistic client paint, the server round trip is the source of truth.
|
|
68
|
+
One math contract, two execution sites. New client module
|
|
69
|
+
`phlex/reactive/compute` (auto-pinned by the engine like `confirm`).
|
|
70
|
+
|
|
71
|
+
- **Draft (unpersisted-record) tokens — `reactive_record` no longer crashes on a
|
|
72
|
+
`new_record?`.** A record-backed component may now render an UNSAVED record (an
|
|
73
|
+
order the user is building before it's saved). `reactive_token` omits the `gid`
|
|
74
|
+
when the record isn't persisted (`to_gid` would raise `MissingModelIdError`) and
|
|
75
|
+
relies on the declared `reactive_state` as the draft seed, so the token still
|
|
76
|
+
signs cleanly and the client controller mounts. The draft is driven client-side
|
|
77
|
+
(`reactive_compute`) until it's saved; once persisted, a re-render signs the
|
|
78
|
+
`gid` as before. Combined with `reactive_compute`, this is the "persisted → server,
|
|
79
|
+
new → in-browser" split as a first-class capability instead of a hand-rolled one.
|
|
80
|
+
|
|
11
81
|
- **Overridable / async confirm resolver — reuse your themed dialog (#55).**
|
|
12
82
|
Follow-up to #52. The `confirm:` gate was hardcoded to the synchronous,
|
|
13
83
|
browser-native `window.confirm`, so a reactive trigger was the one interaction
|
|
@@ -55,6 +125,41 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
55
125
|
server coercion (request specs), the FormData wire shape (bun unit tests), and a
|
|
56
126
|
real browser upload under Puma + Falcon (system spec).
|
|
57
127
|
|
|
128
|
+
### Documentation
|
|
129
|
+
|
|
130
|
+
- **The auto-collected-params contract, spelled out (#64, #65, #66, #67).** Four
|
|
131
|
+
gaps surfaced from building one model-scoped form (numeric fields that rebalance
|
|
132
|
+
live). No behavior changed — the README now documents what the code already
|
|
133
|
+
does:
|
|
134
|
+
- **#67** — a **flat** param schema silently drops **bracketed** field names.
|
|
135
|
+
Because the endpoint expands `invoice[date]` to `{ "invoice" => { … } }`
|
|
136
|
+
*before* matching the schema, a flat `{ date: … }` matches nothing and the
|
|
137
|
+
action gets keyword defaults with no error. The "Model-scoped form fields"
|
|
138
|
+
section now warns to nest the schema under the model key to match the names.
|
|
139
|
+
- **#65** — auto-collected sibling fields are read **at dispatch time**, not
|
|
140
|
+
from a pre-event snapshot: a `change`/`input` trigger sees its own new value
|
|
141
|
+
and every peer's current DOM value. Documented in a new "Auto-collected
|
|
142
|
+
sibling fields — the read contract" subsection.
|
|
143
|
+
- **#66** — reactive collection **includes `disabled` fields**, deliberately
|
|
144
|
+
unlike a native `<form>` submit, so a read-only computed field (a synced
|
|
145
|
+
`total`) reaches the action. Documented as intentional, with the `readonly`
|
|
146
|
+
vs `disabled` guidance for form-submit parity.
|
|
147
|
+
- **#64** — a `reactive_record` action can use the record for **identity +
|
|
148
|
+
authorization only** and compute over live, unsaved params, returning
|
|
149
|
+
`reply.streams(...)` to stream a partial update with **no persist and no
|
|
150
|
+
broadcast**. Documented as a first-class "record-authorized, transient-state
|
|
151
|
+
action" pattern in the `reply` section.
|
|
152
|
+
|
|
153
|
+
The demo app (`docs/`) gains a **live payment-split rebalancer** example — three
|
|
154
|
+
amounts that always sum to a total, editing one rebalances the peers — that
|
|
155
|
+
makes #64–#67 browsable (model-scoped bracketed params, a disabled computed
|
|
156
|
+
field the action still reads, siblings collected at dispatch, transient compute
|
|
157
|
+
with no persist/broadcast). The **todo** and **inline-edit** examples gain
|
|
158
|
+
Enter-to-add / Enter-to-save / Escape-to-cancel via the new `key:` filter,
|
|
159
|
+
covered by request specs and a real-browser (Playwright) Enter-keypress test.
|
|
160
|
+
Combobox keyboard navigation is tracked separately (#72) for the
|
|
161
|
+
minimal-client-seam work.
|
|
162
|
+
|
|
58
163
|
### Changed
|
|
59
164
|
|
|
60
165
|
- **Linter: Standard → RuboCop.** The gem now lints with RuboCop (all new cops
|
data/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)
|
|
4
4
|
[](https://rubygems.org/gems/phlex-reactive)
|
|
5
|
-
[](https://phlex-reactive.zoolutions.llc)
|
|
6
6
|
|
|
7
7
|
**Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
|
|
8
8
|
actions and live cross-tab updates, without writing Stimulus controllers or
|
|
9
9
|
hand-picking Turbo Stream targets.**
|
|
10
10
|
|
|
11
|
-
📖 **[Full documentation](https://
|
|
11
|
+
📖 **[Full documentation](https://phlex-reactive.zoolutions.llc)**
|
|
12
12
|
|
|
13
13
|
```ruby
|
|
14
14
|
class Counter < ApplicationComponent
|
|
@@ -130,7 +130,7 @@ application.register("reactive", ReactiveController)
|
|
|
130
130
|
|
|
131
131
|
The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the
|
|
132
132
|
gem; point your bundler at the gem path or copy it in. See
|
|
133
|
-
[docs/installation.md](docs/installation
|
|
133
|
+
[docs/installation.md](https://phlex-reactive.zoolutions.llc/docs/installation).
|
|
134
134
|
</details>
|
|
135
135
|
|
|
136
136
|
**Requirements:** Rails 7.1+, Phlex 2 (`phlex-rails`), Turbo 8+ (for morphing),
|
|
@@ -265,7 +265,7 @@ action. Keep state small and JSON-serializable.
|
|
|
265
265
|
reactive_state :count, :step # signed; rebuilt on each action
|
|
266
266
|
```
|
|
267
267
|
|
|
268
|
-
The [inline edit example](docs/
|
|
268
|
+
The [inline edit example](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) combines both: a
|
|
269
269
|
`reactive_record :record` plus `reactive_state :attribute, :editing`.
|
|
270
270
|
|
|
271
271
|
---
|
|
@@ -274,14 +274,15 @@ The [inline edit example](docs/examples/inline_edit.md) combines both: a
|
|
|
274
274
|
|
|
275
275
|
| Example | What it shows |
|
|
276
276
|
|---|---|
|
|
277
|
-
| [Counter](docs/
|
|
278
|
-
| [
|
|
279
|
-
| [
|
|
280
|
-
| [
|
|
281
|
-
| [
|
|
277
|
+
| [Counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) | State-backed, the smallest reactive component |
|
|
278
|
+
| [Payment split](https://phlex-reactive.zoolutions.llc/docs/example-payment-split) | Live sum-to-total rebalancer — nested bracketed params, a disabled computed field, auto-collected siblings (#64–#67) |
|
|
279
|
+
| [Cross-tab chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) | Record-backed action **+ pgbus broadcast** → live sync across tabs/browsers |
|
|
280
|
+
| [Live todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) | Per-row components, add/toggle/rename/delete, Enter-to-add, broadcast on change |
|
|
281
|
+
| [Inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) | Show ↔ edit mode toggle, replacing a Stimulus controller + 3 routes |
|
|
282
|
+
| [Notifications / badges](https://phlex-reactive.zoolutions.llc/docs/example-notifications) | Pure broadcast (no client action) — a job pushes a re-render |
|
|
282
283
|
|
|
283
284
|
The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
284
|
-
[docs/examples/chat.md](docs/
|
|
285
|
+
[docs/examples/chat.md](https://phlex-reactive.zoolutions.llc/docs/example-chat).
|
|
285
286
|
|
|
286
287
|
---
|
|
287
288
|
|
|
@@ -311,7 +312,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
311
312
|
| `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. |
|
|
312
313
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
313
314
|
| `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
|
|
315
|
+
| `on(:action, event: "keydown.enter")` | Fire only on a specific key — Enter-to-submit / Escape-to-cancel — via Stimulus's native keyboard filter (`event:` passes straight through). See [Keyboard triggers](#keyboard-triggers-enter-to-submit--escape-to-cancel). |
|
|
314
316
|
| `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |
|
|
317
|
+
| `on(:search, listnav: "[role=option]")` | Add combobox keyboard navigation — Arrow keys move a client-side highlight, Enter picks (clicks the option's own trigger), Escape clears. See [Combobox keyboard navigation](#combobox-keyboard-navigation-listnav). |
|
|
315
318
|
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
|
|
316
319
|
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
317
320
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
@@ -395,6 +398,16 @@ action :save, params: {invoice: {date: :string, status: :string}}
|
|
|
395
398
|
# client posts { "invoice[date]": "…", "invoice[status]": "…" } → save(invoice: { date:, status: })
|
|
396
399
|
```
|
|
397
400
|
|
|
401
|
+
> **A flat schema silently drops bracketed names (issue #67).** The schema must
|
|
402
|
+
> mirror the field *names*, not the conceptual params. Because the endpoint
|
|
403
|
+
> expands `invoice[date]` to `{ "invoice" => { "date" => … } }` **before**
|
|
404
|
+
> matching the schema, a flat `params: { date: :string }` matches nothing — the
|
|
405
|
+
> top-level key is now `invoice`, not `date`. There is no error: the action just
|
|
406
|
+
> receives its keyword defaults (`date` never set). If your inputs are named
|
|
407
|
+
> `invoice[…]` (any `Form(model:)`-style form), nest the schema under `invoice:`
|
|
408
|
+
> to match. When in doubt, read a field's real `name` attribute and shape the
|
|
409
|
+
> schema to it.
|
|
410
|
+
|
|
398
411
|
**Nested reactive components compose.** A reactive component rendered inside
|
|
399
412
|
another is its own root — field collection stops at nested
|
|
400
413
|
`data-controller="reactive"` roots, so an outer action collects only *its own*
|
|
@@ -413,6 +426,90 @@ Omit `debounce:` for the immediate-dispatch default.
|
|
|
413
426
|
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
|
|
414
427
|
```
|
|
415
428
|
|
|
429
|
+
**Auto-collected sibling fields — the read contract.** A reactive action doesn't
|
|
430
|
+
just receive its own trigger's value: the client gathers **every named control**
|
|
431
|
+
in the reactive root (`input[name]`, `select[name]`, `textarea[name]`, and named
|
|
432
|
+
rich-text/`contenteditable` editors) and merges them under the action's params,
|
|
433
|
+
so one action reads the whole form. Explicit `on(:act, x: …)` params win over a
|
|
434
|
+
collected field of the same name; collection stops at nested reactive roots (see
|
|
435
|
+
*Nested reactive components compose* above). Two things worth pinning down:
|
|
436
|
+
|
|
437
|
+
- **Timing — params reflect the DOM at dispatch, not a pre-event snapshot
|
|
438
|
+
(issue #65).** Field values are read when the request is sent (after the
|
|
439
|
+
debounce quiet period, if any), so a `change`/`input` trigger sees **its own
|
|
440
|
+
field's new value and every peer's current value.** There is no capture of the
|
|
441
|
+
values as they were *before* the interaction — if your computation needs a
|
|
442
|
+
peer's prior value (e.g. a spill-back that folds an overflow into the edited
|
|
443
|
+
field), that peer's current DOM value *is* the prior value only because nothing
|
|
444
|
+
else has changed it yet. Read at dispatch time, trust the current DOM.
|
|
445
|
+
- **Disabled fields ARE collected (issue #66) — deliberately different from a
|
|
446
|
+
native form.** A `<form>` submit omits `disabled` controls; reactive collection
|
|
447
|
+
does **not** check `disabled`, so a disabled field that carries a
|
|
448
|
+
computed/display value (a read-only `total` the client keeps in sync) reaches
|
|
449
|
+
the action. This is intentional — it's what makes "read a computed disabled
|
|
450
|
+
field" work. If you need form-submit parity (drop the disabled value), give the
|
|
451
|
+
control no `name`, or make it `readonly` instead of `disabled` when you *do*
|
|
452
|
+
want it collected by both paths.
|
|
453
|
+
|
|
454
|
+
**Keyboard triggers (Enter-to-submit / Escape-to-cancel).** `event:` is
|
|
455
|
+
interpolated straight into the Stimulus action descriptor, so any Stimulus event
|
|
456
|
+
string works — including its **native keyboard filters**. Pass `event:
|
|
457
|
+
"keydown.enter"` to fire only on Enter, `event: "keydown.esc"` for Escape — the
|
|
458
|
+
classic "Enter adds the row", "Escape cancels the edit" interactions. The action
|
|
459
|
+
runs *only* on that key, not on every keypress — no client JavaScript, no
|
|
460
|
+
`event.key` check of your own, and no new option to learn (it's Stimulus's own
|
|
461
|
+
[keyboard-filter syntax](https://stimulus.hotwired.dev/reference/actions#keyboardevent-filter)):
|
|
462
|
+
|
|
463
|
+
```ruby
|
|
464
|
+
# Enter in the composer adds the todo (same action as the Add button).
|
|
465
|
+
input(**mix(on(:add, event: "keydown.enter"), name: "title", placeholder: "New todo…"))
|
|
466
|
+
|
|
467
|
+
# Inline editor: Enter on the field saves; a separate control cancels on Escape.
|
|
468
|
+
input(**mix(on(:save, event: "keydown.enter"), name: "title", value: @todo.title))
|
|
469
|
+
button(**on(:cancel, event: "keydown.esc")) { "Cancel" }
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The filter tokens are Stimulus's (`enter`, `esc`, `space`, `up`, `down`, a bare
|
|
473
|
+
letter, …). Because a keyboard trigger isn't a click, it does **not** get the
|
|
474
|
+
`type="button"` a click trigger does. Folding the key into `event:` keeps `key`
|
|
475
|
+
free as an ordinary action-param name (`on(:switch, key: "pgbus")` still passes
|
|
476
|
+
`key` through as a param).
|
|
477
|
+
|
|
478
|
+
> **One action per element.** Each trigger element carries a single reactive
|
|
479
|
+
> action (its `data-reactive-action-param`), so you can't put `on(:save, event:
|
|
480
|
+
> "keydown.enter")` *and* `on(:cancel, event: "keydown.esc")` on the **same**
|
|
481
|
+
> input — the second would overwrite the first's action name. Bind each key
|
|
482
|
+
> trigger to its own element (the field saves on Enter; a Cancel button — or the
|
|
483
|
+
> field's own blur — handles Escape), as above.
|
|
484
|
+
|
|
485
|
+
### Combobox keyboard navigation (`listnav:`)
|
|
486
|
+
|
|
487
|
+
A searchable list needs Arrow keys to move a highlight, Enter to pick, Escape to
|
|
488
|
+
close — interactions that are *ephemeral client UI state* (a highlight per
|
|
489
|
+
keystroke would be absurd as a server round trip). Pass `listnav:` (a CSS
|
|
490
|
+
selector for the option elements) to a search trigger and the generic controller
|
|
491
|
+
handles all of it client-side, with no bespoke Stimulus controller:
|
|
492
|
+
|
|
493
|
+
```ruby
|
|
494
|
+
# The search input: debounced live search + keyboard list navigation.
|
|
495
|
+
input(**mix(
|
|
496
|
+
on(:search, event: "input", debounce: 200, listnav: "[role=option]"),
|
|
497
|
+
name: "query", value: @query
|
|
498
|
+
))
|
|
499
|
+
|
|
500
|
+
# Each option is BOTH a listnav target (role=option) and its own reactive
|
|
501
|
+
# select trigger — Enter just clicks the highlighted one.
|
|
502
|
+
button(**mix(on(:select, name: opt), role: "option")) { opt }
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
`listnav:` appends Stimulus's native keyboard filters
|
|
506
|
+
(`keydown.down/up/enter/esc`) to the input's `data-action`. Arrow Up/Down move a
|
|
507
|
+
`data-reactive-highlighted` marker among the options **with no round trip**;
|
|
508
|
+
Enter **clicks the highlighted option** — so selection runs through its normal
|
|
509
|
+
`on(:select)` reactive action (signed, default-deny, authorized like any other);
|
|
510
|
+
Escape clears the highlight. Only the highlight is client-side — the selection
|
|
511
|
+
stays a real signed action, and the highlight is never shipped as trusted state.
|
|
512
|
+
|
|
416
513
|
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
417
514
|
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
418
515
|
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
|
|
@@ -567,6 +664,40 @@ update only the targets you name) and refreshes the token via a tiny inert
|
|
|
567
664
|
`reactive:token` stream instead — the token rolls forward without re-rendering
|
|
568
665
|
(and clobbering) the component's live inputs.
|
|
569
666
|
|
|
667
|
+
#### Record-authorized, transient-state actions (issue #64)
|
|
668
|
+
|
|
669
|
+
A `reactive_record` component isn't obligated to persist or broadcast — the
|
|
670
|
+
record can be there purely for **identity + authorization** while the action's
|
|
671
|
+
real job is to recompute **live, unsaved form values** the user is mid-edit. The
|
|
672
|
+
record is re-located and instantiated on each action (`from_identity`), never
|
|
673
|
+
auto-saved and never auto-broadcast; persistence and cross-tab broadcast are both
|
|
674
|
+
opt-in (you call `record.update!` / `broadcast_*_to` yourself). Pair that with
|
|
675
|
+
`reply.streams` and you get a first-class "authorize via the row, compute over
|
|
676
|
+
the params, stream a partial update, touch neither the DB nor peer tabs" action:
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
class Invoice::PaymentFields < ApplicationComponent
|
|
680
|
+
include Phlex::Reactive::Streamable
|
|
681
|
+
include Phlex::Reactive::Component
|
|
682
|
+
|
|
683
|
+
reactive_record :invoice # identity + authorization ONLY — not persisted here
|
|
684
|
+
action :rebalance, params: { invoice: { field_a: :integer, field_b: :integer,
|
|
685
|
+
field_c: :integer, total: :integer } }
|
|
686
|
+
|
|
687
|
+
def rebalance(invoice:)
|
|
688
|
+
authorize! @invoice, :update? # the token proves identity, not permission
|
|
689
|
+
result = recompute(invoice) # pure computation over the collected params
|
|
690
|
+
reply.streams(*set_value_streams(result)) # NO persist, NO broadcast
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
This is deliberate, not a misuse: `reply.streams` is exactly the reply for "emit
|
|
696
|
+
these targeted updates, roll the token forward, and leave everything else — the
|
|
697
|
+
DB, the other tabs, the sibling inputs the user is typing in — untouched."
|
|
698
|
+
Broadcasting is deliberately omitted so peer tabs with their own in-flight edits
|
|
699
|
+
aren't clobbered. Authorize the record as always — identity is never permission.
|
|
700
|
+
|
|
570
701
|
> **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
|
|
571
702
|
> immutable value object the endpoint reads. You can build one directly
|
|
572
703
|
> (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
|
|
@@ -696,7 +827,7 @@ real, so read this once.
|
|
|
696
827
|
but if you have *public* reactive components, ensure the action path isn't
|
|
697
828
|
force-redirected to a login page for logged-out users.
|
|
698
829
|
|
|
699
|
-
See [docs/security.md](docs/security
|
|
830
|
+
See [docs/security.md](https://phlex-reactive.zoolutions.llc/docs/security) for the threat model and a checklist.
|
|
700
831
|
|
|
701
832
|
---
|
|
702
833
|
|
|
@@ -800,24 +931,24 @@ end
|
|
|
800
931
|
replayed, not lost.
|
|
801
932
|
- **No Redis, no Action Cable.**
|
|
802
933
|
|
|
803
|
-
See [docs/broadcasting.md](docs/broadcasting
|
|
804
|
-
[docs/transport-pgbus.md](docs/transport-pgbus
|
|
934
|
+
See [docs/broadcasting.md](https://phlex-reactive.zoolutions.llc/docs/broadcasting) and
|
|
935
|
+
[docs/transport-pgbus.md](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus).
|
|
805
936
|
|
|
806
937
|
---
|
|
807
938
|
|
|
808
939
|
## Documentation
|
|
809
940
|
|
|
810
|
-
- [Installation & bundler setups](docs/installation
|
|
811
|
-
- [Mental model & architecture](docs/architecture
|
|
812
|
-
- [Security & threat model](docs/security
|
|
813
|
-
- [Broadcasting & live updates](docs/broadcasting
|
|
814
|
-
- [Transport: pgbus vs Action Cable](docs/transport-pgbus
|
|
815
|
-
- [Testing reactive components](docs/testing
|
|
816
|
-
- [Performance & benchmarking](docs/performance
|
|
817
|
-
- Examples: [counter](docs/
|
|
818
|
-
[chat](docs/
|
|
819
|
-
[inline edit](docs/
|
|
820
|
-
[notifications](docs/
|
|
941
|
+
- [Installation & bundler setups](https://phlex-reactive.zoolutions.llc/docs/installation)
|
|
942
|
+
- [Mental model & architecture](https://phlex-reactive.zoolutions.llc/docs/architecture)
|
|
943
|
+
- [Security & threat model](https://phlex-reactive.zoolutions.llc/docs/security)
|
|
944
|
+
- [Broadcasting & live updates](https://phlex-reactive.zoolutions.llc/docs/broadcasting)
|
|
945
|
+
- [Transport: pgbus vs Action Cable](https://phlex-reactive.zoolutions.llc/docs/transport-pgbus)
|
|
946
|
+
- [Testing reactive components](https://phlex-reactive.zoolutions.llc/docs/testing)
|
|
947
|
+
- [Performance & benchmarking](https://phlex-reactive.zoolutions.llc/docs/performance)
|
|
948
|
+
- Examples: [counter](https://phlex-reactive.zoolutions.llc/docs/example-counter) ·
|
|
949
|
+
[chat](https://phlex-reactive.zoolutions.llc/docs/example-chat) · [todo list](https://phlex-reactive.zoolutions.llc/docs/example-todo-list) ·
|
|
950
|
+
[inline edit](https://phlex-reactive.zoolutions.llc/docs/example-inline-edit) ·
|
|
951
|
+
[notifications](https://phlex-reactive.zoolutions.llc/docs/example-notifications)
|
|
821
952
|
|
|
822
953
|
## Credits & prior art
|
|
823
954
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The client-side compute (data-binding) registry — the "instant" half of the
|
|
2
|
+
// new/unpersisted-record UX.
|
|
3
|
+
//
|
|
4
|
+
// A record-backed reactive component round-trips every change to the server
|
|
5
|
+
// (the signed identity re-finds the record; the server re-renders). A NEW,
|
|
6
|
+
// unpersisted record has no such server truth to re-render against on every
|
|
7
|
+
// keystroke — the classic answer is a bespoke Stimulus controller doing the math
|
|
8
|
+
// in the browser (carlqvist's new_order_controller.js). This registry lets that
|
|
9
|
+
// math be a DECLARED part of the component instead: `reactive_compute :name,
|
|
10
|
+
// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic
|
|
11
|
+
// reactive controller runs it on `input` — writing the outputs with NO round
|
|
12
|
+
// trip. When the component ALSO carries on(...) (a persisted record, or a draft
|
|
13
|
+
// you sync), the debounced POST reconciles from the authoritative server reply.
|
|
14
|
+
//
|
|
15
|
+
// The seam mirrors confirm.js: a settable registry with a lookup the controller
|
|
16
|
+
// calls. Register once at boot:
|
|
17
|
+
//
|
|
18
|
+
// import { setComputeReducer } from "phlex/reactive/compute"
|
|
19
|
+
// setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({
|
|
20
|
+
// allowance, leasing, cash: total - allowance - leasing,
|
|
21
|
+
// }))
|
|
22
|
+
//
|
|
23
|
+
// The reducer receives a plain object of { inputName: Number } and returns a
|
|
24
|
+
// plain object of { outputName: value } — only the outputs it names are written,
|
|
25
|
+
// so it can leave the edited field (and its caret) untouched. Returning the SAME
|
|
26
|
+
// value it read is a no-op write; the controller still fires `input` on any field
|
|
27
|
+
// it sets so a chained summary repaints, matching the server's set_value +
|
|
28
|
+
// dispatch("input") contract.
|
|
29
|
+
|
|
30
|
+
const reducers = new Map()
|
|
31
|
+
|
|
32
|
+
// Register (or replace) the reducer for `key`. `fn` is
|
|
33
|
+
// (values: Record<string, number>) => Record<string, unknown>.
|
|
34
|
+
export function setComputeReducer(key, fn) {
|
|
35
|
+
reducers.set(key, fn)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Look up a registered reducer; undefined when none — the controller then makes
|
|
39
|
+
// #recompute a no-op rather than throwing (a missing reducer must not break the
|
|
40
|
+
// page; it just means no client-side binding for that root).
|
|
41
|
+
export function computeReducer(key) {
|
|
42
|
+
return reducers.get(key)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Test seam: clear the registry so a reducer registered in one test can't leak.
|
|
46
|
+
export function __resetComputeRegistryForTest() {
|
|
47
|
+
reducers.clear()
|
|
48
|
+
}
|
|
@@ -10,6 +10,9 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
10
10
|
// bundlers/bun resolve it the same way they already resolve
|
|
11
11
|
// "phlex/reactive/reactive_controller" (see tsconfig.json paths for the tests).
|
|
12
12
|
import { confirmResolver } from "phlex/reactive/confirm"
|
|
13
|
+
// Client-side computes (data bindings): the reducer registry behind
|
|
14
|
+
// reactive_compute. Bare specifier for the same import-map reason as confirm.
|
|
15
|
+
import { computeReducer } from "phlex/reactive/compute"
|
|
13
16
|
|
|
14
17
|
// The ONE generic controller behind every reactive Phlex component. It
|
|
15
18
|
// replaces the per-feature Stimulus controllers you'd otherwise hand-write
|
|
@@ -219,6 +222,131 @@ export default class extends Controller {
|
|
|
219
222
|
})
|
|
220
223
|
}
|
|
221
224
|
|
|
225
|
+
// Client-side compute (data binding). Wired by reactive_compute: an `input`
|
|
226
|
+
// trigger (input->reactive#recompute) runs a REGISTERED JS reducer over the
|
|
227
|
+
// named input fields and writes the named output fields WITH NO ROUND TRIP —
|
|
228
|
+
// the "instant" half of the new/unpersisted-record UX. If the field ALSO
|
|
229
|
+
// carries on(...) (a persisted record, or a synced draft), that debounced POST
|
|
230
|
+
// still fires and the server reply reconciles; recompute just paints first.
|
|
231
|
+
//
|
|
232
|
+
// Reads inputs/outputs/reducer from the root's data-reactive-compute-* attrs
|
|
233
|
+
// (set once by reactive_compute_attrs). A missing/unregistered reducer is a
|
|
234
|
+
// no-op — a page must never break because a binding wasn't wired up.
|
|
235
|
+
recompute() {
|
|
236
|
+
const key = this.element.getAttribute("data-reactive-compute-reducer-param")
|
|
237
|
+
if (!key) return
|
|
238
|
+
const reduce = computeReducer(key)
|
|
239
|
+
if (!reduce) return
|
|
240
|
+
|
|
241
|
+
const inputs = this.#parseComputeList("data-reactive-compute-inputs-param")
|
|
242
|
+
const outputs = this.#parseComputeList("data-reactive-compute-outputs-param")
|
|
243
|
+
|
|
244
|
+
const values = {}
|
|
245
|
+
for (const name of inputs) values[name] = this.#numericFieldValue(name)
|
|
246
|
+
|
|
247
|
+
const result = reduce(values) || {}
|
|
248
|
+
for (const name of outputs) {
|
|
249
|
+
if (!(name in result)) continue
|
|
250
|
+
const field = this.#ownedField(name)
|
|
251
|
+
// Setting .value fires the field's own `input` listeners (a chained summary
|
|
252
|
+
// repaint), matching the server's set_value + dispatch("input") contract.
|
|
253
|
+
if (field) field.value = result[name]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Client-side list navigation (combobox keyboard nav, issue #72). Wired by
|
|
258
|
+
// on(:search, …, listnav: "[role=option]"), which appends keyboard filters to
|
|
259
|
+
// the input's data-action (keydown.down/up/enter/esc->reactive#listnav*) and
|
|
260
|
+
// sets data-reactive-listnav-option-param. Arrow keys move a highlight among
|
|
261
|
+
// the options WITH NO ROUND TRIP; Enter picks the highlighted option by
|
|
262
|
+
// CLICKING IT (so its own on(:select) reactive trigger fires — selection stays
|
|
263
|
+
// a signed action); Escape clears. Ephemeral highlight state lives on the DOM
|
|
264
|
+
// (data-reactive-highlighted), never shipped to the client as trusted state.
|
|
265
|
+
listnavNext(event) {
|
|
266
|
+
this.#moveHighlight(event, +1)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
listnavPrev(event) {
|
|
270
|
+
this.#moveHighlight(event, -1)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Enter: activate the highlighted option (fires its reactive select). No-op if
|
|
274
|
+
// nothing is highlighted, and in that case DON'T preventDefault — Enter falls
|
|
275
|
+
// through (there's no selection to make).
|
|
276
|
+
listnavPick(event) {
|
|
277
|
+
const options = this.#listnavOptions(event)
|
|
278
|
+
const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
|
|
279
|
+
if (current < 0) return
|
|
280
|
+
event.preventDefault()
|
|
281
|
+
options[current].click()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
listnavClose(event) {
|
|
285
|
+
for (const el of this.#listnavOptions(event)) el.removeAttribute("data-reactive-highlighted")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Move the highlight by `step` (with wrap-around) among THIS root's options.
|
|
289
|
+
// preventDefault stops Arrow keys from moving the caret in the search input.
|
|
290
|
+
#moveHighlight(event, step) {
|
|
291
|
+
const options = this.#listnavOptions(event)
|
|
292
|
+
if (!options.length) return
|
|
293
|
+
event.preventDefault()
|
|
294
|
+
|
|
295
|
+
const current = options.findIndex((el) => el.hasAttribute("data-reactive-highlighted"))
|
|
296
|
+
// From nothing: Down highlights the first option, Up the last.
|
|
297
|
+
const next = current < 0 ? (step > 0 ? 0 : options.length - 1) : (current + step + options.length) % options.length
|
|
298
|
+
|
|
299
|
+
for (const el of options) el.removeAttribute("data-reactive-highlighted")
|
|
300
|
+
const chosen = options[next]
|
|
301
|
+
chosen.setAttribute("data-reactive-highlighted", "true")
|
|
302
|
+
chosen.scrollIntoView?.({ block: "nearest" })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The option elements this root owns (skips nested reactive roots, issue #15),
|
|
306
|
+
// per the selector on data-reactive-listnav-option-param. The attr rides on the
|
|
307
|
+
// TRIGGER element (the search input on(...) is spread onto), read from the
|
|
308
|
+
// event; the options are still scoped to this controller's root. Empty when
|
|
309
|
+
// unset. Falls back to the root for a directly-invoked call (unit tests).
|
|
310
|
+
#listnavOptions(event) {
|
|
311
|
+
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
312
|
+
const selector =
|
|
313
|
+
trigger.getAttribute?.("data-reactive-listnav-option-param") ??
|
|
314
|
+
this.element.getAttribute("data-reactive-listnav-option-param")
|
|
315
|
+
if (!selector) return []
|
|
316
|
+
const nodes = this.element.querySelectorAll(selector)
|
|
317
|
+
return Array.from(nodes).filter((el) => this.#ownsField(el))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
321
|
+
// a malformed binding degrades to "no fields" rather than throwing on input.
|
|
322
|
+
#parseComputeList(attr) {
|
|
323
|
+
const raw = this.element.getAttribute(attr)
|
|
324
|
+
if (!raw) return []
|
|
325
|
+
try {
|
|
326
|
+
const list = JSON.parse(raw)
|
|
327
|
+
return Array.isArray(list) ? list : []
|
|
328
|
+
} catch {
|
|
329
|
+
return []
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The first named control owned by THIS root (skips nested reactive roots,
|
|
334
|
+
// issue #15) — used by recompute to read inputs and write outputs.
|
|
335
|
+
#ownedField(name) {
|
|
336
|
+
const nodes = this.element.querySelectorAll(`[name="${name}"]`)
|
|
337
|
+
for (const el of nodes) if (this.#ownsField(el)) return el
|
|
338
|
+
return null
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// A field's value as a Number, treating blank/absent/NaN as 0 — mirroring the
|
|
342
|
+
// nanToZero coercion the hand-written calculators use, so a reducer never sees
|
|
343
|
+
// "" or NaN for an empty field.
|
|
344
|
+
#numericFieldValue(name) {
|
|
345
|
+
const field = this.#ownedField(name)
|
|
346
|
+
const n = Number(field?.value)
|
|
347
|
+
return Number.isFinite(n) ? n : 0
|
|
348
|
+
}
|
|
349
|
+
|
|
222
350
|
// Enqueue the action — debounced if a debounce window is set, else immediately.
|
|
223
351
|
// Split out of dispatch so both the no-confirm fast path and the post-confirm
|
|
224
352
|
// microtask share one place (issue #55). `target` is captured up front because
|
|
@@ -62,6 +62,13 @@ module Phlex
|
|
|
62
62
|
# A declared, client-invokable action and its param schema.
|
|
63
63
|
Action = Data.define(:name, :params)
|
|
64
64
|
|
|
65
|
+
# A declared client-side computation (data binding). `inputs`/`outputs` are
|
|
66
|
+
# the action-param names of the fields the reducer reads/writes; `reducer`
|
|
67
|
+
# is the key a JS function is registered under (Reactive.compute(key, fn)).
|
|
68
|
+
# The generic controller runs the reducer on `input` — writing outputs with
|
|
69
|
+
# NO round trip — then the debounced POST reconciles from the server reply.
|
|
70
|
+
ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer)
|
|
71
|
+
|
|
65
72
|
# A declared add/remove-row collection (issue #35): the list contract tied
|
|
66
73
|
# into one unit — the per-row item component, the container DOM id rows
|
|
67
74
|
# live in, an optional companion count id, an optional empty-state
|
|
@@ -175,6 +182,36 @@ module Phlex
|
|
|
175
182
|
reactive_collections.key?(name.to_sym)
|
|
176
183
|
end
|
|
177
184
|
|
|
185
|
+
# Declare a client-side computation, OR (called with just a name) read
|
|
186
|
+
# one back. Dual-purpose so a component reads `reactive_compute :split,
|
|
187
|
+
# inputs: …, outputs: …` and the endpoint/helpers read
|
|
188
|
+
# `reactive_compute(:split)` — mirroring how `on`/`reactive_field` keep a
|
|
189
|
+
# tight surface. `reducer:` defaults to the compute name.
|
|
190
|
+
#
|
|
191
|
+
# reactive_compute :payment_split,
|
|
192
|
+
# inputs: %i[allowance cash leasing total], # fields the JS reducer reads
|
|
193
|
+
# outputs: %i[allowance cash leasing] # fields it writes (no round trip)
|
|
194
|
+
#
|
|
195
|
+
# Register the matching JS once at boot:
|
|
196
|
+
# import { setComputeReducer } from "phlex/reactive/compute"
|
|
197
|
+
# setComputeReducer("payment_split", ({ allowance, cash, leasing, total }) => ({ … }))
|
|
198
|
+
def reactive_compute(name, inputs: nil, outputs: nil, reducer: nil)
|
|
199
|
+
return reactive_computes[name.to_sym] if inputs.nil? && outputs.nil?
|
|
200
|
+
|
|
201
|
+
reactive_computes[name.to_sym] = ComputeDefinition.new(
|
|
202
|
+
name: name.to_sym, inputs: Array(inputs).map(&:to_sym),
|
|
203
|
+
outputs: Array(outputs).map(&:to_sym), reducer: (reducer || name).to_s
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def reactive_computes
|
|
208
|
+
@reactive_computes ||= superclass.respond_to?(:reactive_computes) ? superclass.reactive_computes.dup : {}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def reactive_compute?(name)
|
|
212
|
+
reactive_computes.key?(name.to_sym)
|
|
213
|
+
end
|
|
214
|
+
|
|
178
215
|
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
179
216
|
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
180
217
|
# time would allocate a symbol per render. Nil when record-less. Memoized
|
|
@@ -313,22 +350,53 @@ module Phlex
|
|
|
313
350
|
# enqueue/debounce if the user declines (and prevents the native default so
|
|
314
351
|
# a `submit` trigger can't navigate on cancel). Omit it for no prompt.
|
|
315
352
|
# button(**on(:destroy, confirm: "Really delete this item?")) { "Delete" }
|
|
353
|
+
#
|
|
354
|
+
# `event:` is interpolated verbatim into the Stimulus action descriptor
|
|
355
|
+
# (`#{event}->reactive#dispatch`), so any Stimulus event string works —
|
|
356
|
+
# including its native KEYBOARD FILTERS. Pass `event: "keydown.enter"` for
|
|
357
|
+
# Enter-to-submit or `event: "keydown.esc"` for Escape-to-cancel, and the
|
|
358
|
+
# action fires only on that key — no separate option, no client code, and
|
|
359
|
+
# `key` stays free as an ordinary action-param name (on(:switch, key: …)):
|
|
360
|
+
# input(**on(:add, event: "keydown.enter")) # Enter submits
|
|
361
|
+
# button(**on(:cancel, event: "keydown.esc")) # Escape cancels
|
|
362
|
+
#
|
|
363
|
+
# `listnav:` (a CSS selector for the option elements) adds keyboard list
|
|
364
|
+
# navigation to a search/combobox trigger (issue #72). It appends Stimulus
|
|
365
|
+
# keyboard filters to the SAME element's data-action so Arrow Up/Down move a
|
|
366
|
+
# client-side highlight among the options, Enter picks the highlighted one
|
|
367
|
+
# (clicking its own reactive trigger — so selection stays a signed action),
|
|
368
|
+
# and Escape clears — all with NO server round trip for the highlight (the
|
|
369
|
+
# controller's listnav* handlers, like #recompute). Omit it for no nav.
|
|
370
|
+
# input(**on(:search, event: "input", debounce: 300, listnav: "[role=option]"))
|
|
316
371
|
# The verbatim JSON for an empty explicit-params payload. The common
|
|
317
372
|
# trigger (on(:increment), no params) hits this on EVERY render — skipping
|
|
318
373
|
# params.to_json (which re-serializes {} to the same "{}" each time) avoids
|
|
319
374
|
# a per-render allocation while keeping the wire format byte-identical.
|
|
320
375
|
EMPTY_PARAMS_JSON = "{}"
|
|
321
376
|
|
|
322
|
-
|
|
377
|
+
# The keyboard filters appended to a listnav trigger's data-action. Each is
|
|
378
|
+
# a client-only handler (no POST) except Enter, which clicks the highlighted
|
|
379
|
+
# option's own reactive trigger. Stimulus binds these natively.
|
|
380
|
+
LISTNAV_ACTIONS = [
|
|
381
|
+
"keydown.down->reactive#listnavNext",
|
|
382
|
+
"keydown.up->reactive#listnavPrev",
|
|
383
|
+
"keydown.enter->reactive#listnavPick",
|
|
384
|
+
"keydown.esc->reactive#listnavClose"
|
|
385
|
+
].freeze
|
|
386
|
+
|
|
387
|
+
def on(action_name, event: "click", debounce: nil, confirm: nil, listnav: nil, **params)
|
|
388
|
+
action = "#{event}->reactive#dispatch"
|
|
389
|
+
action = "#{action} #{LISTNAV_ACTIONS.join(" ")}" if listnav
|
|
323
390
|
attrs = {
|
|
324
391
|
data: {
|
|
325
|
-
action
|
|
392
|
+
action:,
|
|
326
393
|
reactive_action_param: action_name.to_s,
|
|
327
394
|
reactive_params_param: params.empty? ? EMPTY_PARAMS_JSON : params.to_json
|
|
328
395
|
}
|
|
329
396
|
}
|
|
330
397
|
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
331
398
|
attrs[:data][:reactive_confirm_param] = confirm if confirm
|
|
399
|
+
attrs[:data][:reactive_listnav_option_param] = listnav if listnav
|
|
332
400
|
attrs[:type] = "button" if event == "click"
|
|
333
401
|
attrs
|
|
334
402
|
end
|
|
@@ -353,6 +421,28 @@ module Phlex
|
|
|
353
421
|
input(**reactive_field(param, **attrs))
|
|
354
422
|
end
|
|
355
423
|
|
|
424
|
+
# Data attributes declaring a client-side compute for the root element.
|
|
425
|
+
# Spread ALONGSIDE reactive_root so the generic controller can find the
|
|
426
|
+
# reducer and the named input/output fields inside this root:
|
|
427
|
+
# div(**mix(reactive_root, reactive_compute_attrs(:payment_split))) { … }
|
|
428
|
+
#
|
|
429
|
+
# It emits the reducer key plus the input/output field names as JSON so the
|
|
430
|
+
# client runs the reducer on `input`, writes the outputs with no round trip,
|
|
431
|
+
# then the debounced POST reconciles from the server reply. Raises for an
|
|
432
|
+
# undeclared compute — a silent no-op would leave the field wiring dead.
|
|
433
|
+
def reactive_compute_attrs(name)
|
|
434
|
+
definition = self.class.reactive_compute(name)
|
|
435
|
+
raise Error, "#{self.class} has no reactive_compute #{name.inspect}" unless definition
|
|
436
|
+
|
|
437
|
+
{
|
|
438
|
+
data: {
|
|
439
|
+
reactive_compute_reducer_param: definition.reducer,
|
|
440
|
+
reactive_compute_inputs_param: definition.inputs.map(&:to_s).to_json,
|
|
441
|
+
reactive_compute_outputs_param: definition.outputs.map(&:to_s).to_json
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
end
|
|
445
|
+
|
|
356
446
|
# Render a <select> bound to an action param (issue #23). The options block
|
|
357
447
|
# is the element's content, so the awkward FormBuilder positional split
|
|
358
448
|
# (where name: lands after the options/html-options args) goes away:
|
|
@@ -403,12 +493,23 @@ module Phlex
|
|
|
403
493
|
# (e.g. which column an inline_edit may write) tamper-proof alongside the
|
|
404
494
|
# record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
|
|
405
495
|
# unchanged.
|
|
496
|
+
#
|
|
497
|
+
# A record that is NOT YET PERSISTED (new_record?) has no id → no GlobalID
|
|
498
|
+
# (to_gid raises MissingModelIdError). A record-backed component may render
|
|
499
|
+
# such a draft (an unsaved order the user is building): we OMIT gid and rely
|
|
500
|
+
# on the declared state (reactive_state) as the draft seed, so the token
|
|
501
|
+
# still signs cleanly and the client controller mounts. The draft is then
|
|
502
|
+
# driven client-side (reactive_compute) until it's saved; once persisted, a
|
|
503
|
+
# re-render signs the gid as usual. If the record is unsaved AND no state is
|
|
504
|
+
# declared, the token carries just {c} — enough to mount, but with no
|
|
505
|
+
# identity to round-trip, so declare reactive_state for a draft you sync.
|
|
406
506
|
def reactive_token
|
|
407
507
|
klass = self.class
|
|
408
508
|
payload = { "c" => klass.name }
|
|
409
509
|
|
|
410
510
|
if (record_ivar = klass.reactive_record_ivar)
|
|
411
|
-
|
|
511
|
+
record = instance_variable_get(record_ivar)
|
|
512
|
+
payload["gid"] = record.to_gid.to_s unless record.respond_to?(:persisted?) && !record.persisted?
|
|
412
513
|
end
|
|
413
514
|
|
|
414
515
|
state_ivars = klass.reactive_state_ivars
|
|
@@ -27,6 +27,7 @@ module Phlex
|
|
|
27
27
|
it.config.assets.precompile += %w[
|
|
28
28
|
phlex/reactive/reactive_controller.js
|
|
29
29
|
phlex/reactive/confirm.js
|
|
30
|
+
phlex/reactive/compute.js
|
|
30
31
|
]
|
|
31
32
|
end
|
|
32
33
|
end
|
|
@@ -54,6 +55,16 @@ module Phlex
|
|
|
54
55
|
to: "phlex/reactive/confirm.js",
|
|
55
56
|
preload: true
|
|
56
57
|
)
|
|
58
|
+
# The client-side compute (data-binding) registry behind
|
|
59
|
+
# reactive_compute. reactive_controller.js imports it by this bare
|
|
60
|
+
# specifier (same import-map rationale as confirm above), and an app
|
|
61
|
+
# registers reducers via `import { setComputeReducer } from
|
|
62
|
+
# "phlex/reactive/compute"` — both resolve through this pin.
|
|
63
|
+
it.importmap.pin(
|
|
64
|
+
"phlex/reactive/compute",
|
|
65
|
+
to: "phlex/reactive/compute.js",
|
|
66
|
+
preload: true
|
|
67
|
+
)
|
|
57
68
|
end
|
|
58
69
|
end
|
|
59
70
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -117,6 +117,7 @@ files:
|
|
|
117
117
|
- LICENSE.txt
|
|
118
118
|
- README.md
|
|
119
119
|
- app/controllers/phlex/reactive/actions_controller.rb
|
|
120
|
+
- app/javascript/phlex/reactive/compute.js
|
|
120
121
|
- app/javascript/phlex/reactive/confirm.js
|
|
121
122
|
- app/javascript/phlex/reactive/reactive_controller.js
|
|
122
123
|
- lib/generators/phlex/reactive/component/USAGE
|