hibiki_rails 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -2
- data/app/assets/javascripts/hibiki.js +165 -15
- data/lib/hibiki/rails/channel.rb +34 -7
- data/lib/hibiki/rails/graph_actor.rb +15 -1
- data/lib/hibiki/rails/helpers.rb +102 -14
- data/lib/hibiki/rails/reactive_form.rb +163 -0
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +19 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45614c5c98f4439de6f22da45a49d163ab7a06eccf53ed36108ee04d6b5c7f78
|
|
4
|
+
data.tar.gz: c1dd28764cc7462dbb7ceaa5d0b76e1a0371bd8cc5523fe54545533d7b759c36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4908f9d337c86d575287ff2d21d3d74cd112334e893e8b042cb07e7e8a1de141ddd892b17b4b86d90ac2008ce8c7d9f4390fd1ebdd1cc1cafe88e75c6530c360
|
|
7
|
+
data.tar.gz: 392976be271a02c7b5e354ec0d3e46117e1a8772cb0a68b99d4570f1f95309bc7abac3e0cd3ca38961eaea5eb849d4e4343effa55911629d367555949ac93408
|
data/README.md
CHANGED
|
@@ -76,8 +76,11 @@ Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
|
|
|
76
76
|
## Development
|
|
77
77
|
|
|
78
78
|
```
|
|
79
|
-
bundle exec rake # specs + rubocop
|
|
79
|
+
bundle exec rake # Ruby specs + rubocop
|
|
80
|
+
bun install && bun run test # the client's own specs
|
|
80
81
|
```
|
|
81
82
|
|
|
82
|
-
The
|
|
83
|
+
Both are what CI runs. The Ruby suite boots a minimal inline Rails app (`spec/support/dummy_app.rb`); the JS suite (`spec/js/`) drives the real Stimulus controller in happy-dom against a stubbed Action Cable consumer.
|
|
84
|
+
|
|
85
|
+
The gem and the npm package are **released in lockstep**: `app/assets/javascripts/hibiki.js` is the single copy — the engine puts it on the asset path and `package.json` points `main`/`module`/`exports` at it — so importmap and bundler apps must never be able to resolve different client code. Bump `lib/hibiki/rails/version.rb` and `package.json` in the same commit, and publish both. The version table lives in [the JS client docs](https://planetaska.github.io/hibiki/the-js-client/).
|
|
83
86
|
|
|
@@ -44,11 +44,23 @@
|
|
|
44
44
|
// island root data-controller="hibiki"
|
|
45
45
|
// data-hibiki-channel-value="CounterChannel"
|
|
46
46
|
// data-hibiki-cid-value="<per-page-load id>"
|
|
47
|
-
//
|
|
47
|
+
// data-hibiki-params-value='{"record_id":7}' extra subscribe
|
|
48
|
+
// params, merged UNDER channel/cid so they can't override them
|
|
49
|
+
// controls data-hibiki-on="<event>-><action> ..." whitespace-separated;
|
|
50
|
+
// e.g. "click->load_more visible->load_more"
|
|
48
51
|
// data-hibiki-with='{"index":3}' optional JSON payload
|
|
52
|
+
// data-hibiki-debounce="250" ms to let the gesture settle
|
|
53
|
+
// data-hibiki-confirm="Are you sure?" window.confirm gate
|
|
54
|
+
// data-hibiki-reset="false" keep a submitted form's inputs
|
|
49
55
|
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
50
56
|
// the server's transmit_value message updates every match
|
|
51
57
|
//
|
|
58
|
+
// The left side of `->` is a hibiki event name, of which DOM events are a
|
|
59
|
+
// subset: click, change, input, submit are delegated listeners, and
|
|
60
|
+
// `visible` is a pseudo-event backed by an IntersectionObserver (the
|
|
61
|
+
// element entering the viewport). Everything that is not "which event"
|
|
62
|
+
// is a sibling attribute, so the token grammar never has to grow.
|
|
63
|
+
//
|
|
52
64
|
// Register the generic controller under the identifier "hibiki" (the
|
|
53
65
|
// helpers hardcode it):
|
|
54
66
|
//
|
|
@@ -65,6 +77,19 @@ let consumer
|
|
|
65
77
|
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
66
78
|
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
67
79
|
|
|
80
|
+
// What a changed control contributes to its action's payload. A checkbox's
|
|
81
|
+
// `value` is its value ATTRIBUTE, not its state, so reading `value` made
|
|
82
|
+
// checking and unchecking send byte-identical payloads; a multi-select's
|
|
83
|
+
// `value` is only its first selected option. A radio needs no special case:
|
|
84
|
+
// `change` fires on the newly-checked input, so `value` is already right.
|
|
85
|
+
const controlValue = (control) => {
|
|
86
|
+
if (control.type === "checkbox") return control.checked
|
|
87
|
+
if (control.multiple && control.selectedOptions) {
|
|
88
|
+
return [...control.selectedOptions].map((option) => option.value)
|
|
89
|
+
}
|
|
90
|
+
return control.value
|
|
91
|
+
}
|
|
92
|
+
|
|
68
93
|
// The subclassable base: one channel subscription per controller element,
|
|
69
94
|
// identified by a per-page-load cid (data-<identifier>-cid-value).
|
|
70
95
|
export class ChannelController extends Controller {
|
|
@@ -82,20 +107,28 @@ export class ChannelController extends Controller {
|
|
|
82
107
|
if (source) await streamConnected(source)
|
|
83
108
|
if (this.aborted) return // disconnected during the await
|
|
84
109
|
this.subscription = consumer.subscriptions.create(
|
|
85
|
-
|
|
110
|
+
this.subscribeParams(),
|
|
86
111
|
{ received: (data) => this.received(data) }
|
|
87
112
|
)
|
|
88
113
|
}
|
|
89
114
|
|
|
115
|
+
// What identifies this subscription to the server. Override to add
|
|
116
|
+
// params; keep channel/cid, which the Ruby side requires.
|
|
117
|
+
subscribeParams() {
|
|
118
|
+
return { channel: this.channelName(), cid: this.cidValue }
|
|
119
|
+
}
|
|
120
|
+
|
|
90
121
|
disconnect() {
|
|
91
122
|
this.aborted = true
|
|
92
123
|
this.subscription?.unsubscribe()
|
|
93
124
|
this.subscription = undefined
|
|
94
125
|
}
|
|
95
126
|
|
|
96
|
-
// DOM → server: what declared action methods call.
|
|
127
|
+
// DOM → server: what declared action methods call. Optional chaining
|
|
128
|
+
// because a debounced action can fire after disconnect, and a sentinel
|
|
129
|
+
// can fire while connect is still awaiting its stream source.
|
|
97
130
|
perform(action, payload = {}) {
|
|
98
|
-
this.subscription
|
|
131
|
+
this.subscription?.perform(action, payload)
|
|
99
132
|
}
|
|
100
133
|
|
|
101
134
|
// Server → DOM (transmit transport). Two message shapes:
|
|
@@ -177,57 +210,174 @@ export class ChannelController extends Controller {
|
|
|
177
210
|
// The generic controller: adds the data-hibiki-* wire protocol on top of
|
|
178
211
|
// the base's plumbing.
|
|
179
212
|
export default class HibikiController extends ChannelController {
|
|
180
|
-
|
|
213
|
+
// cid inherited from the base. `params` defaults to {} when the island
|
|
214
|
+
// stamps no data-hibiki-params-value.
|
|
215
|
+
static values = { channel: String, params: Object }
|
|
181
216
|
|
|
182
217
|
// The island stamps its channel; no inference.
|
|
183
218
|
channelName() {
|
|
184
219
|
return this.channelValue
|
|
185
220
|
}
|
|
186
221
|
|
|
222
|
+
// Extra subscribe params go UNDER channel/cid: they are client-supplied,
|
|
223
|
+
// so a page must not be able to point its subscription at another channel
|
|
224
|
+
// or steal another tab's graph by naming its cid. The server-side rule
|
|
225
|
+
// that goes with this is in Helpers#hibiki_island.
|
|
226
|
+
subscribeParams() {
|
|
227
|
+
return { ...this.paramsValue, ...super.subscribeParams() }
|
|
228
|
+
}
|
|
229
|
+
|
|
187
230
|
async connect() {
|
|
188
231
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
189
232
|
// inside server-replaced fragments keep working with no rebinding.
|
|
190
233
|
// Set up synchronously so disconnect can always tear them down.
|
|
191
|
-
this.listeners = ["click", "change", "submit"].map((type) => {
|
|
234
|
+
this.listeners = ["click", "change", "input", "submit"].map((type) => {
|
|
192
235
|
const handler = (event) => this.forward(event)
|
|
193
236
|
this.element.addEventListener(type, handler)
|
|
194
237
|
return [type, handler]
|
|
195
238
|
})
|
|
239
|
+
|
|
240
|
+
// Debounce bookkeeping: a WeakMap keyed by control (so detached
|
|
241
|
+
// elements don't pin memory) plus a flat set of live timeouts, which is
|
|
242
|
+
// what disconnect can actually iterate.
|
|
243
|
+
this.timers = new WeakMap()
|
|
244
|
+
this.pending = new Set()
|
|
245
|
+
|
|
246
|
+
// `visible` is not a DOM event, so it needs its own observer beside the
|
|
247
|
+
// delegated listeners. Always on, never a pluggable module: an
|
|
248
|
+
// IntersectionObserver watching zero elements costs nothing at runtime,
|
|
249
|
+
// while an optional import brings back the failure mode where the
|
|
250
|
+
// attribute is present, the code isn't, and nothing errors.
|
|
251
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
252
|
+
for (const entry of entries) {
|
|
253
|
+
if (!entry.isIntersecting) continue
|
|
254
|
+
// Fire once per observation. The re-scan after the next swap
|
|
255
|
+
// observes the REPLACEMENT element, and its fresh initial callback
|
|
256
|
+
// is what stops the classic "the new page didn't fill the viewport,
|
|
257
|
+
// so the loop stalls" trap.
|
|
258
|
+
this.observer.unobserve(entry.target)
|
|
259
|
+
this.dispatch(entry.target, { type: "visible", target: entry.target })
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Re-scan at the two points a fragment can be swapped under us, rather
|
|
264
|
+
// than blanket-observing the document: a MutationObserver over the page
|
|
265
|
+
// is a real per-mutation cost paid by every app on it.
|
|
266
|
+
this.streamRender = (event) => {
|
|
267
|
+
const render = event.detail.render
|
|
268
|
+
event.detail.render = async (streamElement) => {
|
|
269
|
+
await render(streamElement)
|
|
270
|
+
this.scanSentinels()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener("turbo:before-stream-render", this.streamRender)
|
|
274
|
+
|
|
196
275
|
await super.connect()
|
|
276
|
+
if (this.aborted) return
|
|
277
|
+
this.scanSentinels()
|
|
197
278
|
}
|
|
198
279
|
|
|
199
280
|
disconnect() {
|
|
200
281
|
for (const [type, handler] of this.listeners) {
|
|
201
282
|
this.element.removeEventListener(type, handler)
|
|
202
283
|
}
|
|
284
|
+
document.removeEventListener("turbo:before-stream-render", this.streamRender)
|
|
285
|
+
for (const id of this.pending) clearTimeout(id)
|
|
286
|
+
this.pending.clear()
|
|
287
|
+
this.observer.disconnect()
|
|
203
288
|
super.disconnect()
|
|
204
289
|
}
|
|
205
290
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
291
|
+
// The other swap point: hibiki's own transmit transport.
|
|
292
|
+
received(data) {
|
|
293
|
+
super.received(data)
|
|
294
|
+
if (data.html) this.scanSentinels()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Observe every `visible->` sentinel this island owns. observe() is a
|
|
298
|
+
// no-op for an element already being observed, so re-scanning is cheap
|
|
299
|
+
// and cannot double-fire a sentinel that merely stayed put.
|
|
300
|
+
scanSentinels() {
|
|
301
|
+
for (const control of this.element.querySelectorAll('[data-hibiki-on*="visible->"]')) {
|
|
302
|
+
if (control.closest('[data-controller~="hibiki"]') === this.element) {
|
|
303
|
+
this.observer.observe(control)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// DOM → server: forward a control's event as a channel action.
|
|
209
309
|
forward(event) {
|
|
210
310
|
const control = event.target.closest("[data-hibiki-on]")
|
|
211
|
-
if (
|
|
311
|
+
if (control) this.dispatch(control, event)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The shared path for both sources of events — the delegated DOM
|
|
315
|
+
// listeners and the visibility observer.
|
|
316
|
+
dispatch(control, event) {
|
|
317
|
+
// Nested islands: events bubble to every ancestor island's listener, so
|
|
318
|
+
// each controller only acts when the control belongs to ITS island.
|
|
212
319
|
if (control.closest('[data-controller~="hibiki"]') !== this.element) return
|
|
213
320
|
|
|
214
321
|
const token = control.dataset.hibikiOn
|
|
215
322
|
.split(/\s+/)
|
|
216
323
|
.find((t) => t.startsWith(`${event.type}->`))
|
|
217
324
|
if (!token) return
|
|
218
|
-
|
|
219
325
|
const action = token.slice(event.type.length + 2)
|
|
326
|
+
|
|
327
|
+
// Before the confirm, not after: declining must not let the form
|
|
328
|
+
// navigate away.
|
|
329
|
+
if (event.type === "submit") event.preventDefault()
|
|
330
|
+
|
|
331
|
+
const message = control.dataset.hibikiConfirm
|
|
332
|
+
if (message && !window.confirm(message)) return
|
|
333
|
+
|
|
334
|
+
// The payload is built when the action actually fires, so a debounced
|
|
335
|
+
// input sends what the user finished typing rather than the first
|
|
336
|
+
// keystroke that started the timer.
|
|
337
|
+
const wait = Number(control.dataset.hibikiDebounce)
|
|
338
|
+
const fire = () => this.send(control, event, action)
|
|
339
|
+
if (wait > 0) this.debounce(control, action, wait, fire)
|
|
340
|
+
else fire()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
send(control, event, action) {
|
|
220
344
|
const payload = control.dataset.hibikiWith
|
|
221
345
|
? JSON.parse(control.dataset.hibikiWith)
|
|
222
346
|
: {}
|
|
223
347
|
if (event.type === "submit") {
|
|
224
|
-
event.preventDefault()
|
|
225
348
|
Object.assign(payload, Object.fromEntries(new FormData(control)))
|
|
226
|
-
} else if (event.type === "change"
|
|
227
|
-
payload[control.name] = control
|
|
349
|
+
} else if (control.name && (event.type === "change" || event.type === "input")) {
|
|
350
|
+
payload[control.name] = controlValue(control)
|
|
228
351
|
}
|
|
229
352
|
this.perform(action, payload)
|
|
230
|
-
|
|
353
|
+
// Resetting is right for an "add" form and wrong for an edit one: it
|
|
354
|
+
// runs synchronously, before the server has replied, so a failed commit
|
|
355
|
+
// would discard what the user typed.
|
|
356
|
+
if (event.type === "submit" && control.dataset.hibikiReset !== "false") {
|
|
357
|
+
control.reset()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// One timer per (control, action): two events on one element debounce
|
|
362
|
+
// independently, and a second control's typing never cancels the first's.
|
|
363
|
+
debounce(control, action, wait, fire) {
|
|
364
|
+
let byAction = this.timers.get(control)
|
|
365
|
+
if (!byAction) {
|
|
366
|
+
byAction = new Map()
|
|
367
|
+
this.timers.set(control, byAction)
|
|
368
|
+
}
|
|
369
|
+
const previous = byAction.get(action)
|
|
370
|
+
if (previous) {
|
|
371
|
+
clearTimeout(previous)
|
|
372
|
+
this.pending.delete(previous)
|
|
373
|
+
}
|
|
374
|
+
const id = setTimeout(() => {
|
|
375
|
+
byAction.delete(action)
|
|
376
|
+
this.pending.delete(id)
|
|
377
|
+
fire()
|
|
378
|
+
}, wait)
|
|
379
|
+
byAction.set(action, id)
|
|
380
|
+
this.pending.add(id)
|
|
231
381
|
}
|
|
232
382
|
}
|
|
233
383
|
|
data/lib/hibiki/rails/channel.rb
CHANGED
|
@@ -36,13 +36,23 @@ module Hibiki
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
module ClassMethods
|
|
39
|
-
|
|
39
|
+
# Lifecycle hooks, never client-invocable actions. Performing
|
|
40
|
+
# "build_graph" would rebuild the graph and leak the old root;
|
|
41
|
+
# "subscribed" would build a SECOND graph actor on the connection.
|
|
42
|
+
#
|
|
43
|
+
# These need subtracting because ActionCable computes action_methods
|
|
44
|
+
# as "public methods this class adds": #subscribed and #unsubscribed
|
|
45
|
+
# are private on its base class, so they are never subtracted for
|
|
46
|
+
# free, and a public app-side override — the shape the ActiveRecord
|
|
47
|
+
# guide's after_commit bridge invites — is added straight back by
|
|
48
|
+
# public_instance_methods(false).
|
|
49
|
+
HIDDEN_ACTIONS = %w[build_graph subscribed unsubscribed].freeze
|
|
40
50
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
def
|
|
51
|
+
# Subtracted here rather than through ActionCable's #internal_methods
|
|
52
|
+
# hook, which only exists on 8.x: on Rails 7.1 and 7.2 that hook is
|
|
53
|
+
# never consulted, so an override of it is silently dead code. This
|
|
54
|
+
# works on every supported version.
|
|
55
|
+
def action_methods = super - HIDDEN_ACTIONS
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
# ActionCable's single dispatch point for incoming actions. The whole
|
|
@@ -101,10 +111,27 @@ module Hibiki
|
|
|
101
111
|
# derived, or an expression over several). Values are text, never
|
|
102
112
|
# markup: the client assigns textContent, so nothing is interpreted as
|
|
103
113
|
# HTML. Returns the effect, owned by the graph root like any other.
|
|
114
|
+
#
|
|
115
|
+
# Equality-gated: an effect re-runs whenever ANY signal it read
|
|
116
|
+
# changed, so a bumped db_version re-sent every value's text even when
|
|
117
|
+
# the text was byte-identical — once per reactive value, per ping.
|
|
118
|
+
# The block is still called unconditionally, so dependency collection
|
|
119
|
+
# is untouched; only the transmit is skipped.
|
|
120
|
+
#
|
|
121
|
+
# NOTE for placeholders rendered INSIDE a broadcast-replaced fragment:
|
|
122
|
+
# render them with their CURRENT value, not a static default. The swap
|
|
123
|
+
# resets the DOM text, and this gate now suppresses the re-send that
|
|
124
|
+
# used to heal it. Placeholders outside the replaced fragment (a nav
|
|
125
|
+
# badge, controls that are never re-rendered) are unaffected.
|
|
104
126
|
def transmit_value(name, &compute)
|
|
105
127
|
name = Helpers.value_name(name)
|
|
128
|
+
last = Object.new # never == a String, so the first run always sends
|
|
106
129
|
Hibiki::Effect.new do
|
|
107
|
-
|
|
130
|
+
text = compute.call.to_s
|
|
131
|
+
unless text == last
|
|
132
|
+
last = text
|
|
133
|
+
transmit({ value: { name:, text: } })
|
|
134
|
+
end
|
|
108
135
|
end
|
|
109
136
|
end
|
|
110
137
|
|
|
@@ -46,9 +46,23 @@ module Hibiki
|
|
|
46
46
|
|
|
47
47
|
# Per-job rescue keeps the worker alive: one bad action must not take
|
|
48
48
|
# the whole graph down. StandardError only — an Interrupt should.
|
|
49
|
+
#
|
|
50
|
+
# Each job is one unit of work in the Rails sense, so it runs inside
|
|
51
|
+
# the executor: CurrentAttributes reset between jobs (state must not
|
|
52
|
+
# leak from one action to the next on a long-lived graph thread),
|
|
53
|
+
# Zeitwerk's permit_concurrent_loads around autoloads off the main
|
|
54
|
+
# thread, an AR query cache per job, and reloader participation.
|
|
55
|
+
#
|
|
56
|
+
# The rescue sits INSIDE the wrap deliberately. ExecutionWrapper.wrap
|
|
57
|
+
# reports anything that escapes it to Rails.error itself — handled:
|
|
58
|
+
# false, source "application.active_support" — and then re-raises, so
|
|
59
|
+
# rescuing outside would report every graph error twice, once as
|
|
60
|
+
# unhandled. Rescuing inside means @on_error stays the single sink for
|
|
61
|
+
# a StandardError; a non-StandardError still escapes the wrap and takes
|
|
62
|
+
# the worker down, exactly as before.
|
|
49
63
|
def work
|
|
50
64
|
while (job = @queue.pop)
|
|
51
|
-
|
|
65
|
+
::Rails.application.executor.wrap do
|
|
52
66
|
job.call
|
|
53
67
|
rescue StandardError => e
|
|
54
68
|
@on_error.call(e)
|
data/lib/hibiki/rails/helpers.rb
CHANGED
|
@@ -15,7 +15,10 @@ module Hibiki
|
|
|
15
15
|
# button(**on(:increment)) { "+" }
|
|
16
16
|
# button(**on(:toggle, with: { index: 3 })) { "toggle" }
|
|
17
17
|
# input(name: "step", **on(:set_step, event: :change))
|
|
18
|
-
#
|
|
18
|
+
# input(name: "q", **on(:search, event: :input))
|
|
19
|
+
# button(**on(:destroy, confirm: "Are you sure?")) { "delete" }
|
|
20
|
+
# button(**on(:load_more, event: %i[click visible])) { "more" }
|
|
21
|
+
# form(**on(:save, event: :submit, reset: false)) { ... }
|
|
19
22
|
# end
|
|
20
23
|
#
|
|
21
24
|
# Both helpers return a `{ data: { ... } }` hash: splat it into Phlex
|
|
@@ -36,7 +39,21 @@ module Hibiki
|
|
|
36
39
|
# lands in raw markup, so allowlist it too.
|
|
37
40
|
VALUE_NAME = /\A[a-z][a-z0-9_-]*\z/i
|
|
38
41
|
VALUE_TAG = /\A[a-z][a-z0-9-]*\z/i
|
|
39
|
-
|
|
42
|
+
|
|
43
|
+
# Event and action names share the hibiki_on attribute, which is a
|
|
44
|
+
# whitespace-separated token list — a name carrying a space or an
|
|
45
|
+
# arrow would silently change what the client dispatches. The dot is
|
|
46
|
+
# allowed so a future event qualifier (keydown.enter) needs no second
|
|
47
|
+
# grammar change.
|
|
48
|
+
EVENT_NAME = /\A[a-z][a-z0-9_.-]*\z/i
|
|
49
|
+
|
|
50
|
+
# Per-keystroke round trips are the failure mode #on exists to avoid,
|
|
51
|
+
# so :input carries a debounce unless the caller says otherwise. It is
|
|
52
|
+
# applied here rather than in the client, so the number is visible in
|
|
53
|
+
# the emitted markup instead of being an invisible default.
|
|
54
|
+
DEFAULT_INPUT_DEBOUNCE = 250
|
|
55
|
+
|
|
56
|
+
private_constant :VALUE_NAME, :VALUE_TAG, :EVENT_NAME
|
|
40
57
|
|
|
41
58
|
# The shared name validator for both halves of a reactive value (the
|
|
42
59
|
# view-side data-hibiki-value placeholder and the channel's
|
|
@@ -50,24 +67,80 @@ module Hibiki
|
|
|
50
67
|
name
|
|
51
68
|
end
|
|
52
69
|
|
|
70
|
+
# The shared validator for both halves of an `event->action` token.
|
|
71
|
+
def self.event_name(name)
|
|
72
|
+
name = name.to_s
|
|
73
|
+
unless EVENT_NAME.match?(name)
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"event or action name #{name.inspect} must match #{EVENT_NAME.inspect}"
|
|
76
|
+
end
|
|
77
|
+
name
|
|
78
|
+
end
|
|
79
|
+
|
|
53
80
|
# The island root: one channel subscription per island, identified by
|
|
54
81
|
# a per-page-load cid (each tab is its own graph). `channel` is the
|
|
55
82
|
# channel class or its name as a string.
|
|
56
|
-
|
|
83
|
+
#
|
|
84
|
+
# `params:` is a hash of extra subscribe params, reaching the channel
|
|
85
|
+
# as `params[:key]` beside `cid`. It is how a channel learns WHICH
|
|
86
|
+
# record its page is about (a show page's `record_id`), which is
|
|
87
|
+
# otherwise unexpressible — the subscription is the only server-side
|
|
88
|
+
# hook that runs before the graph is built.
|
|
89
|
+
#
|
|
90
|
+
# THE TRUST RULE. Subscribe params are client-supplied and untrusted,
|
|
91
|
+
# exactly like query params on a request: anyone can open a socket and
|
|
92
|
+
# send whatever they like. A channel may use one only to LOOK UP A
|
|
93
|
+
# RECORD INSIDE A SCOPE IT CHOOSES ITSELF —
|
|
94
|
+
# `current_user.books.find(params[:record_id])` — and must `reject`
|
|
95
|
+
# when the lookup fails. It must never interpolate a param into a
|
|
96
|
+
# streamable name, a class name, a column name, or a scope. The
|
|
97
|
+
# streamable a channel streams from is always derived server-side from
|
|
98
|
+
# the record it has already loaded and authorized. The client cannot
|
|
99
|
+
# override `channel` or `cid` through this hash.
|
|
100
|
+
def hibiki_island(channel, cid:, params: nil)
|
|
57
101
|
channel_name = channel.is_a?(Class) ? channel.name : channel.to_s
|
|
58
|
-
|
|
59
|
-
|
|
102
|
+
data = { controller: "hibiki", hibiki_channel_value: channel_name,
|
|
103
|
+
hibiki_cid_value: cid }
|
|
104
|
+
data[:hibiki_params_value] = JSON.generate(params) unless params.nil?
|
|
105
|
+
{ data: }
|
|
60
106
|
end
|
|
61
107
|
|
|
62
|
-
# Forward
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
108
|
+
# Forward an event on this element as a channel action.
|
|
109
|
+
#
|
|
110
|
+
# `event:` names the event, or a list of them — the left side of the
|
|
111
|
+
# `->` is a hibiki event name, of which DOM events are a subset:
|
|
112
|
+
# :click, :change, :input, :submit, plus the :visible pseudo-event
|
|
113
|
+
# (an IntersectionObserver sentinel — the element entering the
|
|
114
|
+
# viewport). A list makes one element answer several, which is how a
|
|
115
|
+
# load-more button doubles as an infinite-scroll sentinel:
|
|
116
|
+
#
|
|
117
|
+
# on(:load_more, event: %i[click visible], with: { shown: rows.size })
|
|
118
|
+
#
|
|
119
|
+
# `with:` is a hash sent as the action's payload. The client adds
|
|
120
|
+
# event-derived data on top: a changed control contributes
|
|
121
|
+
# `{ name => value }` — a checkbox its checked state, a multi-select
|
|
122
|
+
# its selected values — and a submitted form contributes its FormData.
|
|
123
|
+
#
|
|
124
|
+
# Everything else is a per-control modifier, kept out of the token
|
|
125
|
+
# grammar so the `->` left side stays purely "which event":
|
|
126
|
+
#
|
|
127
|
+
# debounce: ms wait for the gesture to settle before performing.
|
|
128
|
+
# :input defaults to DEFAULT_INPUT_DEBOUNCE; pass 0 to
|
|
129
|
+
# send every keystroke.
|
|
130
|
+
# confirm: msg window.confirm before performing; declining performs
|
|
131
|
+
# nothing (and does not submit the form).
|
|
132
|
+
# reset: false keep a submitted form's inputs. The default resets
|
|
133
|
+
# them, which is right for an "add" form and wrong for
|
|
134
|
+
# an edit one — a failed commit would otherwise discard
|
|
135
|
+
# what the user typed, synchronously, before the server
|
|
136
|
+
# has even replied.
|
|
137
|
+
def on(action, event: :click, with: nil, debounce: nil, confirm: nil, reset: nil)
|
|
138
|
+
action = Helpers.event_name(action)
|
|
139
|
+
events = Array(event).map { Helpers.event_name(it) }
|
|
140
|
+
debounce = DEFAULT_INPUT_DEBOUNCE if debounce.nil? && events.include?("input")
|
|
141
|
+
tokens = events.map { "#{it}->#{action}" }.join(" ")
|
|
142
|
+
{ data: { hibiki_on: tokens }
|
|
143
|
+
.merge(on_modifiers(with:, debounce:, confirm:, reset:)) }
|
|
71
144
|
end
|
|
72
145
|
|
|
73
146
|
# Placeholder for a single reactive value: `<%= reactive :doubled, 0 %>`
|
|
@@ -92,6 +165,21 @@ module Hibiki
|
|
|
92
165
|
# The value's attributes, for stamping the placeholder yourself — the
|
|
93
166
|
# Phlex form of #reactive: `span(**reactive_attrs(:doubled)) { "0" }`.
|
|
94
167
|
def reactive_attrs(name) = { data: { hibiki_value: Helpers.value_name(name) } }
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# #on's per-control modifiers, kept out of the token grammar. Each is
|
|
172
|
+
# omitted when it matches the client's own default, so the common call
|
|
173
|
+
# still stamps exactly one attribute.
|
|
174
|
+
def on_modifiers(with:, debounce:, confirm:, reset:)
|
|
175
|
+
debounce = Integer(debounce) if debounce
|
|
176
|
+
{
|
|
177
|
+
hibiki_with: (JSON.generate(with) unless with.nil?),
|
|
178
|
+
hibiki_debounce: (debounce unless debounce.nil? || debounce.zero?),
|
|
179
|
+
hibiki_confirm: confirm&.to_s,
|
|
180
|
+
hibiki_reset: ("false" if reset == false)
|
|
181
|
+
}.compact
|
|
182
|
+
end
|
|
95
183
|
end
|
|
96
184
|
end
|
|
97
185
|
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
# A reactive form object over one ActiveRecord record: hydrate its
|
|
6
|
+
# attributes into signals at one edge, work reactively in the middle,
|
|
7
|
+
# commit back at the other. The record itself never enters the graph.
|
|
8
|
+
#
|
|
9
|
+
# class TodoForm
|
|
10
|
+
# include Hibiki::Rails::ReactiveForm
|
|
11
|
+
#
|
|
12
|
+
# reactive_attributes Todo, :title, :done
|
|
13
|
+
#
|
|
14
|
+
# derived(:title_error) { "can't be blank" if title.strip.empty? }
|
|
15
|
+
# derived(:valid?) { title_error.nil? }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# form = TodoForm.from(Todo.find(id)) # or Todo.new — see below
|
|
19
|
+
# form.title = "buy milk" # a plain signal write
|
|
20
|
+
# form.dirty? # => true
|
|
21
|
+
# form.commit # => false if invalid
|
|
22
|
+
# form.error_for(:title) # => the model's own message
|
|
23
|
+
#
|
|
24
|
+
# One form class serves create AND update, the `form_with model:`
|
|
25
|
+
# convention: `from(Todo.new)` hydrates the column defaults and `commit`
|
|
26
|
+
# on an unpersisted record INSERTs, so create-vs-update is invisible to
|
|
27
|
+
# the caller. `dirty?` on a create form means "changed from the
|
|
28
|
+
# defaults" — exactly what enables a Create button.
|
|
29
|
+
#
|
|
30
|
+
# Two layers of validation, deliberately: hand-written deriveds give
|
|
31
|
+
# per-keystroke feedback (hand-picked, like client-side validation),
|
|
32
|
+
# while the model's own `validates` stay authoritative at commit and
|
|
33
|
+
# land in #errors. Nothing here names an ActiveRecord constant — the
|
|
34
|
+
# record is duck-typed (readers, #update, #save!, #errors,
|
|
35
|
+
# #persisted?) — but the casting below is AR's attribute API, which is
|
|
36
|
+
# why this lives in the Rails glue gem and not in the core.
|
|
37
|
+
module ReactiveForm
|
|
38
|
+
def self.included(base)
|
|
39
|
+
base.include(Hibiki::Reactive)
|
|
40
|
+
base.extend(ClassMethods)
|
|
41
|
+
# One derived over the whole attribute set rather than per-field
|
|
42
|
+
# change tracking: cheap, and enough for "enable the save button".
|
|
43
|
+
base.derived(:dirty?) { to_h != __hibiki_snapshot.value }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module ClassMethods
|
|
47
|
+
# Hydrate from a record — the only supported constructor. Extra
|
|
48
|
+
# arguments are forwarded to #initialize, so a form with its own
|
|
49
|
+
# constructor still works.
|
|
50
|
+
def from(record, ...) = new(...).hydrate(record)
|
|
51
|
+
|
|
52
|
+
# reactive_attributes Todo, :title, :done
|
|
53
|
+
#
|
|
54
|
+
# The model may be a Class or a String/Symbol; a name is resolved on
|
|
55
|
+
# every use, so a form class under app/ never pins a constant across
|
|
56
|
+
# a Zeitwerk reload.
|
|
57
|
+
def reactive_attributes(model, *names)
|
|
58
|
+
@hibiki_model = model
|
|
59
|
+
@hibiki_attributes = names.map(&:to_sym)
|
|
60
|
+
casts = __hibiki_casts
|
|
61
|
+
@hibiki_attributes.each do |name|
|
|
62
|
+
state name
|
|
63
|
+
casts.define_method(:"#{name}=") { |value| super(self.class.hibiki_type(name).cast(value)) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def hibiki_attributes
|
|
68
|
+
@hibiki_attributes || __hibiki_inherited(:hibiki_attributes) || []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def hibiki_model
|
|
72
|
+
model = @hibiki_model || __hibiki_inherited(:hibiki_model)
|
|
73
|
+
raise "#{self} has no reactive_attributes declaration" if model.nil?
|
|
74
|
+
|
|
75
|
+
model.is_a?(Module) ? model : model.to_s.constantize
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Channel action params arrive as strings; casting through the
|
|
79
|
+
# model's own attribute type is the difference between
|
|
80
|
+
# `done = "false"` meaning false and meaning true.
|
|
81
|
+
def hibiki_type(name) = hibiki_model.type_for_attribute(name.to_s)
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Declarations live in class ivars, so subclasses have to walk up
|
|
86
|
+
# for them — the generated methods inherit on their own.
|
|
87
|
+
def __hibiki_inherited(reader)
|
|
88
|
+
superclass.public_send(reader) if superclass.respond_to?(reader)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One module per class, prepended once: the casting writer wraps
|
|
92
|
+
# the writer `state` defined on the class itself (via super)
|
|
93
|
+
# instead of replacing it.
|
|
94
|
+
def __hibiki_casts = @__hibiki_casts ||= Module.new.tap { prepend(it) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The record, held in a plain ivar and NEVER in a signal: it is the
|
|
98
|
+
# boundary, touched only by #hydrate and #commit.
|
|
99
|
+
attr_reader :record
|
|
100
|
+
|
|
101
|
+
# Also the "reset from a reloaded record" path. One batch, so a
|
|
102
|
+
# re-hydrate is one effect run rather than one per attribute.
|
|
103
|
+
def hydrate(record)
|
|
104
|
+
@record = record
|
|
105
|
+
Hibiki.batch do
|
|
106
|
+
self.class.hibiki_attributes.each { |name| public_send(:"#{name}=", record.public_send(name)) }
|
|
107
|
+
__hibiki_snapshot.value = to_h
|
|
108
|
+
__hibiki_errors.value = {}
|
|
109
|
+
end
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def persisted? = record&.persisted? || false
|
|
114
|
+
|
|
115
|
+
# Reads every attribute signal, so anything derived from it tracks
|
|
116
|
+
# them all.
|
|
117
|
+
def to_h = self.class.hibiki_attributes.to_h { |name| [name, public_send(name)] }
|
|
118
|
+
|
|
119
|
+
# Write the record. Returns false and mirrors the model's errors into
|
|
120
|
+
# #errors when validation fails (the Rails #save convention). On
|
|
121
|
+
# success the form re-hydrates: callbacks and database defaults may
|
|
122
|
+
# have moved values, and #persisted? flips after an INSERT.
|
|
123
|
+
# rubocop:disable Naming/PredicateMethod -- boolean without a ?, exactly like AR's #save
|
|
124
|
+
def commit
|
|
125
|
+
record = __hibiki_record!
|
|
126
|
+
if record.update(**to_h)
|
|
127
|
+
hydrate(record)
|
|
128
|
+
true
|
|
129
|
+
else
|
|
130
|
+
__hibiki_errors.value = record.errors.to_hash
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Naming/PredicateMethod
|
|
135
|
+
|
|
136
|
+
# The raising half. #commit has already assigned the attributes and
|
|
137
|
+
# mirrored the errors, so #save! re-validates the same record and
|
|
138
|
+
# raises ActiveRecord::RecordInvalid — no rescue, and no AR constant
|
|
139
|
+
# named here.
|
|
140
|
+
def commit! = commit || __hibiki_record!.save!
|
|
141
|
+
|
|
142
|
+
# { title: ["can't be blank"] } — mirrored at a failed commit,
|
|
143
|
+
# cleared at a successful one. Reactive like any other signal read:
|
|
144
|
+
# an effect over #error_for repaints when a commit fails.
|
|
145
|
+
def errors = __hibiki_errors.value
|
|
146
|
+
|
|
147
|
+
def error_for(name) = errors[name.to_sym]&.first
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Snapshot and errors are plain States rather than `state` macros on
|
|
152
|
+
# purpose: the macro would generate public writers and invite writes
|
|
153
|
+
# that bypass #hydrate and #commit.
|
|
154
|
+
def __hibiki_snapshot = @__hibiki_snapshot ||= Hibiki::State.new({})
|
|
155
|
+
|
|
156
|
+
def __hibiki_errors = @__hibiki_errors ||= Hibiki::State.new({})
|
|
157
|
+
|
|
158
|
+
def __hibiki_record!
|
|
159
|
+
record || raise("#{self.class} has no record — build it with .from(record)")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/hibiki/rails/version.rb
CHANGED
data/lib/hibiki/rails.rb
CHANGED
|
@@ -12,8 +12,25 @@ module Hibiki
|
|
|
12
12
|
# This is the outer layer of the funnel — a flush error first goes to
|
|
13
13
|
# an app-set Hibiki.error_handler if there is one, and only re-raises
|
|
14
14
|
# into the actor's per-job rescue when there isn't.
|
|
15
|
+
#
|
|
16
|
+
# It ALSO logs, in local environments only. ActiveSupport::ErrorReporter
|
|
17
|
+
# has no subscribers by default, so report alone means a graph-thread
|
|
18
|
+
# exception vanishes completely in a stock app: no log line, no stack,
|
|
19
|
+
# and the only symptom is a fragment that stops updating. That cost real
|
|
20
|
+
# debugging time while the CRUD reference app was built. `local?` is
|
|
21
|
+
# development + test, so production apps — which do have a subscriber —
|
|
22
|
+
# get no duplicate line. Swap the whole thing per graph via
|
|
23
|
+
# GraphActor.new(on_error:) if you want different behaviour.
|
|
15
24
|
def self.default_error_reporter
|
|
16
|
-
|
|
25
|
+
lambda do |error|
|
|
26
|
+
if ::Rails.env.local? && ::Rails.logger
|
|
27
|
+
::Rails.logger.error(
|
|
28
|
+
"[hibiki_rails] #{error.class}: #{error.message}\n" \
|
|
29
|
+
"#{Array(error.backtrace).first(20).join("\n")}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
::Rails.error.report(error, handled: true, source: "hibiki_rails")
|
|
33
|
+
end
|
|
17
34
|
end
|
|
18
35
|
end
|
|
19
36
|
end
|
|
@@ -25,4 +42,5 @@ require_relative "rails/debounce"
|
|
|
25
42
|
require_relative "rails/broadcasts"
|
|
26
43
|
require_relative "rails/channel"
|
|
27
44
|
require_relative "rails/helpers"
|
|
45
|
+
require_relative "rails/reactive_form"
|
|
28
46
|
require_relative "rails/engine"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hibiki_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- planetaska
|
|
@@ -102,6 +102,7 @@ files:
|
|
|
102
102
|
- lib/hibiki/rails/engine.rb
|
|
103
103
|
- lib/hibiki/rails/graph_actor.rb
|
|
104
104
|
- lib/hibiki/rails/helpers.rb
|
|
105
|
+
- lib/hibiki/rails/reactive_form.rb
|
|
105
106
|
- lib/hibiki/rails/registry.rb
|
|
106
107
|
- lib/hibiki/rails/version.rb
|
|
107
108
|
- lib/hibiki_rails.rb
|