hibiki_rails 0.7.0 → 0.9.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 +138 -0
- data/app/assets/javascripts/hibiki.js +160 -9
- data/lib/generators/hibiki/rails/css_variant.rb +14 -2
- data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +28 -7
- data/lib/generators/hibiki/rails/multiselect_helpers.rb +1 -1
- data/lib/generators/hibiki/rails/multiselect_injections.rb +7 -2
- data/lib/generators/hibiki/rails/nested/USAGE +30 -0
- data/lib/generators/hibiki/rails/nested/nested_generator.rb +152 -0
- data/lib/generators/hibiki/rails/nested/templates/_fields.html.erb.tt +43 -0
- data/lib/generators/hibiki/rails/nested/templates/child_form.rb.tt +34 -0
- data/lib/generators/hibiki/rails/nested/templates/fields_component.rb.tt +70 -0
- data/lib/generators/hibiki/rails/nested/templates/position_migration.rb.tt +7 -0
- data/lib/generators/hibiki/rails/nested_helpers.rb +328 -0
- data/lib/generators/hibiki/rails/nested_injections.rb +641 -0
- data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
- data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +3 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +15 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +15 -9
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +12 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +12 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/controls.rb.tt +30 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/index.rb.tt +18 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +29 -4
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +19 -5
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +11 -11
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +12 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +96 -12
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +3 -2
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +3 -2
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +46 -0
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +28 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +24 -2
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +17 -4
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +8 -7
- data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +18 -3
- data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +15 -9
- data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +15 -2
- data/lib/generators/hibiki/rails/scaffold_shared_views.rb +14 -1
- data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +46 -23
- data/lib/hibiki/rails/channel.rb +23 -0
- data/lib/hibiki/rails/helpers.rb +24 -6
- data/lib/hibiki/rails/nested_actions.rb +95 -0
- data/lib/hibiki/rails/reactive_form.rb +172 -9
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b661448cbe2b84a20e26f5cd0bffaf2cf7be8c755f0f1b9c692f77e9f5798430
|
|
4
|
+
data.tar.gz: b09c3b35032a6990ab08c17e424a147e9e667905a4f9e9378062d52583e213d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc4013a0867b606341dd9af0135a82ab9e5a8c6b5f5cabd788eea48e0f3b604a708d31aa21c044f8639a5f2951242986b710d893b968246d03703359d562235b
|
|
7
|
+
data.tar.gz: d407a4c966366e5b11d9db6eaf7e789dcd4e086ca028dad0b2bc9b69bc4ec8f6b110339e338894b51851aa8ace18f08ef2339ce9198cf7a9226dc48d11b8c592
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,144 @@ 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.9.0 — 2026-08-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**`reactive_nested` — nested forms over `accepts_nested_attributes_for`.**
|
|
12
|
+
`reactive_nested :credits, "CreditForm"` on a ReactiveForm declares a signal
|
|
13
|
+
holding an array of child forms; the child class may itself declare
|
|
14
|
+
`reactive_nested`, so depth is composition — nothing counts levels. `#to_h`
|
|
15
|
+
serializes the tree as recursive `*_attributes` (with `id:` and `_destroy:`),
|
|
16
|
+
so `#commit` persists everything in the record's one save; a failed commit
|
|
17
|
+
distributes each child record's errors onto the matching child form, and
|
|
18
|
+
`dirty?` tracks child edits, adds, removes, and destroy-marks for free. New
|
|
19
|
+
instance API: `nested_add` / `nested_remove` (a persisted child is marked
|
|
20
|
+
`_destroy`, a new one leaves the array), `nested_key` (`"c<id>"` / `"n<seq>"`
|
|
21
|
+
— stable DOM identity across repaints), `mark_for_destruction`. The form
|
|
22
|
+
also unloads its nested associations after every commit attempt: a failed
|
|
23
|
+
save leaves AR-built children in the in-memory association, and without the
|
|
24
|
+
unload a later success would insert them twice.
|
|
25
|
+
|
|
26
|
+
**`Hibiki::Rails::NestedActions` — generic channel actions for nested
|
|
27
|
+
forms.** Opt-in include next to `Hibiki::Rails::Channel`: `nested_add`,
|
|
28
|
+
`nested_remove`, `nested_move` (reorder to an index among visible
|
|
29
|
+
siblings — with position stamped from array order, up/down controls are
|
|
30
|
+
all a reorderable list needs), and `nested_set_field`, addressing any node by a
|
|
31
|
+
`dom` + `path` payload (`"credits/c3/contributions/n1"` — association names
|
|
32
|
+
alternating with child keys, any depth). Every hop is gated against the form
|
|
33
|
+
classes' declarations, keys against live children, fields against
|
|
34
|
+
`hibiki_attributes`. The default form resolver understands the scaffold's
|
|
35
|
+
`@form`/`@editing_id` and `@new_form`/`@creating` ivars; override the private
|
|
36
|
+
`nested_form_for(dom)` for anything else. No client change — nested controls
|
|
37
|
+
name themselves `"#{path}/#{field}"` and ride the existing payload mechanics.
|
|
38
|
+
|
|
39
|
+
**`hibiki:rails:nested` — generate one parent→child edge of a nested form.**
|
|
40
|
+
`bin/rails g hibiki:rails:nested Song Credit`, then `... Credit Contribution`
|
|
41
|
+
for the next level — depth is composition, each run wires one edge. The child
|
|
42
|
+
model must exist and `belongs_to` the parent; attribute arguments only
|
|
43
|
+
reorder or subset what the schema already knows. Emits the child ReactiveForm
|
|
44
|
+
and a `_<child>_fields` partial (or Phlex component) with path-addressed
|
|
45
|
+
inputs, injects the ordered `has_many` + `accepts_nested_attributes_for`,
|
|
46
|
+
`reactive_nested`, the `NestedActions` include, preloads, and the classic
|
|
47
|
+
`fields_for` + `params.expect` degraded path into the full-page form. A
|
|
48
|
+
`position` column is detected for ordering (`--position=COLUMN` names one,
|
|
49
|
+
adding the migration; `--skip-position` opts out) and ordered edges get
|
|
50
|
+
up/down controls. Works against a scaffolded collection (root mode) or an
|
|
51
|
+
already-nested parent's fields partial (deep mode), on both view layers.
|
|
52
|
+
|
|
53
|
+
**`perform(action, payload)` is public API — and a `performOn` helper.**
|
|
54
|
+
The blessed seam for app JS (a drag library's drop handler, any third-party
|
|
55
|
+
widget) to fire actions on an island's graph through the island's OWN
|
|
56
|
+
subscription. Reach the controller instance with Stimulus's standard
|
|
57
|
+
`application.getControllerForElementAndIdentifier(islandEl, "hibiki")`, or
|
|
58
|
+
skip the incantation with the new export — `performOn(element, action,
|
|
59
|
+
payload)` finds the island containing `element` and performs through it,
|
|
60
|
+
Stimulus context not required. The return value is the contract: truthy (the
|
|
61
|
+
trip's sequence number) means the action was accepted — sent live, or queued
|
|
62
|
+
during the island's initial connect window — and a repaint is coming, so
|
|
63
|
+
leave the DOM as the user arranged it; `undefined` means it was dropped (the
|
|
64
|
+
island is offline, the socket turned out to be dead, or no island contains
|
|
65
|
+
the element) and the caller owns recovery: revert the gesture, or stand back
|
|
66
|
+
and let the next repaint self-heal. Nothing queues across an offline gap, on
|
|
67
|
+
purpose — a reconnect builds a fresh server-side graph, and replaying intent
|
|
68
|
+
formed against the old one is worse than dropping it. Hand-writing
|
|
69
|
+
`data-hibiki-*` attributes and subclassing `ChannelController` to reach an
|
|
70
|
+
existing island remain unsupported: the attributes are a private contract,
|
|
71
|
+
and a subclass opens a second subscription with a second graph nobody paints
|
|
72
|
+
from.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
**`perform` during an offline gap reported success while dropping the
|
|
77
|
+
payload.** Between a socket drop and the reconnect, `perform` returned the
|
|
78
|
+
trip's sequence number as if the action had been accepted, while sending
|
|
79
|
+
nothing and queueing nothing. Declared actions never noticed (the fallback
|
|
80
|
+
machinery gates on island state before performing), but with the return
|
|
81
|
+
value now public API the lie mattered: it returns `undefined` there, the
|
|
82
|
+
same dropped signal as a dead socket caught at send.
|
|
83
|
+
|
|
84
|
+
## 0.8.0 — 2026-08-15
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
|
|
88
|
+
**`on(..., fallback: true)` — progressive enhancement over native controls.**
|
|
89
|
+
A control's native behavior — a link's href, a form's `action=` — is its
|
|
90
|
+
degraded path. Only a `ready` island intercepts the gesture and performs the
|
|
91
|
+
channel action; while connecting, offline, or stalled the client stands aside
|
|
92
|
+
entirely and the browser does what the markup says. A dead-but-undetected
|
|
93
|
+
socket is caught too: when the subscription reports the send failed, the
|
|
94
|
+
client settles the trip and runs the native behavior by hand
|
|
95
|
+
(`form.submit()` / `location.assign`) — the gesture never left, so it cannot
|
|
96
|
+
double-fire. Two guarantees ride along: `confirm:` still gates the native
|
|
97
|
+
path while scripts run (a destructive submit must not slip past the dialog
|
|
98
|
+
just because the island is down), and before any native submit the client
|
|
99
|
+
freshens the form's `authenticity_token` from the `csrf-token` meta tag —
|
|
100
|
+
forms repainted by a channel are rendered without a session and carry no
|
|
101
|
+
token, so freshening is load-bearing, not an edge case. A truly script-free
|
|
102
|
+
page still holds its first-paint token and needs no help.
|
|
103
|
+
|
|
104
|
+
**`transmit_url` — mirror graph state into the address bar.**
|
|
105
|
+
`transmit_value`'s URL sibling: an equality-gated effect transmits `{ url: }`
|
|
106
|
+
and the client `history.replaceState`s the bar to it. Never `pushState` — the
|
|
107
|
+
URL is a mirror of graph state, not a history entry, so there is no popstate
|
|
108
|
+
choreography and Back leaves the page normally. Same-origin only; the client
|
|
109
|
+
resolves the URL against the page's own origin and refuses anything else.
|
|
110
|
+
|
|
111
|
+
**The scaffold now generates the whole degraded-path pattern.** The query
|
|
112
|
+
object grows a URL half — `from_params` and canonical `url_params` (defaults
|
|
113
|
+
omitted) — shared by the controller's first paint, the island's
|
|
114
|
+
subscribe-params seeding (the graph starts from the URL's state, so its first
|
|
115
|
+
broadcast repaints what the server painted instead of resetting to defaults),
|
|
116
|
+
the pagination hrefs, and the channel's `transmit_url`, which also mirrors an
|
|
117
|
+
open form's URL so a reload mid-edit lands on the standard edit page. The
|
|
118
|
+
controls become one GET form to the index: live, the controls fire channel
|
|
119
|
+
actions; dead, Enter, Apply, and the direction button submit natively and
|
|
120
|
+
`from_params` answers. Edit is a real link to the edit page, Destroy a real
|
|
121
|
+
`button_to` DELETE form, and the page control's links carry real hrefs — all
|
|
122
|
+
with `fallback: true`, in both view layers and every css variant.
|
|
123
|
+
|
|
124
|
+
**The scaffold's list gains an inline create form, on by default.** The New
|
|
125
|
+
link (now inside the island) opens a channel-owned create form at the top of
|
|
126
|
+
the list — its own `ReactiveForm` instance, so an open row edit and an open
|
|
127
|
+
create never fight over state — and degrades to the standard new page. The
|
|
128
|
+
shared row form is parameterized (`dom:`, `save_action:`, `cancel_action:`,
|
|
129
|
+
`field_action:`, `cancel_with:`) and serves both uses; `--skip-create` omits
|
|
130
|
+
the whole surface. Generated forms also gate live validation on `dirty?`, so
|
|
131
|
+
a freshly opened form paints clean instead of flagging every blank field.
|
|
132
|
+
|
|
133
|
+
**`hibiki:rails:multiselect` serves the inline create form too.** The
|
|
134
|
+
dropdown appears with the full option list and an empty selection; each
|
|
135
|
+
checkbox's toggle names the form it belongs to, so an open row edit and the
|
|
136
|
+
create form never cross-write, even side by side.
|
|
137
|
+
|
|
138
|
+
### Changed
|
|
139
|
+
|
|
140
|
+
**Re-running the scaffold over a pre-0.8.0 app refreshes a same-styled shared
|
|
141
|
+
page control in place** — the regenerated list passes the new `url:` local,
|
|
142
|
+
which the old shared partial does not declare. A page control written under a
|
|
143
|
+
different `--css` style is still kept and noticed, never restyled.
|
|
144
|
+
|
|
7
145
|
## 0.7.0 — 2026-08-12
|
|
8
146
|
|
|
9
147
|
### Added
|
|
@@ -52,6 +52,11 @@
|
|
|
52
52
|
// data-hibiki-debounce="250" ms to let the gesture settle
|
|
53
53
|
// data-hibiki-confirm="Are you sure?" window.confirm gate
|
|
54
54
|
// data-hibiki-reset="false" keep a submitted form's inputs
|
|
55
|
+
// data-hibiki-fallback="true" the control's native behavior
|
|
56
|
+
// (a link's navigation, a form's action=) is its fallback: while the
|
|
57
|
+
// island is `ready` the event is intercepted and only the channel
|
|
58
|
+
// action fires; in every other state the client stands aside and the
|
|
59
|
+
// browser does what the markup says
|
|
55
60
|
// value sites data-hibiki-value="<name>" reactive-value placeholder;
|
|
56
61
|
// the server's transmit_value message updates every match
|
|
57
62
|
//
|
|
@@ -75,6 +80,36 @@
|
|
|
75
80
|
// element entering the viewport). Everything that is not "which event"
|
|
76
81
|
// is a sibling attribute, so the token grammar never has to grow.
|
|
77
82
|
//
|
|
83
|
+
// App JS reaching the graph — the ONE public seam. A gesture that needs
|
|
84
|
+
// script (drag-and-drop, a third-party widget) fires its action through
|
|
85
|
+
// the island's OWN subscription: `perform(action, payload)` on the island
|
|
86
|
+
// controller instance is public API. Reach the instance with Stimulus's
|
|
87
|
+
// standard lookup —
|
|
88
|
+
//
|
|
89
|
+
// const islandEl = element.closest('[data-controller~="hibiki"]')
|
|
90
|
+
// const island = application.getControllerForElementAndIdentifier(islandEl, "hibiki")
|
|
91
|
+
// island?.perform("nested_move", { path, to })
|
|
92
|
+
//
|
|
93
|
+
// — or skip the incantation with the performOn export at the bottom of
|
|
94
|
+
// this file (works outside Stimulus too):
|
|
95
|
+
//
|
|
96
|
+
// import { performOn } from "hibiki-rails"
|
|
97
|
+
// performOn(element, "nested_move", { path, to })
|
|
98
|
+
//
|
|
99
|
+
// The return value is the whole contract: truthy (the trip's seq) means
|
|
100
|
+
// the action was ACCEPTED — sent live, or queued during the initial
|
|
101
|
+
// connect window — so a repaint is coming and the caller should leave the
|
|
102
|
+
// DOM as the user arranged it (the morph lands as a visual no-op). Falsy
|
|
103
|
+
// (undefined) means it was DROPPED — the island is offline, or the socket
|
|
104
|
+
// turned out to be dead at send — and the caller owns recovery: revert
|
|
105
|
+
// the gesture, or stand back and let the next repaint self-heal. Nothing
|
|
106
|
+
// queues across an offline gap on purpose (a reconnect builds a fresh
|
|
107
|
+
// server-side graph, so replayed intent would land on state it was not
|
|
108
|
+
// formed against). Everything else is NOT a seam: the data-hibiki-*
|
|
109
|
+
// attributes are private, and subclassing ChannelController to reach an
|
|
110
|
+
// existing island opens a SECOND subscription — a second server-side
|
|
111
|
+
// graph nobody paints from.
|
|
112
|
+
//
|
|
78
113
|
// Register the generic controller under the identifier "hibiki" (the
|
|
79
114
|
// helpers hardcode it):
|
|
80
115
|
//
|
|
@@ -88,6 +123,13 @@ import { createConsumer } from "@rails/actioncable"
|
|
|
88
123
|
// and go with the DOM, the socket stays.
|
|
89
124
|
let consumer
|
|
90
125
|
|
|
126
|
+
// Live islands by root element, for performOn's ancestor walk — membership
|
|
127
|
+
// here, not an attribute probe, so the helper couples to neither the
|
|
128
|
+
// identifier string nor the wire attributes. Generic islands only:
|
|
129
|
+
// ChannelController subclasses have `this.perform`. WeakMap so a removed
|
|
130
|
+
// island pins nothing even if disconnect never ran.
|
|
131
|
+
const islands = new WeakMap()
|
|
132
|
+
|
|
91
133
|
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
92
134
|
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
93
135
|
|
|
@@ -122,6 +164,27 @@ const formPayload = (form) => {
|
|
|
122
164
|
return payload
|
|
123
165
|
}
|
|
124
166
|
|
|
167
|
+
// Before a fallback form goes native, re-stamp its CSRF token from the
|
|
168
|
+
// page's csrf-token meta — which is first-paint fresh and session-valid.
|
|
169
|
+
// Server-side repaints render without a session (ApplicationController
|
|
170
|
+
// .render has none), so a repainted form embeds a stale token or none at
|
|
171
|
+
// all; and this only ever runs with scripts alive, which is exactly when
|
|
172
|
+
// the DOM may have been repainted. A truly script-free page still holds
|
|
173
|
+
// its first-paint token and never needed the help.
|
|
174
|
+
const freshenToken = (control) => {
|
|
175
|
+
if (!(control instanceof HTMLFormElement)) return
|
|
176
|
+
const meta = document.querySelector('meta[name="csrf-token"]')
|
|
177
|
+
if (!meta) return
|
|
178
|
+
let input = control.querySelector('input[name="authenticity_token"]')
|
|
179
|
+
if (!input) {
|
|
180
|
+
input = document.createElement("input")
|
|
181
|
+
input.type = "hidden"
|
|
182
|
+
input.name = "authenticity_token"
|
|
183
|
+
control.appendChild(input)
|
|
184
|
+
}
|
|
185
|
+
input.value = meta.content
|
|
186
|
+
}
|
|
187
|
+
|
|
125
188
|
// The subclassable base: one channel subscription per controller element,
|
|
126
189
|
// identified by a per-page-load cid (data-<identifier>-cid-value).
|
|
127
190
|
export class ChannelController extends Controller {
|
|
@@ -212,13 +275,28 @@ export class ChannelController extends Controller {
|
|
|
212
275
|
// ActionCable's own Subscription#perform already writes `action`.)
|
|
213
276
|
//
|
|
214
277
|
// Returns the seq so a caller that knows which control fired can attach
|
|
215
|
-
// it; nobody has to.
|
|
278
|
+
// it; nobody has to. Returns undefined instead when the payload went
|
|
279
|
+
// nowhere: the socket turned out to be closed under a subscription still
|
|
280
|
+
// believed live, or the island was already offline. Public API on the
|
|
281
|
+
// island controller (the header's "App JS reaching the graph") — truthy
|
|
282
|
+
// = accepted, falsy = dropped and the caller owns recovery.
|
|
216
283
|
perform(action, payload = {}) {
|
|
217
284
|
const seq = ++this.seq
|
|
218
285
|
payload.hbk = seq
|
|
219
286
|
if (this.subscribed) {
|
|
220
287
|
this.beginBusy(seq)
|
|
221
|
-
|
|
288
|
+
// Action Cable's Subscription#perform returns false when the socket
|
|
289
|
+
// is not open — the gap between the socket dying and the connection
|
|
290
|
+
// monitor noticing, during which `subscribed` still says live. The
|
|
291
|
+
// frame went nowhere: settle rather than letting the trip stall out
|
|
292
|
+
// at the ceiling, and stamp `offline` now instead of when the
|
|
293
|
+
// monitor catches up. The monitor still owns reconnecting; its
|
|
294
|
+
// `connected` callback restores `ready` exactly as after a real gap.
|
|
295
|
+
if (this.subscription.perform(action, payload) === false) {
|
|
296
|
+
this.settle(seq)
|
|
297
|
+
this.linkClosed()
|
|
298
|
+
return undefined
|
|
299
|
+
}
|
|
222
300
|
return seq
|
|
223
301
|
}
|
|
224
302
|
// Queue rather than drop while the subscription is still coming up.
|
|
@@ -237,8 +315,11 @@ export class ChannelController extends Controller {
|
|
|
237
315
|
if (!this.connectedOnce) {
|
|
238
316
|
this.beginBusy(seq)
|
|
239
317
|
this.queued.push([action, payload])
|
|
318
|
+
return seq
|
|
240
319
|
}
|
|
241
|
-
return seq
|
|
320
|
+
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
321
|
+
// would tell a public caller a repaint is coming when none is.
|
|
322
|
+
return undefined
|
|
242
323
|
}
|
|
243
324
|
|
|
244
325
|
// ActionCable's `connected`, i.e. the server confirmed the subscription.
|
|
@@ -368,7 +449,7 @@ export class ChannelController extends Controller {
|
|
|
368
449
|
this.received(data)
|
|
369
450
|
}
|
|
370
451
|
|
|
371
|
-
// Server → DOM (transmit transport).
|
|
452
|
+
// Server → DOM (transmit transport). Three message shapes:
|
|
372
453
|
//
|
|
373
454
|
// { value: { name, text } } — a reactive value (transmit_value): write
|
|
374
455
|
// the text into every [data-hibiki-value=name] placeholder, document-
|
|
@@ -376,11 +457,13 @@ export class ChannelController extends Controller {
|
|
|
376
457
|
// textContent assignment keeps values text-only and preserves each
|
|
377
458
|
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
378
459
|
//
|
|
460
|
+
// { url } — mirror graph state into the address bar (transmit_url).
|
|
461
|
+
//
|
|
379
462
|
// { html } — a fragment: swap it in by its root id.
|
|
380
463
|
//
|
|
381
464
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
382
465
|
// should call super (or handle `value`) to keep reactive values live.
|
|
383
|
-
received({ html, value }) {
|
|
466
|
+
received({ html, value, url }) {
|
|
384
467
|
if (value) {
|
|
385
468
|
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
386
469
|
for (const site of document.querySelectorAll(selector)) {
|
|
@@ -388,6 +471,7 @@ export class ChannelController extends Controller {
|
|
|
388
471
|
}
|
|
389
472
|
return
|
|
390
473
|
}
|
|
474
|
+
if (url !== undefined) return this.replaceUrl(url)
|
|
391
475
|
if (!html) return
|
|
392
476
|
const template = document.createElement("template")
|
|
393
477
|
template.innerHTML = html
|
|
@@ -396,6 +480,17 @@ export class ChannelController extends Controller {
|
|
|
396
480
|
}
|
|
397
481
|
}
|
|
398
482
|
|
|
483
|
+
// replaceState, never pushState: the URL is a mirror of graph state, not
|
|
484
|
+
// a history entry — Back needs no popstate handling and leaves the page
|
|
485
|
+
// normally. Same-origin only, so a channel can move the bar solely
|
|
486
|
+
// within its own app (replaceState would throw on a cross-origin URL;
|
|
487
|
+
// refusing keeps it silent and intentional).
|
|
488
|
+
replaceUrl(url) {
|
|
489
|
+
const resolved = new URL(url, window.location.href)
|
|
490
|
+
if (resolved.origin !== window.location.origin) return
|
|
491
|
+
history.replaceState(history.state, "", resolved)
|
|
492
|
+
}
|
|
493
|
+
|
|
399
494
|
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
400
495
|
// identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
|
|
401
496
|
channelName() {
|
|
@@ -465,6 +560,7 @@ export default class HibikiController extends ChannelController {
|
|
|
465
560
|
}
|
|
466
561
|
|
|
467
562
|
async connect() {
|
|
563
|
+
islands.set(this.element, this)
|
|
468
564
|
// Before the listeners, not after: a click delegated in the next
|
|
469
565
|
// millisecond reaches perform(), which needs the busy map and the
|
|
470
566
|
// queue to exist. This is also what stamps data-hibiki-state
|
|
@@ -528,6 +624,7 @@ export default class HibikiController extends ChannelController {
|
|
|
528
624
|
}
|
|
529
625
|
|
|
530
626
|
disconnect() {
|
|
627
|
+
islands.delete(this.element)
|
|
531
628
|
for (const [type, handler] of this.listeners) {
|
|
532
629
|
this.element.removeEventListener(type, handler)
|
|
533
630
|
}
|
|
@@ -574,9 +671,27 @@ export default class HibikiController extends ChannelController {
|
|
|
574
671
|
if (!token) return
|
|
575
672
|
const action = token.slice(event.type.length + 2)
|
|
576
673
|
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
674
|
+
// A fallback control's native behavior IS the degraded path: unless
|
|
675
|
+
// the island is `ready`, stand aside — no perform, no queueing — and
|
|
676
|
+
// the browser follows the href or submits the form to its own
|
|
677
|
+
// action=. Deliberately not the connect-window queue: a queued gesture
|
|
678
|
+
// renders nothing until the link comes up, while the control's
|
|
679
|
+
// destination answers immediately. Two touches before stepping back:
|
|
680
|
+
// a confirm: still gates the native behavior (scripts are running, so
|
|
681
|
+
// a destructive submit must not slip past the dialog), and a form's
|
|
682
|
+
// authenticity_token is freshened — server-rendered repaints carry no
|
|
683
|
+
// session, so their forms embed a stale token or none at all.
|
|
684
|
+
const fallback = "hibikiFallback" in control.dataset
|
|
685
|
+
if (fallback && this.state !== "ready") {
|
|
686
|
+
const message = control.dataset.hibikiConfirm
|
|
687
|
+
if (message && !window.confirm(message)) return event.preventDefault?.()
|
|
688
|
+
return freshenToken(control)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Before the confirm, not after: declining must not let the form (or
|
|
692
|
+
// a fallback control's navigation) proceed. Optional call because the
|
|
693
|
+
// `visible` pseudo-event arrives as a plain object.
|
|
694
|
+
if (event.type === "submit" || fallback) event.preventDefault?.()
|
|
580
695
|
|
|
581
696
|
const message = control.dataset.hibikiConfirm
|
|
582
697
|
if (message && !window.confirm(message)) return
|
|
@@ -601,7 +716,15 @@ export default class HibikiController extends ChannelController {
|
|
|
601
716
|
}
|
|
602
717
|
// perform stamps `hbk` after this merge, so a field literally named hbk
|
|
603
718
|
// loses to the seq rather than corrupting it.
|
|
604
|
-
this.
|
|
719
|
+
const seq = this.perform(action, payload)
|
|
720
|
+
// The send failed on a socket believed live (perform already marked the
|
|
721
|
+
// island offline). The gesture's default was prevented in dispatch, so
|
|
722
|
+
// for a fallback control honor the contract by hand — the action never
|
|
723
|
+
// left the machine, so the native behavior cannot double-fire.
|
|
724
|
+
if (seq === undefined && "hibikiFallback" in control.dataset) {
|
|
725
|
+
return this.fallthrough(control)
|
|
726
|
+
}
|
|
727
|
+
this.trackControl(seq, control)
|
|
605
728
|
// Resetting is right for an "add" form and wrong for an edit one: it
|
|
606
729
|
// runs synchronously, before the server has replied, so a failed commit
|
|
607
730
|
// would discard what the user typed.
|
|
@@ -610,6 +733,19 @@ export default class HibikiController extends ChannelController {
|
|
|
610
733
|
}
|
|
611
734
|
}
|
|
612
735
|
|
|
736
|
+
// The native behavior the intercepted event would have had. submit(),
|
|
737
|
+
// not requestSubmit(): the submit event already fired and was prevented,
|
|
738
|
+
// and re-dispatching it would loop straight back through the delegated
|
|
739
|
+
// listener.
|
|
740
|
+
fallthrough(control) {
|
|
741
|
+
if (control instanceof HTMLFormElement) {
|
|
742
|
+
freshenToken(control)
|
|
743
|
+
control.submit()
|
|
744
|
+
} else if (control.href) {
|
|
745
|
+
window.location.assign(control.href)
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
613
749
|
// One timer per (control, action): two events on one element debounce
|
|
614
750
|
// independently, and a second control's typing never cancels the first's.
|
|
615
751
|
debounce(control, action, wait, fire) {
|
|
@@ -635,6 +771,21 @@ export default class HibikiController extends ChannelController {
|
|
|
635
771
|
|
|
636
772
|
export { HibikiController }
|
|
637
773
|
|
|
774
|
+
// The public seam without the Stimulus lookup (the header's "App JS
|
|
775
|
+
// reaching the graph"): fire an action through the subscription of the
|
|
776
|
+
// island CONTAINING element. Same return contract as perform — the seq
|
|
777
|
+
// when accepted, undefined when dropped or when no island contains the
|
|
778
|
+
// element (a structural mistake, hence the warn; the offline case stays
|
|
779
|
+
// quiet because it is expected weather).
|
|
780
|
+
export function performOn(element, action, payload = {}) {
|
|
781
|
+
for (let node = element; node; node = node.parentElement) {
|
|
782
|
+
const island = islands.get(node)
|
|
783
|
+
if (island) return island.perform(action, payload)
|
|
784
|
+
}
|
|
785
|
+
console.warn("hibiki: performOn found no island containing", element)
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
638
789
|
// The Turbo-broadcast race helper the base uses internally, still
|
|
639
790
|
// exported for custom (non-Stimulus) clients: resolves once Turbo stamps
|
|
640
791
|
// the `connected` attribute on the given <turbo-cable-stream-source>.
|
|
@@ -113,7 +113,15 @@ module Hibiki
|
|
|
113
113
|
option_label: "label cursor-pointer justify-start gap-2",
|
|
114
114
|
option_note: "p-2 opacity-60 italic",
|
|
115
115
|
checkbox_sm: "checkbox checkbox-sm",
|
|
116
|
-
filter_input: "input input-sm w-full mb-2"
|
|
116
|
+
filter_input: "input input-sm w-full mb-2",
|
|
117
|
+
|
|
118
|
+
# The nested fieldset (hibiki:rails:nested): one bordered card per
|
|
119
|
+
# child row, a legend on the fieldset, and the small row controls
|
|
120
|
+
# (↑/↓/Remove/Add).
|
|
121
|
+
fieldset_legend: "fieldset-legend px-2",
|
|
122
|
+
nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
|
|
123
|
+
btn_ghost_sm: "btn btn-sm btn-ghost",
|
|
124
|
+
btn_outline_sm: "btn btn-sm btn-outline"
|
|
117
125
|
}.freeze
|
|
118
126
|
|
|
119
127
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -167,7 +175,11 @@ module Hibiki
|
|
|
167
175
|
option_label: "flex cursor-pointer items-center gap-2 py-1 text-sm",
|
|
168
176
|
option_note: "p-2 text-gray-500 italic",
|
|
169
177
|
checkbox_sm: CHECKBOX,
|
|
170
|
-
filter_input: "#{FIELD_FULL} mb-2 text-sm"
|
|
178
|
+
filter_input: "#{FIELD_FULL} mb-2 text-sm",
|
|
179
|
+
fieldset_legend: "px-2 text-sm font-medium text-gray-700",
|
|
180
|
+
nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
|
|
181
|
+
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")
|
|
171
183
|
).freeze
|
|
172
184
|
|
|
173
185
|
# Every lookup misses, so every class argument is omitted entirely.
|
|
@@ -26,7 +26,7 @@ module <%= concern_module_name %>
|
|
|
26
26
|
|
|
27
27
|
<% if search? -%>
|
|
28
28
|
@<%= target_plural %>_query = Hibiki::State.new("")
|
|
29
|
-
# Only read while a
|
|
29
|
+
# Only read while a form is open. The filter narrows this list
|
|
30
30
|
# and nothing else — the selection survives it.
|
|
31
31
|
@<%= target_plural %>_options = Hibiki::Derived.new do
|
|
32
32
|
@db_version.value
|
|
@@ -40,7 +40,7 @@ module <%= concern_module_name %>
|
|
|
40
40
|
{ options: rows.first(OPTIONS_LIMIT), truncated: rows.size > OPTIONS_LIMIT }
|
|
41
41
|
end
|
|
42
42
|
<% else -%>
|
|
43
|
-
# Only read while a
|
|
43
|
+
# Only read while a form is open.
|
|
44
44
|
@<%= target_plural %>_options = Hibiki::Derived.new do
|
|
45
45
|
@db_version.value
|
|
46
46
|
{ options: <%= target_class_name %>.order(:<%= label_column %>).pluck(:<%= label_column %>, :id) }
|
|
@@ -54,11 +54,19 @@ module <%= concern_module_name %>
|
|
|
54
54
|
# Each edit starts with an unfiltered dropdown.
|
|
55
55
|
@<%= target_plural %>_query.value = ""
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
# The inline create form's dropdown, likewise. `super if defined?` —
|
|
59
|
+
# on a scaffold without inline create there is nothing to wrap and
|
|
60
|
+
# this hook is inert.
|
|
61
|
+
def new_form(data)
|
|
62
|
+
super if defined?(super)
|
|
63
|
+
@<%= target_plural %>_query.value = ""
|
|
64
|
+
end
|
|
57
65
|
<% end -%>
|
|
58
66
|
|
|
59
67
|
def list_locals
|
|
60
68
|
locals = super
|
|
61
|
-
return locals unless locals[:editing_id]
|
|
69
|
+
return locals unless locals[:editing_id] || locals[:creating]
|
|
62
70
|
|
|
63
71
|
extras = locals.fetch(:extras, {})
|
|
64
72
|
locals.merge(extras: extras.merge(<%= extras_key %>: <%= extras_value_source %>))
|
|
@@ -66,16 +74,18 @@ module <%= concern_module_name %>
|
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
# One checkbox toggled: a SET, not a toggle — the client sends the box's
|
|
69
|
-
# checked state, so two tabs converge.
|
|
77
|
+
# checked state, so two tabs converge. `dom` names the form the box
|
|
78
|
+
# belongs to — a row edit and the inline create form can be open at once.
|
|
70
79
|
def <%= toggle_action %>(data)
|
|
71
|
-
|
|
80
|
+
form = multiselect_form(data["dom"].to_s)
|
|
81
|
+
return unless form
|
|
72
82
|
|
|
73
83
|
# Untrusted id — resolve it against the table before it enters the form.
|
|
74
84
|
id = <%= target_class_name %>.where(id: data["id"]).pick(:id)
|
|
75
85
|
return unless id
|
|
76
86
|
|
|
77
|
-
ids =
|
|
78
|
-
|
|
87
|
+
ids = form.<%= ids_attr %>
|
|
88
|
+
form.<%= ids_attr %> = data["checked"] ? ids | [id] : ids - [id]
|
|
79
89
|
end
|
|
80
90
|
<% if search? -%>
|
|
81
91
|
|
|
@@ -84,4 +94,15 @@ module <%= concern_module_name %>
|
|
|
84
94
|
@<%= target_plural %>_query.value = data["query"].to_s
|
|
85
95
|
end
|
|
86
96
|
<% end -%>
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# Which open form a toggle aims at; nil drops the write.
|
|
101
|
+
def multiselect_form(dom)
|
|
102
|
+
if dom.end_with?("_new")
|
|
103
|
+
@new_form if @creating&.peek
|
|
104
|
+
else
|
|
105
|
+
@form if @editing_id.peek
|
|
106
|
+
end
|
|
107
|
+
end
|
|
87
108
|
end
|
|
@@ -173,7 +173,7 @@ module Hibiki
|
|
|
173
173
|
[%("checked"), "id", "#{form_ref}.#{ids_attr}.include?(id)",
|
|
174
174
|
%(id: "\#{#{dom_ref}}_#{target_singular}_\#{id}"),
|
|
175
175
|
*css_args(:checkbox_sm),
|
|
176
|
-
%(**on(:#{toggle_action}, event: :change, with: { id: id }))]
|
|
176
|
+
%(**on(:#{toggle_action}, event: :change, with: { id: id, dom: #{dom_ref} }))]
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
def filter_field_args
|
|
@@ -145,7 +145,11 @@ module Hibiki
|
|
|
145
145
|
|
|
146
146
|
def inject_component_render
|
|
147
147
|
path = view_path("row_form.rb")
|
|
148
|
-
|
|
148
|
+
# Two vintages of row_form: dom became a keyword (@dom) when the
|
|
149
|
+
# form was parameterized for inline create; before that it was a
|
|
150
|
+
# private method.
|
|
151
|
+
dom = wired?(path, "@dom = dom") ? "@dom" : "dom"
|
|
152
|
+
line = " render #{component_class_name}.new(form: @form, dom: #{dom}, extras: @extras)\n"
|
|
149
153
|
return if wired?(path, "Multiselect.new")
|
|
150
154
|
return manual_wiring(path, line) unless wired?(path, PHLEX_FIELDSET_CLOSE)
|
|
151
155
|
|
|
@@ -218,7 +222,8 @@ module Hibiki
|
|
|
218
222
|
def thread_extras_erb
|
|
219
223
|
[view_path("_list.html.erb"), view_path("_#{row_partial}.html.erb"),
|
|
220
224
|
view_path("_#{row_form_partial}.html.erb")].each do |path|
|
|
221
|
-
|
|
225
|
+
# /m: the row form's locals header wraps onto a second line.
|
|
226
|
+
gsub_file path, /^(<%# locals: \(.*?)\) -%>/m, '\1, extras: {}) -%>', verbose: false
|
|
222
227
|
end
|
|
223
228
|
# `form: form` appears exactly once per file, inside the one render
|
|
224
229
|
# call that must pass extras on.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Wires one parent→child edge of a nested form onto a resource that
|
|
3
|
+
hibiki:rails:scaffold_controller already generated: the child's
|
|
4
|
+
ReactiveForm, a _<child>_fields partial with path-addressed inputs
|
|
5
|
+
(riding the generic Hibiki::Rails::NestedActions channel actions),
|
|
6
|
+
reactive_nested on the parent form, model wiring (has_many +
|
|
7
|
+
accepts_nested_attributes_for allow_destroy), the classic fields_for
|
|
8
|
+
fallback in the full-page form, and the controller's params.expect
|
|
9
|
+
double-array group.
|
|
10
|
+
|
|
11
|
+
Parent and Child models must exist and be migrated, and Child must
|
|
12
|
+
declare belongs_to :<parent>. An optional field list narrows or orders
|
|
13
|
+
the child's form fields; the facts stay the schema's.
|
|
14
|
+
|
|
15
|
+
Depth is composition: run once per edge. A run whose Parent is itself a
|
|
16
|
+
nested child (its _<parent>_fields partial exists) nests into that
|
|
17
|
+
partial instead of the row form.
|
|
18
|
+
|
|
19
|
+
Ordering: a `position` column on the child (or --position=COLUMN) makes
|
|
20
|
+
the fieldset ordered — the has_many gains an order scope, the parent
|
|
21
|
+
form stamps positions from array order, and rows get ↑/↓ buttons. The
|
|
22
|
+
named column is added by migration when missing; --skip-position opts
|
|
23
|
+
out entirely.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
bin/rails g hibiki:rails:nested Song Credit
|
|
27
|
+
|
|
28
|
+
bin/rails g hibiki:rails:nested Credit Contribution
|
|
29
|
+
|
|
30
|
+
bin/rails g hibiki:rails:nested Song Credit artist:references role:string --position=rank
|