hibiki_rails 0.6.0 → 0.8.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 +131 -0
- data/README.md +9 -0
- data/app/assets/javascripts/hibiki.js +119 -10
- data/lib/generators/hibiki/rails/css_variant.rb +25 -2
- data/lib/generators/hibiki/rails/form/USAGE +24 -0
- data/lib/generators/hibiki/rails/form/form_generator.rb +171 -0
- data/lib/generators/hibiki/rails/multiselect/USAGE +27 -0
- data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +149 -0
- data/lib/generators/hibiki/rails/multiselect/templates/_multiselect.html.erb.tt +44 -0
- data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +108 -0
- data/lib/generators/hibiki/rails/multiselect/templates/join_migration.rb.tt +14 -0
- data/lib/generators/hibiki/rails/multiselect/templates/join_model.rb.tt +7 -0
- data/lib/generators/hibiki/rails/multiselect/templates/multiselect_component.rb.tt +65 -0
- data/lib/generators/hibiki/rails/multiselect_helpers.rb +190 -0
- data/lib/generators/hibiki/rails/multiselect_injections.rb +246 -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 +20 -6
- data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +12 -12
- 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 +18 -5
- 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 +19 -4
- data/lib/generators/hibiki/rails/scaffold_post_install.rb +8 -12
- data/lib/generators/hibiki/rails/scaffold_shared_views.rb +27 -4
- data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +56 -26
- data/lib/hibiki/rails/channel.rb +23 -0
- data/lib/hibiki/rails/helpers.rb +24 -6
- data/lib/hibiki/rails/reactive_form.rb +42 -0
- data/lib/hibiki/rails/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2776ffca09f5d60a467d985550fb12c36570a5a22fd5eb7b0a54ee45c30a68e0
|
|
4
|
+
data.tar.gz: 3a05960da6f7ab9d80a2f6574f6413491610314fd3917a62b48c48127b866830
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 29f7fc27b77dbb5e32e94440afc233da170b4095b77bac378426f834a47da38065b5b35881c63dcefe4e3c0053849f0fd2fd23d87d80708aebbec207fe765e93
|
|
7
|
+
data.tar.gz: 5f85d166f5ff9dec3dc23a4d8e4aae9fbece2f53ee63b10a163622e799c77f6b989743f27a935db3c1915b9bb56c96332462b983ccf3fc0cd7ab9ea8f5477fa4
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,137 @@ 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.8.0 — 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**`on(..., fallback: true)` — progressive enhancement over native controls.**
|
|
12
|
+
A control's native behavior — a link's href, a form's `action=` — is its
|
|
13
|
+
degraded path. Only a `ready` island intercepts the gesture and performs the
|
|
14
|
+
channel action; while connecting, offline, or stalled the client stands aside
|
|
15
|
+
entirely and the browser does what the markup says. A dead-but-undetected
|
|
16
|
+
socket is caught too: when the subscription reports the send failed, the
|
|
17
|
+
client settles the trip and runs the native behavior by hand
|
|
18
|
+
(`form.submit()` / `location.assign`) — the gesture never left, so it cannot
|
|
19
|
+
double-fire. Two guarantees ride along: `confirm:` still gates the native
|
|
20
|
+
path while scripts run (a destructive submit must not slip past the dialog
|
|
21
|
+
just because the island is down), and before any native submit the client
|
|
22
|
+
freshens the form's `authenticity_token` from the `csrf-token` meta tag —
|
|
23
|
+
forms repainted by a channel are rendered without a session and carry no
|
|
24
|
+
token, so freshening is load-bearing, not an edge case. A truly script-free
|
|
25
|
+
page still holds its first-paint token and needs no help.
|
|
26
|
+
|
|
27
|
+
**`transmit_url` — mirror graph state into the address bar.**
|
|
28
|
+
`transmit_value`'s URL sibling: an equality-gated effect transmits `{ url: }`
|
|
29
|
+
and the client `history.replaceState`s the bar to it. Never `pushState` — the
|
|
30
|
+
URL is a mirror of graph state, not a history entry, so there is no popstate
|
|
31
|
+
choreography and Back leaves the page normally. Same-origin only; the client
|
|
32
|
+
resolves the URL against the page's own origin and refuses anything else.
|
|
33
|
+
|
|
34
|
+
**The scaffold now generates the whole degraded-path pattern.** The query
|
|
35
|
+
object grows a URL half — `from_params` and canonical `url_params` (defaults
|
|
36
|
+
omitted) — shared by the controller's first paint, the island's
|
|
37
|
+
subscribe-params seeding (the graph starts from the URL's state, so its first
|
|
38
|
+
broadcast repaints what the server painted instead of resetting to defaults),
|
|
39
|
+
the pagination hrefs, and the channel's `transmit_url`, which also mirrors an
|
|
40
|
+
open form's URL so a reload mid-edit lands on the standard edit page. The
|
|
41
|
+
controls become one GET form to the index: live, the controls fire channel
|
|
42
|
+
actions; dead, Enter, Apply, and the direction button submit natively and
|
|
43
|
+
`from_params` answers. Edit is a real link to the edit page, Destroy a real
|
|
44
|
+
`button_to` DELETE form, and the page control's links carry real hrefs — all
|
|
45
|
+
with `fallback: true`, in both view layers and every css variant.
|
|
46
|
+
|
|
47
|
+
**The scaffold's list gains an inline create form, on by default.** The New
|
|
48
|
+
link (now inside the island) opens a channel-owned create form at the top of
|
|
49
|
+
the list — its own `ReactiveForm` instance, so an open row edit and an open
|
|
50
|
+
create never fight over state — and degrades to the standard new page. The
|
|
51
|
+
shared row form is parameterized (`dom:`, `save_action:`, `cancel_action:`,
|
|
52
|
+
`field_action:`, `cancel_with:`) and serves both uses; `--skip-create` omits
|
|
53
|
+
the whole surface. Generated forms also gate live validation on `dirty?`, so
|
|
54
|
+
a freshly opened form paints clean instead of flagging every blank field.
|
|
55
|
+
|
|
56
|
+
**`hibiki:rails:multiselect` serves the inline create form too.** The
|
|
57
|
+
dropdown appears with the full option list and an empty selection; each
|
|
58
|
+
checkbox's toggle names the form it belongs to, so an open row edit and the
|
|
59
|
+
create form never cross-write, even side by side.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
**Re-running the scaffold over a pre-0.8.0 app refreshes a same-styled shared
|
|
64
|
+
page control in place** — the regenerated list passes the new `url:` local,
|
|
65
|
+
which the old shared partial does not declare. A page control written under a
|
|
66
|
+
different `--css` style is still kept and noticed, never restyled.
|
|
67
|
+
|
|
68
|
+
## 0.7.0 — 2026-08-12
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
**`hibiki:rails:multiselect` — a dropdown multi-select over a
|
|
73
|
+
`has_many :through`, layered onto a scaffolded resource.**
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
bin/rails g hibiki:rails:multiselect Album Song Track
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Owner and target must exist and be migrated; the join model is generated when
|
|
80
|
+
missing (a `belongs_to` pair with `touch:` on the owner side — a join write IS
|
|
81
|
+
an owner change — and a unique index over the pair) and named off the owner's
|
|
82
|
+
reflection when the `has_many :through` is already declared, where the third
|
|
83
|
+
argument may be omitted. An existing join gets `touch: true` added to its
|
|
84
|
+
`belongs_to`.
|
|
85
|
+
|
|
86
|
+
The channel half is a generated concern (`AlbumsChannel::SongsMultiselect`
|
|
87
|
+
under `app/channels/concerns/`) that wraps `build_graph`, `list_locals` and
|
|
88
|
+
`edit` with `super` via a prepended module — one `include` line is the
|
|
89
|
+
channel's only edit, and the concern's public `toggle_song` / `search_songs`
|
|
90
|
+
methods are client-invocable actions like any other. The form object gains
|
|
91
|
+
`reactive_association :songs`.
|
|
92
|
+
|
|
93
|
+
The selection is owned by the graph, not the form markup: each checkbox sends
|
|
94
|
+
its own toggle carrying its checked state (a set, so two tabs converge), and
|
|
95
|
+
the submit payload never carries the ids. That is what keeps the built-in
|
|
96
|
+
searchable filter honest — narrowing the option list can never drop
|
|
97
|
+
selections it hides. Options are capped (`--limit`, default 50) with a
|
|
98
|
+
LIMIT+1 fetch whose extra row becomes the "type to narrow" hint — no COUNT
|
|
99
|
+
per keystroke. `--skip-search` drops the filter AND the cap, which would
|
|
100
|
+
strand the options it hides. `--label` overrides the inferred display column;
|
|
101
|
+
`--phlex` and `--css` are detected from what the scaffold left behind.
|
|
102
|
+
|
|
103
|
+
**A multiple `<select>`'s full selection now reaches the channel, and
|
|
104
|
+
`ReactiveForm` can carry a collection's ids.** Two halves shipped together:
|
|
105
|
+
the client collects every FormData entry of a `[]`-suffixed field name as an
|
|
106
|
+
array under the bare key (previously last-wins, so a multi-select submitted
|
|
107
|
+
only one option; non-`[]` duplicate keys stay last-wins on purpose — the
|
|
108
|
+
hidden-field checkbox convention depends on it), and
|
|
109
|
+
`reactive_association :tags` on a `ReactiveForm` defines a `tag_ids` signal
|
|
110
|
+
that hydrates from the record's ids reader and commits through the
|
|
111
|
+
association writer, casting each id via the target model's primary-key type.
|
|
112
|
+
|
|
113
|
+
**`hibiki:rails:form` — re-derive a resource's form from its validators.**
|
|
114
|
+
`bin/rails g hibiki:rails:form Book` reads the migrated schema and rewrites the
|
|
115
|
+
validator-shaped files only: the `ReactiveForm` (its `live_errors` clauses) and
|
|
116
|
+
the two form views (number-field bounds, requiredness). Everything else the
|
|
117
|
+
scaffold wrote is untouched, and Thor still asks per file before replacing
|
|
118
|
+
edits. `--skip-views` limits it to the form object; the view layer and css
|
|
119
|
+
variant are detected from the files a previous run left, with `--phlex` and
|
|
120
|
+
`--css` as overrides. An optional field list chooses order and subset, never
|
|
121
|
+
facts.
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
**Scaffolded partials pass a generic `extras: {}` hash down the
|
|
126
|
+
list → row → row form chain, in both view layers.** The extension point
|
|
127
|
+
add-on generators ride: merge locals into the broadcast under one key and
|
|
128
|
+
they arrive at the row form with no partial edits. Existing scaffolds keep
|
|
129
|
+
working untouched — a generator that needs the hash on pre-0.7.0 output
|
|
130
|
+
threads it in with a `compat` notice.
|
|
131
|
+
|
|
132
|
+
**The scaffold's empty-`live_errors` notice now recommends
|
|
133
|
+
`bin/rails g hibiki:rails:form Book`** — on its own line, with no retyped field
|
|
134
|
+
list — instead of re-running `scaffold_controller` with the full arguments. The
|
|
135
|
+
migration the scaffold wrote preserves the argument order, so once it has run
|
|
136
|
+
the schema answers both order and facts.
|
|
137
|
+
|
|
7
138
|
## 0.6.0 — 2026-08-10
|
|
8
139
|
|
|
9
140
|
### Added
|
data/README.md
CHANGED
|
@@ -73,6 +73,9 @@ bin/rails g hibiki:rails:scaffold_controller Book
|
|
|
73
73
|
|
|
74
74
|
# Same, but you pick the field order; everything else still comes from the model
|
|
75
75
|
bin/rails g hibiki:rails:scaffold_controller Book title:string author:references
|
|
76
|
+
|
|
77
|
+
# After adding validators: re-derive the form and its views, touching nothing else
|
|
78
|
+
bin/rails g hibiki:rails:form Book
|
|
76
79
|
```
|
|
77
80
|
|
|
78
81
|
Your plain `rails g scaffold` is untouched. The generated markup is styled to
|
|
@@ -91,6 +94,12 @@ Listing the fields yourself only chooses their order and which ones appear — t
|
|
|
91
94
|
model still answers everything else, so the live validation, a number field's
|
|
92
95
|
`min:`/`max:` and a `belongs_to`'s display label all survive the choice.
|
|
93
96
|
|
|
97
|
+
Because the live validation is derived from the model's validators, a scaffold
|
|
98
|
+
generated before its migration ran starts with none. `hibiki:rails:form` is the
|
|
99
|
+
catch-up: it rewrites only the `ReactiveForm` and the two form views from the
|
|
100
|
+
schema as it stands now, asks before replacing anything you edited, and takes
|
|
101
|
+
`--skip-views` to rewrite the form object alone.
|
|
102
|
+
|
|
94
103
|
Your models are edited, not just read: the one being scaffolded gains the
|
|
95
104
|
`after_commit` broadcast that makes writes from anywhere reach an open list, and
|
|
96
105
|
each model a `belongs_to` points at gains the `has_many` half plus a ping of its
|
|
@@ -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
|
//
|
|
@@ -104,6 +109,45 @@ const controlValue = (control) => {
|
|
|
104
109
|
return control.value
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// A trailing [] is a serialization artifact, not part of the attribute name:
|
|
113
|
+
// the channel payload is JSON, where arrays are native.
|
|
114
|
+
const payloadKey = (name) => (name.endsWith("[]") ? name.slice(0, -2) : name)
|
|
115
|
+
|
|
116
|
+
// A submitted form's contribution to the payload. A []-named field collects
|
|
117
|
+
// EVERY entry as an array under the bare key; other duplicate keys stay
|
|
118
|
+
// last-wins, which is what lets Rails' hidden-field checkbox convention
|
|
119
|
+
// submit "1" when checked and "0" when not.
|
|
120
|
+
const formPayload = (form) => {
|
|
121
|
+
const data = new FormData(form)
|
|
122
|
+
const payload = {}
|
|
123
|
+
for (const key of new Set(data.keys())) {
|
|
124
|
+
const all = data.getAll(key)
|
|
125
|
+
payload[payloadKey(key)] = key.endsWith("[]") ? all : all.at(-1)
|
|
126
|
+
}
|
|
127
|
+
return payload
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Before a fallback form goes native, re-stamp its CSRF token from the
|
|
131
|
+
// page's csrf-token meta — which is first-paint fresh and session-valid.
|
|
132
|
+
// Server-side repaints render without a session (ApplicationController
|
|
133
|
+
// .render has none), so a repainted form embeds a stale token or none at
|
|
134
|
+
// all; and this only ever runs with scripts alive, which is exactly when
|
|
135
|
+
// the DOM may have been repainted. A truly script-free page still holds
|
|
136
|
+
// its first-paint token and never needed the help.
|
|
137
|
+
const freshenToken = (control) => {
|
|
138
|
+
if (!(control instanceof HTMLFormElement)) return
|
|
139
|
+
const meta = document.querySelector('meta[name="csrf-token"]')
|
|
140
|
+
if (!meta) return
|
|
141
|
+
let input = control.querySelector('input[name="authenticity_token"]')
|
|
142
|
+
if (!input) {
|
|
143
|
+
input = document.createElement("input")
|
|
144
|
+
input.type = "hidden"
|
|
145
|
+
input.name = "authenticity_token"
|
|
146
|
+
control.appendChild(input)
|
|
147
|
+
}
|
|
148
|
+
input.value = meta.content
|
|
149
|
+
}
|
|
150
|
+
|
|
107
151
|
// The subclassable base: one channel subscription per controller element,
|
|
108
152
|
// identified by a per-page-load cid (data-<identifier>-cid-value).
|
|
109
153
|
export class ChannelController extends Controller {
|
|
@@ -194,13 +238,25 @@ export class ChannelController extends Controller {
|
|
|
194
238
|
// ActionCable's own Subscription#perform already writes `action`.)
|
|
195
239
|
//
|
|
196
240
|
// Returns the seq so a caller that knows which control fired can attach
|
|
197
|
-
// it; nobody has to.
|
|
241
|
+
// it; nobody has to. Returns undefined instead when the socket turned
|
|
242
|
+
// out to be closed under a subscription still believed live.
|
|
198
243
|
perform(action, payload = {}) {
|
|
199
244
|
const seq = ++this.seq
|
|
200
245
|
payload.hbk = seq
|
|
201
246
|
if (this.subscribed) {
|
|
202
247
|
this.beginBusy(seq)
|
|
203
|
-
|
|
248
|
+
// Action Cable's Subscription#perform returns false when the socket
|
|
249
|
+
// is not open — the gap between the socket dying and the connection
|
|
250
|
+
// monitor noticing, during which `subscribed` still says live. The
|
|
251
|
+
// frame went nowhere: settle rather than letting the trip stall out
|
|
252
|
+
// at the ceiling, and stamp `offline` now instead of when the
|
|
253
|
+
// monitor catches up. The monitor still owns reconnecting; its
|
|
254
|
+
// `connected` callback restores `ready` exactly as after a real gap.
|
|
255
|
+
if (this.subscription.perform(action, payload) === false) {
|
|
256
|
+
this.settle(seq)
|
|
257
|
+
this.linkClosed()
|
|
258
|
+
return undefined
|
|
259
|
+
}
|
|
204
260
|
return seq
|
|
205
261
|
}
|
|
206
262
|
// Queue rather than drop while the subscription is still coming up.
|
|
@@ -350,7 +406,7 @@ export class ChannelController extends Controller {
|
|
|
350
406
|
this.received(data)
|
|
351
407
|
}
|
|
352
408
|
|
|
353
|
-
// Server → DOM (transmit transport).
|
|
409
|
+
// Server → DOM (transmit transport). Three message shapes:
|
|
354
410
|
//
|
|
355
411
|
// { value: { name, text } } — a reactive value (transmit_value): write
|
|
356
412
|
// the text into every [data-hibiki-value=name] placeholder, document-
|
|
@@ -358,11 +414,13 @@ export class ChannelController extends Controller {
|
|
|
358
414
|
// textContent assignment keeps values text-only and preserves each
|
|
359
415
|
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
360
416
|
//
|
|
417
|
+
// { url } — mirror graph state into the address bar (transmit_url).
|
|
418
|
+
//
|
|
361
419
|
// { html } — a fragment: swap it in by its root id.
|
|
362
420
|
//
|
|
363
421
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
364
422
|
// should call super (or handle `value`) to keep reactive values live.
|
|
365
|
-
received({ html, value }) {
|
|
423
|
+
received({ html, value, url }) {
|
|
366
424
|
if (value) {
|
|
367
425
|
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
368
426
|
for (const site of document.querySelectorAll(selector)) {
|
|
@@ -370,6 +428,7 @@ export class ChannelController extends Controller {
|
|
|
370
428
|
}
|
|
371
429
|
return
|
|
372
430
|
}
|
|
431
|
+
if (url !== undefined) return this.replaceUrl(url)
|
|
373
432
|
if (!html) return
|
|
374
433
|
const template = document.createElement("template")
|
|
375
434
|
template.innerHTML = html
|
|
@@ -378,6 +437,17 @@ export class ChannelController extends Controller {
|
|
|
378
437
|
}
|
|
379
438
|
}
|
|
380
439
|
|
|
440
|
+
// replaceState, never pushState: the URL is a mirror of graph state, not
|
|
441
|
+
// a history entry — Back needs no popstate handling and leaves the page
|
|
442
|
+
// normally. Same-origin only, so a channel can move the bar solely
|
|
443
|
+
// within its own app (replaceState would throw on a cross-origin URL;
|
|
444
|
+
// refusing keeps it silent and intentional).
|
|
445
|
+
replaceUrl(url) {
|
|
446
|
+
const resolved = new URL(url, window.location.href)
|
|
447
|
+
if (resolved.origin !== window.location.origin) return
|
|
448
|
+
history.replaceState(history.state, "", resolved)
|
|
449
|
+
}
|
|
450
|
+
|
|
381
451
|
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
382
452
|
// identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
|
|
383
453
|
channelName() {
|
|
@@ -556,9 +626,27 @@ export default class HibikiController extends ChannelController {
|
|
|
556
626
|
if (!token) return
|
|
557
627
|
const action = token.slice(event.type.length + 2)
|
|
558
628
|
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
629
|
+
// A fallback control's native behavior IS the degraded path: unless
|
|
630
|
+
// the island is `ready`, stand aside — no perform, no queueing — and
|
|
631
|
+
// the browser follows the href or submits the form to its own
|
|
632
|
+
// action=. Deliberately not the connect-window queue: a queued gesture
|
|
633
|
+
// renders nothing until the link comes up, while the control's
|
|
634
|
+
// destination answers immediately. Two touches before stepping back:
|
|
635
|
+
// a confirm: still gates the native behavior (scripts are running, so
|
|
636
|
+
// a destructive submit must not slip past the dialog), and a form's
|
|
637
|
+
// authenticity_token is freshened — server-rendered repaints carry no
|
|
638
|
+
// session, so their forms embed a stale token or none at all.
|
|
639
|
+
const fallback = "hibikiFallback" in control.dataset
|
|
640
|
+
if (fallback && this.state !== "ready") {
|
|
641
|
+
const message = control.dataset.hibikiConfirm
|
|
642
|
+
if (message && !window.confirm(message)) return event.preventDefault?.()
|
|
643
|
+
return freshenToken(control)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Before the confirm, not after: declining must not let the form (or
|
|
647
|
+
// a fallback control's navigation) proceed. Optional call because the
|
|
648
|
+
// `visible` pseudo-event arrives as a plain object.
|
|
649
|
+
if (event.type === "submit" || fallback) event.preventDefault?.()
|
|
562
650
|
|
|
563
651
|
const message = control.dataset.hibikiConfirm
|
|
564
652
|
if (message && !window.confirm(message)) return
|
|
@@ -577,13 +665,21 @@ export default class HibikiController extends ChannelController {
|
|
|
577
665
|
? JSON.parse(control.dataset.hibikiWith)
|
|
578
666
|
: {}
|
|
579
667
|
if (event.type === "submit") {
|
|
580
|
-
Object.assign(payload,
|
|
668
|
+
Object.assign(payload, formPayload(control))
|
|
581
669
|
} else if (control.name && (event.type === "change" || event.type === "input")) {
|
|
582
|
-
payload[control.name] = controlValue(control)
|
|
670
|
+
payload[payloadKey(control.name)] = controlValue(control)
|
|
583
671
|
}
|
|
584
672
|
// perform stamps `hbk` after this merge, so a field literally named hbk
|
|
585
673
|
// loses to the seq rather than corrupting it.
|
|
586
|
-
this.
|
|
674
|
+
const seq = this.perform(action, payload)
|
|
675
|
+
// The send failed on a socket believed live (perform already marked the
|
|
676
|
+
// island offline). The gesture's default was prevented in dispatch, so
|
|
677
|
+
// for a fallback control honor the contract by hand — the action never
|
|
678
|
+
// left the machine, so the native behavior cannot double-fire.
|
|
679
|
+
if (seq === undefined && "hibikiFallback" in control.dataset) {
|
|
680
|
+
return this.fallthrough(control)
|
|
681
|
+
}
|
|
682
|
+
this.trackControl(seq, control)
|
|
587
683
|
// Resetting is right for an "add" form and wrong for an edit one: it
|
|
588
684
|
// runs synchronously, before the server has replied, so a failed commit
|
|
589
685
|
// would discard what the user typed.
|
|
@@ -592,6 +688,19 @@ export default class HibikiController extends ChannelController {
|
|
|
592
688
|
}
|
|
593
689
|
}
|
|
594
690
|
|
|
691
|
+
// The native behavior the intercepted event would have had. submit(),
|
|
692
|
+
// not requestSubmit(): the submit event already fired and was prevented,
|
|
693
|
+
// and re-dispatching it would loop straight back through the delegated
|
|
694
|
+
// listener.
|
|
695
|
+
fallthrough(control) {
|
|
696
|
+
if (control instanceof HTMLFormElement) {
|
|
697
|
+
freshenToken(control)
|
|
698
|
+
control.submit()
|
|
699
|
+
} else if (control.href) {
|
|
700
|
+
window.location.assign(control.href)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
595
704
|
// One timer per (control, action): two events on one element debounce
|
|
596
705
|
// independently, and a second control's typing never cancels the first's.
|
|
597
706
|
debounce(control, action, wait, fire) {
|
|
@@ -99,7 +99,21 @@ module Hibiki
|
|
|
99
99
|
counts: "ml-auto text-sm opacity-70",
|
|
100
100
|
# my-, not mt-: the control renders above the list as well as below,
|
|
101
101
|
# and the top copy needs the gap on its other side.
|
|
102
|
-
pagination_nav: "join my-2 mx-auto flex justify-center w-fit"
|
|
102
|
+
pagination_nav: "join my-2 mx-auto flex justify-center w-fit",
|
|
103
|
+
|
|
104
|
+
# The multiselect dropdown (hibiki:rails:multiselect). One structure
|
|
105
|
+
# for every variant: DaisyUI's dropdown opens on :focus-within, the
|
|
106
|
+
# tailwind override hand-rolls the same rule with group-focus-within,
|
|
107
|
+
# and under --css=none nothing hides the panel so the list renders
|
|
108
|
+
# inline — unstyled, still functional.
|
|
109
|
+
dropdown: "dropdown w-full",
|
|
110
|
+
dropdown_trigger: "select w-full items-center",
|
|
111
|
+
dropdown_panel: "dropdown-content bg-base-100 rounded-box z-10 mt-1 w-full p-2 shadow-md",
|
|
112
|
+
option_list: "menu w-full max-h-60 flex-nowrap overflow-y-auto p-0",
|
|
113
|
+
option_label: "label cursor-pointer justify-start gap-2",
|
|
114
|
+
option_note: "p-2 opacity-60 italic",
|
|
115
|
+
checkbox_sm: "checkbox checkbox-sm",
|
|
116
|
+
filter_input: "input input-sm w-full mb-2"
|
|
103
117
|
}.freeze
|
|
104
118
|
|
|
105
119
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -144,7 +158,16 @@ module Hibiki
|
|
|
144
158
|
muted: "text-gray-500 italic",
|
|
145
159
|
muted_inline: "text-gray-400",
|
|
146
160
|
counts: "ml-auto text-sm text-gray-500",
|
|
147
|
-
pagination_nav: "my-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm"
|
|
161
|
+
pagination_nav: "my-2 mx-auto flex justify-center w-fit -space-x-px rounded-md shadow-sm",
|
|
162
|
+
dropdown: "group relative w-full",
|
|
163
|
+
dropdown_trigger: "#{FIELD_FULL} flex cursor-pointer items-center",
|
|
164
|
+
dropdown_panel: "absolute inset-x-0 z-10 mt-1 hidden rounded-md border border-gray-200 " \
|
|
165
|
+
"bg-white p-2 shadow-lg group-focus-within:block",
|
|
166
|
+
option_list: "max-h-60 space-y-1 overflow-y-auto",
|
|
167
|
+
option_label: "flex cursor-pointer items-center gap-2 py-1 text-sm",
|
|
168
|
+
option_note: "p-2 text-gray-500 italic",
|
|
169
|
+
checkbox_sm: CHECKBOX,
|
|
170
|
+
filter_input: "#{FIELD_FULL} mb-2 text-sm"
|
|
148
171
|
).freeze
|
|
149
172
|
|
|
150
173
|
# Every lookup misses, so every class argument is omitted entirely.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Regenerates a resource's ReactiveForm and its form views from the model's
|
|
3
|
+
schema and validators. Run it after adding or changing validators so
|
|
4
|
+
live_errors, number-field bounds and requiredness match the model again.
|
|
5
|
+
|
|
6
|
+
The model must exist and its table must be migrated. With no field list,
|
|
7
|
+
columns and their order come from the schema; pass fields explicitly only
|
|
8
|
+
to choose a different order or subset — the facts stay the schema's.
|
|
9
|
+
|
|
10
|
+
Only the validator-shaped files are rewritten; the rest of the scaffold's
|
|
11
|
+
output is untouched. Thor asks per file before replacing anything you have
|
|
12
|
+
edited. The view layer and css variant are detected from the files a
|
|
13
|
+
previous scaffold left; --phlex and --css override.
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
bin/rails generate hibiki:rails:form Book
|
|
17
|
+
|
|
18
|
+
This will rewrite:
|
|
19
|
+
app/forms/book_form.rb
|
|
20
|
+
app/views/books/_form.html.erb
|
|
21
|
+
app/views/books/_book_form.html.erb
|
|
22
|
+
|
|
23
|
+
Under --phlex, the views are app/views/books/{form,row_form}.rb instead.
|
|
24
|
+
Pass --skip-views to rewrite only app/forms/book_form.rb.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/resource_helpers"
|
|
5
|
+
require_relative "../generator_helpers"
|
|
6
|
+
require_relative "../scaffold_helpers"
|
|
7
|
+
require_relative "../scaffold_view_helpers"
|
|
8
|
+
require_relative "../scaffold_phlex_helpers"
|
|
9
|
+
require_relative "../scaffold_post_install"
|
|
10
|
+
require_relative "../scaffold_shared_views"
|
|
11
|
+
require_relative "../scaffold_schema"
|
|
12
|
+
require_relative "../css_variant"
|
|
13
|
+
|
|
14
|
+
module Hibiki
|
|
15
|
+
module Rails
|
|
16
|
+
module Generators
|
|
17
|
+
# Re-derives the validator-shaped slice of a scaffolded resource: the
|
|
18
|
+
# ReactiveForm (live_errors) and the two form views (bounds,
|
|
19
|
+
# requiredness). Run it after adding or changing validators on the model;
|
|
20
|
+
# everything else the scaffold wrote is deliberately out of scope, so a
|
|
21
|
+
# customized index or controller is never put at risk over a validator.
|
|
22
|
+
#
|
|
23
|
+
# Always the introspection path: the model and its table must exist,
|
|
24
|
+
# because validators are the one thing this generator is for and only a
|
|
25
|
+
# live model can answer them. A field list is optional and chooses order
|
|
26
|
+
# and subset only — the facts stay the schema's, like the scaffold's own
|
|
27
|
+
# merge.
|
|
28
|
+
class FormGenerator < ::Rails::Generators::NamedBase
|
|
29
|
+
include ::Rails::Generators::ResourceHelpers
|
|
30
|
+
include GeneratorHelpers
|
|
31
|
+
include ScaffoldHelpers
|
|
32
|
+
include ScaffoldViewHelpers
|
|
33
|
+
include ScaffoldPhlexHelpers
|
|
34
|
+
include ScaffoldPostInstall
|
|
35
|
+
include ScaffoldSharedViews
|
|
36
|
+
|
|
37
|
+
# The scaffold's templates, reused wholesale — this generator re-emits
|
|
38
|
+
# a subset of that output and must never drift from it. Its own
|
|
39
|
+
# source_root stays beside its own USAGE (Base#usage_path resolves
|
|
40
|
+
# ../USAGE from source_root, and pointing it at the scaffold's tree
|
|
41
|
+
# would serve the scaffold's help text).
|
|
42
|
+
SCAFFOLD_TEMPLATES = File.expand_path("../scaffold_controller/templates", __dir__)
|
|
43
|
+
|
|
44
|
+
source_root File.expand_path("templates", __dir__)
|
|
45
|
+
|
|
46
|
+
desc "Regenerates a resource's ReactiveForm and form views from the " \
|
|
47
|
+
"model's schema and validators."
|
|
48
|
+
|
|
49
|
+
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
|
50
|
+
|
|
51
|
+
class_option :css, type: :string, enum: CssVariant::NAMES,
|
|
52
|
+
desc: "Markup variant for the form views (default: detect)"
|
|
53
|
+
class_option :skip_views, type: :boolean, default: false,
|
|
54
|
+
desc: "Rewrite only the form object — leave the form views alone"
|
|
55
|
+
class_option :phlex, type: :boolean,
|
|
56
|
+
desc: "Emit Phlex form components (default: detect from existing views)"
|
|
57
|
+
|
|
58
|
+
def check_phlex_wiring
|
|
59
|
+
warn_without_phlex_rails unless skip_views?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolving the schema first means a missing model or an unmigrated
|
|
63
|
+
# table aborts before anything is written.
|
|
64
|
+
def resolve_schema
|
|
65
|
+
schema
|
|
66
|
+
@new_app_dirs = %w[app/forms].reject { exists?(it) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_form
|
|
70
|
+
template "form.rb.tt", form_path
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def create_views
|
|
74
|
+
return if skip_views?
|
|
75
|
+
|
|
76
|
+
if phlex?
|
|
77
|
+
template "views/form.rb.tt", view_path("form.rb")
|
|
78
|
+
template "views/row_form.rb.tt", view_path("row_form.rb")
|
|
79
|
+
else
|
|
80
|
+
template "views/_form.html.erb.tt", view_path("_form.html.erb")
|
|
81
|
+
template "views/_row_form.html.erb.tt", view_path("_#{row_form_partial}.html.erb")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
create_form_shared_views
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def post_install
|
|
88
|
+
restart_notice
|
|
89
|
+
shared_views_notice
|
|
90
|
+
skipped_notice
|
|
91
|
+
live_errors_notice
|
|
92
|
+
uniqueness_notice
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# The same layering as the scaffold's, minus nothing: the css-variant
|
|
98
|
+
# fork holds only the page control today, but an app's own
|
|
99
|
+
# lib/templates override must keep winning first either way.
|
|
100
|
+
def source_paths
|
|
101
|
+
@source_paths ||= [*self.class.source_paths_for_search,
|
|
102
|
+
*phlex_source_paths,
|
|
103
|
+
File.join(SCAFFOLD_TEMPLATES, css_variant.to_s),
|
|
104
|
+
File.join(SCAFFOLD_TEMPLATES, "shared")]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def phlex_source_paths
|
|
108
|
+
return [] unless phlex?
|
|
109
|
+
|
|
110
|
+
[File.join(SCAFFOLD_TEMPLATES, "phlex", css_variant.to_s),
|
|
111
|
+
File.join(SCAFFOLD_TEMPLATES, "phlex", "shared")]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Unlike the scaffold pair, the model is REQUIRED on both branches: an
|
|
115
|
+
# argument list here chooses order and subset, never facts, because
|
|
116
|
+
# facts with no validators behind them are exactly the output this
|
|
117
|
+
# generator exists to replace.
|
|
118
|
+
def schema
|
|
119
|
+
@schema ||= if attributes.any?
|
|
120
|
+
ScaffoldSchema.from_attributes(attributes, model: model_class)
|
|
121
|
+
else
|
|
122
|
+
ScaffoldSchema.from_model(model_class)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Aborts rather than emitting a form with nothing derived — like the
|
|
127
|
+
# scaffold's own model_class, but with no field-list escape hatch.
|
|
128
|
+
def model_class
|
|
129
|
+
klass = class_name.safe_constantize
|
|
130
|
+
raise ::Rails::Generators::Error, missing_model_message unless klass
|
|
131
|
+
|
|
132
|
+
# Touch the schema here so an unmigrated table fails with our message
|
|
133
|
+
# rather than somewhere inside a template.
|
|
134
|
+
klass.tap(&:columns_hash)
|
|
135
|
+
rescue ::Rails::Generators::Error
|
|
136
|
+
raise
|
|
137
|
+
rescue StandardError
|
|
138
|
+
raise ::Rails::Generators::Error, missing_table_message
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def missing_model_message
|
|
142
|
+
"No model #{class_name}. Generate the resource first:\n " \
|
|
143
|
+
"bin/rails g hibiki:rails:scaffold #{name} title:string"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def missing_table_message
|
|
147
|
+
"#{class_name} has no table yet. Run bin/rails db:migrate first"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def skip_views? = options[:skip_views]
|
|
151
|
+
|
|
152
|
+
# Which view layer a previous scaffold left behind. app/views/<res>/
|
|
153
|
+
# form.rb only ever comes from a --phlex run (the ERB layer's file is
|
|
154
|
+
# _form.html.erb), so its presence is the layer answer; --phlex and
|
|
155
|
+
# --no-phlex override for the odd case.
|
|
156
|
+
def phlex?
|
|
157
|
+
return @phlex if defined?(@phlex)
|
|
158
|
+
|
|
159
|
+
@phlex = options[:phlex].nil? ? exists?(view_path("form.rb")) : options[:phlex]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def css_variant
|
|
163
|
+
@css_variant ||= (options[:css] || CssVariant.detect(destination_root)).to_sym
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def css(token) = CssVariant.token(css_variant, token)
|
|
167
|
+
def css? = css_variant != :none
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Adds a dropdown multi-select for a has_many :through association onto a
|
|
3
|
+
resource that hibiki:rails:scaffold_controller already generated.
|
|
4
|
+
|
|
5
|
+
Owner and Target must exist and be migrated. Join is required only when
|
|
6
|
+
the owner doesn't declare the association yet — the join model and its
|
|
7
|
+
migration are generated (belongs_to pair, touch: on the owner side, a
|
|
8
|
+
unique index over the pair), and the has_many :through is injected into
|
|
9
|
+
the owner.
|
|
10
|
+
|
|
11
|
+
The channel gains one `include`; everything else lives in a generated
|
|
12
|
+
concern under app/channels/concerns that wraps build_graph/list_locals
|
|
13
|
+
with super. The selection is owned by the graph (the form's <target>_ids
|
|
14
|
+
signal), so filtering the option list can never drop hidden selections.
|
|
15
|
+
|
|
16
|
+
The option list is capped (OPTIONS_LIMIT in the concern, --limit to
|
|
17
|
+
choose it); the cap is dropped under --skip-search, where it would strand
|
|
18
|
+
the options it hides. Scaffolds generated before 0.7.0 lack the extras:
|
|
19
|
+
hash the view rides on — it is threaded through their partials
|
|
20
|
+
automatically.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
bin/rails g hibiki:rails:multiselect Album Song Track
|
|
24
|
+
|
|
25
|
+
bin/rails g hibiki:rails:multiselect Album Song --skip-search
|
|
26
|
+
|
|
27
|
+
bin/rails g hibiki:rails:multiselect Album Song Track --label=name --limit=25
|