hibiki_rails 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/app/assets/javascripts/hibiki.js +63 -3
- data/lib/generators/hibiki/rails/css_variant.rb +14 -2
- data/lib/generators/hibiki/rails/nested/USAGE +30 -0
- data/lib/generators/hibiki/rails/nested/nested_generator.rb +152 -0
- data/lib/generators/hibiki/rails/nested/templates/_fields.html.erb.tt +43 -0
- data/lib/generators/hibiki/rails/nested/templates/child_form.rb.tt +34 -0
- data/lib/generators/hibiki/rails/nested/templates/fields_component.rb.tt +70 -0
- data/lib/generators/hibiki/rails/nested/templates/position_migration.rb.tt +7 -0
- data/lib/generators/hibiki/rails/nested_helpers.rb +328 -0
- data/lib/generators/hibiki/rails/nested_injections.rb +641 -0
- data/lib/hibiki/rails/nested_actions.rb +95 -0
- data/lib/hibiki/rails/reactive_form.rb +172 -9
- data/lib/hibiki/rails/version.rb +1 -1
- data/lib/hibiki/rails.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b661448cbe2b84a20e26f5cd0bffaf2cf7be8c755f0f1b9c692f77e9f5798430
|
|
4
|
+
data.tar.gz: b09c3b35032a6990ab08c17e424a147e9e667905a4f9e9378062d52583e213d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc4013a0867b606341dd9af0135a82ab9e5a8c6b5f5cabd788eea48e0f3b604a708d31aa21c044f8639a5f2951242986b710d893b968246d03703359d562235b
|
|
7
|
+
data.tar.gz: d407a4c966366e5b11d9db6eaf7e789dcd4e086ca028dad0b2bc9b69bc4ec8f6b110339e338894b51851aa8ace18f08ef2339ce9198cf7a9226dc48d11b8c592
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ The gem and the npm package are released in lockstep and share these version
|
|
|
4
4
|
numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
|
|
5
5
|
so importmap and bundler apps always resolve identical client code.
|
|
6
6
|
|
|
7
|
+
## 0.9.0 — 2026-08-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
**`reactive_nested` — nested forms over `accepts_nested_attributes_for`.**
|
|
12
|
+
`reactive_nested :credits, "CreditForm"` on a ReactiveForm declares a signal
|
|
13
|
+
holding an array of child forms; the child class may itself declare
|
|
14
|
+
`reactive_nested`, so depth is composition — nothing counts levels. `#to_h`
|
|
15
|
+
serializes the tree as recursive `*_attributes` (with `id:` and `_destroy:`),
|
|
16
|
+
so `#commit` persists everything in the record's one save; a failed commit
|
|
17
|
+
distributes each child record's errors onto the matching child form, and
|
|
18
|
+
`dirty?` tracks child edits, adds, removes, and destroy-marks for free. New
|
|
19
|
+
instance API: `nested_add` / `nested_remove` (a persisted child is marked
|
|
20
|
+
`_destroy`, a new one leaves the array), `nested_key` (`"c<id>"` / `"n<seq>"`
|
|
21
|
+
— stable DOM identity across repaints), `mark_for_destruction`. The form
|
|
22
|
+
also unloads its nested associations after every commit attempt: a failed
|
|
23
|
+
save leaves AR-built children in the in-memory association, and without the
|
|
24
|
+
unload a later success would insert them twice.
|
|
25
|
+
|
|
26
|
+
**`Hibiki::Rails::NestedActions` — generic channel actions for nested
|
|
27
|
+
forms.** Opt-in include next to `Hibiki::Rails::Channel`: `nested_add`,
|
|
28
|
+
`nested_remove`, `nested_move` (reorder to an index among visible
|
|
29
|
+
siblings — with position stamped from array order, up/down controls are
|
|
30
|
+
all a reorderable list needs), and `nested_set_field`, addressing any node by a
|
|
31
|
+
`dom` + `path` payload (`"credits/c3/contributions/n1"` — association names
|
|
32
|
+
alternating with child keys, any depth). Every hop is gated against the form
|
|
33
|
+
classes' declarations, keys against live children, fields against
|
|
34
|
+
`hibiki_attributes`. The default form resolver understands the scaffold's
|
|
35
|
+
`@form`/`@editing_id` and `@new_form`/`@creating` ivars; override the private
|
|
36
|
+
`nested_form_for(dom)` for anything else. No client change — nested controls
|
|
37
|
+
name themselves `"#{path}/#{field}"` and ride the existing payload mechanics.
|
|
38
|
+
|
|
39
|
+
**`hibiki:rails:nested` — generate one parent→child edge of a nested form.**
|
|
40
|
+
`bin/rails g hibiki:rails:nested Song Credit`, then `... Credit Contribution`
|
|
41
|
+
for the next level — depth is composition, each run wires one edge. The child
|
|
42
|
+
model must exist and `belongs_to` the parent; attribute arguments only
|
|
43
|
+
reorder or subset what the schema already knows. Emits the child ReactiveForm
|
|
44
|
+
and a `_<child>_fields` partial (or Phlex component) with path-addressed
|
|
45
|
+
inputs, injects the ordered `has_many` + `accepts_nested_attributes_for`,
|
|
46
|
+
`reactive_nested`, the `NestedActions` include, preloads, and the classic
|
|
47
|
+
`fields_for` + `params.expect` degraded path into the full-page form. A
|
|
48
|
+
`position` column is detected for ordering (`--position=COLUMN` names one,
|
|
49
|
+
adding the migration; `--skip-position` opts out) and ordered edges get
|
|
50
|
+
up/down controls. Works against a scaffolded collection (root mode) or an
|
|
51
|
+
already-nested parent's fields partial (deep mode), on both view layers.
|
|
52
|
+
|
|
53
|
+
**`perform(action, payload)` is public API — and a `performOn` helper.**
|
|
54
|
+
The blessed seam for app JS (a drag library's drop handler, any third-party
|
|
55
|
+
widget) to fire actions on an island's graph through the island's OWN
|
|
56
|
+
subscription. Reach the controller instance with Stimulus's standard
|
|
57
|
+
`application.getControllerForElementAndIdentifier(islandEl, "hibiki")`, or
|
|
58
|
+
skip the incantation with the new export — `performOn(element, action,
|
|
59
|
+
payload)` finds the island containing `element` and performs through it,
|
|
60
|
+
Stimulus context not required. The return value is the contract: truthy (the
|
|
61
|
+
trip's sequence number) means the action was accepted — sent live, or queued
|
|
62
|
+
during the island's initial connect window — and a repaint is coming, so
|
|
63
|
+
leave the DOM as the user arranged it; `undefined` means it was dropped (the
|
|
64
|
+
island is offline, the socket turned out to be dead, or no island contains
|
|
65
|
+
the element) and the caller owns recovery: revert the gesture, or stand back
|
|
66
|
+
and let the next repaint self-heal. Nothing queues across an offline gap, on
|
|
67
|
+
purpose — a reconnect builds a fresh server-side graph, and replaying intent
|
|
68
|
+
formed against the old one is worse than dropping it. Hand-writing
|
|
69
|
+
`data-hibiki-*` attributes and subclassing `ChannelController` to reach an
|
|
70
|
+
existing island remain unsupported: the attributes are a private contract,
|
|
71
|
+
and a subclass opens a second subscription with a second graph nobody paints
|
|
72
|
+
from.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
**`perform` during an offline gap reported success while dropping the
|
|
77
|
+
payload.** Between a socket drop and the reconnect, `perform` returned the
|
|
78
|
+
trip's sequence number as if the action had been accepted, while sending
|
|
79
|
+
nothing and queueing nothing. Declared actions never noticed (the fallback
|
|
80
|
+
machinery gates on island state before performing), but with the return
|
|
81
|
+
value now public API the lie mattered: it returns `undefined` there, the
|
|
82
|
+
same dropped signal as a dead socket caught at send.
|
|
83
|
+
|
|
7
84
|
## 0.8.0 — 2026-08-15
|
|
8
85
|
|
|
9
86
|
### Added
|
|
@@ -80,6 +80,36 @@
|
|
|
80
80
|
// element entering the viewport). Everything that is not "which event"
|
|
81
81
|
// is a sibling attribute, so the token grammar never has to grow.
|
|
82
82
|
//
|
|
83
|
+
// App JS reaching the graph — the ONE public seam. A gesture that needs
|
|
84
|
+
// script (drag-and-drop, a third-party widget) fires its action through
|
|
85
|
+
// the island's OWN subscription: `perform(action, payload)` on the island
|
|
86
|
+
// controller instance is public API. Reach the instance with Stimulus's
|
|
87
|
+
// standard lookup —
|
|
88
|
+
//
|
|
89
|
+
// const islandEl = element.closest('[data-controller~="hibiki"]')
|
|
90
|
+
// const island = application.getControllerForElementAndIdentifier(islandEl, "hibiki")
|
|
91
|
+
// island?.perform("nested_move", { path, to })
|
|
92
|
+
//
|
|
93
|
+
// — or skip the incantation with the performOn export at the bottom of
|
|
94
|
+
// this file (works outside Stimulus too):
|
|
95
|
+
//
|
|
96
|
+
// import { performOn } from "hibiki-rails"
|
|
97
|
+
// performOn(element, "nested_move", { path, to })
|
|
98
|
+
//
|
|
99
|
+
// The return value is the whole contract: truthy (the trip's seq) means
|
|
100
|
+
// the action was ACCEPTED — sent live, or queued during the initial
|
|
101
|
+
// connect window — so a repaint is coming and the caller should leave the
|
|
102
|
+
// DOM as the user arranged it (the morph lands as a visual no-op). Falsy
|
|
103
|
+
// (undefined) means it was DROPPED — the island is offline, or the socket
|
|
104
|
+
// turned out to be dead at send — and the caller owns recovery: revert
|
|
105
|
+
// the gesture, or stand back and let the next repaint self-heal. Nothing
|
|
106
|
+
// queues across an offline gap on purpose (a reconnect builds a fresh
|
|
107
|
+
// server-side graph, so replayed intent would land on state it was not
|
|
108
|
+
// formed against). Everything else is NOT a seam: the data-hibiki-*
|
|
109
|
+
// attributes are private, and subclassing ChannelController to reach an
|
|
110
|
+
// existing island opens a SECOND subscription — a second server-side
|
|
111
|
+
// graph nobody paints from.
|
|
112
|
+
//
|
|
83
113
|
// Register the generic controller under the identifier "hibiki" (the
|
|
84
114
|
// helpers hardcode it):
|
|
85
115
|
//
|
|
@@ -93,6 +123,13 @@ import { createConsumer } from "@rails/actioncable"
|
|
|
93
123
|
// and go with the DOM, the socket stays.
|
|
94
124
|
let consumer
|
|
95
125
|
|
|
126
|
+
// Live islands by root element, for performOn's ancestor walk — membership
|
|
127
|
+
// here, not an attribute probe, so the helper couples to neither the
|
|
128
|
+
// identifier string nor the wire attributes. Generic islands only:
|
|
129
|
+
// ChannelController subclasses have `this.perform`. WeakMap so a removed
|
|
130
|
+
// island pins nothing even if disconnect never ran.
|
|
131
|
+
const islands = new WeakMap()
|
|
132
|
+
|
|
96
133
|
// camelCase Stimulus method name → snake_case Ruby channel action.
|
|
97
134
|
const underscore = (name) => name.replace(/([A-Z])/g, "_$1").toLowerCase()
|
|
98
135
|
|
|
@@ -238,8 +275,11 @@ export class ChannelController extends Controller {
|
|
|
238
275
|
// ActionCable's own Subscription#perform already writes `action`.)
|
|
239
276
|
//
|
|
240
277
|
// Returns the seq so a caller that knows which control fired can attach
|
|
241
|
-
// it; nobody has to. Returns undefined instead when the
|
|
242
|
-
// out to be closed under a subscription still
|
|
278
|
+
// it; nobody has to. Returns undefined instead when the payload went
|
|
279
|
+
// nowhere: the socket turned out to be closed under a subscription still
|
|
280
|
+
// believed live, or the island was already offline. Public API on the
|
|
281
|
+
// island controller (the header's "App JS reaching the graph") — truthy
|
|
282
|
+
// = accepted, falsy = dropped and the caller owns recovery.
|
|
243
283
|
perform(action, payload = {}) {
|
|
244
284
|
const seq = ++this.seq
|
|
245
285
|
payload.hbk = seq
|
|
@@ -275,8 +315,11 @@ export class ChannelController extends Controller {
|
|
|
275
315
|
if (!this.connectedOnce) {
|
|
276
316
|
this.beginBusy(seq)
|
|
277
317
|
this.queued.push([action, payload])
|
|
318
|
+
return seq
|
|
278
319
|
}
|
|
279
|
-
return seq
|
|
320
|
+
// The offline gap: dropped, and the return says so — a truthy seq here
|
|
321
|
+
// would tell a public caller a repaint is coming when none is.
|
|
322
|
+
return undefined
|
|
280
323
|
}
|
|
281
324
|
|
|
282
325
|
// ActionCable's `connected`, i.e. the server confirmed the subscription.
|
|
@@ -517,6 +560,7 @@ export default class HibikiController extends ChannelController {
|
|
|
517
560
|
}
|
|
518
561
|
|
|
519
562
|
async connect() {
|
|
563
|
+
islands.set(this.element, this)
|
|
520
564
|
// Before the listeners, not after: a click delegated in the next
|
|
521
565
|
// millisecond reaches perform(), which needs the busy map and the
|
|
522
566
|
// queue to exist. This is also what stamps data-hibiki-state
|
|
@@ -580,6 +624,7 @@ export default class HibikiController extends ChannelController {
|
|
|
580
624
|
}
|
|
581
625
|
|
|
582
626
|
disconnect() {
|
|
627
|
+
islands.delete(this.element)
|
|
583
628
|
for (const [type, handler] of this.listeners) {
|
|
584
629
|
this.element.removeEventListener(type, handler)
|
|
585
630
|
}
|
|
@@ -726,6 +771,21 @@ export default class HibikiController extends ChannelController {
|
|
|
726
771
|
|
|
727
772
|
export { HibikiController }
|
|
728
773
|
|
|
774
|
+
// The public seam without the Stimulus lookup (the header's "App JS
|
|
775
|
+
// reaching the graph"): fire an action through the subscription of the
|
|
776
|
+
// island CONTAINING element. Same return contract as perform — the seq
|
|
777
|
+
// when accepted, undefined when dropped or when no island contains the
|
|
778
|
+
// element (a structural mistake, hence the warn; the offline case stays
|
|
779
|
+
// quiet because it is expected weather).
|
|
780
|
+
export function performOn(element, action, payload = {}) {
|
|
781
|
+
for (let node = element; node; node = node.parentElement) {
|
|
782
|
+
const island = islands.get(node)
|
|
783
|
+
if (island) return island.perform(action, payload)
|
|
784
|
+
}
|
|
785
|
+
console.warn("hibiki: performOn found no island containing", element)
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
|
|
729
789
|
// The Turbo-broadcast race helper the base uses internally, still
|
|
730
790
|
// exported for custom (non-Stimulus) clients: resolves once Turbo stamps
|
|
731
791
|
// the `connected` attribute on the given <turbo-cable-stream-source>.
|
|
@@ -113,7 +113,15 @@ module Hibiki
|
|
|
113
113
|
option_label: "label cursor-pointer justify-start gap-2",
|
|
114
114
|
option_note: "p-2 opacity-60 italic",
|
|
115
115
|
checkbox_sm: "checkbox checkbox-sm",
|
|
116
|
-
filter_input: "input input-sm w-full mb-2"
|
|
116
|
+
filter_input: "input input-sm w-full mb-2",
|
|
117
|
+
|
|
118
|
+
# The nested fieldset (hibiki:rails:nested): one bordered card per
|
|
119
|
+
# child row, a legend on the fieldset, and the small row controls
|
|
120
|
+
# (↑/↓/Remove/Add).
|
|
121
|
+
fieldset_legend: "fieldset-legend px-2",
|
|
122
|
+
nested_row: "border border-base-300 rounded-box p-3 mb-2 space-y-2",
|
|
123
|
+
btn_ghost_sm: "btn btn-sm btn-ghost",
|
|
124
|
+
btn_outline_sm: "btn btn-sm btn-outline"
|
|
117
125
|
}.freeze
|
|
118
126
|
|
|
119
127
|
# DaisyUI is a plugin over Tailwind, and the merge says so literally:
|
|
@@ -167,7 +175,11 @@ module Hibiki
|
|
|
167
175
|
option_label: "flex cursor-pointer items-center gap-2 py-1 text-sm",
|
|
168
176
|
option_note: "p-2 text-gray-500 italic",
|
|
169
177
|
checkbox_sm: CHECKBOX,
|
|
170
|
-
filter_input: "#{FIELD_FULL} mb-2 text-sm"
|
|
178
|
+
filter_input: "#{FIELD_FULL} mb-2 text-sm",
|
|
179
|
+
fieldset_legend: "px-2 text-sm font-medium text-gray-700",
|
|
180
|
+
nested_row: "rounded-md border border-gray-200 p-3 mb-2 space-y-2",
|
|
181
|
+
btn_ghost_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1"),
|
|
182
|
+
btn_outline_sm: SECONDARY_BUTTON.sub("px-3 py-2", "px-2 py-1")
|
|
171
183
|
).freeze
|
|
172
184
|
|
|
173
185
|
# Every lookup misses, so every class argument is omitted entirely.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Wires one parent→child edge of a nested form onto a resource that
|
|
3
|
+
hibiki:rails:scaffold_controller already generated: the child's
|
|
4
|
+
ReactiveForm, a _<child>_fields partial with path-addressed inputs
|
|
5
|
+
(riding the generic Hibiki::Rails::NestedActions channel actions),
|
|
6
|
+
reactive_nested on the parent form, model wiring (has_many +
|
|
7
|
+
accepts_nested_attributes_for allow_destroy), the classic fields_for
|
|
8
|
+
fallback in the full-page form, and the controller's params.expect
|
|
9
|
+
double-array group.
|
|
10
|
+
|
|
11
|
+
Parent and Child models must exist and be migrated, and Child must
|
|
12
|
+
declare belongs_to :<parent>. An optional field list narrows or orders
|
|
13
|
+
the child's form fields; the facts stay the schema's.
|
|
14
|
+
|
|
15
|
+
Depth is composition: run once per edge. A run whose Parent is itself a
|
|
16
|
+
nested child (its _<parent>_fields partial exists) nests into that
|
|
17
|
+
partial instead of the row form.
|
|
18
|
+
|
|
19
|
+
Ordering: a `position` column on the child (or --position=COLUMN) makes
|
|
20
|
+
the fieldset ordered — the has_many gains an order scope, the parent
|
|
21
|
+
form stamps positions from array order, and rows get ↑/↓ buttons. The
|
|
22
|
+
named column is added by migration when missing; --skip-position opts
|
|
23
|
+
out entirely.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
bin/rails g hibiki:rails:nested Song Credit
|
|
27
|
+
|
|
28
|
+
bin/rails g hibiki:rails:nested Credit Contribution
|
|
29
|
+
|
|
30
|
+
bin/rails g hibiki:rails:nested Song Credit artist:references role:string --position=rank
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/resource_helpers"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
require_relative "../generator_helpers"
|
|
7
|
+
require_relative "../scaffold_helpers"
|
|
8
|
+
require_relative "../scaffold_view_helpers"
|
|
9
|
+
require_relative "../scaffold_phlex_helpers"
|
|
10
|
+
require_relative "../scaffold_model_injection"
|
|
11
|
+
require_relative "../scaffold_schema"
|
|
12
|
+
require_relative "../css_variant"
|
|
13
|
+
require_relative "../nested_helpers"
|
|
14
|
+
require_relative "../nested_injections"
|
|
15
|
+
|
|
16
|
+
module Hibiki
|
|
17
|
+
module Rails
|
|
18
|
+
module Generators
|
|
19
|
+
# One parent→child edge of a nested form, onto a resource
|
|
20
|
+
# hibiki:rails:scaffold_controller already generated: the child's
|
|
21
|
+
# ReactiveForm and fields partial (path-addressed inputs riding the
|
|
22
|
+
# generic Hibiki::Rails::NestedActions), reactive_nested on the parent
|
|
23
|
+
# form, the model wiring, the classic fields_for fallback in the
|
|
24
|
+
# full-page form, and the controller's params.expect double-array group.
|
|
25
|
+
#
|
|
26
|
+
# Depth is composition — run once per edge. A run whose parent is
|
|
27
|
+
# itself a nested child injects into the parent's own fields partial
|
|
28
|
+
# instead of the row form, and nests every other wiring one level
|
|
29
|
+
# deeper.
|
|
30
|
+
class NestedGenerator < ::Rails::Generators::NamedBase
|
|
31
|
+
include ::Rails::Generators::ResourceHelpers
|
|
32
|
+
include ::ActiveRecord::Generators::Migration
|
|
33
|
+
include GeneratorHelpers
|
|
34
|
+
include ScaffoldHelpers
|
|
35
|
+
include ScaffoldViewHelpers
|
|
36
|
+
include ScaffoldPhlexHelpers
|
|
37
|
+
include ScaffoldModelInjection
|
|
38
|
+
include NestedHelpers
|
|
39
|
+
include NestedInjections
|
|
40
|
+
|
|
41
|
+
source_root File.expand_path("templates", __dir__)
|
|
42
|
+
|
|
43
|
+
desc "Wires one parent→child edge of a nested form (reactive_nested " \
|
|
44
|
+
"+ the generic nested_* actions) onto a hibiki:rails scaffold."
|
|
45
|
+
|
|
46
|
+
argument :child, type: :string, banner: "Child"
|
|
47
|
+
argument :attributes, type: :array, default: [], banner: "field[:type] field[:type]"
|
|
48
|
+
|
|
49
|
+
class_option :css, type: :string, enum: CssVariant::NAMES,
|
|
50
|
+
desc: "Markup variant for the generated view (default: detect)"
|
|
51
|
+
class_option :position, type: :string,
|
|
52
|
+
desc: "Order the rows by this child column — added by migration " \
|
|
53
|
+
"when missing (default: a column named position, when present)"
|
|
54
|
+
class_option :skip_position, type: :boolean, default: false,
|
|
55
|
+
desc: "No ordering, even when the child has a position column"
|
|
56
|
+
class_option :phlex, type: :boolean,
|
|
57
|
+
desc: "Emit a Phlex fields component (default: detect from the scaffold)"
|
|
58
|
+
|
|
59
|
+
# Everything that can refuse, before anything is written.
|
|
60
|
+
def preflight
|
|
61
|
+
check_wireable!
|
|
62
|
+
warn_without_phlex_rails
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def create_child_form
|
|
66
|
+
template "child_form.rb.tt", child_form_path
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_fields_view
|
|
70
|
+
if phlex?
|
|
71
|
+
template "fields_component.rb.tt", fields_view_target
|
|
72
|
+
else
|
|
73
|
+
template "_fields.html.erb.tt", fields_view_target
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def create_position_migration
|
|
78
|
+
return unless (@position_migration = position_migration_needed?)
|
|
79
|
+
|
|
80
|
+
migration_template "position_migration.rb.tt",
|
|
81
|
+
"db/migrate/add_#{position_column}_to_#{child_table_name}.rb"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wire_model
|
|
85
|
+
inject_parent_model
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def wire_form
|
|
89
|
+
inject_form_nested
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# BEFORE the fieldset injection: the threading guards probe for each
|
|
93
|
+
# options local's name, and the injected render line would satisfy
|
|
94
|
+
# them vacuously.
|
|
95
|
+
def wire_channel
|
|
96
|
+
inject_channel_include
|
|
97
|
+
inject_edit_preload
|
|
98
|
+
inject_option_collections
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def wire_views
|
|
102
|
+
inject_container_fieldset
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def wire_page_form
|
|
106
|
+
inject_page_form
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def wire_controller
|
|
110
|
+
inject_permitted_params
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def post_install
|
|
114
|
+
if @position_migration
|
|
115
|
+
say_status :migrate, "#{child_table_name}.#{position_column} was added — " \
|
|
116
|
+
"run bin/rails db:migrate before using the fieldset.", :yellow
|
|
117
|
+
end
|
|
118
|
+
schema.belongs_tos.each do |column|
|
|
119
|
+
say_status :assoc, "using #{column.association_class_name}##{column.label_column} as " \
|
|
120
|
+
"the #{column.human_name.downcase} option label — edit " \
|
|
121
|
+
"@#{column.options_local} in #{resource_channel_path} if that's wrong", :blue
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# The scaffold's own view layer, read from what it left on disk; an
|
|
128
|
+
# explicit --phlex wins. Deep mode reads it off the parent's fields
|
|
129
|
+
# partial instead of the row form.
|
|
130
|
+
def phlex?
|
|
131
|
+
return @phlex if defined?(@phlex)
|
|
132
|
+
|
|
133
|
+
@phlex = if !options[:phlex].nil?
|
|
134
|
+
options[:phlex]
|
|
135
|
+
elsif root_mode?
|
|
136
|
+
exists?(view_path("row_form.rb"))
|
|
137
|
+
else
|
|
138
|
+
parent_fields_files.any? { it.end_with?(".rb") } &&
|
|
139
|
+
parent_fields_files.none? { it.end_with?(".html.erb") }
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def css_variant
|
|
144
|
+
@css_variant ||= (options[:css] || CssVariant.detect(destination_root)).to_sym
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def css(token) = CssVariant.token(css_variant, token)
|
|
148
|
+
def css? = css_variant != :none
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<%#- ESCAPING: <%% emits runtime ERB into the generated view; a bare <% loops
|
|
2
|
+
over the schema at generation time. #{...} inside an escaped tag is
|
|
3
|
+
runtime Ruby and passes through untouched. -%>
|
|
4
|
+
<%%# locals: (<%= child_singular %>:, dom:, path:<%= schema.belongs_tos.map { ", #{it.options_local}: []" }.join %>, index: 0, count: 1) -%>
|
|
5
|
+
<%%# One <%= child_human_singular.downcase %> row. `path` addresses this node for the nested_* actions;
|
|
6
|
+
input names ride "#{path}/#{field}", so a submit's FormData can never
|
|
7
|
+
collide with the parent form's own attribute names. %>
|
|
8
|
+
<%% dom_path = "#{dom}_#{path.tr('/', '_')}" %>
|
|
9
|
+
<div id="<%%= dom_path %>"<%= css_attr(:nested_row) %>>
|
|
10
|
+
<% schema.columns.each do |column| -%>
|
|
11
|
+
<% if column.type == :boolean -%>
|
|
12
|
+
<div<%= css_attr(:checkbox_wrap) %>>
|
|
13
|
+
<%%= label_tag "#{dom_path}_<%= column.name %>"<%= css? ? %(, class: "#{css(:label)}") : "" %> do %>
|
|
14
|
+
<%%= check_box_tag <%= wrapped_list(nested_toggle_args(column), indent: 24).lstrip %> %>
|
|
15
|
+
<%= column.human_name %>
|
|
16
|
+
<%% end %>
|
|
17
|
+
</div>
|
|
18
|
+
<% elsif column.belongs_to? -%>
|
|
19
|
+
<%%= label_tag "#{dom_path}_<%= column.name %>", "<%= column.human_name %>"<%= css? ? %(, class: "#{css(:label)}") : "" %> %>
|
|
20
|
+
<%%= select_tag <%= wrapped_list(nested_select_args(column), indent: 17).lstrip %> %>
|
|
21
|
+
<%%= render "shared/field_error", message: <%= child_singular %>.error_for(:<%= column.name %>) %>
|
|
22
|
+
<% else -%>
|
|
23
|
+
<%%= label_tag "#{dom_path}_<%= column.name %>", "<%= column.human_name %>"<%= css? ? %(, class: "#{css(:label)}") : "" %> %>
|
|
24
|
+
<%%= <%= column.tag_field_helper %> <%= wrapped_list(nested_field_args(column), indent: column.tag_field_helper.length + 7).lstrip %> %>
|
|
25
|
+
<%%= render "shared/field_error", message: <%= child_singular %>.error_for(:<%= column.name %>) %>
|
|
26
|
+
<% end -%>
|
|
27
|
+
|
|
28
|
+
<% end -%>
|
|
29
|
+
<div<%= css_attr(:form_actions) %>>
|
|
30
|
+
<% if ordered? -%>
|
|
31
|
+
<%%# Reorder: `to` is the index among visible rows — <%= position_column %>
|
|
32
|
+
persists from array order at save. %>
|
|
33
|
+
<%% if index.positive? %>
|
|
34
|
+
<%%= tag.button("↑", <%= wrapped_list(nested_move_args(:up), indent: 21).lstrip %>) %>
|
|
35
|
+
<%% end %>
|
|
36
|
+
<%% if index < count - 1 %>
|
|
37
|
+
<%%= tag.button("↓", <%= wrapped_list(nested_move_args(:down), indent: 21).lstrip %>) %>
|
|
38
|
+
<%% end %>
|
|
39
|
+
<% end -%>
|
|
40
|
+
<%%= tag.button("Remove <%= child_human_singular.downcase %>", type: "button"<%= css? ? %(, class: "#{css(:btn_ghost_sm)}") : "" %>,
|
|
41
|
+
**on(:nested_remove, with: { dom: dom, path: path })) %>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# GENERATION-time ERB only. -%>
|
|
2
|
+
class <%= child_form_class_name %>
|
|
3
|
+
include Hibiki::Rails::ReactiveForm
|
|
4
|
+
|
|
5
|
+
# The strong-params analogue: a channel action cannot assign past this list.
|
|
6
|
+
# Writers cast through the model's own type_for_attribute.
|
|
7
|
+
<% if ordered? -%>
|
|
8
|
+
# <%= position_column %> is deliberately absent: the parent form's to_h
|
|
9
|
+
# stamps it from the array order, so the visual order IS the ordering.
|
|
10
|
+
<% end -%>
|
|
11
|
+
reactive_attributes <%= child_class_name %>, <%= wrapped_symbols(schema.attribute_names, indent: child_class_name.length + 24).lstrip %>
|
|
12
|
+
|
|
13
|
+
<% if schema.live_errors.any? -%>
|
|
14
|
+
# Live validation before any round trip, derived from what the model
|
|
15
|
+
# declares. The model's own validates stay authoritative at commit time.
|
|
16
|
+
derived(:live_errors) do
|
|
17
|
+
{
|
|
18
|
+
<% schema.live_errors.each_with_index do |(name, clause), index| -%>
|
|
19
|
+
<%= name %>: <%= clause %><%= "," unless index == schema.live_errors.size - 1 %>
|
|
20
|
+
<% end -%>
|
|
21
|
+
}.compact
|
|
22
|
+
end
|
|
23
|
+
<% else -%>
|
|
24
|
+
# Empty until the model declares validators — add clauses here (or
|
|
25
|
+
# validators on the model) and they light up with no other change.
|
|
26
|
+
derived(:live_errors) { {} }
|
|
27
|
+
<% end -%>
|
|
28
|
+
|
|
29
|
+
derived(:valid?) { live_errors.empty? }
|
|
30
|
+
|
|
31
|
+
# The record's mirrored errors win once a commit has actually failed. Live
|
|
32
|
+
# errors wait for dirty? — a freshly added row must not paint them.
|
|
33
|
+
def error_for(name) = super || (live_errors[name.to_sym] if dirty?)
|
|
34
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<%#- ESCAPING: none — plain Ruby out, every <% is generation time. #{...}
|
|
2
|
+
inside a quoted string is runtime Ruby and passes through untouched. -%>
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# One <%= child_human_singular.downcase %> row of the nested fieldset. `@path` addresses this node
|
|
6
|
+
# for the nested_* actions; input names ride "#{path}/#{field}", so a submit's
|
|
7
|
+
# FormData can never collide with the parent form's own attribute names.
|
|
8
|
+
class <%= fields_component_class_name %> < Views::Base
|
|
9
|
+
<%= phlex_includes(:label_tag, *row_field_helper_modules,
|
|
10
|
+
*(%i[select_tag options_for_select] if schema.belongs_tos.any?), hibiki: true) %>
|
|
11
|
+
|
|
12
|
+
<%= initializer_source([[child_singular, nil], %w[dom], %w[path],
|
|
13
|
+
*schema.belongs_tos.map { [it.options_local.to_s, "[]"] },
|
|
14
|
+
%w[index 0], %w[count 1]]) %>
|
|
15
|
+
|
|
16
|
+
def view_template
|
|
17
|
+
div(id: dom_path<%= css? ? %(, class: "#{css(:nested_row)}") : "" %>) do
|
|
18
|
+
<% schema.columns.each do |column| -%>
|
|
19
|
+
<%= column.name %>_field
|
|
20
|
+
<% end -%>
|
|
21
|
+
controls
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def dom_path = "#{@dom}_#{@path.tr('/', '_')}"
|
|
28
|
+
|
|
29
|
+
<% schema.columns.each do |column| -%>
|
|
30
|
+
def <%= column.name %>_field
|
|
31
|
+
<% if column.type == :boolean -%>
|
|
32
|
+
div<%= arg_list(*css_args(:checkbox_wrap)) %> do
|
|
33
|
+
label_tag<%= arg_list(%("\#{dom_path}_#{column.name}"), *css_args(:label)) %> do
|
|
34
|
+
check_box_tag <%= wrapped_list(nested_toggle_args(column), indent: 22).lstrip %>
|
|
35
|
+
whitespace
|
|
36
|
+
plain "<%= column.human_name %>"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
<% elsif column.belongs_to? -%>
|
|
40
|
+
label_tag <%= wrapped_list([%("\#{dom_path}_#{column.name}"), %("#{column.human_name}"), *css_args(:label)], indent: 14).lstrip %>
|
|
41
|
+
# options_for_select outputs directly, so the options are a block here —
|
|
42
|
+
# phlex-rails' own prescription.
|
|
43
|
+
select_tag<%= arg_list(*nested_select_head_args(column), indent: 15) %> do
|
|
44
|
+
<%= nested_select_options_source(column) %>
|
|
45
|
+
end
|
|
46
|
+
<%= render_call(:field_error, [%(message: #{nested_record_ref}.error_for(:#{column.name}))], indent: 4, shared: true) %>
|
|
47
|
+
<% else -%>
|
|
48
|
+
label_tag <%= wrapped_list([%("\#{dom_path}_#{column.name}"), %("#{column.human_name}"), *css_args(:label)], indent: 14).lstrip %>
|
|
49
|
+
<%= column.tag_field_helper %> <%= wrapped_list(nested_field_args(column), indent: column.tag_field_helper.length + 5).lstrip %>
|
|
50
|
+
<%= render_call(:field_error, [%(message: #{nested_record_ref}.error_for(:#{column.name}))], indent: 4, shared: true) %>
|
|
51
|
+
<% end -%>
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
<% end -%>
|
|
55
|
+
def controls
|
|
56
|
+
div<%= arg_list(*css_args(:form_actions)) %> do
|
|
57
|
+
<% if ordered? -%>
|
|
58
|
+
# Reorder: `to` is the index among visible rows — <%= position_column %>
|
|
59
|
+
# persists from array order at save.
|
|
60
|
+
if @index.positive?
|
|
61
|
+
button<%= arg_list(*nested_move_args(:up), indent: 15) %> { "↑" }
|
|
62
|
+
end
|
|
63
|
+
if @index < @count - 1
|
|
64
|
+
button<%= arg_list(*nested_move_args(:down), indent: 15) %> { "↓" }
|
|
65
|
+
end
|
|
66
|
+
<% end -%>
|
|
67
|
+
button<%= arg_list(%(type: "button"), *css_args(:btn_ghost_sm), "**on(:nested_remove, with: { dom: @dom, path: @path })", indent: 13) %> { "Remove <%= child_human_singular.downcase %>" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# GENERATION-time ERB only. -%>
|
|
2
|
+
class Add<%= position_column.camelize %>To<%= child_table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
3
|
+
def change
|
|
4
|
+
# Ordered nested rows: the parent form stamps this from array order.
|
|
5
|
+
add_column :<%= child_table_name %>, :<%= position_column %>, :integer
|
|
6
|
+
end
|
|
7
|
+
end
|