phlex-reactive 0.2.2 → 0.2.4
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 +49 -4
- data/README.md +14 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +20 -0
- data/lib/phlex/reactive/component.rb +45 -16
- 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: cd797b375cedb73b2a6f5695bf38a7eddd0a939aca5f8602c661537f6407ad7b
|
|
4
|
+
data.tar.gz: 994da28bed60c495d87d01fb736e80f19439ca52417f2c70f61a17aa7d15b351
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11e13357335ec795cc5d0da9b60da2ce059128fed4f355f3864d037fbf5c0dab692844d3b8dd9d10f0b386e7bccd86ae635b2d2bc17a24e4ed1988bd37ce014e
|
|
7
|
+
data.tar.gz: 11c0e74b06f6d55818fc65d83336d9ec77eee65422adfe477a5ebc8a83d6dda4ae6f1dad3c6fdf17a9e215cdef5bcc958bcbaa43c3e6d32b02adb15124c372e1
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,49 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Fixed
|
|
10
10
|
|
|
11
|
+
- **Reactive save silently dropped rich-text / custom-editor fields.** The
|
|
12
|
+
client's field auto-collection (`#collectFields`) queried only
|
|
13
|
+
`input[name], select[name], textarea[name]`, so a named rich-text editor
|
|
14
|
+
(`lexxy-editor`, `trix-editor`) or any `[contenteditable]` was skipped — a
|
|
15
|
+
reactive `save` posted an empty value and silently wiped the field, with no
|
|
16
|
+
error. `#collectFields` now also reads named custom editors and contenteditable
|
|
17
|
+
elements (by `name` *attribute*, since a plain element has no `name` IDL
|
|
18
|
+
property), reading the serialized `.value` else the contenteditable text. It
|
|
19
|
+
only fills a name the standard controls left absent or empty, so a hidden input
|
|
20
|
+
a rich editor mirrors into (e.g. Trix) still wins when populated. The vendored
|
|
21
|
+
client copy the system suite runs (`spec/dummy/public/vendor/...`) is now kept
|
|
22
|
+
byte-identical to source by a guard spec, so the browser tests never validate
|
|
23
|
+
stale client code again. Closes #8.
|
|
24
|
+
|
|
25
|
+
## [0.2.3] - 2026-06-24
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Record-backed components silently lost `reactive_state` every action.** When
|
|
30
|
+
a component declared BOTH `reactive_record` and `reactive_state`, the state
|
|
31
|
+
branch was dead: `reactive_token` signed only the record GID and
|
|
32
|
+
`from_identity` rebuilt with only the record — so the listed instance vars
|
|
33
|
+
(e.g. `attribute`, `editing`) reset to their `initialize` defaults on every
|
|
34
|
+
action. This broke the documented `inline_edit` example (clicking "edit"
|
|
35
|
+
couldn't stay in edit mode; `save` wrote the wrong/blank column) and quietly
|
|
36
|
+
voided the docs' promise that `@attribute` is signed/tamper-proof.
|
|
37
|
+
`reactive_token` now signs the record GID AND the declared state into one
|
|
38
|
+
`MessageVerifier` payload, and `from_identity` restores both — record + state
|
|
39
|
+
are composable, and the state stays tamper-proof. Record-only (`{c, gid}`) and
|
|
40
|
+
state-only (`{c, s}`) token shapes are unchanged; a signed `false` survives the
|
|
41
|
+
round trip (only a genuinely absent value falls back to the `initialize`
|
|
42
|
+
default). No API changes. Closes #6.
|
|
43
|
+
|
|
44
|
+
### Documentation
|
|
45
|
+
|
|
46
|
+
- Documented the combined record + state identity (the `{c, gid, s}` token
|
|
47
|
+
shape): the README, `docs/architecture.md`, and `docs/security.md` no longer
|
|
48
|
+
frame `reactive_record` and `reactive_state` as mutually exclusive. (#9)
|
|
49
|
+
|
|
50
|
+
## [0.2.2] - 2026-06-24
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
11
54
|
- **Record-backed component built with a different init keyword by the action
|
|
12
55
|
endpoint vs the broadcast path.** The click path (`Component.from_identity`)
|
|
13
56
|
builds with `reactive_record_key` (the `reactive_record :name`), but the
|
|
@@ -22,7 +65,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
22
65
|
an explicit `def self.model_param_name` override still wins. No API changes.
|
|
23
66
|
Closes #4.
|
|
24
67
|
|
|
25
|
-
## [0.2.1]
|
|
68
|
+
## [0.2.1] - 2026-06-24
|
|
26
69
|
|
|
27
70
|
### Fixed
|
|
28
71
|
|
|
@@ -37,7 +80,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
37
80
|
the gem eager-loads cleanly. Added a regression spec that calls
|
|
38
81
|
`eager_load_all`. No API changes.
|
|
39
82
|
|
|
40
|
-
## [0.2.0]
|
|
83
|
+
## [0.2.0] - 2026-06-24
|
|
41
84
|
|
|
42
85
|
### Added
|
|
43
86
|
|
|
@@ -56,7 +99,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
56
99
|
helpers. phlex-reactive still works on Action Cable without pgbus; the
|
|
57
100
|
`exclude:` argument is simply ignored there.
|
|
58
101
|
|
|
59
|
-
## [0.1.0]
|
|
102
|
+
## [0.1.0] - 2026-06-20
|
|
60
103
|
|
|
61
104
|
### Added
|
|
62
105
|
|
|
@@ -80,7 +123,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
80
123
|
scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
|
|
81
124
|
state-backed by default or record-backed with `--record`.
|
|
82
125
|
|
|
83
|
-
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.
|
|
126
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...HEAD
|
|
127
|
+
[0.2.3]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.2...v0.2.3
|
|
128
|
+
[0.2.2]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.1...v0.2.2
|
|
84
129
|
[0.2.1]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.0...v0.2.1
|
|
85
130
|
[0.2.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.1.0...v0.2.0
|
|
86
131
|
[0.1.0]: https://github.com/mhenrixon/phlex-reactive/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -211,15 +211,21 @@ class Todos::Item < ApplicationComponent
|
|
|
211
211
|
end
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
-
### 2. State-backed (
|
|
214
|
+
### 2. State-backed (signed instance vars)
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
Sign small, JSON-serializable instance vars into the token. Use it **alone** for
|
|
217
|
+
a record-less widget (a counter, a wizard step), or **alongside `reactive_record`**
|
|
218
|
+
to carry transient UI state — which field, what mode — next to the row. Both the
|
|
219
|
+
record's GlobalID and the state are signed into one token and rebuilt on each
|
|
220
|
+
action. Keep state small and JSON-serializable.
|
|
218
221
|
|
|
219
222
|
```ruby
|
|
220
223
|
reactive_state :count, :step # signed; rebuilt on each action
|
|
221
224
|
```
|
|
222
225
|
|
|
226
|
+
The [inline edit example](docs/examples/inline_edit.md) combines both: a
|
|
227
|
+
`reactive_record :record` plus `reactive_state :attribute, :editing`.
|
|
228
|
+
|
|
223
229
|
---
|
|
224
230
|
|
|
225
231
|
## Concrete examples
|
|
@@ -257,7 +263,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
257
263
|
| Macro / helper | Use |
|
|
258
264
|
|---|---|
|
|
259
265
|
| `reactive_record :name` | Record-backed identity (GlobalID). State = the DB. |
|
|
260
|
-
| `reactive_state :a, :b` |
|
|
266
|
+
| `reactive_state :a, :b` | Signed instance-var identity. Standalone, or combined with `reactive_record` to sign transient UI state alongside the row. |
|
|
261
267
|
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
262
268
|
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
263
269
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
@@ -314,8 +320,10 @@ phlex-reactive is built so the easy path is the safe path — but the boundary i
|
|
|
314
320
|
real, so read this once.
|
|
315
321
|
|
|
316
322
|
- **State is never trusted from the client.** The DOM holds a `MessageVerifier`-
|
|
317
|
-
signed identity
|
|
318
|
-
|
|
323
|
+
signed identity — `{component, gid}` (record-backed), `{component, state}`
|
|
324
|
+
(state-backed), or `{component, gid, state}` when a component declares both —
|
|
325
|
+
not raw state. A tampered class, record, or state value fails signature
|
|
326
|
+
verification → 400.
|
|
319
327
|
- **Actions are default-deny.** Only methods declared with `action :name` are
|
|
320
328
|
invokable. A public method without `action` is unreachable.
|
|
321
329
|
- **You must authorize.** The signature proves the *token is yours*, not that
|
|
@@ -122,6 +122,7 @@ export default class extends Controller {
|
|
|
122
122
|
|
|
123
123
|
#collectFields() {
|
|
124
124
|
const fields = {}
|
|
125
|
+
// Standard form controls.
|
|
125
126
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
126
127
|
if (field.type === "checkbox") {
|
|
127
128
|
fields[field.name] = field.checked
|
|
@@ -131,6 +132,25 @@ export default class extends Controller {
|
|
|
131
132
|
fields[field.name] = field.value
|
|
132
133
|
}
|
|
133
134
|
})
|
|
135
|
+
// Named rich-text / custom editors (lexxy-editor, trix-editor) and bare
|
|
136
|
+
// [contenteditable]. These aren't input/select/textarea, so the query above
|
|
137
|
+
// skips them — without this, a reactive save posts an empty value and
|
|
138
|
+
// silently wipes the field (issue #8). Read whatever the element exposes:
|
|
139
|
+
// a custom editor's serialized `.value`, else its contenteditable text.
|
|
140
|
+
// Only fill a name the standard controls left absent or empty, so a synced
|
|
141
|
+
// hidden input (e.g. Trix mirrors into one) still wins when populated.
|
|
142
|
+
this.element
|
|
143
|
+
.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
|
|
144
|
+
.forEach((el) => {
|
|
145
|
+
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
146
|
+
// property — only the attribute — so read getAttribute, not el.name.
|
|
147
|
+
const name = el.getAttribute("name")
|
|
148
|
+
if (!name) return
|
|
149
|
+
const existing = fields[name]
|
|
150
|
+
if (existing == null || existing === "") {
|
|
151
|
+
fields[name] = el.value ?? el.textContent ?? el.innerHTML ?? ""
|
|
152
|
+
}
|
|
153
|
+
})
|
|
134
154
|
return fields
|
|
135
155
|
}
|
|
136
156
|
|
|
@@ -20,8 +20,12 @@ module Phlex
|
|
|
20
20
|
# can neither forge the component class nor swap the record. State =
|
|
21
21
|
# the database.
|
|
22
22
|
# * State-backed (record-less, e.g. a counter): reactive_state :count
|
|
23
|
-
# signs the listed instance variables. Use
|
|
24
|
-
#
|
|
23
|
+
# signs the listed instance variables. Use when there is genuinely no
|
|
24
|
+
# record to re-find.
|
|
25
|
+
# * Both (the inline_edit pattern): reactive_record :record plus
|
|
26
|
+
# reactive_state :attribute, :editing signs the record's GlobalID AND
|
|
27
|
+
# the transient mode in one token, so "which field / what mode" survives
|
|
28
|
+
# every action round trip and stays tamper-proof.
|
|
25
29
|
#
|
|
26
30
|
# Actions are DEFAULT-DENY: only methods declared with `action :name` may be
|
|
27
31
|
# invoked. The signature proves the token is ours, NOT that this user may
|
|
@@ -102,17 +106,35 @@ module Phlex
|
|
|
102
106
|
|
|
103
107
|
# Rebuild a component instance from a verified identity payload. Called
|
|
104
108
|
# by the action endpoint after the token signature is verified.
|
|
109
|
+
#
|
|
110
|
+
# A component may carry a record (re-found via GlobalID), signed state
|
|
111
|
+
# (instance vars listed in reactive_state), or BOTH (the inline_edit
|
|
112
|
+
# pattern: a record plus "which field / what mode"). We assemble the
|
|
113
|
+
# init kwargs from whichever identity pieces are declared.
|
|
105
114
|
def from_identity(payload)
|
|
115
|
+
kwargs = {}
|
|
116
|
+
|
|
106
117
|
if reactive_record_key
|
|
107
118
|
record = GlobalID::Locator.locate(payload.fetch("gid"))
|
|
108
119
|
raise(ActiveRecord::RecordNotFound, "reactive record missing") unless record
|
|
109
120
|
|
|
110
|
-
|
|
111
|
-
|
|
121
|
+
kwargs[reactive_record_key] = record
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if reactive_state_keys.any?
|
|
112
125
|
state = payload.fetch("s", {})
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
reactive_state_keys.each do |key|
|
|
127
|
+
# Use key presence, not the value: a signed `nil` (nullable state)
|
|
128
|
+
# must round-trip distinctly. Only a genuinely absent key falls
|
|
129
|
+
# back to the component's initialize default; `false` and `nil`
|
|
130
|
+
# both survive.
|
|
131
|
+
next unless state.key?(key.to_s)
|
|
132
|
+
|
|
133
|
+
kwargs[key] = state[key.to_s]
|
|
134
|
+
end
|
|
115
135
|
end
|
|
136
|
+
|
|
137
|
+
new(**kwargs)
|
|
116
138
|
end
|
|
117
139
|
end
|
|
118
140
|
|
|
@@ -164,18 +186,25 @@ module Phlex
|
|
|
164
186
|
|
|
165
187
|
private
|
|
166
188
|
|
|
167
|
-
# Signed
|
|
189
|
+
# Signed identity payload: the class name plus whichever identity pieces
|
|
190
|
+
# the component declares — a record GlobalID (`gid`), signed state (`s`),
|
|
191
|
+
# or both. Keeping them in ONE MessageVerifier payload makes the state
|
|
192
|
+
# (e.g. which column an inline_edit may write) tamper-proof alongside the
|
|
193
|
+
# record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
|
|
194
|
+
# unchanged.
|
|
168
195
|
def reactive_token
|
|
169
|
-
payload =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
payload = {"c" => self.class.name}
|
|
197
|
+
|
|
198
|
+
if self.class.reactive_record_key
|
|
199
|
+
record = instance_variable_get(:"@#{self.class.reactive_record_key}")
|
|
200
|
+
payload["gid"] = record.to_gid.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if self.class.reactive_state_keys.any?
|
|
204
|
+
payload["s"] = self.class.reactive_state_keys.to_h do |k|
|
|
205
|
+
[k.to_s, instance_variable_get(:"@#{k}").as_json]
|
|
178
206
|
end
|
|
207
|
+
end
|
|
179
208
|
|
|
180
209
|
Phlex::Reactive.sign(payload)
|
|
181
210
|
end
|