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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2776ffca09f5d60a467d985550fb12c36570a5a22fd5eb7b0a54ee45c30a68e0
4
- data.tar.gz: 3a05960da6f7ab9d80a2f6574f6413491610314fd3917a62b48c48127b866830
3
+ metadata.gz: b661448cbe2b84a20e26f5cd0bffaf2cf7be8c755f0f1b9c692f77e9f5798430
4
+ data.tar.gz: b09c3b35032a6990ab08c17e424a147e9e667905a4f9e9378062d52583e213d6
5
5
  SHA512:
6
- metadata.gz: 29f7fc27b77dbb5e32e94440afc233da170b4095b77bac378426f834a47da38065b5b35881c63dcefe4e3c0053849f0fd2fd23d87d80708aebbec207fe765e93
7
- data.tar.gz: 5f85d166f5ff9dec3dc23a4d8e4aae9fbece2f53ee63b10a163622e799c77f6b989743f27a935db3c1915b9bb56c96332462b983ccf3fc0cd7ab9ea8f5477fa4
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 socket turned
242
- // out to be closed under a subscription still believed live.
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