hibiki_rails 0.7.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 +61 -0
- data/app/assets/javascripts/hibiki.js +99 -8
- 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/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/version.rb +1 -1
- metadata +1 -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,67 @@ 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
|
+
|
|
7
68
|
## 0.7.0 — 2026-08-12
|
|
8
69
|
|
|
9
70
|
### 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
|
//
|
|
@@ -122,6 +127,27 @@ const formPayload = (form) => {
|
|
|
122
127
|
return payload
|
|
123
128
|
}
|
|
124
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
|
+
|
|
125
151
|
// The subclassable base: one channel subscription per controller element,
|
|
126
152
|
// identified by a per-page-load cid (data-<identifier>-cid-value).
|
|
127
153
|
export class ChannelController extends Controller {
|
|
@@ -212,13 +238,25 @@ export class ChannelController extends Controller {
|
|
|
212
238
|
// ActionCable's own Subscription#perform already writes `action`.)
|
|
213
239
|
//
|
|
214
240
|
// Returns the seq so a caller that knows which control fired can attach
|
|
215
|
-
// 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.
|
|
216
243
|
perform(action, payload = {}) {
|
|
217
244
|
const seq = ++this.seq
|
|
218
245
|
payload.hbk = seq
|
|
219
246
|
if (this.subscribed) {
|
|
220
247
|
this.beginBusy(seq)
|
|
221
|
-
|
|
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
|
+
}
|
|
222
260
|
return seq
|
|
223
261
|
}
|
|
224
262
|
// Queue rather than drop while the subscription is still coming up.
|
|
@@ -368,7 +406,7 @@ export class ChannelController extends Controller {
|
|
|
368
406
|
this.received(data)
|
|
369
407
|
}
|
|
370
408
|
|
|
371
|
-
// Server → DOM (transmit transport).
|
|
409
|
+
// Server → DOM (transmit transport). Three message shapes:
|
|
372
410
|
//
|
|
373
411
|
// { value: { name, text } } — a reactive value (transmit_value): write
|
|
374
412
|
// the text into every [data-hibiki-value=name] placeholder, document-
|
|
@@ -376,11 +414,13 @@ export class ChannelController extends Controller {
|
|
|
376
414
|
// textContent assignment keeps values text-only and preserves each
|
|
377
415
|
// site's own tag/classes, so per-placeholder styling survives updates.
|
|
378
416
|
//
|
|
417
|
+
// { url } — mirror graph state into the address bar (transmit_url).
|
|
418
|
+
//
|
|
379
419
|
// { html } — a fragment: swap it in by its root id.
|
|
380
420
|
//
|
|
381
421
|
// Anything else is not ours to interpret. Subclasses may override, but
|
|
382
422
|
// should call super (or handle `value`) to keep reactive values live.
|
|
383
|
-
received({ html, value }) {
|
|
423
|
+
received({ html, value, url }) {
|
|
384
424
|
if (value) {
|
|
385
425
|
const selector = `[data-hibiki-value="${CSS.escape(value.name)}"]`
|
|
386
426
|
for (const site of document.querySelectorAll(selector)) {
|
|
@@ -388,6 +428,7 @@ export class ChannelController extends Controller {
|
|
|
388
428
|
}
|
|
389
429
|
return
|
|
390
430
|
}
|
|
431
|
+
if (url !== undefined) return this.replaceUrl(url)
|
|
391
432
|
if (!html) return
|
|
392
433
|
const template = document.createElement("template")
|
|
393
434
|
template.innerHTML = html
|
|
@@ -396,6 +437,17 @@ export class ChannelController extends Controller {
|
|
|
396
437
|
}
|
|
397
438
|
}
|
|
398
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
|
+
|
|
399
451
|
// `static channel = "..."` wins; otherwise infer Rails-style from the
|
|
400
452
|
// identifier: "counter" → CounterChannel, "my-thing" → MyThingChannel.
|
|
401
453
|
channelName() {
|
|
@@ -574,9 +626,27 @@ export default class HibikiController extends ChannelController {
|
|
|
574
626
|
if (!token) return
|
|
575
627
|
const action = token.slice(event.type.length + 2)
|
|
576
628
|
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
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?.()
|
|
580
650
|
|
|
581
651
|
const message = control.dataset.hibikiConfirm
|
|
582
652
|
if (message && !window.confirm(message)) return
|
|
@@ -601,7 +671,15 @@ export default class HibikiController extends ChannelController {
|
|
|
601
671
|
}
|
|
602
672
|
// perform stamps `hbk` after this merge, so a field literally named hbk
|
|
603
673
|
// loses to the seq rather than corrupting it.
|
|
604
|
-
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)
|
|
605
683
|
// Resetting is right for an "add" form and wrong for an edit one: it
|
|
606
684
|
// runs synchronously, before the server has replied, so a failed commit
|
|
607
685
|
// would discard what the user typed.
|
|
@@ -610,6 +688,19 @@ export default class HibikiController extends ChannelController {
|
|
|
610
688
|
}
|
|
611
689
|
}
|
|
612
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
|
+
|
|
613
704
|
// One timer per (control, action): two events on one element debounce
|
|
614
705
|
// independently, and a second control's typing never cancels the first's.
|
|
615
706
|
debounce(control, action, wait, fire) {
|
|
@@ -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.
|
|
@@ -44,6 +44,8 @@ module Hibiki
|
|
|
44
44
|
desc: "No windowing at all — skips both pagination modes"
|
|
45
45
|
class_option :skip_search, type: :boolean, default: false,
|
|
46
46
|
desc: "Omit the search box and the LIKE terms behind it"
|
|
47
|
+
class_option :skip_create, type: :boolean, default: false,
|
|
48
|
+
desc: "Omit the inline create form — New always navigates"
|
|
47
49
|
class_option :page_size, type: :numeric, default: 20, desc: "Rows per page"
|
|
48
50
|
class_option :phlex, type: :boolean, default: false,
|
|
49
51
|
desc: "Emit Phlex components under app/views instead of ERB templates"
|
|
@@ -57,6 +57,8 @@ module Hibiki
|
|
|
57
57
|
desc: "No windowing at all — skips both pagination modes"
|
|
58
58
|
class_option :skip_search, type: :boolean, default: false,
|
|
59
59
|
desc: "Omit the search box and the LIKE terms behind it"
|
|
60
|
+
class_option :skip_create, type: :boolean, default: false,
|
|
61
|
+
desc: "Omit the inline create form — New always navigates"
|
|
60
62
|
class_option :page_size, type: :numeric, default: 20,
|
|
61
63
|
desc: "Rows per page"
|
|
62
64
|
class_option :skip_routes, type: :boolean,
|
|
@@ -297,6 +299,7 @@ module Hibiki
|
|
|
297
299
|
|
|
298
300
|
def infinite? = options[:infinite_scroll]
|
|
299
301
|
def searchable? = schema.searchable? && !options[:skip_search]
|
|
302
|
+
def inline_create? = !options[:skip_create]
|
|
300
303
|
|
|
301
304
|
# The view layer. It reaches the templates and nothing else: the graph,
|
|
302
305
|
# the query object, the form, the actions and the whole data-hibiki
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/daisyui/views/_pagination.html.erb.tt
CHANGED
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
the page-number window) arrives as a local from the list partial. The
|
|
8
8
|
`--css=` marker below is how a later scaffold run detects a variant
|
|
9
9
|
mismatch — keep it. -%>
|
|
10
|
-
<%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
|
|
10
|
+
<%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:, url: nil) -%>
|
|
11
11
|
<%%# Shared page control (--css=<%= css_variant %>), rendered above and below
|
|
12
|
-
every scaffolded list.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
every scaffolded list. `url` is an optional ->(page) { path } builder:
|
|
13
|
+
when given, links carry real hrefs (plus the fragment) and fallback —
|
|
14
|
+
a dead island degrades to native navigation. Without it the links are
|
|
15
|
+
plain anchors to the list, as before. data-turbo="false" keeps Turbo
|
|
16
|
+
Drive out. `on` returns a { data: } hash — merge, don't splat, or the
|
|
17
|
+
anchor stops scrolling. %>
|
|
18
|
+
<%% href = ->(n) { url ? "#{url.(n)}#{anchor}" : anchor } %>
|
|
19
|
+
<%% page_data = ->(n) do
|
|
20
|
+
{ turbo: false }.merge(on(:go_to_page, with: { page: n },
|
|
21
|
+
fallback: url ? true : nil)[:data])
|
|
22
|
+
end %>
|
|
17
23
|
<%%# The wrapper always renders (so idiomorph has a node to pair with) and
|
|
18
24
|
stays bare — layout classes here would leave dead space when empty. %>
|
|
19
25
|
<div id="<%%= id %>">
|
|
@@ -24,7 +30,7 @@
|
|
|
24
30
|
<%% if page == 1 %>
|
|
25
31
|
<span class="join-item btn btn-disabled" aria-disabled="true">«</span>
|
|
26
32
|
<%% else %>
|
|
27
|
-
<%%= link_to "«",
|
|
33
|
+
<%%= link_to "«", href.(page - 1), class: "join-item btn", rel: "prev",
|
|
28
34
|
"aria-label": "Previous page", data: page_data.(page - 1) %>
|
|
29
35
|
<%% end %>
|
|
30
36
|
|
|
@@ -34,7 +40,7 @@
|
|
|
34
40
|
<%% elsif n == page %>
|
|
35
41
|
<span class="join-item btn btn-active" aria-current="page"><%%= n %></span>
|
|
36
42
|
<%% else %>
|
|
37
|
-
<%%= link_to n,
|
|
43
|
+
<%%= link_to n, href.(n), class: "join-item btn",
|
|
38
44
|
"aria-label": "Page #{n}", data: page_data.(n) %>
|
|
39
45
|
<%% end %>
|
|
40
46
|
<%% end %>
|
|
@@ -42,7 +48,7 @@
|
|
|
42
48
|
<%% if page == page_count %>
|
|
43
49
|
<span class="join-item btn btn-disabled" aria-disabled="true">»</span>
|
|
44
50
|
<%% else %>
|
|
45
|
-
<%%= link_to "»",
|
|
51
|
+
<%%= link_to "»", href.(page + 1), class: "join-item btn", rel: "next",
|
|
46
52
|
"aria-label": "Next page", data: page_data.(page + 1) %>
|
|
47
53
|
<%% end %>
|
|
48
54
|
</nav>
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/none/views/_pagination.html.erb.tt
CHANGED
|
@@ -10,13 +10,19 @@
|
|
|
10
10
|
the page-number window) arrives as a local from the list partial. The
|
|
11
11
|
`--css=` marker below is how a later scaffold run detects a variant
|
|
12
12
|
mismatch — keep it. -%>
|
|
13
|
-
<%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:) -%>
|
|
13
|
+
<%%# locals: (page: 1, page_count: 1, pages: [], anchor:, id:, url: nil) -%>
|
|
14
14
|
<%%# Shared page control (--css=<%= css_variant %>), rendered above and below
|
|
15
|
-
every scaffolded list.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
every scaffolded list. `url` is an optional ->(page) { path } builder:
|
|
16
|
+
when given, links carry real hrefs (plus the fragment) and fallback —
|
|
17
|
+
a dead island degrades to native navigation. Without it the links are
|
|
18
|
+
plain anchors to the list, as before. data-turbo="false" keeps Turbo
|
|
19
|
+
Drive out. `on` returns a { data: } hash — merge, don't splat, or the
|
|
20
|
+
anchor stops scrolling. %>
|
|
21
|
+
<%% href = ->(n) { url ? "#{url.(n)}#{anchor}" : anchor } %>
|
|
22
|
+
<%% page_data = ->(n) do
|
|
23
|
+
{ turbo: false }.merge(on(:go_to_page, with: { page: n },
|
|
24
|
+
fallback: url ? true : nil)[:data])
|
|
25
|
+
end %>
|
|
20
26
|
<%%# The wrapper always renders, so idiomorph always has a node to pair with. %>
|
|
21
27
|
<div id="<%%= id %>">
|
|
22
28
|
<%% if page_count > 1 %>
|
|
@@ -26,7 +32,7 @@
|
|
|
26
32
|
<%% if page == 1 %>
|
|
27
33
|
<span aria-disabled="true">«</span>
|
|
28
34
|
<%% else %>
|
|
29
|
-
<%%= link_to "«",
|
|
35
|
+
<%%= link_to "«", href.(page - 1), rel: "prev",
|
|
30
36
|
"aria-label": "Previous page", data: page_data.(page - 1) %>
|
|
31
37
|
<%% end %>
|
|
32
38
|
|
|
@@ -36,14 +42,14 @@
|
|
|
36
42
|
<%% elsif n == page %>
|
|
37
43
|
<strong aria-current="page"><%%= n %></strong>
|
|
38
44
|
<%% else %>
|
|
39
|
-
<%%= link_to n,
|
|
45
|
+
<%%= link_to n, href.(n), "aria-label": "Page #{n}", data: page_data.(n) %>
|
|
40
46
|
<%% end %>
|
|
41
47
|
<%% end %>
|
|
42
48
|
|
|
43
49
|
<%% if page == page_count %>
|
|
44
50
|
<span aria-disabled="true">»</span>
|
|
45
51
|
<%% else %>
|
|
46
|
-
<%%= link_to "»",
|
|
52
|
+
<%%= link_to "»", href.(page + 1), rel: "next",
|
|
47
53
|
"aria-label": "Next page", data: page_data.(page + 1) %>
|
|
48
54
|
<%% end %>
|
|
49
55
|
</nav>
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/daisyui/views/pagination.rb.tt
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
# frozen_string_literal: true
|
|
7
7
|
|
|
8
8
|
# Shared page control (--css=<%= css_variant %>), rendered above and below
|
|
9
|
-
# every scaffolded list.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# every scaffolded list. `url` is an optional ->(page) { path } builder:
|
|
10
|
+
# when given, links carry real hrefs (plus the fragment) and fallback — a
|
|
11
|
+
# dead island degrades to native navigation. Without it the links are plain
|
|
12
|
+
# anchors to the list, as before. data-turbo="false" keeps Turbo Drive out.
|
|
12
13
|
class Views::Shared::Pagination < Views::Base
|
|
13
14
|
<%= phlex_includes(:link_to, hibiki: true) %>
|
|
14
15
|
|
|
15
|
-
<%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]
|
|
16
|
+
<%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"],
|
|
17
|
+
%w[url nil]]) %>
|
|
16
18
|
|
|
17
19
|
def view_template
|
|
18
20
|
# The wrapper always renders (so idiomorph has a node to pair with) and
|
|
@@ -53,7 +55,11 @@ class Views::Shared::Pagination < Views::Base
|
|
|
53
55
|
# The STRING "false", not the boolean — Phlex omits false-valued attributes.
|
|
54
56
|
# And merge, don't splat: the anchor needs its own data attribute too.
|
|
55
57
|
def step(text, n, label:, rel: nil)
|
|
56
|
-
link_to text,
|
|
57
|
-
data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n }
|
|
58
|
+
link_to text, href(n), class: "join-item btn", rel:, "aria-label": label,
|
|
59
|
+
data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n },
|
|
60
|
+
fallback: @url ? true : nil)[:data])
|
|
58
61
|
end
|
|
62
|
+
|
|
63
|
+
# A real href when a url builder was given; a bare anchor otherwise.
|
|
64
|
+
def href(n) = @url ? "#{@url.(n)}#{@anchor}" : @anchor
|
|
59
65
|
end
|
data/lib/generators/hibiki/rails/scaffold_controller/templates/phlex/none/views/pagination.rb.tt
CHANGED
|
@@ -6,16 +6,18 @@
|
|
|
6
6
|
# frozen_string_literal: true
|
|
7
7
|
|
|
8
8
|
# Shared page control (--css=<%= css_variant %>), rendered above and below
|
|
9
|
-
# every scaffolded list.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# every scaffolded list. `url` is an optional ->(page) { path } builder:
|
|
10
|
+
# when given, links carry real hrefs (plus the fragment) and fallback — a
|
|
11
|
+
# dead island degrades to native navigation. Without it the links are plain
|
|
12
|
+
# anchors to the list, as before. data-turbo="false" keeps Turbo Drive out.
|
|
12
13
|
#
|
|
13
14
|
# Unstyled, so the current page changes ELEMENT (strong) — there is no class
|
|
14
15
|
# to carry the signal.
|
|
15
16
|
class Views::Shared::Pagination < Views::Base
|
|
16
17
|
<%= phlex_includes(:link_to, hibiki: true) %>
|
|
17
18
|
|
|
18
|
-
<%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"]
|
|
19
|
+
<%= initializer_source([%w[page 1], %w[page_count 1], %w[pages []], ["anchor"], ["id"],
|
|
20
|
+
%w[url nil]]) %>
|
|
19
21
|
|
|
20
22
|
def view_template
|
|
21
23
|
# The wrapper always renders, so idiomorph always has a node to pair with.
|
|
@@ -55,7 +57,11 @@ class Views::Shared::Pagination < Views::Base
|
|
|
55
57
|
# The STRING "false", not the boolean — Phlex omits false-valued attributes.
|
|
56
58
|
# And merge, don't splat: the anchor needs its own data attribute too.
|
|
57
59
|
def step(text, n, label:, rel: nil)
|
|
58
|
-
link_to text,
|
|
59
|
-
data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n }
|
|
60
|
+
link_to text, href(n), rel:, "aria-label": label,
|
|
61
|
+
data: { turbo: "false" }.merge(on(:go_to_page, with: { page: n },
|
|
62
|
+
fallback: @url ? true : nil)[:data])
|
|
60
63
|
end
|
|
64
|
+
|
|
65
|
+
# A real href when a url builder was given; a bare anchor otherwise.
|
|
66
|
+
def href(n) = @url ? "#{@url.(n)}#{@anchor}" : @anchor
|
|
61
67
|
end
|