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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +9 -0
  4. data/app/assets/javascripts/hibiki.js +119 -10
  5. data/lib/generators/hibiki/rails/css_variant.rb +25 -2
  6. data/lib/generators/hibiki/rails/form/USAGE +24 -0
  7. data/lib/generators/hibiki/rails/form/form_generator.rb +171 -0
  8. data/lib/generators/hibiki/rails/multiselect/USAGE +27 -0
  9. data/lib/generators/hibiki/rails/multiselect/multiselect_generator.rb +149 -0
  10. data/lib/generators/hibiki/rails/multiselect/templates/_multiselect.html.erb.tt +44 -0
  11. data/lib/generators/hibiki/rails/multiselect/templates/concern.rb.tt +108 -0
  12. data/lib/generators/hibiki/rails/multiselect/templates/join_migration.rb.tt +14 -0
  13. data/lib/generators/hibiki/rails/multiselect/templates/join_model.rb.tt +7 -0
  14. data/lib/generators/hibiki/rails/multiselect/templates/multiselect_component.rb.tt +65 -0
  15. data/lib/generators/hibiki/rails/multiselect_helpers.rb +190 -0
  16. data/lib/generators/hibiki/rails/multiselect_injections.rb +246 -0
  17. data/lib/generators/hibiki/rails/scaffold/scaffold_generator.rb +2 -0
  18. data/lib/generators/hibiki/rails/scaffold_controller/scaffold_controller_generator.rb +3 -0
  19. data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt +15 -9
  20. data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt +15 -9
  21. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt +12 -6
  22. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt +12 -6
  23. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/controls.rb.tt +30 -6
  24. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/index.rb.tt +18 -3
  25. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/list.rb.tt +29 -4
  26. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row.rb.tt +20 -6
  27. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/shared/views/row_form.rb.tt +12 -12
  28. data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/tailwind/views/pagination.rb.tt +12 -6
  29. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/channel.rb.tt +96 -12
  30. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/controller.rb.tt +3 -2
  31. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/form.rb.tt +3 -2
  32. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/query.rb.tt +46 -0
  33. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_controls.html.erb.tt +28 -6
  34. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_list.html.erb.tt +24 -2
  35. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row.html.erb.tt +18 -5
  36. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/_row_form.html.erb.tt +8 -7
  37. data/lib/generators/hibiki/rails/scaffold_controller/templates/shared/views/index.html.erb.tt +18 -3
  38. data/lib/generators/hibiki/rails/scaffold_controller/templates/tailwind/views/_pagination.html.erb.tt +15 -9
  39. data/lib/generators/hibiki/rails/scaffold_phlex_helpers.rb +19 -4
  40. data/lib/generators/hibiki/rails/scaffold_post_install.rb +8 -12
  41. data/lib/generators/hibiki/rails/scaffold_shared_views.rb +27 -4
  42. data/lib/generators/hibiki/rails/scaffold_view_helpers.rb +56 -26
  43. data/lib/hibiki/rails/channel.rb +23 -0
  44. data/lib/hibiki/rails/helpers.rb +24 -6
  45. data/lib/hibiki/rails/reactive_form.rb +42 -0
  46. data/lib/hibiki/rails/version.rb +1 -1
  47. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d708875aa67bf5d9b9137ac8d9bb05cf23604db936011706d068ec0b7116429c
4
- data.tar.gz: 941b16f996127706d699e8812cf712b755c0f833f7a04c9e71912844421645ba
3
+ metadata.gz: 2776ffca09f5d60a467d985550fb12c36570a5a22fd5eb7b0a54ee45c30a68e0
4
+ data.tar.gz: 3a05960da6f7ab9d80a2f6574f6413491610314fd3917a62b48c48127b866830
5
5
  SHA512:
6
- metadata.gz: 74175d980c70f1b4ea1bb9ba593ef9940326eccfce6c783ceefdebfb2a0659706faa1040393759549366c5880ea23459740e0f3f280e64fb47a4bc32a371f69c
7
- data.tar.gz: c88a903f15b13516310beb32a7ed741942c49ae65823a6422fb36823400a9fea420ae08bd2fe047bc1d2e6ff3e38cef8f86c0665eeee6014e0f495c880ad90a4
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
- this.subscription.perform(action, payload)
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). Two message shapes:
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
- // Before the confirm, not after: declining must not let the form
560
- // navigate away.
561
- if (event.type === "submit") event.preventDefault()
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, Object.fromEntries(new FormData(control)))
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.trackControl(this.perform(action, payload), control)
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