phlex-reactive 0.11.4 → 0.11.5
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 +57 -0
- data/README.md +149 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +43 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +99 -0
- 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/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +20 -0
- data/lib/phlex/reactive/apm/adapter.rb +43 -0
- data/lib/phlex/reactive/apm/appsignal.rb +52 -0
- data/lib/phlex/reactive/apm/datadog.rb +44 -0
- data/lib/phlex/reactive/apm/sentry.rb +40 -0
- data/lib/phlex/reactive/apm/subscriber.rb +61 -0
- data/lib/phlex/reactive/apm.rb +91 -0
- data/lib/phlex/reactive/component/dsl.rb +27 -4
- data/lib/phlex/reactive/component/helpers.rb +119 -0
- data/lib/phlex/reactive/engine.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +89 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7758f95f9e1a35e71bf870416d6e042367b7ca19bb2a1c3b618939e72aa5775
|
|
4
|
+
data.tar.gz: 2203a04fcf550a95e828945e56d06ed1b335fc430a1fdb8669f566893aa17c7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 311c87cf131ec0db83082089133b32dcc0583aa49203950ff59495929a2b512d72bedff7ced52747b2074bbce16a6b32eac99249e2b985cb19acfb9afce3a1ab
|
|
7
|
+
data.tar.gz: 5856533fe3e2207246082a56f6709d732f32db50b8a8c6f1e2f3c8bd74b0f5b2c2330440e0c2b4c9bc078d785d33b85e95ae22f7edc91f746df959f86819fa37
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Turnkey APM adapters — `Phlex::Reactive.apm = :appsignal` (#207).** The gem
|
|
12
|
+
already emits `*.phlex_reactive` `ActiveSupport::Notifications` events (#107), but
|
|
13
|
+
every reactive action still POSTs to the same endpoint, so an APM rolled all
|
|
14
|
+
reactive traffic into one blurry `ActionsController#create` transaction and an
|
|
15
|
+
action-body crash surfaced as an anonymous 500. One config line closes both gaps:
|
|
16
|
+
- **`Phlex::Reactive.apm = :appsignal` (or `:sentry`, `:datadog`, or a custom
|
|
17
|
+
object)** names each action as its own `Component#action` transaction/span in the
|
|
18
|
+
APM, tagged with the component, action, and outcome. The vendor SDK is
|
|
19
|
+
**runtime-detected** (`defined?(::Appsignal)` …) — never a gemspec dependency, the
|
|
20
|
+
same optionality invariant as pgbus — so a set-but-absent SDK logs one warning and
|
|
21
|
+
no-ops. A custom object need only respond to `record_action(payload, duration_ms)`
|
|
22
|
+
and `record_error(error, payload)`.
|
|
23
|
+
- **Action-body errors are reported with context.** A previously-uncaught error from
|
|
24
|
+
an action body (a genuine 500, not a registered 4xx) is now OBSERVED at the
|
|
25
|
+
endpoint: the `action.phlex_reactive` event is tagged `outcome: :error` (filling the
|
|
26
|
+
#107 nil-outcome gap), the exception is reported to the APM adapter AND every
|
|
27
|
+
registered `Phlex::Reactive.on_action_error { |error, ctx| … }` hook WITH the
|
|
28
|
+
name-only `{ component:, action: }` context, the `error_flash` renders so the actor
|
|
29
|
+
sees a flash on a crash (500s now flow through the same path the 4xx errors used) —
|
|
30
|
+
and THEN the error is **re-raised unchanged**, so Rails' own error reporting and the
|
|
31
|
+
app's middleware fire exactly as before. Each reporter is isolated: a broken reporter
|
|
32
|
+
(or a raising `error_flash` lambda) can never turn one 500 into a different 500 or
|
|
33
|
+
swallow the original. The status never changes; the existing 4xx rescues, their
|
|
34
|
+
statuses, and their outcomes (`invalid_token`/`denied_undeclared`/`not_found`/
|
|
35
|
+
`unauthorized`/`unverified`) are untouched. Payloads and error tags stay name-only —
|
|
36
|
+
never the token, params, or state.
|
|
37
|
+
|
|
38
|
+
- **Draft nested-attribute rows — the "new parent + child rows" form (#208).**
|
|
39
|
+
A collection whose PARENT isn't persisted yet (a new order accumulating line
|
|
40
|
+
items) can't be a reactive collection — an unsaved parent has no gid to sign.
|
|
41
|
+
The pre-save window is now a client-side primitive, the `reactive_tags`
|
|
42
|
+
posture: `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)`
|
|
43
|
+
mark the container + the server-owned `<template>` holding ONE row's markup;
|
|
44
|
+
`reactive_nested_add(:assoc)` clones it, swapping every `NEW_ROW` placeholder
|
|
45
|
+
in the clone's `name`/`id`/`for` for a fresh unique index (clock-seeded
|
|
46
|
+
monotonic — server-rendered `0..n` and same-millisecond double clicks can't
|
|
47
|
+
collide) and focusing the new row's first field; `reactive_nested_remove`
|
|
48
|
+
deletes a draft row from the DOM, or `_destroy`-marks + hides a row carrying
|
|
49
|
+
a hidden `[_destroy]` input (an edit form's persisted row) so Rails destroys
|
|
50
|
+
it on save. `nested_field_name(:assoc, :field, index:)` builds the
|
|
51
|
+
scope-aware Rails wire name (`order[line_items_attributes][i][quantity]`).
|
|
52
|
+
Zero round trips, no token (works in a `ClientBindings` component); the REAL
|
|
53
|
+
form submit reconciles parent + rows in ONE request via
|
|
54
|
+
`accepts_nested_attributes_for`. Several collections can share a root
|
|
55
|
+
(association-keyed); the row markup is authored ONCE and serves the template,
|
|
56
|
+
server-rendered edit rows, and the persisted `reactive_collection` flow.
|
|
57
|
+
|
|
11
58
|
- **`reactive_tags` — the tag-chip input primitive (#203).** The composed
|
|
12
59
|
combobox/tags widget (preload suggestions, type to narrow, Enter/click to add,
|
|
13
60
|
remove chips) with zero bespoke JavaScript and zero round trips. The value is
|
|
@@ -67,6 +114,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
67
114
|
|
|
68
115
|
### Fixed
|
|
69
116
|
|
|
117
|
+
- **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
|
|
118
|
+
`Component::Identity` already signed a gid-less `{c, state}` token for an
|
|
119
|
+
unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
|
|
120
|
+
`gid` — the FIRST action against a draft crashed with an uncaught `KeyError`
|
|
121
|
+
(500). It now rebuilds through the record kwarg's **initialize default**
|
|
122
|
+
(e.g. `def initialize(order: Order.new, …)`), with the declared
|
|
123
|
+
`reactive_state` riding the token as before; a component whose initialize
|
|
124
|
+
REQUIRES the record kwarg raises a guided `Phlex::Reactive::Error` naming the
|
|
125
|
+
fix instead of a bare missing-keyword `ArgumentError`.
|
|
126
|
+
|
|
70
127
|
- **`have_reactive_value` now reads the field's `.value` property, so it can verify a
|
|
71
128
|
reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
|
|
72
129
|
Capybara's `have_field(with:)`, which matches the value **attribute** — but a
|
data/README.md
CHANGED
|
@@ -396,6 +396,9 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
396
396
|
| `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
|
|
397
397
|
| `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
|
|
398
398
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
399
|
+
| `reactive_nested_list(:assoc)` / `reactive_nested_template(:assoc)` / `reactive_nested_row` | **Draft nested-attribute rows** (the "new parent + child rows" form): the container rows land in, the `<template>` holding ONE row's markup, and the row wrapper marker — all **client-only** form state, keyed by association (several collections per root). See [Draft rows for a new parent](#draft-rows-for-a-new-parent-reactive_nested_). |
|
|
400
|
+
| `reactive_nested_add(:assoc)` / `reactive_nested_remove` | The row triggers, client-only (zero round trips): add clones the template and renumbers its placeholder index; remove deletes a draft row from the DOM, or `_destroy`-marks + hides a persisted row (a hidden `[_destroy]` input present). |
|
|
401
|
+
| `nested_field_name(:assoc, :field, index: nil)` | The Rails `accepts_nested_attributes_for` wire name for one row field — `order[line_items_attributes][NEW_ROW][quantity]` (the template placeholder) by default, a real index when given. Scope-aware under `reactive_scope`. |
|
|
399
402
|
| `reactive_collection :name, item:, container:, count:, empty:, size:` | Declare an add/remove-row list once; actions call `reply.append`/`prepend`/`remove`. See [Reactive collections](#reactive-collections-addremove-rows--count--empty-state). |
|
|
400
403
|
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` / `.js(ops)` | Return from an action to control the reply (flash, remove, redirect, multi-stream, server-pushed client ops). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
401
404
|
| `reply.append(name, model)` / `.prepend(...)` / `.remove(name, model)` | Add/remove a row in a declared `reactive_collection` (row + count + empty-state in one reply). |
|
|
@@ -1252,6 +1255,92 @@ The styled, form-builder-integrated version of this widget (label/errors/
|
|
|
1252
1255
|
theming) belongs in your form layer — these helpers are deliberately the
|
|
1253
1256
|
unstyled primitives, like `reactive_filter` before them.
|
|
1254
1257
|
|
|
1258
|
+
### Draft rows for a new parent (`reactive_nested_*`)
|
|
1259
|
+
|
|
1260
|
+
The "new order + line items" form: the user builds up child rows **before the
|
|
1261
|
+
parent exists**. An unsaved parent has no GlobalID to sign, so this pre-save
|
|
1262
|
+
window can't be a reactive collection — and every app ends up hand-writing the
|
|
1263
|
+
same imperative JS (clone a row, renumber indexes, serialize on submit).
|
|
1264
|
+
`reactive_nested_*` is that pattern as a primitive, the `reactive_tags`
|
|
1265
|
+
posture: the rows are **form state**, add/remove run **entirely client-side**
|
|
1266
|
+
(zero round trips, no token — works in a `ClientBindings` component), and the
|
|
1267
|
+
surrounding **real form submit** posts standard Rails
|
|
1268
|
+
`parent[assoc_attributes][<index>][field]` names, so
|
|
1269
|
+
`accepts_nested_attributes_for` creates the parent + rows in **one request**.
|
|
1270
|
+
|
|
1271
|
+
```ruby
|
|
1272
|
+
class DraftOrderForm < ApplicationComponent
|
|
1273
|
+
include Phlex::Reactive::ClientBindings
|
|
1274
|
+
|
|
1275
|
+
reactive_scope :order # names post as order[line_items_attributes][…]
|
|
1276
|
+
|
|
1277
|
+
def view_template
|
|
1278
|
+
div(**reactive_root(id: "draft_order_form")) do
|
|
1279
|
+
form(action: orders_path, method: "post", data: { turbo: "false" }) do
|
|
1280
|
+
input(**reactive_field(:total, type: "number", value: "0"))
|
|
1281
|
+
|
|
1282
|
+
div(**reactive_nested_list(:line_items)) { } # rows land here
|
|
1283
|
+
template(**reactive_nested_template(:line_items)) { row_fields }
|
|
1284
|
+
button(**reactive_nested_add(:line_items)) { "Add item" }
|
|
1285
|
+
|
|
1286
|
+
button(type: "submit") { "Create order" }
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
private
|
|
1292
|
+
|
|
1293
|
+
# ONE row's markup, authored once. The default renders the template
|
|
1294
|
+
# prototype (NEW_ROW placeholder names — the client swaps in a fresh index
|
|
1295
|
+
# per add); pass index: to server-render an edit form's persisted rows.
|
|
1296
|
+
def row_fields(index: nil)
|
|
1297
|
+
kwargs = index.nil? ? {} : { index: }
|
|
1298
|
+
div(**reactive_nested_row) do
|
|
1299
|
+
input(name: nested_field_name(:line_items, :quantity, **kwargs), type: "number")
|
|
1300
|
+
input(name: nested_field_name(:line_items, :price, **kwargs), type: "number")
|
|
1301
|
+
button(**reactive_nested_remove) { "×" }
|
|
1302
|
+
end
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
```
|
|
1306
|
+
|
|
1307
|
+
```ruby
|
|
1308
|
+
# The model + controller side is plain Rails — the reconcile is one create:
|
|
1309
|
+
class Order < ApplicationRecord
|
|
1310
|
+
has_many :line_items, dependent: :destroy
|
|
1311
|
+
accepts_nested_attributes_for :line_items, allow_destroy: true
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
Order.create!(params.require(:order).permit(:total,
|
|
1315
|
+
line_items_attributes: %i[id quantity price _destroy]))
|
|
1316
|
+
```
|
|
1317
|
+
|
|
1318
|
+
Clicking add clones your `<template>` row and swaps every `NEW_ROW` in the
|
|
1319
|
+
clone's `name`/`id`/`for` attributes for a fresh unique index (clock-seeded and
|
|
1320
|
+
strictly monotonic — server-rendered `0..n` indexes and same-millisecond double
|
|
1321
|
+
clicks can't collide), then focuses the new row's first field. Remove on a
|
|
1322
|
+
draft row deletes it from the DOM — it was never persisted, so removing its
|
|
1323
|
+
fields *is* the removal. Remove on a row carrying a hidden `[_destroy]` input
|
|
1324
|
+
(an **edit** form's persisted row, rendered with `nested_field_name(:line_items,
|
|
1325
|
+
:_destroy, index: i)`) marks it `"1"` and hides the row instead — Rails destroys
|
|
1326
|
+
it on save. Several collections can share one root (everything is keyed by the
|
|
1327
|
+
association name); nesting a collection inside another's template is not
|
|
1328
|
+
supported.
|
|
1329
|
+
|
|
1330
|
+
Two boundaries to respect: the DOM is the single source of truth for unsent
|
|
1331
|
+
draft rows, so a **server re-render of the root replaces them** — keep
|
|
1332
|
+
replace-shaped actions out of a root holding unsent rows. And once the parent
|
|
1333
|
+
is saved, the persisted flow takes over: the same row markup renders with real
|
|
1334
|
+
indexes, or the list graduates to a [reactive collection](#reactive-collections-addremove-rows--count--empty-state)
|
|
1335
|
+
(`reactive_collection` + `reply.append`/`reply.remove`).
|
|
1336
|
+
|
|
1337
|
+
Relatedly, a **draft parent can now run real server actions too** (issue #208):
|
|
1338
|
+
an unsaved record signs a gid-less `{c, state}` token, and the endpoint rebuilds
|
|
1339
|
+
the component through the record kwarg's **initialize default** —
|
|
1340
|
+
`def initialize(order: Order.new, …)` — with the declared `reactive_state`
|
|
1341
|
+
riding the token. A component whose initialize *requires* the record kwarg
|
|
1342
|
+
raises a guided error on the first draft action, telling you to add the default.
|
|
1343
|
+
|
|
1255
1344
|
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
1256
1345
|
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
1257
1346
|
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
|
|
@@ -2002,10 +2091,11 @@ Phlex::Reactive.action_path = "/_r/actions"
|
|
|
2002
2091
|
Phlex::Reactive.verbose_errors = true
|
|
2003
2092
|
|
|
2004
2093
|
# User-visible flash on endpoint failures (default nil = off). When set, every
|
|
2005
|
-
# rescue path (400/403/404) ALSO renders a turbo-stream flash the
|
|
2006
|
-
# the SAME status it returns today (statuses never change). The
|
|
2007
|
-
# the failure kind (:tampered/:unknown_class/:not_reactive_class
|
|
2008
|
-
# :not_found), so you can map it to
|
|
2094
|
+
# rescue path (400/403/404 AND a 500 crash) ALSO renders a turbo-stream flash the
|
|
2095
|
+
# user sees — at the SAME status it returns today (statuses never change). The
|
|
2096
|
+
# lambda receives the failure kind (:tampered/:unknown_class/:not_reactive_class/
|
|
2097
|
+
# :forbidden/:not_found, or :error for an action-body crash), so you can map it to
|
|
2098
|
+
# a friendly message:
|
|
2009
2099
|
Phlex::Reactive.error_flash = ->(kind) do
|
|
2010
2100
|
case kind
|
|
2011
2101
|
when :not_found then "That item is no longer available."
|
|
@@ -2014,6 +2104,17 @@ Phlex::Reactive.error_flash = ->(kind) do
|
|
|
2014
2104
|
end
|
|
2015
2105
|
end
|
|
2016
2106
|
|
|
2107
|
+
# Turnkey APM integration. Names each action Component#action in AppSignal/Sentry/
|
|
2108
|
+
# Datadog and reports action-body crashes with component/action tags. SDK is
|
|
2109
|
+
# runtime-detected (no gem dependency); a custom object responding to
|
|
2110
|
+
# record_action/record_error works too. See "Observability" above.
|
|
2111
|
+
Phlex::Reactive.apm = :appsignal
|
|
2112
|
+
|
|
2113
|
+
# Report action-body crashes to any tracker yourself (the DIY escape hatch):
|
|
2114
|
+
Phlex::Reactive.on_action_error do |error, ctx|
|
|
2115
|
+
Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
|
|
2116
|
+
end
|
|
2117
|
+
|
|
2017
2118
|
# Component-aware wrapper around every action (audit / rate-limit / assert).
|
|
2018
2119
|
# Sees the resolved component, action name, and COERCED params; runs inside
|
|
2019
2120
|
# the connection-id scope but OUTSIDE the transaction. See "Two seams" below.
|
|
@@ -2293,6 +2394,50 @@ The events fire whether or not you enable the LogSubscriber; the flag only
|
|
|
2293
2394
|
controls the gem's own log lines. See
|
|
2294
2395
|
[docs/performance.md](https://phlex-reactive.zoolutions.llc/docs/performance).
|
|
2295
2396
|
|
|
2397
|
+
### Turnkey APM adapters (AppSignal, Sentry, Datadog)
|
|
2398
|
+
|
|
2399
|
+
Subscribing by hand is the DIY path. For the common trackers there's a one-liner
|
|
2400
|
+
that both names the transaction and reports errors — so reactive traffic stops
|
|
2401
|
+
rolling into one blurry `ActionsController#create` and a crash arrives at your
|
|
2402
|
+
tracker with context:
|
|
2403
|
+
|
|
2404
|
+
```ruby
|
|
2405
|
+
# config/initializers/phlex_reactive.rb
|
|
2406
|
+
Phlex::Reactive.apm = :appsignal # or :sentry, :datadog, or a custom object
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
With it set:
|
|
2410
|
+
|
|
2411
|
+
- Each reactive action shows in the APM as its OWN transaction/span —
|
|
2412
|
+
`Counter#increment`, not `Phlex::Reactive::ActionsController#create` — tagged
|
|
2413
|
+
with the component, action, and outcome.
|
|
2414
|
+
- An action body that raises a genuine error (a 500, not a registered 4xx) is
|
|
2415
|
+
**reported to the tracker with `component`/`action` tags**, then re-raised
|
|
2416
|
+
unchanged so Rails' own error reporting still fires.
|
|
2417
|
+
|
|
2418
|
+
The SDK is **runtime-detected** — no gem dependency is added. If the named SDK
|
|
2419
|
+
isn't loaded, `apm =` logs one warning at boot and no-ops (the same optionality
|
|
2420
|
+
invariant as pgbus). A custom object works too — anything responding to
|
|
2421
|
+
`record_action(payload, duration_ms)` and `record_error(error, payload)`.
|
|
2422
|
+
|
|
2423
|
+
For a tracker with no built-in adapter, report errors yourself — this hook fires
|
|
2424
|
+
on any previously-uncaught action-body error, with the name-only context:
|
|
2425
|
+
|
|
2426
|
+
```ruby
|
|
2427
|
+
Phlex::Reactive.on_action_error do |error, ctx|
|
|
2428
|
+
Honeybadger.notify(error, context: { component: ctx[:component], action: ctx[:action] })
|
|
2429
|
+
end
|
|
2430
|
+
```
|
|
2431
|
+
|
|
2432
|
+
And to show the user a flash when an action crashes (the SAME hook the 4xx errors
|
|
2433
|
+
already use — `kind` is `:error` for a crash):
|
|
2434
|
+
|
|
2435
|
+
```ruby
|
|
2436
|
+
Phlex::Reactive.error_flash = ->(kind) { "Something went wrong — please retry." }
|
|
2437
|
+
```
|
|
2438
|
+
|
|
2439
|
+
See [docs/observability.md](https://phlex-reactive.zoolutions.llc/docs/observability).
|
|
2440
|
+
|
|
2296
2441
|
### Client debug mode (devtools-lite)
|
|
2297
2442
|
|
|
2298
2443
|
The `LogSubscriber` above is the **server** lens. The client lens is
|
|
@@ -96,6 +96,13 @@ module Phlex
|
|
|
96
96
|
rescue *authorization_errors => e
|
|
97
97
|
event[:outcome] = :unauthorized
|
|
98
98
|
reactive_error(:forbidden, deferred_authorization_message(e, component_class), kind: :forbidden)
|
|
99
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
100
|
+
# Issue #207, the defer (read) leg: a render that raises uncaught is
|
|
101
|
+
# OBSERVED (tag + report + flash) then re-raised unchanged, mirroring the
|
|
102
|
+
# action leg. The defer payload has no `action:` — report_error/the
|
|
103
|
+
# adapters treat a nil action gracefully (transaction is the component).
|
|
104
|
+
report_action_error(e, event)
|
|
105
|
+
raise
|
|
99
106
|
end
|
|
100
107
|
|
|
101
108
|
def verified_defer_payload
|
|
@@ -156,6 +163,42 @@ module Phlex
|
|
|
156
163
|
rescue *authorization_errors => e
|
|
157
164
|
event[:outcome] = :unauthorized
|
|
158
165
|
reactive_error(:forbidden, authorization_error_message(e, component_class, action_def), kind: :forbidden)
|
|
166
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
167
|
+
# Turnkey APM error reporting (issue #207). A previously-UNCAUGHT
|
|
168
|
+
# action-body error — NOT one of the specific 4xx cases above (those still
|
|
169
|
+
# win by ordering) — is OBSERVED here and then re-raised UNCHANGED, so
|
|
170
|
+
# Rails' own error reporting and the app's middleware fire exactly as
|
|
171
|
+
# today. The status never changes: this catch adds no new 4xx.
|
|
172
|
+
# 1. tag the event outcome (fills the #107 nil-outcome gap),
|
|
173
|
+
# 2. report to the APM adapter + on_action_error hooks WITH the name-only
|
|
174
|
+
# component/action context (each reporter isolated — see report_error),
|
|
175
|
+
# 3. render the error_flash for the crash so the actor SEES a flash (500s
|
|
176
|
+
# now flow through the same error_flash path 4xx already used), THEN
|
|
177
|
+
# 4. re-raise. The flash is built but MUST NOT swallow the raise.
|
|
178
|
+
report_action_error(e, event)
|
|
179
|
+
raise
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# OBSERVE a previously-uncaught action-body error (issue #207) without
|
|
183
|
+
# altering what propagates. Tag the outcome, fan the error out to the APM
|
|
184
|
+
# adapter + on_action_error hooks (report_error isolates each reporter), and
|
|
185
|
+
# render the error_flash for the crash. Every step is best-effort and MUST
|
|
186
|
+
# NOT raise (the caller re-raises the ORIGINAL error immediately after): a
|
|
187
|
+
# broken reporter is swallowed inside report_error; the flash render is
|
|
188
|
+
# guarded here. The flash reuses the SAME 4xx machinery — error_flash_stream
|
|
189
|
+
# degrades to nil on its own failure, and we render at :internal_server_error
|
|
190
|
+
# so the body (if any) matches the 500 the re-raise ultimately yields.
|
|
191
|
+
def report_action_error(error, event)
|
|
192
|
+
event[:outcome] = :error
|
|
193
|
+
Phlex::Reactive.report_error(error, event)
|
|
194
|
+
|
|
195
|
+
flash = error_flash_stream(:error)
|
|
196
|
+
render turbo_stream: flash, status: :internal_server_error if flash
|
|
197
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
198
|
+
# The observation path itself failed — log it, but NEVER let it replace the
|
|
199
|
+
# action-body error the caller is about to re-raise.
|
|
200
|
+
::Rails.logger&.warn("[phlex-reactive] error observation failed: #{e.class}: #{e.message}") if
|
|
201
|
+
defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
159
202
|
end
|
|
160
203
|
|
|
161
204
|
# Reply to an endpoint failure. The status NEVER changes with any flag —
|
|
@@ -1137,6 +1137,11 @@ export default class extends Controller {
|
|
|
1137
1137
|
// missing-template warning latch.
|
|
1138
1138
|
#boundSyncTags
|
|
1139
1139
|
#tagsWarnedTemplate = false
|
|
1140
|
+
// Draft nested-attribute rows (issue #208): the strictly-monotonic index
|
|
1141
|
+
// counter (clock-seeded so it never collides with server-rendered 0..n
|
|
1142
|
+
// indexes) plus the once-only missing-list/template warning latch.
|
|
1143
|
+
#nestedIndex = 0
|
|
1144
|
+
#nestedWarned = false
|
|
1140
1145
|
// Connect-time compute seed (issue #199): the bound re-seed attached to
|
|
1141
1146
|
// turbo:morph-element so an in-place morph re-runs the compute, held for teardown.
|
|
1142
1147
|
#boundSeedCompute
|
|
@@ -1793,6 +1798,67 @@ export default class extends Controller {
|
|
|
1793
1798
|
this.#tagsWrite(field, next)
|
|
1794
1799
|
}
|
|
1795
1800
|
|
|
1801
|
+
// Draft nested-attribute rows (issue #208) — the "new parent + child rows"
|
|
1802
|
+
// window. The rows are FORM state (the reactive_tags posture): no token, no
|
|
1803
|
+
// POST, ever — the surrounding REAL form submit carries Rails'
|
|
1804
|
+
// accepts_nested_attributes_for names and the server reconciles parent +
|
|
1805
|
+
// rows in ONE create. Add clones the association's server-owned
|
|
1806
|
+
// <template data-reactive-nested-template="assoc"> row, swaps every NEW_ROW
|
|
1807
|
+
// in the clone's name/id/for for a fresh unique index (each row posts as its
|
|
1808
|
+
// own `…_attributes[<index>][field]` group), appends it to the owned
|
|
1809
|
+
// [data-reactive-nested-list="assoc"] container, and focuses the new row's
|
|
1810
|
+
// first field. Several collections can share one root — everything is keyed
|
|
1811
|
+
// by the association name the trigger carries.
|
|
1812
|
+
nestedAdd(event) {
|
|
1813
|
+
event?.preventDefault?.()
|
|
1814
|
+
const trigger = event?.currentTarget ?? event?.target
|
|
1815
|
+
const assoc = trigger?.getAttribute?.("data-reactive-association-param")
|
|
1816
|
+
if (!assoc) return
|
|
1817
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
1818
|
+
|
|
1819
|
+
const owns = this.#ownershipFilter()
|
|
1820
|
+
const list = [...this.element.querySelectorAll(`[data-reactive-nested-list="${assoc}"]`)].find(owns)
|
|
1821
|
+
const template = [...this.element.querySelectorAll(`[data-reactive-nested-template="${assoc}"]`)].find(owns)
|
|
1822
|
+
const proto = template?.content?.firstElementChild
|
|
1823
|
+
if (!list || !proto) {
|
|
1824
|
+
this.#warnNestedOnce(assoc)
|
|
1825
|
+
return
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
const row = proto.cloneNode(true)
|
|
1829
|
+
this.#renumberNestedRow(row, this.#nextNestedIndex())
|
|
1830
|
+
list.appendChild(row)
|
|
1831
|
+
const first = [...(row.querySelectorAll?.("input, select, textarea") ?? [])][0]
|
|
1832
|
+
first?.focus?.()
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Remove the trigger's closest row wrapper. A DRAFT row (no [_destroy]
|
|
1836
|
+
// input) leaves the DOM — it was never persisted, so removing its fields IS
|
|
1837
|
+
// the removal. A PERSISTED row (an edit form rendered a hidden [_destroy]
|
|
1838
|
+
// input via nested_field_name) is marked "1" and hidden instead — Rails
|
|
1839
|
+
// destroys it on save. The mark dispatches a real bubbling `input` (the
|
|
1840
|
+
// set-value + dispatch contract, issue #183) so dirty tracking/compute see it.
|
|
1841
|
+
nestedRemove(event) {
|
|
1842
|
+
event?.preventDefault?.()
|
|
1843
|
+
const trigger = event?.currentTarget ?? event?.target
|
|
1844
|
+
const row = trigger?.closest?.("[data-reactive-nested-row]")
|
|
1845
|
+
if (!row) return
|
|
1846
|
+
// The closest() walk must not escape this root — a root can itself sit
|
|
1847
|
+
// inside ANOTHER collection's row (the issue #15 closest-form posture).
|
|
1848
|
+
if (row.closest?.('[data-controller~="reactive"]') !== this.element) return
|
|
1849
|
+
|
|
1850
|
+
const destroy = [...(row.querySelectorAll?.('input[name$="[_destroy]"]') ?? [])][0]
|
|
1851
|
+
if (destroy) {
|
|
1852
|
+
destroy.value = "1"
|
|
1853
|
+
if (typeof destroy.dispatchEvent === "function") {
|
|
1854
|
+
destroy.dispatchEvent(new Event("input", { bubbles: true }))
|
|
1855
|
+
}
|
|
1856
|
+
row.hidden = true
|
|
1857
|
+
} else {
|
|
1858
|
+
row.parentNode?.removeChild?.(row)
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1796
1862
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
1797
1863
|
// a malformed binding degrades to "no fields" rather than throwing on input.
|
|
1798
1864
|
#parseComputeList(attr) {
|
|
@@ -3180,6 +3246,39 @@ export default class extends Controller {
|
|
|
3180
3246
|
if (this.#filterEnabled()) this.#syncFilter()
|
|
3181
3247
|
}
|
|
3182
3248
|
|
|
3249
|
+
// A fresh index per nested-row add (issue #208) — strictly monotonic and
|
|
3250
|
+
// clock-seeded, so it can never collide with server-rendered integer indexes
|
|
3251
|
+
// (0..n) NOR with a rapid same-millisecond double add.
|
|
3252
|
+
#nextNestedIndex() {
|
|
3253
|
+
this.#nestedIndex = Math.max(this.#nestedIndex + 1, Date.now())
|
|
3254
|
+
return this.#nestedIndex
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
// Swap every NEW_ROW in the clone's name/id/for for the fresh index, so the
|
|
3258
|
+
// row posts as its own `…_attributes[<index>][field]` group and labels keep
|
|
3259
|
+
// pointing at their (renumbered) inputs.
|
|
3260
|
+
#renumberNestedRow(row, index) {
|
|
3261
|
+
const nodes = [row, ...(row.querySelectorAll?.("*") ?? [])]
|
|
3262
|
+
for (const el of nodes) {
|
|
3263
|
+
for (const attr of ["name", "id", "for"]) {
|
|
3264
|
+
const value = el.getAttribute?.(attr)
|
|
3265
|
+
if (value && value.includes("NEW_ROW")) el.setAttribute?.(attr, value.replaceAll("NEW_ROW", String(index)))
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// A half-built nested-rows binding should be loud, but never per-click.
|
|
3271
|
+
#warnNestedOnce(assoc) {
|
|
3272
|
+
if (this.#nestedWarned) return
|
|
3273
|
+
console.warn(
|
|
3274
|
+
`[phlex-reactive] nested rows: no owned [data-reactive-nested-list="${assoc}"] container + ` +
|
|
3275
|
+
`<template data-reactive-nested-template="${assoc}"> pair found in this root — the add ` +
|
|
3276
|
+
"trigger did nothing. Render both inside the same reactive root (reactive_nested_list / " +
|
|
3277
|
+
"reactive_nested_template)."
|
|
3278
|
+
)
|
|
3279
|
+
this.#nestedWarned = true
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3183
3282
|
// Remove the tags morph listener on disconnect, so a stray morph event after
|
|
3184
3283
|
// the element leaves the DOM never re-projects against a detached root.
|
|
3185
3284
|
#teardownTagsSync() {
|