phlex-reactive 0.13.1 → 0.13.3
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 +153 -0
- data/README.md +88 -13
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +417 -51
- 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 +7 -0
- data/lib/phlex/reactive/component/helpers.rb +4 -3
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/settles.rb +50 -13
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f070adcfcb6e3061829d42551e50803c209bd895c10a612d981b0359d40f59a
|
|
4
|
+
data.tar.gz: 3ff24e3f7c13b1aee4cf66f9187cced2ecad0cf98a69ec1a536d9eb452a86b3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7166c081bba35822dd588ead985237cbee23f11dcb6ef62ffaecfb483626eb007cc8613824896c0916253bdaea034c1b0794b64adf9f73c6e30fa462f5075a15
|
|
7
|
+
data.tar.gz: f9a8c4b4b09eec913bd4f9d0085620503e43e7d7beb5a2cb190dfb1e7fd3f23c702c6ee768fd2bb8304a76ffbd038b4bde4827e92c04f0f1a754c7fcf611c421
|
data/CHANGELOG.md
CHANGED
|
@@ -441,6 +441,159 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
441
441
|
|
|
442
442
|
### Fixed
|
|
443
443
|
|
|
444
|
+
- **`reactive_compute` never saw a checkbox's checked state (#262).**
|
|
445
|
+
`#recompute` resolved each declared name first-wins and read `.value`, which
|
|
446
|
+
for a checkbox is a constant. Measured: a Rails `check_box` pair read `0`
|
|
447
|
+
ticked or not (the hidden companion comes first), a lone `value="1"` box read
|
|
448
|
+
`1` either way, and a box with no value attribute read `0` (`Number("on")` is
|
|
449
|
+
NaN). A radio group read its FIRST radio's value, whichever was checked. The
|
|
450
|
+
reducer ran on the toggle, from values that could not have changed.
|
|
451
|
+
|
|
452
|
+
A checkbox now contributes its checked state, coerced by the declared type:
|
|
453
|
+
`1`/`0` as a `:number` (and in the array form), `"true"`/`"false"` as a
|
|
454
|
+
`:string`, and `true`/`false` under the new **`:boolean`** type. It wins over
|
|
455
|
+
its same-named hidden companion, as it already did for `reactive_show`. A radio
|
|
456
|
+
group contributes its checked radio's value. The identity mirror and the
|
|
457
|
+
`mirror:` fallback paint the same reading.
|
|
458
|
+
|
|
459
|
+
Outputs were wrong the same way: a result whose name resolved to a checkbox
|
|
460
|
+
pair wrote `.value` on the hidden companion, changing what the UNCHECKED state
|
|
461
|
+
submits and leaving the box alone. An output now sets a checkbox's `checked`
|
|
462
|
+
from the result's truthiness (`""`, `"0"`, `"false"`, `0` and `false` untick)
|
|
463
|
+
and checks the radio of a group that carries the result.
|
|
464
|
+
|
|
465
|
+
**If a reducer relied on the old reading**, it changes: a lone checkbox with a
|
|
466
|
+
numeric `value` used to read that number regardless of state and now reads
|
|
467
|
+
`1`/`0` — write the amount in the reducer (`gift ? 25 : 0`). Apps that worked
|
|
468
|
+
around the defect by reading the box from `document` inside the reducer keep
|
|
469
|
+
working and can drop the workaround.
|
|
470
|
+
|
|
471
|
+
Cost, same machine, happy-dom (engine-relative): the 30-input calculator
|
|
472
|
+
bench goes from 16.3 to 18.4 µs/iter, allocations flat. That is one `el.type`
|
|
473
|
+
read per declared name, which is what telling a checkbox from a text field
|
|
474
|
+
takes.
|
|
475
|
+
|
|
476
|
+
- **The dropped-param hints said nothing for a schema with string keys.**
|
|
477
|
+
`ParamSchema.compile` keeps keys as the author wrote them, so
|
|
478
|
+
`params: { "date" => :string }` is valid, but the #16/#21 hints only looked
|
|
479
|
+
up symbol keys. The param was still dropped and logged, just without the hint
|
|
480
|
+
that says where the schema declares it.
|
|
481
|
+
|
|
482
|
+
- **A checkbox group collapsed to one boolean, and the chosen values never left
|
|
483
|
+
the browser (#258).** `#collectFields` wrote `fields[name] = field.checked` for
|
|
484
|
+
every checkbox, so several boxes sharing a `features[]` name overwrote each
|
|
485
|
+
other and the action received the LAST box's checked state — `{}` under a
|
|
486
|
+
`[:string]` schema, `"false"` under a flat `:string` one, silent either way,
|
|
487
|
+
while a native submission of the same boxes sends
|
|
488
|
+
`features[]=news&features[]=events`. A name ending in `[]` is now collected as
|
|
489
|
+
an array of the chosen values: a ticked box contributes its `value`, an
|
|
490
|
+
unticked one nothing, a `<select multiple>` its selected options. The suffix is
|
|
491
|
+
the only trigger — a group says so rather than being inferred from two controls
|
|
492
|
+
sharing a name.
|
|
493
|
+
|
|
494
|
+
**That makes the suffix a migration point.** ANY `[]`-named control now
|
|
495
|
+
contributes to an array, including a single one and including a named rich
|
|
496
|
+
editor or bare contenteditable, which the collector reads in a second pass: a
|
|
497
|
+
lone `<input type="text" name="tags[]">` used to post `"abc"` and now posts
|
|
498
|
+
`["abc"]`. An editor sharing its `[]` name with another control adds its
|
|
499
|
+
value to the group instead of standing down behind it, so the entry count
|
|
500
|
+
changes there too. Beside a radio it still stands down, because a radio keeps
|
|
501
|
+
its single value with or without the suffix. A ready but empty editor
|
|
502
|
+
contributes an empty string, the way an empty text field in the same group
|
|
503
|
+
does. An unticked box still contributes nothing. A hidden input under the
|
|
504
|
+
same name keeps contributing: nothing in the DOM tells a hidden that mirrors
|
|
505
|
+
an editor from one that is a list JS maintains, and a value posted twice is
|
|
506
|
+
visible where a swallowed one is not. Against a flat `params: { tags: :string
|
|
507
|
+
}` the array coerces to the literal `"[\"abc\"]"` — silently, with a 200. A
|
|
508
|
+
control whose name ends in `[]` has to be declared as an array type (`tags:
|
|
509
|
+
[:string]`), or renamed without the suffix if it was never meant as a list.
|
|
510
|
+
|
|
511
|
+
Three shapes keep their meaning on purpose: a lone checkbox without `[]` stays
|
|
512
|
+
the documented yes/no boolean, a radio group keeps its single checked value
|
|
513
|
+
with or without the suffix, and a hidden input sharing a name with a checkbox
|
|
514
|
+
is that box's companion. The companion is identified by the shared name, not
|
|
515
|
+
by its value, because Rails renders three of them: `check_box` emits
|
|
516
|
+
`value="0"`, `check_box(..., multiple: true)` the same under a `[]` name, and
|
|
517
|
+
`collection_check_boxes` a blank one — or none at all with
|
|
518
|
+
`unchecked_value: nil`. Reading the second shape by value collected
|
|
519
|
+
`["0","0","0","3"]` for three boxes with the third ticked.
|
|
520
|
+
|
|
521
|
+
A form body can't carry an empty array, so a cleared group used to go missing
|
|
522
|
+
whenever the client sent a form body (as soon as a file input holds a file),
|
|
523
|
+
and the action got its keyword default instead of `[]`. Now the client names
|
|
524
|
+
the cleared group in `empty_groups[]`, a field next to `token`, `act` and
|
|
525
|
+
`params`, and the endpoint sets it to `[]`. A request without that field
|
|
526
|
+
behaves as before, and a group that carries values keeps them.
|
|
527
|
+
|
|
528
|
+
It applies to params the action declares as an array, by plain name or with
|
|
529
|
+
the component's `reactive_scope` in front, and only at the top level. A group
|
|
530
|
+
declared one level down or inside nested attributes is ignored and keeps its
|
|
531
|
+
keyword default; `verbose_errors` logs it. The endpoint never takes the key it
|
|
532
|
+
writes from the request. It looks the name up in the declaration and writes
|
|
533
|
+
the declared key, so a request can't create a param the action didn't ask for.
|
|
534
|
+
|
|
535
|
+
A blank entry (`params[name][]=""`) would have been the other way, but the
|
|
536
|
+
schema reads `[""]` per element type, and for a `[:file]` param behind
|
|
537
|
+
`has_many_attached` that is the difference between "not sent" and "remove the
|
|
538
|
+
attachments".
|
|
539
|
+
|
|
540
|
+
`post_reactive_multipart` takes `empty_groups:` so a request spec can send a
|
|
541
|
+
cleared group the way the client does. `ParamSchema#array_param(name)`
|
|
542
|
+
returns the declared key for an array param, or `nil`.
|
|
543
|
+
|
|
544
|
+
`reactive_persist` drafts such a group as the list of ticked values and
|
|
545
|
+
restores exactly those boxes; before, the draft held one boolean and the
|
|
546
|
+
restore ticked every box of the group. Whether the server already rendered a
|
|
547
|
+
box ticked — in which case it keeps its say and the draft yields — is decided
|
|
548
|
+
once before the restore walks the controls, because the walk writes `checked`
|
|
549
|
+
as it goes and asking from inside it would read the restore's own work: the
|
|
550
|
+
first box it ticks would make every later box of the group look
|
|
551
|
+
server-rendered, and a draft of two values would come back as one. A
|
|
552
|
+
non-checkbox control sharing the group's name additionally threw inside the
|
|
553
|
+
draft write, which is swallowed — the root then persisted nothing at all,
|
|
554
|
+
silently. On restore, such a control keeps what the server rendered whenever
|
|
555
|
+
the group has two or more contributors: the list records the values, not
|
|
556
|
+
which control each one came from, so replaying it would paste `freeform,news`
|
|
557
|
+
into a text field, an editor, or a contenteditable. A group of ONE
|
|
558
|
+
contributor has no such ambiguity — its single entry can only have come from
|
|
559
|
+
that control — so a plain field whose name merely ends in `[]`, the usual
|
|
560
|
+
shape for a list JS maintains, keeps its draft exactly as it did before
|
|
561
|
+
groups existed. A `<select multiple>` reads a list by matching option values,
|
|
562
|
+
which is only sound when the list is its own: sharing a group with another
|
|
563
|
+
contributor, it too keeps what the server rendered, since a text value that
|
|
564
|
+
happens to equal an option would otherwise select it.
|
|
565
|
+
|
|
566
|
+
Drafts written before this release are not discarded, but their group key is
|
|
567
|
+
no longer applied to the controls that read a list: it holds one boolean (or,
|
|
568
|
+
in a mixed group, whichever control wrote last), and applying that kept
|
|
569
|
+
causing damage for as long as the draft lived — by default seven days after
|
|
570
|
+
the upgrade. The damage differed by control. A checkbox group came back fully
|
|
571
|
+
ticked. A `<select multiple>` sharing the group's name lost its rendered
|
|
572
|
+
selection instead, because under `restore: "always"` the select branch skips
|
|
573
|
+
the "the server had a say" check and matches `Set{"true"}` against its
|
|
574
|
+
options, where nothing matches. The next snapshot replaces the key with the
|
|
575
|
+
list. Only that one key changed meaning, which is why `PERSIST_VERSION` stays
|
|
576
|
+
where it is: bumping it would also throw away the drafted prose of every form
|
|
577
|
+
that has no checkbox group at all.
|
|
578
|
+
|
|
579
|
+
- **`reply.pending` kept its settle handle under
|
|
580
|
+
`enqueue_after_transaction_commit = true` (#254).** The handle was captured in
|
|
581
|
+
`Phlex::Reactive::Settles#serialize`, which reads a thread-local that lives
|
|
582
|
+
only for the duration of `reply.pending`'s enqueue block. Under Rails'
|
|
583
|
+
`ActiveJob::Base.enqueue_after_transaction_commit = true` (the 7.2+
|
|
584
|
+
recommended setting) `job.enqueue` is deferred to
|
|
585
|
+
`ActiveRecord.after_all_transactions_commit`, and the endpoint runs every
|
|
586
|
+
action inside a transaction — so `serialize` always ran AFTER the block had
|
|
587
|
+
exited. The job serialized with no `phlex_reactive_settle` key,
|
|
588
|
+
`reactive_settle` was a no-op, and the row sat shimmering until someone
|
|
589
|
+
reloaded. The handle is now captured when the job INSTANCE is created
|
|
590
|
+
(`perform_later` → `job_or_instantiate` → `new`), which is synchronous inside
|
|
591
|
+
the block whether or not the enqueue itself is deferred; `serialize` prefers
|
|
592
|
+
the captured handle and still falls back to the thread-local. Both the
|
|
593
|
+
`job:`/`args:` sugar (narrowing per record, so failures stay attributable) and
|
|
594
|
+
the block form are covered, and a retry re-enqueue keeps its handle. Jobs
|
|
595
|
+
enqueued outside any `reply.pending` still carry nothing.
|
|
596
|
+
|
|
444
597
|
- **`rake release` bumps the pin in every tracked lockfile (#247, #253).** Since
|
|
445
598
|
#246 the gem root's `Gemfile.lock` is committed alongside `docs/Gemfile.lock`,
|
|
446
599
|
and both pin `phlex-reactive` by local path — so a version bump that left them
|
data/README.md
CHANGED
|
@@ -396,7 +396,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
396
396
|
| `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. |
|
|
397
397
|
| `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). `name:` is the escape hatch — a **verbatim** wire name (`name: "user[tags]"`, never re-scoped), the form-builder case. See [Tag-chip input](#tag-chip-input-reactive_tags). |
|
|
398
398
|
| `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
|
|
399
|
-
| `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number
|
|
399
|
+
| `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`, a `:boolean` is a checkbox's checked state. 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). |
|
|
400
400
|
| `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). |
|
|
401
401
|
| `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). |
|
|
402
402
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
@@ -491,12 +491,55 @@ def view_template
|
|
|
491
491
|
end
|
|
492
492
|
```
|
|
493
493
|
|
|
494
|
-
> **
|
|
495
|
-
> the
|
|
496
|
-
>
|
|
497
|
-
>
|
|
498
|
-
>
|
|
499
|
-
>
|
|
494
|
+
> **Clearing a checkbox group next to a file.** When the reactive root holds a
|
|
495
|
+
> file, the action goes out as `FormData`, and `FormData` has no way to write an
|
|
496
|
+
> empty array. A group with values is fine: each value goes out as
|
|
497
|
+
> `params[name][]`. A group with nothing ticked would simply be missing, so the
|
|
498
|
+
> client lists its name in a separate field, `empty_groups[]`, and the endpoint
|
|
499
|
+
> sets that param to `[]`. The action gets the same value it would get over JSON.
|
|
500
|
+
>
|
|
501
|
+
> This works for any param the action declares as an array, by its plain name or
|
|
502
|
+
> with the component's `reactive_scope` in front. A `features[]` group is
|
|
503
|
+
> announced as `features`, and a `todo[tags][]` group under `reactive_scope :todo`
|
|
504
|
+
> as `todo[tags]`. Under a scope you write the group's name by hand, because
|
|
505
|
+
> `reactive_field(:"tags[]")` gives `todo[tags[]]`, which isn't read as a group:
|
|
506
|
+
>
|
|
507
|
+
> ```ruby
|
|
508
|
+
> input(type: "checkbox", name: "todo[tags][]", value: "ruby") # reactive_scope :todo
|
|
509
|
+
> ```
|
|
510
|
+
>
|
|
511
|
+
> The endpoint only fills a key the body didn't send, and only at the top level.
|
|
512
|
+
> A group declared one level down (`params: { project: { features: [:string] } }`)
|
|
513
|
+
> or inside nested attributes is left alone, and the action gets its keyword
|
|
514
|
+
> default as before. With `verbose_errors` on, the log tells you when that happens.
|
|
515
|
+
>
|
|
516
|
+
> Other empty `[]` or `{}` params are still left out of a form body. If you rely
|
|
517
|
+
> on `tags: []` to clear something that isn't a `[]` group, send that action
|
|
518
|
+
> without a file.
|
|
519
|
+
|
|
520
|
+
**Checkbox groups.** Controls sharing a name that ends in `[]` are collected
|
|
521
|
+
as an **array of the chosen values** — a ticked box contributes its `value`, an
|
|
522
|
+
unticked one nothing, a `<select multiple>` its selected options. Nothing ticked is
|
|
523
|
+
an empty array, not a missing key, so an action can tell a cleared group from one
|
|
524
|
+
that never rendered, over a form body as well (see above). Declare it as an
|
|
525
|
+
array type:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
action :save, params: { features: [:string] } # <input type="checkbox" name="features[]" value="news">
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
Three shapes keep their own meaning: a lone checkbox without `[]` stays the
|
|
532
|
+
documented yes/no boolean; a radio group keeps its single checked value, `[]` or
|
|
533
|
+
not; and a hidden input sharing a name with a checkbox is that box's **companion**
|
|
534
|
+
(Rails' `check_box` emits one, carrying the `unchecked_value`) and contributes
|
|
535
|
+
nothing. A hidden input *without* a same-named checkbox is an ordinary value — the
|
|
536
|
+
usual shape for a list maintained by JS.
|
|
537
|
+
|
|
538
|
+
The suffix is the only trigger, and it applies to a single control too: a lone
|
|
539
|
+
`<input type="text" name="tags[]">` posts `["abc"]` where it used to post
|
|
540
|
+
`"abc"`. Declare such a param as an array type (`tags: [:string]`) — against a
|
|
541
|
+
flat `tags: :string` the array coerces to its literal `to_s`. If the `[]` was
|
|
542
|
+
never meant as a list, drop it from the name.
|
|
500
543
|
|
|
501
544
|
**Array & nested params.** Wrap a type in an array for an array param, or a hash
|
|
502
545
|
schema in an array for Rails-style nested attributes — so one reactive action can
|
|
@@ -1244,6 +1287,29 @@ setComputeReducer("preview", ({ title }) => ({
|
|
|
1244
1287
|
**permit-style form** (`inputs: [:qty, title: :string]`) combines both in one
|
|
1245
1288
|
declaration — bare symbols default to `:number`, a trailing hash types the
|
|
1246
1289
|
exceptions.
|
|
1290
|
+
- **Checkboxes and radios read their checked state.** A checkbox's `.value` is a
|
|
1291
|
+
constant, so a compute reads whether the box is ticked instead, coerced by the
|
|
1292
|
+
declared type: `1`/`0` as a `:number` (and in the array form),
|
|
1293
|
+
`"true"`/`"false"` as a `:string`, `true`/`false` as a **`:boolean`**. The
|
|
1294
|
+
checkbox wins over the hidden companion Rails' `check_box` renders before it.
|
|
1295
|
+
A radio group reads its checked radio's value (`""`, or `0` as a number, when
|
|
1296
|
+
none is checked). A `:boolean` on any other control is `false` for `""`, `"0"`
|
|
1297
|
+
and `"false"`.
|
|
1298
|
+
|
|
1299
|
+
```ruby
|
|
1300
|
+
reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
```js
|
|
1304
|
+
setComputeReducer("total", ({ price, gift_wrap }) => ({ total: price + (gift_wrap ? 25 : 0) }))
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
An **output** that resolves to a checkbox sets `checked` from the result's
|
|
1308
|
+
truthiness, and one that resolves to a radio group checks the radio carrying
|
|
1309
|
+
the result (a value no radio carries clears the group). Neither rewrites a
|
|
1310
|
+
`value` attribute, so what the control submits is unchanged. A `[]`-named
|
|
1311
|
+
checkbox *group* is not a compute input — compute values are scalars; declare
|
|
1312
|
+
each box under its own name.
|
|
1247
1313
|
- **`reactive_text(:name, initial)`** mirrors a value into a **text node** via
|
|
1248
1314
|
`textContent` (XSS-safe by construction). Every reducer-result key paints any
|
|
1249
1315
|
matching sink: an owned **field** if declared in `outputs:`, any owned
|
|
@@ -2591,10 +2657,17 @@ bug). It emits:
|
|
|
2591
2657
|
|
|
2592
2658
|
- **The handle rides ActiveJob metadata, not `perform`'s arity.** `reply.pending`
|
|
2593
2659
|
installs it in a thread-local and runs your enqueue inside it;
|
|
2594
|
-
`Settles#
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
almost always have
|
|
2660
|
+
`Settles#initialize` captures it onto the job instance and `#serialize` copies
|
|
2661
|
+
it into the job's metadata. So **every other caller of the same job — a
|
|
2662
|
+
nightly sweep, a webhook — keeps working unchanged**, and `reactive_settle` is
|
|
2663
|
+
simply a no-op there. That is load-bearing: these jobs almost always have
|
|
2664
|
+
non-UI callers.
|
|
2665
|
+
- **It works under `enqueue_after_transaction_commit = true`.** Rails defers that
|
|
2666
|
+
enqueue to `ActiveRecord.after_all_transactions_commit`, and the endpoint runs
|
|
2667
|
+
your action inside a transaction — so the enqueue (and its `serialize`) happens
|
|
2668
|
+
*after* the block has exited. The capture is therefore at job
|
|
2669
|
+
**instantiation**, which is synchronous inside the block either way. Nothing to
|
|
2670
|
+
configure.
|
|
2598
2671
|
- **A job that raises still clears the pending state — when it can attribute
|
|
2599
2672
|
it.** The `job:`/`args:` form enqueues one job per record and narrows each
|
|
2600
2673
|
job's handle to *that record's* target, so a failure clears exactly its row
|
|
@@ -3215,8 +3288,10 @@ endpoint maps it to 403). Matchers: `have_reactive_replace`,
|
|
|
3215
3288
|
refresh so a reply that would silently break the next click fails your test.
|
|
3216
3289
|
|
|
3217
3290
|
**HTTP helpers** — `post_reactive_action(component_or_class, act, params:, payload:)`
|
|
3218
|
-
and `post_reactive_multipart(
|
|
3219
|
-
`Phlex::Reactive.action_path` exactly as the client does
|
|
3291
|
+
and `post_reactive_multipart(..., empty_groups: [])` POST a signed token to
|
|
3292
|
+
`Phlex::Reactive.action_path` exactly as the client does; `empty_groups:` lists
|
|
3293
|
+
the groups the client cleared by name, without the `[]`, the way a form body
|
|
3294
|
+
sends them. **Token minting** —
|
|
3220
3295
|
`reactive_token_for(component_or_class, payload = {})`.
|
|
3221
3296
|
|
|
3222
3297
|
> `verbose_errors` defaults ON in test (it changes only an error BODY, never a
|
|
@@ -556,6 +556,7 @@ module Phlex
|
|
|
556
556
|
def coerce_params(action_def, component_class: nil, action_name: nil)
|
|
557
557
|
dropped = Phlex::Reactive.verbose_errors ? [] : nil
|
|
558
558
|
raw = unwrap_scope(params.fetch(:params, {}), component_class)
|
|
559
|
+
raw = apply_empty_groups(raw, action_def.schema, component_class, dropped) if params[:empty_groups]
|
|
559
560
|
|
|
560
561
|
coerced = action_def.schema.coerce(raw, dropped)
|
|
561
562
|
log_dropped_params(dropped, action_def.params, component_class, action_name)
|
|
@@ -570,7 +571,7 @@ module Phlex
|
|
|
570
571
|
# raw params pass through untouched (unscoped components + nested_attributes
|
|
571
572
|
# shapes are unaffected).
|
|
572
573
|
def unwrap_scope(raw, component_class)
|
|
573
|
-
scope = component_class
|
|
574
|
+
scope = reactive_scope_of(component_class)
|
|
574
575
|
return raw unless scope
|
|
575
576
|
|
|
576
577
|
# At the endpoint `raw` is ActionController::Parameters, so `raw[scope]` is
|
|
@@ -580,6 +581,68 @@ module Phlex
|
|
|
580
581
|
nested.is_a?(Hash) || nested.is_a?(ActionController::Parameters) ? nested : raw
|
|
581
582
|
end
|
|
582
583
|
|
|
584
|
+
# Issue #258: a form body cannot carry an empty array, so the client
|
|
585
|
+
# ANNOUNCES a cleared `[]` group — its key absent from params, its name in
|
|
586
|
+
# `empty_groups[]` beside token/act/params. Those names are written back as
|
|
587
|
+
# empty arrays, the value the JSON path sends outright. The README documents
|
|
588
|
+
# what the rule accepts and what it leaves alone.
|
|
589
|
+
#
|
|
590
|
+
# The key written is whatever `array_param` hands back, so it is always a
|
|
591
|
+
# key the DECLARATION holds — never the announced name, which is only ever
|
|
592
|
+
# compared. Runs AFTER unwrap_scope so the params root is the only target:
|
|
593
|
+
# no node is built to reach a group, hence no nested-attributes row.
|
|
594
|
+
def apply_empty_groups(raw, schema, component_class, dropped)
|
|
595
|
+
names = params[:empty_groups]
|
|
596
|
+
return raw unless names.is_a?(Array)
|
|
597
|
+
# `raw` is whatever arrived: a String from `params=x` in a query string
|
|
598
|
+
# normalises to {} in coerce, but writing into it would raise.
|
|
599
|
+
return raw unless raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)
|
|
600
|
+
|
|
601
|
+
scope = reactive_scope_of(component_class)
|
|
602
|
+
# Flat is enough — only the root is written — and it keeps the filled key
|
|
603
|
+
# off the request's own params object.
|
|
604
|
+
raw = raw.dup
|
|
605
|
+
names.each do
|
|
606
|
+
key = announced_key(it, schema, scope)
|
|
607
|
+
# Its OWN reason, not :undeclared: that one routes through the #16/#21
|
|
608
|
+
# shape hints, which would read `empty_groups` as a param and advise
|
|
609
|
+
# nesting the group under it. `dropped` is nil unless verbose_errors.
|
|
610
|
+
next dropped&.<<(["empty_groups #{it}", ANNOUNCED_UNDECLARED]) unless key
|
|
611
|
+
|
|
612
|
+
raw[key] = [] unless raw.key?(key)
|
|
613
|
+
end
|
|
614
|
+
raw
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
ANNOUNCED_UNDECLARED = :"undeclared — the action declares no array param by that name"
|
|
618
|
+
private_constant :ANNOUNCED_UNDECLARED
|
|
619
|
+
|
|
620
|
+
# The declared key an announced name resolves to, or nil — bare (`tags`) or
|
|
621
|
+
# carrying the component's scope (`todo[tags]`), the two shapes the client
|
|
622
|
+
# emits for one group. Anything else resolves to nothing, whatever its
|
|
623
|
+
# type: array_param only answers with a key it already holds.
|
|
624
|
+
def announced_key(name, schema, scope)
|
|
625
|
+
schema.array_param(unscoped_group_name(name.to_s, scope))
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# `todo[tags]` => `tags` under `reactive_scope :todo`. One level off the
|
|
629
|
+
# front, never a walk — a deeper name (todo[a][tags]) keeps its remaining
|
|
630
|
+
# brackets and simply fails the lookup.
|
|
631
|
+
def unscoped_group_name(name, scope)
|
|
632
|
+
return name unless scope
|
|
633
|
+
|
|
634
|
+
prefix = "#{scope}["
|
|
635
|
+
return name unless name.start_with?(prefix) && name.end_with?("]")
|
|
636
|
+
|
|
637
|
+
name.delete_prefix(prefix).delete_suffix("]")
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Read the way unwrap_scope reads it — the peel and this strip have to
|
|
641
|
+
# agree on the scope or an announced name lands at the wrong depth.
|
|
642
|
+
def reactive_scope_of(component_class)
|
|
643
|
+
component_class.reactive_scope if component_class.respond_to?(:reactive_scope)
|
|
644
|
+
end
|
|
645
|
+
|
|
583
646
|
# ---- verbose_errors dropped-param logging --------------------------
|
|
584
647
|
# ParamSchema collects the dropped entries; the controller formats the ONE
|
|
585
648
|
# warn line (with the #16/#21 shape hints). Everything below runs ONLY when
|
|
@@ -615,7 +678,7 @@ module Phlex
|
|
|
615
678
|
segments = bracket_path(path)
|
|
616
679
|
if segments.length > 1
|
|
617
680
|
leaf = segments.last
|
|
618
|
-
return unless
|
|
681
|
+
return unless declared_key?(schema, leaf)
|
|
619
682
|
|
|
620
683
|
"schema declares :#{leaf} at top level; nested schemas look like " \
|
|
621
684
|
"{ #{segments.first}: { #{leaf}: :string } }"
|
|
@@ -628,12 +691,18 @@ module Phlex
|
|
|
628
691
|
end
|
|
629
692
|
end
|
|
630
693
|
|
|
694
|
+
# A schema declares `name` whether it was written with a symbol or a
|
|
695
|
+
# string key; ParamSchema.compile keeps whichever the author used.
|
|
696
|
+
def declared_key?(schema, name)
|
|
697
|
+
schema.key?(name.to_sym) || schema.key?(name.to_s)
|
|
698
|
+
end
|
|
699
|
+
|
|
631
700
|
# The first schema key whose nested hash (or array-of-hash element
|
|
632
701
|
# schema) declares `name` one level down.
|
|
633
702
|
def nested_declaration_of(name, schema)
|
|
634
703
|
schema.find do |_key, type|
|
|
635
704
|
inner = type.is_a?(Array) ? type.first : type
|
|
636
|
-
inner.is_a?(Hash) &&
|
|
705
|
+
inner.is_a?(Hash) && declared_key?(inner, name)
|
|
637
706
|
end&.first
|
|
638
707
|
end
|
|
639
708
|
|
|
@@ -28,13 +28,21 @@
|
|
|
28
28
|
// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:
|
|
29
29
|
// { title: :string, qty: :number }` is what selects per-input types;
|
|
30
30
|
// `inputs: %i[a b]` stays all-numeric (backward compatible).
|
|
31
|
+
// A CHECKBOX arrives as its checked state, never its constant
|
|
32
|
+
// .value (issue #262): 1/0 untyped or :number, "true"/"false" as a
|
|
33
|
+
// :string, true/false as a :boolean. A radio group arrives as its
|
|
34
|
+
// checked radio's value ("" / 0 when none is checked).
|
|
31
35
|
// meta — { changed }: the name (string) of the declared input the
|
|
32
36
|
// triggering event edited, or null (a direct recompute() call, or a
|
|
33
37
|
// target this root doesn't own / didn't declare as an input).
|
|
34
38
|
//
|
|
35
39
|
// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name
|
|
36
40
|
// matches an owned control writes its .value (+ the change-guarded input
|
|
37
|
-
// dispatch below)
|
|
41
|
+
// dispatch below) — or, for a checkbox or a radio group, its CHECKED state
|
|
42
|
+
// (issue #262): a truthy result ticks the box ("", "0", "false", 0 and false
|
|
43
|
+
// untick it), and a radio group checks the radio carrying the result. Their
|
|
44
|
+
// value attributes are never rewritten, so what they submit is unchanged.
|
|
45
|
+
// An output with NO matching field writes textContent to every
|
|
38
46
|
// owned [data-reactive-text="<name>"] node (reactive_text(:name)) — XSS-safe by
|
|
39
47
|
// construction, change-guarded, NO input dispatch (a text node has no listener
|
|
40
48
|
// contract). A declared INPUT also mirrors into its own text node on every
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["compute.js"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below). An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
|
|
5
|
+
"// The client-side compute (data-binding) registry — the \"instant\" half of the\n// new/unpersisted-record UX.\n//\n// A record-backed reactive component round-trips every change to the server\n// (the signed identity re-finds the record; the server re-renders). A NEW,\n// unpersisted record has no such server truth to re-render against on every\n// keystroke — the classic answer is a bespoke Stimulus controller doing the math\n// in the browser (carlqvist's new_order_controller.js). This registry lets that\n// math be a DECLARED part of the component instead: `reactive_compute :name,\n// inputs:, outputs:` (Ruby) names a reducer registered here, and the generic\n// reactive controller runs it on `input` — writing the outputs with NO round\n// trip. When the component ALSO carries on(...) (a persisted record, or a draft\n// you sync), the debounced POST reconciles from the authoritative server reply.\n//\n// The seam mirrors confirm.js: a settable registry with a lookup the controller\n// calls. Register once at boot:\n//\n// import { setComputeReducer } from \"phlex/reactive/compute\"\n// setComputeReducer(\"payment_split\", ({ allowance, cash, leasing, total }) => ({\n// allowance, leasing, cash: total - allowance - leasing,\n// }))\n//\n// The reducer's signature is (values, meta):\n//\n// values — a plain object of { inputName: value } over the declared inputs.\n// Untyped inputs (the array form) AND :number-typed inputs arrive as\n// Numbers (blank/NaN → 0); :string-typed inputs (the hash form,\n// issue #104) arrive as the RAW string. `reactive_compute :x, inputs:\n// { title: :string, qty: :number }` is what selects per-input types;\n// `inputs: %i[a b]` stays all-numeric (backward compatible).\n// A CHECKBOX arrives as its checked state, never its constant\n// .value (issue #262): 1/0 untyped or :number, \"true\"/\"false\" as a\n// :string, true/false as a :boolean. A radio group arrives as its\n// checked radio's value (\"\" / 0 when none is checked).\n// meta — { changed }: the name (string) of the declared input the\n// triggering event edited, or null (a direct recompute() call, or a\n// target this root doesn't own / didn't declare as an input).\n//\n// OUTPUTS may be a form FIELD or a TEXT NODE (issue #104). An output whose name\n// matches an owned control writes its .value (+ the change-guarded input\n// dispatch below) — or, for a checkbox or a radio group, its CHECKED state\n// (issue #262): a truthy result ticks the box (\"\", \"0\", \"false\", 0 and false\n// untick it), and a radio group checks the radio carrying the result. Their\n// value attributes are never rewritten, so what they submit is unchanged.\n// An output with NO matching field writes textContent to every\n// owned [data-reactive-text=\"<name>\"] node (reactive_text(:name)) — XSS-safe by\n// construction, change-guarded, NO input dispatch (a text node has no listener\n// contract). A declared INPUT also mirrors into its own text node on every\n// input via an always-run pass — so reactive_text(:title) is a live field\n// preview with NO registered reducer at all.\n//\n// It returns a plain object of { outputName: value } — only the outputs it\n// names are written, so it can leave the edited field (and its caret)\n// untouched. A one-argument reducer keeps working unchanged (it just ignores\n// meta). `changed` is what makes a MULTI-WAY / MUTUAL rebalance expressible as\n// one reducer (issue #75) — branch on which field the user edited:\n//\n// setComputeReducer(\"three_way_split\", ({ field_a, field_b, field_c, total }, { changed }) => {\n// if (changed === \"field_c\") return { field_a: total - field_c - field_b }\n// return { field_c: total - field_a - field_b }\n// })\n//\n// Output writes are CHANGE-GUARDED: the controller writes a field and\n// dispatches a bubbling `input` event on it ONLY when the new value differs\n// from the field's current value (real browsers never fire `input` on a\n// programmatic .value write, so the controller dispatches explicitly — that's\n// what drives a chained summary repaint, matching the server's set_value +\n// dispatch(\"input\") contract). Returning the SAME value a field already holds\n// is skipped entirely — no write, no event — which is why a reducer with\n// overlapping inputs/outputs (like payment_split above) settles instead of\n// re-entering itself forever.\n//\n// CONVERGENCE REQUIREMENT: because an output write dispatches a REAL input\n// event (issue #76), recompute re-enters with changed = that OUTPUT field's\n// name (when it's also a declared input). A branching reducer must therefore\n// be convergent: the re-entrant pass must compute values EQUAL to what the\n// first pass already wrote to the DOM, so the change guard settles the chain.\n// The three_way_split above is: after `changed === \"field_a\"` writes field_c,\n// the re-entrant `changed === \"field_c\"` pass derives field_a back to the\n// value it already holds — no write, no event, settled in one bounce.\n\n// THE RESERVED `$ops` OUTPUT (issue #226). Besides field/text outputs, a\n// reducer may return the reserved key `$ops` holding a chain of client DOM\n// ops — built with the `ops` builder below, or a raw [[name, args], ...]\n// array. The controller consumes it as a PHASE 4 of the single-pass write set:\n// the ops run AFTER the field writes, text sinks, and phase-3 input dispatches\n// settle, through the SAME frozen CLIENT_OPS whitelist on_client uses (an\n// unknown op warns + is skipped). `null`/`undefined`/absent = no effect.\n//\n// RISING-EDGE semantics, keyed on CONTENT: the chain runs only when it\n// DIFFERS from the previous pass's chain (including from \"absent\"), and only\n// on EVENT-DRIVEN passes. Returning the SAME chain again is settled — no\n// re-fire (a 7th keystroke capped back to the same complete value can't\n// re-submit), mirroring the change-guarded field writes. Returning a\n// DIFFERENT chain fires again — a multi-box reducer advancing focus\n// box-by-box emits a new focus target per digit, each a new intent. The\n// connect/morph SEED pass (issue #199 — recompute with no event) ARMS the\n// latch but never fires — a form re-rendered with an already-complete value\n// (a validation-error morph, a browser restore) must not auto-fire; that is\n// what breaks the submit → error re-render → re-seed → submit loop. A later\n// pass returning no $ops re-arms. The canonical use — a one-time-code field\n// that normalizes on input and commits when complete:\n//\n// import { setComputeReducer, ops } from \"phlex/reactive/compute\"\n// setComputeReducer(\"otp\", ({ code }) => {\n// const digits = code.replace(/\\D/g, \"\").slice(0, 6)\n// return { code: digits, $ops: digits.length === 6 ? ops.submit() : null }\n// })\n//\n// `submit` commits the target's own form via requestSubmit() — the real submit\n// event fires, so an on(:verify, event: \"submit\") interception (or a native/\n// Turbo form) handles it exactly like a user submit. submit/focus are\n// ACTOR-ONLY: usable here and in on_client/reply.js, refused in broadcasts.\n// paste_into (issue #228) is actor-only too but DELIBERATELY absent from this\n// builder: a reducer runs on every input event, and a clipboard read per\n// keystroke (each changed chain fires) would spam permission prompts. Use it\n// from on_client / reply.js / reactive_on_complete instead; a raw\n// [[\"paste_into\", …]] pair still interprets if you truly need it.\n\nconst reducers = new Map()\n\n// Register (or replace) the reducer for `key`. `fn` is\n// (values: Record<string, number>, meta: { changed: string | null })\n// => Record<string, unknown>.\nexport function setComputeReducer(key, fn) {\n reducers.set(key, fn)\n}\n\n// Look up a registered reducer; undefined when none — the controller then makes\n// #recompute a no-op rather than throwing (a missing reducer must not break the\n// page; it just means no client-side binding for that root).\nexport function computeReducer(key) {\n return reducers.get(key)\n}\n\n// Test seam: clear the registry so a reducer registered in one test can't leak.\nexport function __resetComputeRegistryForTest() {\n reducers.clear()\n}\n\n// --- The reducer-side op-chain builder (issue #226) --------------------------\n//\n// A thin, IMMUTABLE mirror of the Ruby Phlex::Reactive::JS builder (minus\n// paste_into — see the actor-only note above): verbs carry\n// the WIRE op names (snake_case) and append [name, args] pairs; every verb\n// returns a NEW instance, so a chain held in a constant can never be mutated by\n// later use. `.ops` exposes the raw [[name, args], ...] list the controller\n// interprets (and toJSON serializes it, so a chain can also feed a hand-built\n// ops attr). Targets: a CSS selector string, or omit for \"@root\" (the\n// component's own root). No build-time attr validation here — the interpreter's\n// allowlist (guardAttr) is the enforcement point; this builder only shapes the\n// wire.\nconst ROOT_SENTINEL = \"@root\"\n\nfunction targetArgs(to, { global, transition } = {}) {\n const args = { to: to ?? ROOT_SENTINEL }\n if (global) args.global = true\n if (transition) args.transition = normalizeTransition(transition)\n return args\n}\n\n// Named legs { during, from, to } → the [during, from, to] wire array (the\n// issue #186 vocabulary). Loud at authoring time, like the Ruby builder.\n// Frozen, like every nested payload — the chain's immutability contract must\n// hold all the way down (the Ruby twin freezes its legs array too).\nfunction normalizeTransition(transition) {\n const named =\n transition && typeof transition === \"object\" && !Array.isArray(transition) &&\n [\"during\", \"from\", \"to\"].every((k) => k in transition)\n if (!named) throw new Error(\"[phlex-reactive] ops transition takes named legs { during, from, to }\")\n return Object.freeze([String(transition.during), String(transition.from), String(transition.to)])\n}\n\nclass OpsChain {\n constructor(list = Object.freeze([])) {\n this.ops = list\n Object.freeze(this)\n }\n\n show(to, opts) {\n return this.#append(\"show\", targetArgs(to, opts))\n }\n\n hide(to, opts) {\n return this.#append(\"hide\", targetArgs(to, opts))\n }\n\n toggle(to, opts) {\n return this.#append(\"toggle\", targetArgs(to, opts))\n }\n\n add_class(to, classes, opts) {\n return this.#append(\"add_class\", classArgs(to, classes, opts))\n }\n\n remove_class(to, classes, opts) {\n return this.#append(\"remove_class\", classArgs(to, classes, opts))\n }\n\n toggle_class(to, classes, opts) {\n return this.#append(\"toggle_class\", classArgs(to, classes, opts))\n }\n\n set_attr(to, name, value, opts) {\n return this.#append(\"set_attr\", { ...targetArgs(to, opts), name: String(name), value: String(value) })\n }\n\n remove_attr(to, name, opts) {\n return this.#append(\"remove_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n toggle_attr(to, name, opts) {\n return this.#append(\"toggle_attr\", { ...targetArgs(to, opts), name: String(name) })\n }\n\n focus(to, opts) {\n return this.#append(\"focus\", targetArgs(to, opts))\n }\n\n focus_first(to, opts) {\n return this.#append(\"focus_first\", targetArgs(to, opts))\n }\n\n text(to, value, opts) {\n return this.#append(\"text\", { ...targetArgs(to, opts), value: String(value ?? \"\") })\n }\n\n dispatch(name, { to, detail, global } = {}) {\n const args = { name: String(name), to: to ?? ROOT_SENTINEL, detail: detail ?? {} }\n if (global) args.global = true\n return this.#append(\"dispatch\", args)\n }\n\n submit(to, opts) {\n return this.#append(\"submit\", targetArgs(to, opts))\n }\n\n toJSON() {\n return this.ops\n }\n\n #append(name, args) {\n return new OpsChain(Object.freeze([...this.ops, Object.freeze([name, Object.freeze(args)])]))\n }\n}\n\n// classes: one class string or an array of them (never whitespace-split — a\n// classList token can't contain spaces, so splitting would only mask a bug).\n// Loud on an empty/missing list, and frozen (the Ruby twin freezes its class\n// list too) — a chain held in a constant must stay immutable all the way down.\nfunction classArgs(to, classes, opts) {\n const list = classes == null ? [] : (Array.isArray(classes) ? classes : [classes]).map(String)\n if (list.length === 0) throw new Error(\"[phlex-reactive] a class op needs at least one class\")\n return { ...targetArgs(to, opts), classes: Object.freeze(list) }\n}\n\n// The shared empty chain — start every reducer effect from here:\n// $ops: done ? ops.dispatch(\"code:complete\").submit() : null\nexport const ops = new OpsChain()\n"
|
|
6
6
|
],
|
|
7
|
-
"mappings": "
|
|
7
|
+
"mappings": "AAuHA,IAAM,EAAW,IAAI,IAKd,SAAS,CAAiB,CAAC,EAAK,EAAI,CACzC,EAAS,IAAI,EAAK,CAAE,EAMf,SAAS,CAAc,CAAC,EAAK,CAClC,OAAO,EAAS,IAAI,CAAG,EAIlB,SAAS,CAA6B,EAAG,CAC9C,EAAS,MAAM,EAejB,IAAM,EAAgB,QAEtB,SAAS,CAAU,CAAC,GAAM,SAAQ,cAAe,CAAC,EAAG,CACnD,IAAM,EAAO,CAAE,GAAI,GAAM,CAAc,EACvC,GAAI,EAAQ,EAAK,OAAS,GAC1B,GAAI,EAAY,EAAK,WAAa,EAAoB,CAAU,EAChE,OAAO,EAOT,SAAS,CAAmB,CAAC,EAAY,CAIvC,GAAI,EAFF,GAAc,OAAO,IAAe,UAAY,CAAC,MAAM,QAAQ,CAAU,GACzE,CAAC,SAAU,OAAQ,IAAI,EAAE,MAAM,CAAC,KAAM,KAAK,EAAU,GAC3C,MAAU,MAAM,uEAAuE,EACnG,OAAO,OAAO,OAAO,CAAC,OAAO,EAAW,MAAM,EAAG,OAAO,EAAW,IAAI,EAAG,OAAO,EAAW,EAAE,CAAC,CAAC,EAGlG,MAAM,CAAS,CACb,WAAW,CAAC,EAAO,OAAO,OAAO,CAAC,CAAC,EAAG,CACpC,KAAK,IAAM,EACX,OAAO,OAAO,IAAI,EAGpB,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,IAAI,CAAC,EAAI,EAAM,CACb,OAAO,KAAK,GAAQ,OAAQ,EAAW,EAAI,CAAI,CAAC,EAGlD,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,SAAS,CAAC,EAAI,EAAS,EAAM,CAC3B,OAAO,KAAK,GAAQ,YAAa,EAAU,EAAI,EAAS,CAAI,CAAC,EAG/D,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,YAAY,CAAC,EAAI,EAAS,EAAM,CAC9B,OAAO,KAAK,GAAQ,eAAgB,EAAU,EAAI,EAAS,CAAI,CAAC,EAGlE,QAAQ,CAAC,EAAI,EAAM,EAAO,EAAM,CAC9B,OAAO,KAAK,GAAQ,WAAY,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,EAAG,MAAO,OAAO,CAAK,CAAE,CAAC,EAGvG,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,WAAW,CAAC,EAAI,EAAM,EAAM,CAC1B,OAAO,KAAK,GAAQ,cAAe,IAAK,EAAW,EAAI,CAAI,EAAG,KAAM,OAAO,CAAI,CAAE,CAAC,EAGpF,KAAK,CAAC,EAAI,EAAM,CACd,OAAO,KAAK,GAAQ,QAAS,EAAW,EAAI,CAAI,CAAC,EAGnD,WAAW,CAAC,EAAI,EAAM,CACpB,OAAO,KAAK,GAAQ,cAAe,EAAW,EAAI,CAAI,CAAC,EAGzD,IAAI,CAAC,EAAI,EAAO,EAAM,CACpB,OAAO,KAAK,GAAQ,OAAQ,IAAK,EAAW,EAAI,CAAI,EAAG,MAAO,OAAO,GAAS,EAAE,CAAE,CAAC,EAGrF,QAAQ,CAAC,GAAQ,KAAI,SAAQ,UAAW,CAAC,EAAG,CAC1C,IAAM,EAAO,CAAE,KAAM,OAAO,CAAI,EAAG,GAAI,GAAM,EAAe,OAAQ,GAAU,CAAC,CAAE,EACjF,GAAI,EAAQ,EAAK,OAAS,GAC1B,OAAO,KAAK,GAAQ,WAAY,CAAI,EAGtC,MAAM,CAAC,EAAI,EAAM,CACf,OAAO,KAAK,GAAQ,SAAU,EAAW,EAAI,CAAI,CAAC,EAGpD,MAAM,EAAG,CACP,OAAO,KAAK,IAGd,EAAO,CAAC,EAAM,EAAM,CAClB,OAAO,IAAI,EAAS,OAAO,OAAO,CAAC,GAAG,KAAK,IAAK,OAAO,OAAO,CAAC,EAAM,OAAO,OAAO,CAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAEhG,CAMA,SAAS,CAAS,CAAC,EAAI,EAAS,EAAM,CACpC,IAAM,EAAO,GAAW,KAAO,CAAC,GAAK,MAAM,QAAQ,CAAO,EAAI,EAAU,CAAC,CAAO,GAAG,IAAI,MAAM,EAC7F,GAAI,EAAK,SAAW,EAAG,MAAU,MAAM,sDAAsD,EAC7F,MAAO,IAAK,EAAW,EAAI,CAAI,EAAG,QAAS,OAAO,OAAO,CAAI,CAAE,EAK1D,IAAM,EAAM,IAAI",
|
|
8
8
|
"debugId": "58A4BBBA7E66031D64756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
//
|
|
20
20
|
// fields — a plain object of { name: value } over the trigger root's collected
|
|
21
21
|
// controls (the SAME snapshot reactive_compute reads — #collectFields).
|
|
22
|
-
// Values are the raw control values (strings; a checkbox is a
|
|
22
|
+
// Values are the raw control values (strings; a lone checkbox is a
|
|
23
|
+
// boolean; a `[]` group is an array of the TICKED values, issue #258 —
|
|
24
|
+
// before that fix such a group arrived as one box's checked state).
|
|
25
|
+
// A group with nothing ticked is PRESENT as an empty array, not
|
|
26
|
+
// missing. The key keeps its suffix, so it is `fields["tags[]"]`,
|
|
27
|
+
// and `[]` is truthy in JS — test `fields["tags[]"].length`, never
|
|
28
|
+
// `if (fields["tags[]"])`.
|
|
23
29
|
//
|
|
24
30
|
// It returns truthy to WARN (the confirm dialog fires with the declared message)
|
|
25
31
|
// or falsy to PROCEED with no dialog. The predicate is soft-validation UX, NOT
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["confirm_predicate.js"],
|
|
4
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
|
|
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 lone checkbox is a\n// boolean; a `[]` group is an array of the TICKED values, issue #258 —\n// before that fix such a group arrived as one box's checked state).\n// A group with nothing ticked is PRESENT as an empty array, not\n// missing. The key keeps its suffix, so it is `fields[\"tags[]\"]`,\n// and `[]` is truthy in JS — test `fields[\"tags[]\"].length`, never\n// `if (fields[\"tags[]\"])`.\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
6
|
],
|
|
7
|
-
"mappings": "
|
|
7
|
+
"mappings": "AAwCA,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
8
|
"debugId": "64DE097A0F67E08564756E2164756E21",
|
|
9
9
|
"names": []
|
|
10
10
|
}
|