hibiki_rails 0.9.1 → 0.11.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/CHANGELOG.md +80 -0
- data/README.md +1 -1
- data/app/assets/javascripts/hibiki.js +110 -117
- data/config/importmap.rb +3 -2
- data/lib/generators/hibiki/rails/css_variant.rb +29 -2
- data/lib/generators/hibiki/rails/extras_threading.rb +55 -0
- data/lib/generators/hibiki/rails/install/install_generator.rb +4 -14
- data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +2 -0
- data/lib/generators/hibiki/rails/multiselect_injections.rb +2 -39
- data/lib/generators/hibiki/rails/upload_field/USAGE +46 -0
- data/lib/generators/hibiki/rails/upload_field/templates/_upload.html.erb.tt +38 -0
- data/lib/generators/hibiki/rails/upload_field/templates/_upload_many.html.erb.tt +47 -0
- data/lib/generators/hibiki/rails/upload_field/templates/concern.rb.tt +133 -0
- data/lib/generators/hibiki/rails/upload_field/templates/concern_many.rb.tt +160 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_component.rb.tt +56 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_field_controller.js.tt +41 -0
- data/lib/generators/hibiki/rails/upload_field/templates/upload_many_component.rb.tt +64 -0
- data/lib/generators/hibiki/rails/upload_field/upload_field_generator.rb +200 -0
- data/lib/generators/hibiki/rails/upload_field_helpers.rb +219 -0
- data/lib/generators/hibiki/rails/upload_field_injections.rb +501 -0
- data/lib/hibiki/rails/reactive_form.rb +1 -2
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e95a440701dfeb3c02a3f6535b59cc18e51d66569dd76f9d793153f671120edc
|
|
4
|
+
data.tar.gz: 14869e53518e44f14089c7a1be15c166a51e9f61ff87867cd9fcbb848b20a3dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3937c28fbcd8ec47e540a2837b6c48d34b4152553037491a576a67b22679d58578c09c4b63ea316390a1a5d5d49e9c37b29fe277dd8150484a881899ee8f1b00
|
|
7
|
+
data.tar.gz: 88ca77fa4732bf3d08f7b207c57ec457bc601afa7111dda3d4660b51d62a1b305b88c9a98b1801cfdc5a29f2723f8d29f5b617b545cd3c9c952e1b3c9aef8329
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,86 @@ The gem and the npm package are released in lockstep and share these version
|
|
|
4
4
|
numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
|
|
5
5
|
so importmap and bundler apps always resolve identical client code.
|
|
6
6
|
|
|
7
|
+
## 0.11.0 — 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
**The client rides turbo-rails' Action Cable consumer.** `hibiki.js` now
|
|
12
|
+
takes its consumer from `cable.getConsumer()` (exported by
|
|
13
|
+
`@hotwired/turbo-rails`) instead of importing `@rails/actioncable` itself.
|
|
14
|
+
Islands and `turbo_stream_from` share ONE websocket where each page used to
|
|
15
|
+
open two, an app's `cable.setConsumer(...)` applies to islands too, and the
|
|
16
|
+
library stops reaching the page twice: bundler apps shed the second copy
|
|
17
|
+
(~14.6 KB; turbo-rails imports `@rails/actioncable/src`, the client imported
|
|
18
|
+
the package root, and a bundler cannot dedupe two specifiers), importmap apps
|
|
19
|
+
stop fetching `actioncable.esm.js` next to the copy `turbo.min.js` already
|
|
20
|
+
carries (~14.8 KB, one request).
|
|
21
|
+
|
|
22
|
+
- npm: the `@rails/actioncable` peer dependency is gone; `@hotwired/turbo-rails
|
|
23
|
+
>= 8.0` is the peer instead (every Rails 8 app has it).
|
|
24
|
+
- `hibiki:rails:install` no longer pins `@rails/actioncable`. An existing pin
|
|
25
|
+
is harmless — nothing imports it — and may be deleted.
|
|
26
|
+
- Subscribing now awaits the consumer once (a microtask after the first
|
|
27
|
+
island); nothing observable changes, actions are still queued until the
|
|
28
|
+
subscription confirms.
|
|
29
|
+
|
|
30
|
+
## 0.10.0 — 2026-08-22
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
**`hibiki:rails:upload_field` — a `has_one_attached` upload on both edit
|
|
35
|
+
surfaces.** `bin/rails g hibiki:rails:upload_field Album cover` extends a
|
|
36
|
+
hibiki:rails scaffold: the classic form gains a direct-upload file field plus
|
|
37
|
+
a remove checkbox (a virtual `remove_cover` attribute whose purge yields to a
|
|
38
|
+
same-submit upload), and the inline channel form gains a nameless file input
|
|
39
|
+
that direct-uploads on change and hands only the signed blob id to the graph.
|
|
40
|
+
The pending state lives in the channel (a generated concern under
|
|
41
|
+
`app/channels/concerns`, one `include` in the channel), keyed by the form's
|
|
42
|
+
dom, so it survives repaints and the edit row and inline create form stay
|
|
43
|
+
independent; attach/purge happens after a successful commit. Both channel
|
|
44
|
+
queries are preloaded with `with_attached_cover`, `:cover`/`:remove_cover`
|
|
45
|
+
join the permitted params, and one shared
|
|
46
|
+
`app/javascript/controllers/upload_field_controller.js` is emitted per app —
|
|
47
|
+
parameterized by Stimulus values, so a second attachment reuses it. One
|
|
48
|
+
attachment per run; `has_one_attached` only.
|
|
49
|
+
|
|
50
|
+
**`--accept` — non-image formats.** `--accept=pdf,doc` narrows both file
|
|
51
|
+
inputs (image by default; tokens `image`, `audio`, `video`, `pdf`, `csv`,
|
|
52
|
+
`text`, `doc`, `xls`, `ppt`, plus any raw `type/subtype` or `.ext` passed
|
|
53
|
+
through; an unknown token refuses before anything is written). Every display
|
|
54
|
+
site — the inline form, the classic form, and the row on the index and show
|
|
55
|
+
page — branches per blob at render time on `variable?`: an image gets its
|
|
56
|
+
variant thumbnail, anything else its filename and size, with a
|
|
57
|
+
`rails_blob_path` link on the row. That is one template for every accept
|
|
58
|
+
list, so mixed lists work and a wrong-typed file past the advisory `accept`
|
|
59
|
+
no longer breaks the index at `variant`. The image_processing notice fires
|
|
60
|
+
only when an image type is accepted. The accept list stays advisory — the
|
|
61
|
+
app owns any server-side content-type validation.
|
|
62
|
+
|
|
63
|
+
**`--many` — `has_many_attached` galleries.** `bin/rails g
|
|
64
|
+
hibiki:rails:upload_field Album photos --many` (implied when the model
|
|
65
|
+
already declares `has_many_attached :photos`; `--many` against an existing
|
|
66
|
+
`has_one_attached` refuses) generates the gallery shape on both surfaces.
|
|
67
|
+
The inline form's input takes several files — the shared
|
|
68
|
+
`upload_field_controller.js` now uploads one file after another, so a
|
|
69
|
+
gallery's pending list keeps the pick order; a single-file input behaves
|
|
70
|
+
as before, and a many-mode run refreshes a copy from before the loop. Each
|
|
71
|
+
attached file carries a graph-owned ✕ (toggle: ✕ marks, Undo unmarks) and
|
|
72
|
+
each pending file its own badge with a ✕ to drop it; the pending state per
|
|
73
|
+
form dom is `{ adds:, removes: }`, applied after a successful commit by
|
|
74
|
+
APPENDING through `attach` and purging the marked ids — the collection is
|
|
75
|
+
never assigned, which since Rails 7.1 replaces it. The classic form gets
|
|
76
|
+
the same append/remove split as two virtual model attributes,
|
|
77
|
+
`add_photos` (a `multiple` direct-upload field, attached in `before_save`)
|
|
78
|
+
and `remove_photo_ids` (a checkbox per attached file, purged in
|
|
79
|
+
`after_save`), riding `params.expect` as a braced group. The row and show
|
|
80
|
+
page render the gallery with the same per-blob thumbnail-or-filename
|
|
81
|
+
branch, so `--many --accept=pdf` is a document list. Two css tokens
|
|
82
|
+
(`gallery_item`, `upload_status_list`).
|
|
83
|
+
|
|
84
|
+
The packaged client did not change — the emitted Stimulus controller imports
|
|
85
|
+
`@rails/activestorage` from the app's own dependencies.
|
|
86
|
+
|
|
7
87
|
## 0.9.1 — 2026-08-18
|
|
8
88
|
|
|
9
89
|
### Added
|
data/README.md
CHANGED
|
@@ -157,7 +157,7 @@ A few things that make a change easier to accept:
|
|
|
157
157
|
|
|
158
158
|
The gem side needs nothing special: point an app's Gemfile at your clone with `gem "hibiki_rails", path: "../hibiki-rails"`.
|
|
159
159
|
|
|
160
|
-
The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@rails/
|
|
160
|
+
The npm side has one trap. `bun link` from an app resolves the linked package's own imports from the **symlink's realpath**, not from the app — so `hibiki.js` looks for `@hotwired/turbo-rails` and `@hotwired/stimulus` inside your checkout rather than inside the app that linked it, and if the checkout has no `node_modules` the import fails at build time with nothing pointing at the cause.
|
|
161
161
|
|
|
162
162
|
```sh
|
|
163
163
|
cd hibiki-rails && bun install # before linking, not after
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// The packaged client for hibiki_rails. Two shapes, one plumbing:
|
|
2
2
|
//
|
|
3
|
-
// 1. The generic controller (HibikiController,
|
|
3
|
+
// 1. The generic controller (HibikiController, registered as "hibiki"):
|
|
4
4
|
// drives any island stamped by the Hibiki::Rails::Helpers Ruby
|
|
5
|
-
// helpers. Stimulus is the lifecycle host
|
|
5
|
+
// helpers. Stimulus is only the lifecycle host (connect/disconnect
|
|
6
6
|
// across Turbo navigation, morphs, and dynamic insertion); the wire
|
|
7
7
|
// protocol is hibiki-owned data attributes, so server-side components
|
|
8
|
-
// never write Stimulus vocabulary
|
|
9
|
-
//
|
|
10
|
-
// 2. The subclassable base (ChannelController): for apps that prefer the
|
|
8
|
+
// never write Stimulus vocabulary.
|
|
9
|
+
// 2. The subclassable base (ChannelController): for apps that prefer
|
|
11
10
|
// familiar Stimulus structure (data-controller="counter",
|
|
12
11
|
// data-action="counter#increment"). The base owns the consumer, the
|
|
13
|
-
// subscription lifecycle, and transport handling
|
|
14
|
-
// auto-forwarded to the channel
|
|
12
|
+
// subscription lifecycle, and transport handling. Plain actions are
|
|
13
|
+
// auto-forwarded to the channel; a subclass declares a method only
|
|
15
14
|
// when it needs something custom:
|
|
16
15
|
//
|
|
17
16
|
// import { ChannelController } from "hibiki-rails"
|
|
@@ -27,18 +26,17 @@
|
|
|
27
26
|
// Both shapes speak both transports. Transmit: the server's render
|
|
28
27
|
// effects `transmit({ html: })` fragments that are swapped in by their
|
|
29
28
|
// root DOM id, and `transmit_value` messages that update every
|
|
30
|
-
// data-hibiki-value placeholder
|
|
31
|
-
// time
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// `received` stays idle.
|
|
29
|
+
// data-hibiki-value placeholder. `received` is registered at subscribe
|
|
30
|
+
// time, before the server runs build_graph, so the effects' first
|
|
31
|
+
// transmits always land; the server-rendered HTML fills the space only
|
|
32
|
+
// until they do. Turbo broadcasts: when the controller's element
|
|
33
|
+
// contains its own <turbo-cable-stream-source> (turbo_stream_from), the
|
|
34
|
+
// graph's first broadcast is lost unless that stream has already
|
|
35
|
+
// confirmed ITS subscription — so connect awaits it (streamConnected)
|
|
36
|
+
// before subscribing, and rendering flows back over the Turbo stream
|
|
37
|
+
// while `received` stays idle.
|
|
40
38
|
//
|
|
41
|
-
// The attribute contract
|
|
39
|
+
// The generic controller's attribute contract (private to this gem —
|
|
42
40
|
// emitted by the Ruby helpers, interpreted here, versioned together):
|
|
43
41
|
//
|
|
44
42
|
// island root data-controller="hibiki"
|
|
@@ -60,25 +58,24 @@
|
|
|
60
58
|
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
61
59
|
// the server's transmit_value message updates every match
|
|
62
60
|
//
|
|
63
|
-
// The
|
|
64
|
-
//
|
|
65
|
-
// read-only to app code; app CSS is their whole audience:
|
|
61
|
+
// The client writes the protocol's other half at runtime — read-only to
|
|
62
|
+
// app code, addressed to app CSS:
|
|
66
63
|
//
|
|
67
64
|
// island root data-hibiki-busy present while an action is in flight
|
|
68
65
|
// aria-busy="true" the same fact, for assistive tech
|
|
69
66
|
// data-hibiki-state connecting | ready | offline | stalled
|
|
70
67
|
// firing control data-hibiki-busy on the control that started it
|
|
71
68
|
//
|
|
72
|
-
//
|
|
73
|
-
// `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row
|
|
74
|
-
// per-button feedback needs no server state
|
|
75
|
-
//
|
|
69
|
+
// App CSS reaches all of it with descendant selectors —
|
|
70
|
+
// `[data-hibiki-busy] .spinner { display: inline-block }` — so per-row
|
|
71
|
+
// and per-button feedback needs no server state. Content is stale during
|
|
72
|
+
// a round trip, never absent.
|
|
76
73
|
//
|
|
77
|
-
// The left side of `->` is a hibiki event name,
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
74
|
+
// The left side of `->` is a hibiki event name: click, change, input,
|
|
75
|
+
// and submit are delegated DOM listeners; `visible` is a pseudo-event
|
|
76
|
+
// backed by an IntersectionObserver (the element entering the viewport).
|
|
77
|
+
// Everything that is not "which event" is a sibling attribute, so the
|
|
78
|
+
// token grammar never has to grow.
|
|
82
79
|
//
|
|
83
80
|
// App JS reaching the graph — the ONE public seam. A gesture that needs
|
|
84
81
|
// script (drag-and-drop, a third-party widget) fires its action through
|
|
@@ -96,19 +93,18 @@
|
|
|
96
93
|
// import { performOn } from "hibiki-rails"
|
|
97
94
|
// performOn(element, "nested_move", { path, to })
|
|
98
95
|
//
|
|
99
|
-
// The return value is the whole contract
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
// graph nobody paints from.
|
|
96
|
+
// The return value is the whole contract. Truthy (the trip's seq): the
|
|
97
|
+
// action was ACCEPTED — sent live, or queued during the initial connect
|
|
98
|
+
// window — a repaint is coming, so leave the DOM as the user arranged it
|
|
99
|
+
// and let the morph land as a visual no-op. Falsy (undefined): it was
|
|
100
|
+
// DROPPED — the island is offline, or the socket was dead at send — and
|
|
101
|
+
// the caller owns recovery: revert the gesture, or let the next repaint
|
|
102
|
+
// self-heal. Nothing queues across an offline gap, on purpose: a
|
|
103
|
+
// reconnect builds a fresh server-side graph, so replayed intent would
|
|
104
|
+
// land on state it was not formed against. Everything else is NOT a
|
|
105
|
+
// seam: the data-hibiki-* attributes are private, and subclassing
|
|
106
|
+
// ChannelController to reach an existing island opens a SECOND
|
|
107
|
+
// subscription — a second server-side graph nobody paints from.
|
|
112
108
|
//
|
|
113
109
|
// Register the generic controller under the identifier "hibiki" (the
|
|
114
110
|
// helpers hardcode it):
|
|
@@ -116,11 +112,16 @@
|
|
|
116
112
|
// import HibikiController from "hibiki-rails"
|
|
117
113
|
// application.register("hibiki", HibikiController)
|
|
118
114
|
import { Controller } from "@hotwired/stimulus"
|
|
119
|
-
import {
|
|
120
|
-
|
|
121
|
-
// One consumer shared by every controller —
|
|
122
|
-
//
|
|
123
|
-
//
|
|
115
|
+
import { cable } from "@hotwired/turbo-rails"
|
|
116
|
+
|
|
117
|
+
// One consumer shared by every controller — and with Turbo Streams: it is
|
|
118
|
+
// turbo-rails' own (`cable.getConsumer()`), so islands and
|
|
119
|
+
// `turbo_stream_from` multiplex over ONE websocket, and an app's
|
|
120
|
+
// `cable.setConsumer(...)` applies to both. Never disconnected: islands
|
|
121
|
+
// come and go with the DOM, the socket stays. Importing turbo-rails'
|
|
122
|
+
// consumer rather than @rails/actioncable also keeps the library out of
|
|
123
|
+
// the page twice (turbo-rails bundles it by a subpath specifier that no
|
|
124
|
+
// importmap can serve).
|
|
124
125
|
let consumer
|
|
125
126
|
|
|
126
127
|
// Live islands by root element, for performOn's ancestor walk — membership
|
|
@@ -165,12 +166,12 @@ const formPayload = (form) => {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
// Before a fallback form goes native, re-stamp its CSRF token from the
|
|
168
|
-
// page's csrf-token meta
|
|
169
|
-
// Server-side repaints render without a session
|
|
170
|
-
// .render has none), so a repainted form embeds a
|
|
171
|
-
// all
|
|
172
|
-
// the DOM may have been repainted
|
|
173
|
-
// its first-paint token
|
|
169
|
+
// page's csrf-token meta tag, which is first-paint fresh and
|
|
170
|
+
// session-valid. Server-side repaints render without a session
|
|
171
|
+
// (ApplicationController.render has none), so a repainted form embeds a
|
|
172
|
+
// stale token or none at all. This only runs with scripts alive —
|
|
173
|
+
// exactly when the DOM may have been repainted; a script-free page
|
|
174
|
+
// still holds its first-paint token.
|
|
174
175
|
const freshenToken = (control) => {
|
|
175
176
|
if (!(control instanceof HTMLFormElement)) return
|
|
176
177
|
const meta = document.querySelector('meta[name="csrf-token"]')
|
|
@@ -191,21 +192,17 @@ export class ChannelController extends Controller {
|
|
|
191
192
|
static values = { cid: String }
|
|
192
193
|
|
|
193
194
|
// Transport-state timings. Class properties on purpose: not Stimulus
|
|
194
|
-
// values and not helper options, so the Ruby surface stays unchanged
|
|
195
|
-
// an island stamps nothing about them. An app that wants different
|
|
195
|
+
// values and not helper options, so the Ruby surface stays unchanged
|
|
196
|
+
// and an island stamps nothing about them. An app that wants different
|
|
196
197
|
// numbers subclasses and re-registers.
|
|
197
198
|
//
|
|
198
|
-
// busyDelay ms before a round trip is worth mentioning
|
|
199
|
-
// trip
|
|
200
|
-
// outright while a 150 ms-RTT link crosses it about exactly.
|
|
201
|
-
// Borrowed from Turbo's progress bar, which waits ~500 ms.
|
|
199
|
+
// busyDelay ms before a round trip is worth mentioning — long enough
|
|
200
|
+
// to swallow a localhost trip (18–25 ms) without flicker.
|
|
202
201
|
// busyGrace ms to wait after an ack for a Turbo render still in
|
|
203
|
-
// flight. The ack
|
|
204
|
-
// broadcast takes a pubsub hop,
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
// backend, so it does not shrink on a fast link — and Redis
|
|
208
|
-
// widens it. Hence a bound in tens of ms, not single digits.
|
|
202
|
+
// flight. The ack rides the island's own socket while the
|
|
203
|
+
// broadcast takes a pubsub hop, so the ack routinely wins
|
|
204
|
+
// by a few ms — a gap owned by the server's backend, which
|
|
205
|
+
// no fast link shrinks. Hence tens of ms, not single digits.
|
|
209
206
|
// busyCeiling ms before a trip is declared stalled rather than silently
|
|
210
207
|
// cleared. On a bad link "we lost it" beats "nothing
|
|
211
208
|
// happened".
|
|
@@ -220,11 +217,10 @@ export class ChannelController extends Controller {
|
|
|
220
217
|
|
|
221
218
|
// The synchronous half of connect: everything a listener firing in the
|
|
222
219
|
// next millisecond depends on. HibikiController attaches its delegated
|
|
223
|
-
// listeners before openSubscription is even called, and the
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// real remote link. Clicks in it used to vanish without a trace.
|
|
220
|
+
// listeners before openSubscription is even called, and on the
|
|
221
|
+
// Turbo-broadcast path the subscription takes ~3 serialised round
|
|
222
|
+
// trips to confirm — up to a second on a real link. Clicks in that
|
|
223
|
+
// window used to vanish without a trace.
|
|
228
224
|
prepareTransport() {
|
|
229
225
|
this.aborted = false
|
|
230
226
|
this.subscribed = false
|
|
@@ -237,15 +233,16 @@ export class ChannelController extends Controller {
|
|
|
237
233
|
}
|
|
238
234
|
|
|
239
235
|
async openSubscription() {
|
|
240
|
-
consumer ??= createConsumer()
|
|
241
236
|
this.defineForwarders()
|
|
242
237
|
// Turbo-broadcast transport: wait for the element's own stream source
|
|
243
238
|
// to confirm before subscribing, so the graph's dependency-collecting
|
|
244
239
|
// first run broadcasts into a live stream. No source (transmit
|
|
245
|
-
// transport) →
|
|
240
|
+
// transport) → only the consumer await below, a microtask once it
|
|
241
|
+
// exists (turbo-rails memoizes it).
|
|
246
242
|
const source = this.streamSource()
|
|
247
243
|
if (source) await streamConnected(source)
|
|
248
|
-
|
|
244
|
+
consumer ??= await cable.getConsumer()
|
|
245
|
+
if (this.aborted) return // disconnected during the awaits
|
|
249
246
|
this.subscription = consumer.subscriptions.create(this.subscribeParams(), {
|
|
250
247
|
received: (data) => this.handleMessage(data),
|
|
251
248
|
connected: () => this.linkOpened(),
|
|
@@ -274,24 +271,23 @@ export class ChannelController extends Controller {
|
|
|
274
271
|
// can never overwrite it. (`hbk` is the second reserved payload key —
|
|
275
272
|
// ActionCable's own Subscription#perform already writes `action`.)
|
|
276
273
|
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
274
|
+
// Public API on the island controller (the header's "App JS reaching
|
|
275
|
+
// the graph"). Returns the seq when the action was accepted, so a
|
|
276
|
+
// caller that knows which control fired can attach it; returns
|
|
277
|
+
// undefined when the payload went nowhere — the socket turned out to
|
|
278
|
+
// be closed, or the island was already offline — and the caller owns
|
|
279
|
+
// recovery.
|
|
283
280
|
perform(action, payload = {}) {
|
|
284
281
|
const seq = ++this.seq
|
|
285
282
|
payload.hbk = seq
|
|
286
283
|
if (this.subscribed) {
|
|
287
284
|
this.beginBusy(seq)
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// `connected` callback restores `ready` exactly as after a real gap.
|
|
285
|
+
// Subscription#perform returns false when the socket is not open —
|
|
286
|
+
// the gap between the socket dying and the connection monitor
|
|
287
|
+
// noticing, during which `subscribed` still says live. The frame
|
|
288
|
+
// went nowhere: settle the trip rather than let it stall out at
|
|
289
|
+
// the ceiling, and stamp `offline` now instead of when the monitor
|
|
290
|
+
// catches up. The monitor still owns reconnecting.
|
|
295
291
|
if (this.subscription.perform(action, payload) === false) {
|
|
296
292
|
this.settle(seq)
|
|
297
293
|
this.linkClosed()
|
|
@@ -299,19 +295,17 @@ export class ChannelController extends Controller {
|
|
|
299
295
|
}
|
|
300
296
|
return seq
|
|
301
297
|
}
|
|
302
|
-
// Queue rather than drop while the subscription is still coming up
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
298
|
+
// Queue rather than drop while the subscription is still coming up:
|
|
299
|
+
// Subscription#perform silently returns false on a socket not open
|
|
300
|
+
// yet, so the whole connect window used to be a silent no-op. The
|
|
301
|
+
// gate must be the `connected` callback, not "the subscription
|
|
302
|
+
// object exists" — the second websocket's handshake sits between
|
|
303
|
+
// the two.
|
|
308
304
|
//
|
|
309
|
-
// Only that first window. Once the link has been up, a gap means
|
|
310
|
-
// socket dropped, and
|
|
311
|
-
//
|
|
312
|
-
// is
|
|
313
|
-
// whole gap, which is the signal the connect window cannot give: there
|
|
314
|
-
// the page is painted and looks live.
|
|
305
|
+
// Only that first window. Once the link has been up, a gap means
|
|
306
|
+
// the socket dropped, and the island is stamped `offline` for the
|
|
307
|
+
// whole gap — the signal the connect window cannot give, where the
|
|
308
|
+
// page is painted and looks live.
|
|
315
309
|
if (!this.connectedOnce) {
|
|
316
310
|
this.beginBusy(seq)
|
|
317
311
|
this.queued.push([action, payload])
|
|
@@ -336,10 +330,10 @@ export class ChannelController extends Controller {
|
|
|
336
330
|
linkClosed() {
|
|
337
331
|
this.subscribed = false
|
|
338
332
|
this.setState("offline")
|
|
339
|
-
// Deliberately NOT queued across the gap
|
|
340
|
-
// graph server-side
|
|
341
|
-
//
|
|
342
|
-
//
|
|
333
|
+
// Deliberately NOT queued across the gap: a reconnect builds a fresh
|
|
334
|
+
// graph server-side, so replayed intent would land on state it was
|
|
335
|
+
// not formed against. Outstanding records settle for the same
|
|
336
|
+
// reason — their acks are never coming.
|
|
343
337
|
this.queued = []
|
|
344
338
|
this.settleAll()
|
|
345
339
|
}
|
|
@@ -505,10 +499,10 @@ export class ChannelController extends Controller {
|
|
|
505
499
|
// Auto-forwarding: every data-action token addressed to this identifier
|
|
506
500
|
// whose method the subclass did NOT declare gets a generated forwarder
|
|
507
501
|
// that performs the underscored action with no payload — so plain
|
|
508
|
-
// forwards need zero code. Declared methods always win.
|
|
509
|
-
// defined at connect
|
|
510
|
-
// markup
|
|
511
|
-
// Stimulus binds the elements itself
|
|
502
|
+
// forwards need zero code. Declared methods always win. Forwarders are
|
|
503
|
+
// defined at connect, so an action NAME first appearing in
|
|
504
|
+
// later-inserted markup isn't discovered; a name already seen keeps
|
|
505
|
+
// working anywhere, because Stimulus binds the elements itself.
|
|
512
506
|
defineForwarders() {
|
|
513
507
|
for (const control of this.element.querySelectorAll("[data-action]")) {
|
|
514
508
|
for (const token of control.dataset.action.trim().split(/\s+/)) {
|
|
@@ -563,9 +557,9 @@ export default class HibikiController extends ChannelController {
|
|
|
563
557
|
islands.set(this.element, this)
|
|
564
558
|
// Before the listeners, not after: a click delegated in the next
|
|
565
559
|
// millisecond reaches perform(), which needs the busy map and the
|
|
566
|
-
// queue to exist. This
|
|
567
|
-
//
|
|
568
|
-
//
|
|
560
|
+
// queue to exist. This also stamps data-hibiki-state="connecting"
|
|
561
|
+
// synchronously, so the island can be dimmed for the whole window
|
|
562
|
+
// rather than from the middle of it.
|
|
569
563
|
this.prepareTransport()
|
|
570
564
|
|
|
571
565
|
// Root-scoped delegation (bound to the island, not document): controls
|
|
@@ -674,13 +668,12 @@ export default class HibikiController extends ChannelController {
|
|
|
674
668
|
// A fallback control's native behavior IS the degraded path: unless
|
|
675
669
|
// the island is `ready`, stand aside — no perform, no queueing — and
|
|
676
670
|
// the browser follows the href or submits the form to its own
|
|
677
|
-
// action=. Deliberately not the connect-window queue: a queued
|
|
678
|
-
// renders nothing until the link comes up, while the
|
|
679
|
-
// destination answers immediately. Two touches before
|
|
680
|
-
// a confirm: still gates the native behavior
|
|
681
|
-
// a destructive submit must not slip past
|
|
682
|
-
//
|
|
683
|
-
// session, so their forms embed a stale token or none at all.
|
|
671
|
+
// action=. Deliberately not the connect-window queue: a queued
|
|
672
|
+
// gesture renders nothing until the link comes up, while the
|
|
673
|
+
// control's destination answers immediately. Two touches before
|
|
674
|
+
// stepping back: a confirm: still gates the native behavior
|
|
675
|
+
// (scripts are running, so a destructive submit must not slip past
|
|
676
|
+
// the dialog), and the form's CSRF token is freshened.
|
|
684
677
|
const fallback = "hibikiFallback" in control.dataset
|
|
685
678
|
if (fallback && this.state !== "ready") {
|
|
686
679
|
const message = control.dataset.hibikiConfirm
|
data/config/importmap.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Merged into the host app's import map by the Engine's importmap
|
|
4
4
|
# initializer (importmap-rails reads every path in config.importmap.paths).
|
|
5
|
-
# The app supplies the "@hotwired/stimulus" and "@rails
|
|
6
|
-
# both are part of the Rails 8 default stack.
|
|
5
|
+
# The app supplies the "@hotwired/stimulus" and "@hotwired/turbo-rails" pins
|
|
6
|
+
# — both are part of the Rails 8 default stack. The client takes Action
|
|
7
|
+
# Cable from turbo-rails' consumer, so no "@rails/actioncable" pin is needed.
|
|
7
8
|
pin "hibiki-rails", to: "hibiki.js"
|
|
@@ -121,7 +121,25 @@ module Hibiki
|
|
|
121
121
|
fieldset_legend: "fieldset-legend px-2",
|
|
122
122
|
nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
|
|
123
123
|
btn_ghost_sm: "btn btn-sm btn-ghost",
|
|
124
|
-
btn_outline_sm: "btn btn-sm btn-outline"
|
|
124
|
+
btn_outline_sm: "btn btn-sm btn-outline",
|
|
125
|
+
|
|
126
|
+
# The upload field (hibiki:rails:upload_field): the attachment row
|
|
127
|
+
# in both forms, its thumbnail, the non-image filename link on the
|
|
128
|
+
# row, and the pending badge on its own full-width line under the
|
|
129
|
+
# controls (the row wraps, so a long filename never pushes Remove
|
|
130
|
+
# off-screen). upload_row, upload_status and thumb are plain
|
|
131
|
+
# utilities, so the tailwind variant inherits them.
|
|
132
|
+
upload_row: "flex flex-wrap items-center gap-3",
|
|
133
|
+
upload_status: "w-full",
|
|
134
|
+
# --many: one chip per attached file (thumb + ✕) and the pending
|
|
135
|
+
# list, which wraps like the row.
|
|
136
|
+
gallery_item: "inline-flex items-center gap-1",
|
|
137
|
+
upload_status_list: "w-full flex flex-wrap gap-2",
|
|
138
|
+
thumb: "rounded",
|
|
139
|
+
file_link: "link link-hover",
|
|
140
|
+
file_input: "file-input w-full",
|
|
141
|
+
file_input_sm: "file-input file-input-sm",
|
|
142
|
+
badge_info: "badge badge-info"
|
|
125
143
|
}.freeze
|
|
126
144
|
|
|
127
145
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -179,7 +197,16 @@ module Hibiki
|
|
|
179
197
|
fieldset_legend: "px-2 text-sm font-medium text-gray-700",
|
|
180
198
|
nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
|
|
181
199
|
btn_ghost_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
|
|
182
|
-
btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1")
|
|
200
|
+
btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
|
|
201
|
+
file_link: "text-indigo-600 hover:underline",
|
|
202
|
+
file_input: "block w-full text-sm text-gray-700 file:mr-3 file:rounded-md " \
|
|
203
|
+
"file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm " \
|
|
204
|
+
"file:font-medium file:text-gray-700 hover:file:bg-gray-200",
|
|
205
|
+
file_input_sm: "block text-xs text-gray-700 file:mr-2 file:rounded-md " \
|
|
206
|
+
"file:border-0 file:bg-gray-100 file:px-2 file:py-1 file:text-xs " \
|
|
207
|
+
"file:font-medium file:text-gray-700 hover:file:bg-gray-200",
|
|
208
|
+
badge_info: "inline-flex items-center rounded-full bg-sky-100 px-2 py-1 " \
|
|
209
|
+
"text-xs font-medium text-sky-800"
|
|
183
210
|
).freeze
|
|
184
211
|
|
|
185
212
|
# Every lookup misses, so every class argument is omitted entirely.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hibiki
|
|
4
|
+
module Rails
|
|
5
|
+
module Generators
|
|
6
|
+
# Threading the extras: hash through a pre-0.7.0 scaffold's partial
|
|
7
|
+
# chain. Scaffolds since 0.7.0 emit it themselves (list -> row ->
|
|
8
|
+
# row_form, untouched), so on those this is a no-op probe; older
|
|
9
|
+
# output gets the header/keyword plus the two render calls retrofitted,
|
|
10
|
+
# anchored on text every scaffold emitted verbatim.
|
|
11
|
+
#
|
|
12
|
+
# Shared by every add-on generator that rides extras:
|
|
13
|
+
# (hibiki:rails:multiselect, hibiki:rails:upload_field). Depends on the
|
|
14
|
+
# generator for view_path/row_partial/row_form_partial/phlex?.
|
|
15
|
+
module ExtrasThreading
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def thread_extras_through_partials
|
|
19
|
+
return if wired?(extras_probe_path, "extras")
|
|
20
|
+
|
|
21
|
+
say_status :compat, "pre-0.7 scaffold — threading extras: through the partials", :yellow
|
|
22
|
+
phlex? ? thread_extras_phlex : thread_extras_erb
|
|
23
|
+
return if wired?(extras_probe_path, "extras")
|
|
24
|
+
|
|
25
|
+
say_status :warn, "could not thread extras: automatically. Pass an extras: {} local " \
|
|
26
|
+
"from the list partial down to #{row_form_partial} yourself.", :yellow
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def extras_probe_path = phlex? ? view_path("row_form.rb") : view_path("_#{row_form_partial}.html.erb")
|
|
30
|
+
|
|
31
|
+
def thread_extras_erb
|
|
32
|
+
[view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
|
|
33
|
+
view_path("_#{row_form_partial}.html.erb")].each do |path|
|
|
34
|
+
# /m: the row form's locals header wraps onto a second line.
|
|
35
|
+
gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
|
|
36
|
+
end
|
|
37
|
+
# `form: form` appears exactly once per file, inside the one render
|
|
38
|
+
# call that must pass extras on.
|
|
39
|
+
gsub_file view_path("_list.html.erb"), "form: form", "form: form, extras: extras"
|
|
40
|
+
gsub_file view_path("_#{row_partial}.html.erb"), "form: form", "form: form, extras: extras"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def thread_extras_phlex
|
|
44
|
+
%w[list.rb row.rb row_form.rb].each do |basename|
|
|
45
|
+
path = view_path(basename)
|
|
46
|
+
gsub_file path, /(def initialize\([^)]*)\)/, '\1, extras: {})', verbose: false
|
|
47
|
+
gsub_file path, /^(\s*)@form = form$/, "\\1@form = form\n\\1@extras = extras", verbose: false
|
|
48
|
+
end
|
|
49
|
+
gsub_file view_path("list.rb"), "form: @form", "form: @form, extras: @extras"
|
|
50
|
+
gsub_file view_path("row.rb"), "form: @form", "form: @form, extras: @extras"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -18,7 +18,7 @@ module Hibiki
|
|
|
18
18
|
|
|
19
19
|
desc "Wires the packaged hibiki client: registers the \"hibiki\" Stimulus " \
|
|
20
20
|
"controller, includes Hibiki::Rails::Helpers in ApplicationHelper, " \
|
|
21
|
-
"creates the ApplicationCable boilerplate
|
|
21
|
+
"and creates the ApplicationCable boilerplate."
|
|
22
22
|
|
|
23
23
|
# Exactly the lines `stimulus:manifest:update` emits for the shim,
|
|
24
24
|
# so a later manifest run converges instead of duplicating.
|
|
@@ -38,14 +38,6 @@ module Hibiki
|
|
|
38
38
|
"connection.rb.tt" => "app/channels/application_cable/connection.rb"
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
|
-
# Same event: the stock importmap has no @rails/actioncable pin
|
|
42
|
-
# until the first `rails g channel` adds it. The packaged client
|
|
43
|
-
# imports it, so pin it here (the asset ships in actioncable).
|
|
44
|
-
PIN = <<~RUBY
|
|
45
|
-
|
|
46
|
-
pin "@rails/actioncable", to: "actioncable.esm.js"
|
|
47
|
-
RUBY
|
|
48
|
-
|
|
49
41
|
# The registration lives in a file-backed shim so that
|
|
50
42
|
# `stimulus:manifest:update` (which rewrites index.js wholesale from
|
|
51
43
|
# the *_controller.js files) re-derives it instead of dropping it.
|
|
@@ -81,11 +73,9 @@ module Hibiki
|
|
|
81
73
|
end
|
|
82
74
|
end
|
|
83
75
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
append_to_file IMPORTMAP, PIN
|
|
76
|
+
# Bundler apps get the client from npm, not from the engine's asset.
|
|
77
|
+
def note_npm_package
|
|
78
|
+
bundler_note unless importmap?
|
|
89
79
|
end
|
|
90
80
|
|
|
91
81
|
private
|