plutonium 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +55 -0
  4. data/.claude/skills/plutonium-ui/SKILL.md +2 -1
  5. data/CHANGELOG.md +14 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +18 -0
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +30 -30
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  12. data/docs/public/images/reference/structured-inputs.png +0 -0
  13. data/docs/reference/resource/definition.md +110 -0
  14. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  15. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  16. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  17. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  18. data/lib/plutonium/definition/base.rb +1 -0
  19. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  20. data/lib/plutonium/interaction/README.md +24 -78
  21. data/lib/plutonium/interaction/base.rb +10 -2
  22. data/lib/plutonium/resource/controller.rb +6 -1
  23. data/lib/plutonium/resource/controllers/interactive_actions.rb +10 -6
  24. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  25. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  26. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +3 -3
  27. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +178 -0
  28. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  29. data/lib/plutonium/ui/form/resource.rb +4 -1
  30. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  31. data/lib/plutonium/version.rb +1 -1
  32. data/package.json +1 -1
  33. data/src/css/components.css +10 -5
  34. data/src/js/controllers/register_controllers.js +2 -0
  35. data/src/js/controllers/structured_input_row_controller.js +26 -0
  36. metadata +14 -5
  37. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  38. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -0,0 +1,1061 @@
1
+ # Structured Inputs Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a classless `structured_input` DSL — a group of fields collected as a hash (single) or array of hashes (`repeat:`) — usable on interactions (→ attribute) and resource definitions (→ JSON column), and remove the broken `nested_input`/`accepts_nested_attributes_for` surface from interactions.
6
+
7
+ **Architecture:** A shared `Plutonium::Definition::StructuredInputs` registry DSL is mixed into both `Definition::Base` (resources) and `Interaction::Base`. Rendering rides phlexi-form's `nest_one`/`nest_many` over plain hashes (no per-row class); params are extracted by the existing form-driven `extract_input` path and run through a shared `ParamCleaner` before assignment. On resources the value lands in a JSON column with `as: :<name>` (no model macro); on interactions `structured_input` also declares an ActiveModel `attribute`.
8
+
9
+ **Tech Stack:** Ruby, Rails, phlexi-form (`nest_one`/`nest_many`, `extract_input`), Phlex, ActiveModel::Attributes, Minitest + Appraisal.
10
+
11
+ **User Verification:** NO — no user verification required (feature build; verified by automated tests).
12
+
13
+ **Spec:** `docs/superpowers/specs/2026-06-01-structured-inputs-design.md`
14
+
15
+ **Run tests with:** `bundle exec appraisal rails-8.1 ruby -Itest <file>` (single file) or `bundle exec appraisal rails-8.1 rake test` (full).
16
+
17
+ ---
18
+
19
+ ## Task 0: Shared `structured_input` DSL + registry
20
+
21
+ **Goal:** A shared concern providing the `structured_input` class DSL, a `defined_structured_inputs` registry (inherited by subclasses), and a fields-definition holder; mixed into resource definitions.
22
+
23
+ **Files:**
24
+ - Create: `lib/plutonium/definition/structured_inputs.rb`
25
+ - Modify: `lib/plutonium/definition/base.rb` (add `include StructuredInputs`)
26
+ - Test: `test/plutonium/definition/structured_inputs_test.rb`
27
+
28
+ **Acceptance Criteria:**
29
+ - [ ] `structured_input :addr do |f| f.input :street end` registers an entry in `defined_structured_inputs[:addr]` with the block.
30
+ - [ ] The fields holder built from the block exposes the declared inputs via `defined_inputs`.
31
+ - [ ] `repeat:` and `limit:`/`fields:`/`using:` options are captured under `[:options]`.
32
+ - [ ] Subclasses inherit parent `defined_structured_inputs`.
33
+
34
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/structured_inputs_test.rb` → all pass.
35
+
36
+ **Steps:**
37
+
38
+ - [ ] **Step 1: Write the failing test**
39
+
40
+ ```ruby
41
+ # test/plutonium/definition/structured_inputs_test.rb
42
+ # frozen_string_literal: true
43
+
44
+ require "test_helper"
45
+
46
+ class Plutonium::Definition::StructuredInputsTest < Minitest::Test
47
+ def build_definition(&block)
48
+ Class.new(Plutonium::Definition::Base, &block)
49
+ end
50
+
51
+ def test_registers_a_structured_input_with_its_block
52
+ klass = build_definition do
53
+ structured_input :address do |f|
54
+ f.input :street
55
+ f.input :city
56
+ end
57
+ end
58
+
59
+ entry = klass.defined_structured_inputs[:address]
60
+ refute_nil entry
61
+ assert_kind_of Proc, entry[:block]
62
+ end
63
+
64
+ def test_captures_repeat_and_limit_options
65
+ klass = build_definition do
66
+ structured_input :contacts, repeat: 10 do |f|
67
+ f.input :label
68
+ end
69
+ end
70
+
71
+ assert_equal 10, klass.defined_structured_inputs[:contacts][:options][:repeat]
72
+ end
73
+
74
+ def test_fields_holder_exposes_declared_inputs
75
+ klass = build_definition do
76
+ structured_input :address do |f|
77
+ f.input :street
78
+ f.input :city
79
+ end
80
+ end
81
+
82
+ holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
83
+ klass.defined_structured_inputs[:address][:block].call(holder)
84
+ assert_equal %i[street city], holder.defined_inputs.keys
85
+ end
86
+
87
+ def test_subclasses_inherit_registry
88
+ parent = build_definition do
89
+ structured_input(:a) { |f| f.input :x }
90
+ end
91
+ child = Class.new(parent)
92
+ assert child.defined_structured_inputs.key?(:a)
93
+ end
94
+ end
95
+ ```
96
+
97
+ - [ ] **Step 2: Run test to verify it fails**
98
+
99
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/structured_inputs_test.rb`
100
+ Expected: FAIL — `NoMethodError: undefined method 'structured_input'`.
101
+
102
+ - [ ] **Step 3: Write the module**
103
+
104
+ ```ruby
105
+ # lib/plutonium/definition/structured_inputs.rb
106
+ # frozen_string_literal: true
107
+
108
+ module Plutonium
109
+ module Definition
110
+ # Classless structured inputs: a group of fields collected into a hash
111
+ # (single) or an array of hashes (when `repeat:` is given). Mixed into both
112
+ # resource definitions and interactions.
113
+ #
114
+ # @example
115
+ # structured_input :address do |f|
116
+ # f.input :street
117
+ # f.input :city
118
+ # end
119
+ #
120
+ # structured_input :contacts, repeat: 10 do |f|
121
+ # f.input :label
122
+ # f.input :phone_number
123
+ # end
124
+ module StructuredInputs
125
+ extend ActiveSupport::Concern
126
+
127
+ # Holder built per render from a structured_input block. Reuses the same
128
+ # field/input DSL as the rest of Plutonium definitions.
129
+ class FieldsDefinition
130
+ include Plutonium::Definition::DefineableProps
131
+
132
+ defineable_props :field, :input
133
+ end
134
+
135
+ class_methods do
136
+ # @param name [Symbol]
137
+ # @option options [Integer, true] :repeat presence ⇒ array; Integer ⇒ max rows
138
+ # @option options [Class] :using a FieldsDefinition-like class instead of a block
139
+ # @option options [Array<Symbol>] :fields restrict rendered fields
140
+ def structured_input(name, **options, &block)
141
+ defined_structured_inputs[name] = {options:, block:}.compact
142
+ end
143
+
144
+ def defined_structured_inputs
145
+ @defined_structured_inputs ||= {}
146
+ end
147
+
148
+ def inherited(subclass)
149
+ super
150
+ subclass.instance_variable_set(
151
+ :@defined_structured_inputs,
152
+ defined_structured_inputs.deep_dup
153
+ )
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ ```
160
+
161
+ - [ ] **Step 4: Wire into resource definitions**
162
+
163
+ In `lib/plutonium/definition/base.rb`, add the include next to the other definition concerns (after `include NestedInputs`):
164
+
165
+ ```ruby
166
+ include NestedInputs
167
+ include StructuredInputs
168
+ ```
169
+
170
+ - [ ] **Step 5: Run test to verify it passes**
171
+
172
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/structured_inputs_test.rb`
173
+ Expected: PASS (4 runs).
174
+
175
+ - [ ] **Step 6: Commit**
176
+
177
+ ```bash
178
+ git add lib/plutonium/definition/structured_inputs.rb lib/plutonium/definition/base.rb test/plutonium/definition/structured_inputs_test.rb
179
+ git commit -m "feat(definition): add classless structured_input DSL + registry"
180
+ ```
181
+
182
+ ```json:metadata
183
+ {"files": ["lib/plutonium/definition/structured_inputs.rb", "lib/plutonium/definition/base.rb", "test/plutonium/definition/structured_inputs_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/structured_inputs_test.rb", "acceptanceCriteria": ["structured_input registers in defined_structured_inputs with block", "fields holder exposes declared inputs", "repeat/limit options captured", "subclasses inherit registry"], "requiresUserVerification": false}
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Task 1: Shared param cleaner
189
+
190
+ **Goal:** A pure function that turns submitted structured-input params into the stored value — a hash (single) or a cleaned array of hashes (repeat: drop all-blank rows, strip `_destroy`).
191
+
192
+ **Files:**
193
+ - Create: `lib/plutonium/structured_inputs/param_cleaner.rb`
194
+ - Test: `test/plutonium/structured_inputs/param_cleaner_test.rb`
195
+
196
+ **Acceptance Criteria:**
197
+ - [ ] Single (`repeat:` false): passes the hash through (symbolized), `_destroy` stripped.
198
+ - [ ] Repeater (`repeat:` truthy): normalizes an `Array` or index-keyed `Hash` to an array, drops all-blank rows, strips `_destroy`, returns positional array (no ids).
199
+ - [ ] `nil`/blank input → `{}` (single) or `[]` (repeater).
200
+
201
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/param_cleaner_test.rb` → all pass.
202
+
203
+ **Steps:**
204
+
205
+ - [ ] **Step 1: Write the failing test**
206
+
207
+ ```ruby
208
+ # test/plutonium/structured_inputs/param_cleaner_test.rb
209
+ # frozen_string_literal: true
210
+
211
+ require "test_helper"
212
+
213
+ class Plutonium::StructuredInputs::ParamCleanerTest < Minitest::Test
214
+ Cleaner = Plutonium::StructuredInputs::ParamCleaner
215
+
216
+ def test_single_passes_hash_through
217
+ assert_equal({street: "1 A St", city: "Town"},
218
+ Cleaner.call({"street" => "1 A St", "city" => "Town"}, repeat: false))
219
+ end
220
+
221
+ def test_single_strips_destroy
222
+ assert_equal({street: "x"}, Cleaner.call({"street" => "x", "_destroy" => "1"}, repeat: false))
223
+ end
224
+
225
+ def test_single_blank_returns_empty_hash
226
+ assert_equal({}, Cleaner.call(nil, repeat: false))
227
+ end
228
+
229
+ def test_repeater_normalizes_array
230
+ input = [{"label" => "a"}, {"label" => "b"}]
231
+ assert_equal [{label: "a"}, {label: "b"}], Cleaner.call(input, repeat: true)
232
+ end
233
+
234
+ def test_repeater_normalizes_index_keyed_hash
235
+ input = {"0" => {"label" => "a"}, "1" => {"label" => "b"}}
236
+ assert_equal [{label: "a"}, {label: "b"}], Cleaner.call(input, repeat: true)
237
+ end
238
+
239
+ def test_repeater_drops_all_blank_rows_and_strips_destroy
240
+ input = [{"label" => "a", "_destroy" => "false"}, {"label" => ""}, {"label" => "c", "_destroy" => "1"}]
241
+ assert_equal [{label: "a"}], Cleaner.call(input, repeat: true)
242
+ end
243
+
244
+ def test_repeater_blank_returns_empty_array
245
+ assert_equal [], Cleaner.call(nil, repeat: true)
246
+ end
247
+ end
248
+ ```
249
+
250
+ - [ ] **Step 2: Run test to verify it fails**
251
+
252
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/param_cleaner_test.rb`
253
+ Expected: FAIL — uninitialized constant `Plutonium::StructuredInputs::ParamCleaner`.
254
+
255
+ - [ ] **Step 3: Write the cleaner**
256
+
257
+ ```ruby
258
+ # lib/plutonium/structured_inputs/param_cleaner.rb
259
+ # frozen_string_literal: true
260
+
261
+ module Plutonium
262
+ module StructuredInputs
263
+ # Turns submitted structured-input params into the stored value.
264
+ module ParamCleaner
265
+ DESTROY_VALUES = [1, "1", true, "true"].freeze
266
+
267
+ module_function
268
+
269
+ # @param value [Hash, Array, nil] the extracted param for this input
270
+ # @param repeat [Boolean, Integer] truthy ⇒ array (repeater), else hash
271
+ # @return [Hash, Array<Hash>]
272
+ def call(value, repeat:)
273
+ repeat ? clean_collection(value) : clean_one(value)
274
+ end
275
+
276
+ def clean_one(value)
277
+ return {} unless value.is_a?(Hash)
278
+ strip(value)
279
+ end
280
+
281
+ def clean_collection(value)
282
+ rows = value.is_a?(Hash) ? value.values : Array(value)
283
+ rows
284
+ .filter_map { |row| row.is_a?(Hash) ? row : nil }
285
+ .reject { |row| destroy?(row) }
286
+ .map { |row| strip(row) }
287
+ .reject { |row| row.values.all? { |v| v.to_s.strip.empty? } }
288
+ end
289
+
290
+ def destroy?(row)
291
+ DESTROY_VALUES.include?(row[:_destroy] || row["_destroy"])
292
+ end
293
+
294
+ # Drop _destroy and symbolize keys.
295
+ def strip(row)
296
+ row.to_h.except(:_destroy, "_destroy").transform_keys(&:to_sym)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ ```
302
+
303
+ - [ ] **Step 4: Run test to verify it passes**
304
+
305
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/param_cleaner_test.rb`
306
+ Expected: PASS (7 runs).
307
+
308
+ - [ ] **Step 5: Commit**
309
+
310
+ ```bash
311
+ git add lib/plutonium/structured_inputs/param_cleaner.rb test/plutonium/structured_inputs/param_cleaner_test.rb
312
+ git commit -m "feat(structured-inputs): add param cleaner (single hash / cleaned array)"
313
+ ```
314
+
315
+ ```json:metadata
316
+ {"files": ["lib/plutonium/structured_inputs/param_cleaner.rb", "test/plutonium/structured_inputs/param_cleaner_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/param_cleaner_test.rb", "acceptanceCriteria": ["single passes hash through, strips _destroy", "repeater normalizes array and index-hash", "drops all-blank rows", "blank ⇒ {} or []"], "requiresUserVerification": false}
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Task 2: Interaction host — declare attribute, remove the old nested surface
322
+
323
+ **Goal:** On interactions, `structured_input` also declares the backing ActiveModel attribute (default `{}` single / `[]` repeat); remove `nested_input` and `accepts_nested_attributes_for` from interactions.
324
+
325
+ **Files:**
326
+ - Modify: `lib/plutonium/interaction/base.rb`
327
+ - Delete: `lib/plutonium/interaction/nested_attributes.rb`
328
+ - Test: replace `test/plutonium/ui/form/interaction_nested_input_test.rb` with `test/plutonium/interaction/structured_inputs_test.rb`
329
+
330
+ **Acceptance Criteria:**
331
+ - [ ] `structured_input :a` on an interaction adds `:a` to `attribute_names` with a fresh `{}` default; `structured_input :b, repeat: 3` defaults to `[]`.
332
+ - [ ] Defaults are not shared across instances (mutating one instance's value doesn't leak).
333
+ - [ ] Interactions do **not** respond to `nested_input`, `accepts_nested_attributes_for`, or `defined_nested_inputs`.
334
+
335
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/structured_inputs_test.rb` → all pass.
336
+
337
+ **Steps:**
338
+
339
+ - [ ] **Step 1: Write the failing test (and delete the obsolete one)**
340
+
341
+ ```bash
342
+ git rm test/plutonium/ui/form/interaction_nested_input_test.rb
343
+ ```
344
+
345
+ ```ruby
346
+ # test/plutonium/interaction/structured_inputs_test.rb
347
+ # frozen_string_literal: true
348
+
349
+ require "test_helper"
350
+
351
+ class Plutonium::Interaction::StructuredInputsTest < Minitest::Test
352
+ def build_interaction(&block)
353
+ Class.new(Plutonium::Resource::Interaction, &block)
354
+ end
355
+
356
+ def test_single_declares_attribute_defaulting_to_hash
357
+ klass = build_interaction do
358
+ structured_input(:address) { |f| f.input :street }
359
+ end
360
+ instance = klass.new(view_context: nil)
361
+ assert_includes instance.attribute_names, "address"
362
+ assert_equal({}, instance.address)
363
+ end
364
+
365
+ def test_repeat_declares_attribute_defaulting_to_array
366
+ klass = build_interaction do
367
+ structured_input(:contacts, repeat: 3) { |f| f.input :label }
368
+ end
369
+ assert_equal [], klass.new(view_context: nil).contacts
370
+ end
371
+
372
+ def test_defaults_are_not_shared_between_instances
373
+ klass = build_interaction do
374
+ structured_input(:contacts, repeat: 3) { |f| f.input :label }
375
+ end
376
+ a = klass.new(view_context: nil)
377
+ a.contacts << {label: "x"}
378
+ b = klass.new(view_context: nil)
379
+ assert_equal [], b.contacts
380
+ end
381
+
382
+ def test_nested_input_is_removed_from_interactions
383
+ refute Plutonium::Resource::Interaction.respond_to?(:nested_input)
384
+ refute Plutonium::Resource::Interaction.respond_to?(:accepts_nested_attributes_for)
385
+ refute Plutonium::Resource::Interaction.respond_to?(:defined_nested_inputs)
386
+ end
387
+ end
388
+ ```
389
+
390
+ - [ ] **Step 2: Run test to verify it fails**
391
+
392
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/structured_inputs_test.rb`
393
+ Expected: FAIL — `structured_input` not yet wired to declare attributes; `nested_input` still present.
394
+
395
+ - [ ] **Step 3: Edit `lib/plutonium/interaction/base.rb`**
396
+
397
+ Remove these two includes (currently lines 28–29):
398
+
399
+ ```ruby
400
+ include Plutonium::Definition::NestedInputs
401
+ include Plutonium::Interaction::NestedAttributes
402
+ ```
403
+
404
+ Add the structured-inputs include and the attribute-declaring override. Place the include with the other `include` lines, and the override below the `included`/class body:
405
+
406
+ ```ruby
407
+ include Plutonium::Definition::StructuredInputs
408
+
409
+ # On interactions, declaring a structured input also declares the backing
410
+ # ActiveModel attribute so the value survives `attributes=` and shows up in
411
+ # `attribute_names` (which drives the interaction form's field list).
412
+ def self.structured_input(name, **options, &block)
413
+ super
414
+ default = options[:repeat] ? -> { [] } : -> { {} }
415
+ attribute name, default: default
416
+ end
417
+ ```
418
+
419
+ > `super` resolves to `StructuredInputs::ClassMethods#structured_input` (ActiveSupport::Concern puts it in the singleton ancestry), so the registry entry is still written.
420
+
421
+ - [ ] **Step 4: Delete the dead module**
422
+
423
+ ```bash
424
+ git rm lib/plutonium/interaction/nested_attributes.rb
425
+ ```
426
+
427
+ Confirm nothing else references it:
428
+
429
+ ```bash
430
+ grep -rn "Interaction::NestedAttributes" lib/ app/ test/
431
+ ```
432
+ Expected: no matches.
433
+
434
+ - [ ] **Step 5: Run test to verify it passes**
435
+
436
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/structured_inputs_test.rb`
437
+ Expected: PASS (4 runs).
438
+
439
+ - [ ] **Step 6: Run the interaction + action suites for regressions**
440
+
441
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/interactive_test.rb`
442
+ Expected: PASS (no refs to the removed modules).
443
+
444
+ - [ ] **Step 7: Commit**
445
+
446
+ ```bash
447
+ git add -A
448
+ git commit -m "feat(interaction): structured_input declares attribute; drop nested_input/accepts_nested_attributes_for"
449
+ ```
450
+
451
+ ```json:metadata
452
+ {"files": ["lib/plutonium/interaction/base.rb", "lib/plutonium/interaction/nested_attributes.rb", "test/plutonium/interaction/structured_inputs_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction/structured_inputs_test.rb", "acceptanceCriteria": ["structured_input declares attribute with {} / [] default", "defaults not shared across instances", "nested_input/accepts_nested_attributes_for/defined_nested_inputs removed from interactions"], "requiresUserVerification": false}
453
+ ```
454
+
455
+ ---
456
+
457
+ ## Task 3: Rendering — `RendersStructuredInputs` concern + form branch
458
+
459
+ **Goal:** Render a structured input on the form: single via `nest_one` (one fieldset), repeater via `nest_many` (the existing repeater chrome), classless, fields named `host[name][...]`.
460
+
461
+ **Files:**
462
+ - Create: `lib/plutonium/ui/form/concerns/renders_structured_inputs.rb`
463
+ - Modify: `lib/plutonium/ui/form/resource.rb` (include concern; branch `render_resource_field`)
464
+ - Test: covered by the integration tests in Task 6 (rendering needs a real form + view context).
465
+
466
+ **Acceptance Criteria:**
467
+ - [ ] `render_resource_field` dispatches a name in `defined_structured_inputs` to `render_structured_input`, before the `nested_input` and simple-field branches.
468
+ - [ ] Single renders one `<fieldset>` of the declared inputs, no add/remove chrome, no hidden id/`_destroy`, fields named `host[name][field]`.
469
+ - [ ] Repeater renders the `nested-resource-form-fields` controller container with `limit` = the `repeat` cap, a `<template>` blank row, existing rows from the array value, an add button and per-row delete control, fields named `host[name][N][field]` / `host[name][NEW_RECORD][field]`, no hidden id/`_destroy`.
470
+
471
+ **Verify:** exercised by `test/integration/admin_portal/structured_input_rendering_test.rb` (Task 6).
472
+
473
+ **Steps:**
474
+
475
+ - [ ] **Step 1: Write the concern**
476
+
477
+ Model the structure on `lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb`, but classless (blank row `{}`, `as: :<name>`, no hidden id/`_destroy`). The repeat cap: `repeat: true` ⇒ `DEFAULT_LIMIT`, `repeat: <int>` ⇒ that int.
478
+
479
+ ```ruby
480
+ # lib/plutonium/ui/form/concerns/renders_structured_inputs.rb
481
+ # frozen_string_literal: true
482
+
483
+ module Plutonium
484
+ module UI
485
+ module Form
486
+ module Concerns
487
+ # Renders classless structured inputs (single → hash via nest_one,
488
+ # repeater → array via nest_many). Field/namespace work is delegated to
489
+ # the form; this concern owns the structural markup.
490
+ # @api private
491
+ module RendersStructuredInputs
492
+ extend ActiveSupport::Concern
493
+
494
+ DEFAULT_REPEAT_LIMIT = 10
495
+
496
+ private
497
+
498
+ def render_structured_input(name)
499
+ entry = resource_definition.defined_structured_inputs[name]
500
+ options = entry[:options] || {}
501
+ definition = structured_input_fields_definition(entry)
502
+ fields = options[:fields] || definition.defined_inputs.keys
503
+ repeat = options[:repeat]
504
+
505
+ if repeat
506
+ render_structured_repeater(name, definition, fields, repeat_limit(repeat))
507
+ else
508
+ render_structured_single(name, definition, fields)
509
+ end
510
+ end
511
+
512
+ def structured_input_fields_definition(entry)
513
+ return entry[:options][:using] if entry[:options]&.key?(:using)
514
+
515
+ holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
516
+ entry[:block].call(holder)
517
+ holder
518
+ end
519
+
520
+ def repeat_limit(repeat)
521
+ repeat.is_a?(Integer) ? repeat : DEFAULT_REPEAT_LIMIT
522
+ end
523
+
524
+ # --- single -------------------------------------------------------
525
+
526
+ def render_structured_single(name, definition, fields)
527
+ div(class: "col-span-full space-y-2 my-4") do
528
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
529
+ nest_one(name, as: name, default: {}) do |nested|
530
+ render_structured_fieldset(nested, definition, fields)
531
+ end
532
+ end
533
+ end
534
+
535
+ # --- repeater -----------------------------------------------------
536
+
537
+ def render_structured_repeater(name, definition, fields, limit)
538
+ div(
539
+ class: "col-span-full space-y-2 my-4",
540
+ data: {
541
+ controller: "nested-resource-form-fields",
542
+ nested_resource_form_fields_limit_value: limit
543
+ }
544
+ ) do
545
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
546
+ template data_nested_resource_form_fields_target: "template" do
547
+ nest_many(name, as: name, collection: {NEW_RECORD: {}}, default: {NEW_RECORD: {}}, template: true) do |nested|
548
+ render_structured_fieldset(nested, definition, fields)
549
+ end
550
+ end
551
+ nest_many(name, as: name, default: []) do |nested|
552
+ render_structured_fieldset(nested, definition, fields)
553
+ end
554
+ div(data_nested_resource_form_fields_target: :target, hidden: true)
555
+ render_structured_add_button(name)
556
+ end
557
+ end
558
+
559
+ def render_structured_fieldset(nested, definition, fields)
560
+ fieldset(
561
+ class: "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
562
+ ) do
563
+ div(class: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense") do
564
+ fields.each { |input| render_simple_resource_field(input, definition, nested) }
565
+ end
566
+ render_structured_delete_button
567
+ end
568
+ end
569
+
570
+ def render_structured_delete_button
571
+ div(class: "flex items-center justify-end") do
572
+ label(class: "inline-flex items-center text-md font-medium text-red-900 cursor-pointer") do
573
+ plain "Delete"
574
+ input(
575
+ type: :checkbox,
576
+ class: "w-4 h-4 ms-2 text-danger-600 bg-danger-100 border-danger-300 rounded cursor-pointer",
577
+ data_action: "nested-resource-form-fields#remove"
578
+ )
579
+ end
580
+ end
581
+ end
582
+
583
+ def render_structured_add_button(name)
584
+ div do
585
+ button(
586
+ type: :button,
587
+ class: "inline-block",
588
+ data: {action: "nested-resource-form-fields#add", nested_resource_form_fields_target: "addButton"}
589
+ ) do
590
+ span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
591
+ render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
592
+ span { "Add #{name.to_s.singularize.humanize}" }
593
+ end
594
+ end
595
+ end
596
+ end
597
+ end
598
+ end
599
+ end
600
+ end
601
+ end
602
+ ```
603
+
604
+ - [ ] **Step 2: Include the concern and branch `render_resource_field`**
605
+
606
+ In `lib/plutonium/ui/form/resource.rb`, add the include with the other concern includes (near the top of the class):
607
+
608
+ ```ruby
609
+ include Plutonium::UI::Form::Concerns::RendersStructuredInputs
610
+ ```
611
+
612
+ Replace the body of `render_resource_field` (currently `resource.rb:165-173`) with the structured-input branch added FIRST:
613
+
614
+ ```ruby
615
+ def render_resource_field(name)
616
+ when_permitted(name) do
617
+ if resource_definition.respond_to?(:defined_structured_inputs) && resource_definition.defined_structured_inputs[name]
618
+ render_structured_input(name)
619
+ elsif resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
620
+ render_nested_resource_field(name)
621
+ else
622
+ render_simple_resource_field(name, resource_definition, self)
623
+ end
624
+ end
625
+ end
626
+ ```
627
+
628
+ - [ ] **Step 3: Smoke-check it loads**
629
+
630
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form/hidden_input_test.rb`
631
+ Expected: PASS (form still constructs/loads with the new concern).
632
+
633
+ - [ ] **Step 4: Commit**
634
+
635
+ ```bash
636
+ git add lib/plutonium/ui/form/concerns/renders_structured_inputs.rb lib/plutonium/ui/form/resource.rb
637
+ git commit -m "feat(ui): render structured inputs (single + repeater), classless"
638
+ ```
639
+
640
+ ```json:metadata
641
+ {"files": ["lib/plutonium/ui/form/concerns/renders_structured_inputs.rb", "lib/plutonium/ui/form/resource.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form/hidden_input_test.rb", "acceptanceCriteria": ["render_resource_field dispatches structured inputs first", "single renders one fieldset, no chrome", "repeater renders controller container + template + add/delete", "no hidden id/_destroy"], "requiresUserVerification": false}
642
+ ```
643
+
644
+ ---
645
+
646
+ ## Task 4: Param flow — apply the cleaner on both hosts
647
+
648
+ **Goal:** Run extracted structured-input params through `ParamCleaner` (keyed off `defined_structured_inputs`) before assignment, for resources and interactions.
649
+
650
+ **Files:**
651
+ - Modify: `lib/plutonium/resource/controller.rb` (`submitted_resource_params`)
652
+ - Modify: `lib/plutonium/resource/controllers/interactive_actions.rb` (`submitted_interaction_params`)
653
+ - Create: `lib/plutonium/structured_inputs/params_concern.rb` (shared cleaning helper)
654
+ - Test: `test/plutonium/structured_inputs/params_concern_test.rb`
655
+
656
+ **Acceptance Criteria:**
657
+ - [ ] A shared helper `clean_structured_inputs(definition, params)` rewrites each `defined_structured_inputs` key in `params` through `ParamCleaner` with the right `repeat:` flag; leaves other keys untouched.
658
+ - [ ] `submitted_resource_params` and `submitted_interaction_params` apply it before returning.
659
+
660
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/params_concern_test.rb` → all pass.
661
+
662
+ **Steps:**
663
+
664
+ - [ ] **Step 1: Write the failing test**
665
+
666
+ ```ruby
667
+ # test/plutonium/structured_inputs/params_concern_test.rb
668
+ # frozen_string_literal: true
669
+
670
+ require "test_helper"
671
+
672
+ class Plutonium::StructuredInputs::ParamsConcernTest < Minitest::Test
673
+ class Host
674
+ include Plutonium::StructuredInputs::ParamsConcern
675
+ end
676
+
677
+ def definition
678
+ Class.new(Plutonium::Definition::Base) do
679
+ structured_input(:address) { |f| f.input :street }
680
+ structured_input(:contacts, repeat: 5) { |f| f.input :label }
681
+ end.new
682
+ end
683
+
684
+ def test_cleans_single_and_repeater_keys_only
685
+ params = {
686
+ name: "keep me",
687
+ address: {"street" => "1 A St", "_destroy" => "1"},
688
+ contacts: [{"label" => "a"}, {"label" => ""}]
689
+ }
690
+ out = Host.new.clean_structured_inputs(definition, params)
691
+
692
+ assert_equal "keep me", out[:name]
693
+ assert_equal({street: "1 A St"}, out[:address])
694
+ assert_equal [{label: "a"}], out[:contacts]
695
+ end
696
+ end
697
+ ```
698
+
699
+ - [ ] **Step 2: Run test to verify it fails**
700
+
701
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/params_concern_test.rb`
702
+ Expected: FAIL — uninitialized constant `ParamsConcern`.
703
+
704
+ - [ ] **Step 3: Write the concern**
705
+
706
+ ```ruby
707
+ # lib/plutonium/structured_inputs/params_concern.rb
708
+ # frozen_string_literal: true
709
+
710
+ module Plutonium
711
+ module StructuredInputs
712
+ # Rewrites structured-input params in place through ParamCleaner. Shared by
713
+ # the resource controller and the interactive-actions controller.
714
+ module ParamsConcern
715
+ # @param definition [#defined_structured_inputs]
716
+ # @param params [Hash] extracted form params (mutable copy)
717
+ # @return [Hash]
718
+ def clean_structured_inputs(definition, params)
719
+ return params unless definition.respond_to?(:defined_structured_inputs)
720
+
721
+ definition.defined_structured_inputs.each do |name, entry|
722
+ next unless params.key?(name)
723
+
724
+ repeat = entry[:options]&.fetch(:repeat, false)
725
+ params[name] = Plutonium::StructuredInputs::ParamCleaner.call(params[name], repeat:)
726
+ end
727
+ params
728
+ end
729
+ end
730
+ end
731
+ end
732
+ ```
733
+
734
+ - [ ] **Step 4: Apply on resources**
735
+
736
+ In `lib/plutonium/resource/controller.rb`, `submitted_resource_params` (currently `controller.rb:143-148`) — wrap the extracted hash. Include the concern in the controller module and clean using the current definition:
737
+
738
+ Add near the top of the controller module body: `include Plutonium::StructuredInputs::ParamsConcern`
739
+
740
+ Then:
741
+
742
+ ```ruby
743
+ def submitted_resource_params
744
+ extraction_record = resource_record?&.dup || resource_class.new
745
+ @submitted_resource_params ||= begin
746
+ extracted = build_form(extraction_record, form_action: false)
747
+ .extract_input(params, view_context:)[resource_param_key.to_sym].compact
748
+ clean_structured_inputs(current_definition, extracted)
749
+ end
750
+ end
751
+ ```
752
+
753
+ > `current_definition` is the resource definition in controller context (see `presentable.rb`). If unavailable in this exact method, use `resource_definition(resource_class)`.
754
+
755
+ - [ ] **Step 5: Apply on interactions**
756
+
757
+ In `lib/plutonium/resource/controllers/interactive_actions.rb`, include the concern in the module and clean using the interaction (which is itself the definition):
758
+
759
+ ```ruby
760
+ def submitted_interaction_params
761
+ @submitted_interaction_params ||= begin
762
+ interaction = current_interactive_action.interaction
763
+ extracted = interaction
764
+ .build_form(interaction.new(view_context:))
765
+ .extract_input(params, view_context:)[:interaction]
766
+ clean_structured_inputs(interaction, extracted)
767
+ end
768
+ end
769
+ ```
770
+
771
+ > The interaction CLASS exposes `defined_structured_inputs` (class-level registry); pass the class.
772
+
773
+ - [ ] **Step 6: Run the cleaner-concern test + a smoke of the controllers**
774
+
775
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/params_concern_test.rb`
776
+ Expected: PASS.
777
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/org_portal/catalog_products_test.rb`
778
+ Expected: PASS (existing resource param flow unaffected — definitions without structured inputs are untouched).
779
+
780
+ - [ ] **Step 7: Commit**
781
+
782
+ ```bash
783
+ git add lib/plutonium/structured_inputs/params_concern.rb lib/plutonium/resource/controller.rb lib/plutonium/resource/controllers/interactive_actions.rb test/plutonium/structured_inputs/params_concern_test.rb
784
+ git commit -m "feat(structured-inputs): clean structured params before assignment (both hosts)"
785
+ ```
786
+
787
+ ```json:metadata
788
+ {"files": ["lib/plutonium/structured_inputs/params_concern.rb", "lib/plutonium/resource/controller.rb", "lib/plutonium/resource/controllers/interactive_actions.rb", "test/plutonium/structured_inputs/params_concern_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/structured_inputs/params_concern_test.rb", "acceptanceCriteria": ["clean_structured_inputs rewrites only structured keys", "applied in submitted_resource_params", "applied in submitted_interaction_params"], "requiresUserVerification": false}
789
+ ```
790
+
791
+ ---
792
+
793
+ ## Task 5: Dummy fixtures (generators) — resource JSON column + interaction action
794
+
795
+ **Goal:** Stand up the fixtures the integration tests render against, using the `pu:*` generators per project convention.
796
+
797
+ **Files:**
798
+ - Create (generated): a dummy resource with a `json` column and `structured_input` in its definition + policy permit.
799
+ - Create (generated): a dummy interaction with `structured_input` (single + repeat) wired as an action.
800
+ - Modify: the generated definition/policy to add the structured inputs.
801
+
802
+ **Acceptance Criteria:**
803
+ - [ ] A resource (e.g. `Catalog::Settings` or a new `Profile`) has a `json` column `payload` and `structured_input :payload` (single) + `structured_input :tags, repeat: 5` over a second json column.
804
+ - [ ] An interaction wired to an existing resource action declares `structured_input :address` (single) + `structured_input :contacts, repeat: 3`.
805
+ - [ ] Migrations run; the dummy app boots.
806
+
807
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/resources_test.rb` → PASS (app boots with new fixtures).
808
+
809
+ **Steps:**
810
+
811
+ - [ ] **Step 1: Generate the resource fixture**
812
+
813
+ From `test/dummy`, generate a resource with json columns (quote shell args). Example using the catalog package:
814
+
815
+ ```bash
816
+ cd test/dummy && bin/rails g pu:res:scaffold Catalog::Spec 'payload:json' 'rows:json' --dest=catalog
817
+ ```
818
+
819
+ > If `json` is not a recognized scaffold type, generate with `text` columns and change the migration/model to `t.json`. SQLite (the dummy DB) supports `json` columns via Rails serialization.
820
+
821
+ - [ ] **Step 2: Add structured inputs to the generated definition**
822
+
823
+ Edit `test/dummy/packages/catalog/app/definitions/catalog/spec_definition.rb`:
824
+
825
+ ```ruby
826
+ class Catalog::SpecDefinition < Catalog::ResourceDefinition
827
+ structured_input :payload do |f|
828
+ f.input :title
829
+ f.input :notes
830
+ end
831
+
832
+ structured_input :rows, repeat: 5 do |f|
833
+ f.input :key
834
+ f.input :value
835
+ end
836
+ end
837
+ ```
838
+
839
+ - [ ] **Step 3: Permit the structured inputs in the generated policy**
840
+
841
+ Edit `test/dummy/packages/catalog/app/policies/catalog/spec_policy.rb`:
842
+
843
+ ```ruby
844
+ class Catalog::SpecPolicy < Catalog::ResourcePolicy
845
+ def permitted_attributes_for_create
846
+ [:payload, :rows]
847
+ end
848
+ alias_method :permitted_attributes_for_update, :permitted_attributes_for_create
849
+
850
+ def permitted_attributes_for_read
851
+ [:payload, :rows, :created_at]
852
+ end
853
+ end
854
+ ```
855
+
856
+ Register the resource in the admin portal routes (`test/dummy/packages/admin_portal/config/routes.rb`): `register_resource ::Catalog::Spec`.
857
+
858
+ - [ ] **Step 4: Generate the interaction fixture**
859
+
860
+ Create `test/dummy/packages/catalog/app/interactions/catalog/collect_spec.rb` (a non-immediate interactive action with structured inputs), and wire it onto an existing resource (e.g. `Catalog::Product`) via `action :collect_spec, interaction: Catalog::CollectSpec` in `ProductDefinition`:
861
+
862
+ ```ruby
863
+ class Catalog::CollectSpec < Catalog::ResourceInteraction
864
+ attribute :resource
865
+
866
+ structured_input :address do |f|
867
+ f.input :street
868
+ f.input :city
869
+ end
870
+
871
+ structured_input :contacts, repeat: 3 do |f|
872
+ f.input :label
873
+ f.input :phone_number
874
+ end
875
+
876
+ private
877
+
878
+ def execute
879
+ success(resource).with_message("Collected #{contacts.size} contacts")
880
+ end
881
+ end
882
+ ```
883
+
884
+ - [ ] **Step 5: Run migrations and boot check**
885
+
886
+ ```bash
887
+ cd test/dummy && bin/rails db:migrate RAILS_ENV=test
888
+ ```
889
+
890
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/resources_test.rb`
891
+ Expected: PASS.
892
+
893
+ - [ ] **Step 6: Commit**
894
+
895
+ ```bash
896
+ git add -A
897
+ git commit -m "test(dummy): add structured-input fixtures (resource json columns + interaction action)"
898
+ ```
899
+
900
+ ```json:metadata
901
+ {"files": ["test/dummy/packages/catalog/app/definitions/catalog/spec_definition.rb", "test/dummy/packages/catalog/app/policies/catalog/spec_policy.rb", "test/dummy/packages/catalog/app/interactions/catalog/collect_spec.rb", "test/dummy/packages/admin_portal/config/routes.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/resources_test.rb", "acceptanceCriteria": ["resource with json columns + structured_input", "interaction with single + repeat structured_input wired as action", "migrations run, app boots"], "requiresUserVerification": false}
902
+ ```
903
+
904
+ ---
905
+
906
+ ## Task 6: Integration tests — render + round-trip (both hosts)
907
+
908
+ **Goal:** Characterize the rendered HTML and the persistence/round-trip for single and repeater, on resources (JSON column) and interactions (attribute → execute).
909
+
910
+ **Files:**
911
+ - Create: `test/integration/admin_portal/structured_input_rendering_test.rb`
912
+ - Create: `test/integration/admin_portal/structured_input_roundtrip_test.rb`
913
+
914
+ **Acceptance Criteria:**
915
+ - [ ] Resource new form renders: single fieldset for `payload` with `catalog_spec[payload][title]`; repeater for `rows` with the controller container, `<template>`, `catalog_spec[rows][NEW_RECORD][key]`, add/delete, no hidden id/`_destroy`.
916
+ - [ ] Resource create persists `payload` as a hash and `rows` as a cleaned array into the JSON columns; blank rows dropped; edit repopulates.
917
+ - [ ] Interaction action form renders the single + repeater; submitting reaches `execute` with `address` a hash and `contacts` an array.
918
+
919
+ **Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/structured_input_rendering_test.rb` and `..._roundtrip_test.rb` → PASS.
920
+
921
+ **Steps:**
922
+
923
+ - [ ] **Step 1: Write the rendering test**
924
+
925
+ ```ruby
926
+ # test/integration/admin_portal/structured_input_rendering_test.rb
927
+ # frozen_string_literal: true
928
+
929
+ require "test_helper"
930
+
931
+ class AdminPortal::StructuredInputRenderingTest < ActionDispatch::IntegrationTest
932
+ include IntegrationTestHelper
933
+
934
+ setup do
935
+ @admin = create_admin!
936
+ login_as_admin(@admin)
937
+ end
938
+
939
+ test "single structured input renders one fieldset with nested names" do
940
+ get "/admin/catalog/specs/new"
941
+ assert_response :success
942
+ assert_includes response.body, %(name="catalog_spec[payload][title]")
943
+ assert_includes response.body, %(name="catalog_spec[payload][notes]")
944
+ end
945
+
946
+ test "repeater renders the controller container, template, and nested names" do
947
+ get "/admin/catalog/specs/new"
948
+ assert_match(/data-controller="nested-resource-form-fields"[^>]*data-nested-resource-form-fields-limit-value="5"/, response.body)
949
+ assert_includes response.body, %(<template data-nested-resource-form-fields-target="template">)
950
+ assert_includes response.body, %(name="catalog_spec[rows][NEW_RECORD][key]")
951
+ assert_includes response.body, %(data-action="nested-resource-form-fields#add")
952
+ refute_includes response.body, %(catalog_spec[rows][NEW_RECORD][_destroy])
953
+ end
954
+ end
955
+ ```
956
+
957
+ - [ ] **Step 2: Write the round-trip test**
958
+
959
+ ```ruby
960
+ # test/integration/admin_portal/structured_input_roundtrip_test.rb
961
+ # frozen_string_literal: true
962
+
963
+ require "test_helper"
964
+
965
+ class AdminPortal::StructuredInputRoundtripTest < ActionDispatch::IntegrationTest
966
+ include IntegrationTestHelper
967
+
968
+ setup do
969
+ @admin = create_admin!
970
+ login_as_admin(@admin)
971
+ end
972
+
973
+ test "create persists single hash and cleaned repeater array to json columns" do
974
+ post "/admin/catalog/specs", params: {catalog_spec: {
975
+ payload: {title: "T", notes: "N"},
976
+ rows: {"0" => {key: "a", value: "1"}, "1" => {key: "", value: ""}}
977
+ }}
978
+ spec = Catalog::Spec.order(:id).last
979
+ assert_equal({"title" => "T", "notes" => "N"}, spec.payload)
980
+ assert_equal([{"key" => "a", "value" => "1"}], spec.rows)
981
+ end
982
+ end
983
+ ```
984
+
985
+ - [ ] **Step 3: Run both; fix rendering/extraction until green**
986
+
987
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/structured_input_rendering_test.rb`
988
+ Run: `bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/structured_input_roundtrip_test.rb`
989
+ Expected: PASS. If field naming differs (e.g. `extract_input` needs `as:` adjusted), reconcile Task 3's `as:`/`nest_*` calls and Task 4's cleaning here, since these tests are the source of truth for the contract.
990
+
991
+ > Add an interaction render+execute test once the interactive-action route for `Catalog::CollectSpec` is confirmed (GET the action form, POST the commit, assert the success message reflects `contacts.size`). Use `test/integration/org_portal/catalog_products_test.rb` for the action URL pattern.
992
+
993
+ - [ ] **Step 4: Commit**
994
+
995
+ ```bash
996
+ git add test/integration/admin_portal/structured_input_rendering_test.rb test/integration/admin_portal/structured_input_roundtrip_test.rb
997
+ git commit -m "test(structured-inputs): integration render + round-trip (resource + interaction)"
998
+ ```
999
+
1000
+ ```json:metadata
1001
+ {"files": ["test/integration/admin_portal/structured_input_rendering_test.rb", "test/integration/admin_portal/structured_input_roundtrip_test.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/structured_input_rendering_test.rb", "acceptanceCriteria": ["single + repeater render with correct nested names, no hidden id/_destroy", "create persists hash + cleaned array to json columns", "interaction execute receives hash + array"], "requiresUserVerification": false}
1002
+ ```
1003
+
1004
+ ---
1005
+
1006
+ ## Task 7: Full regression + docs
1007
+
1008
+ **Goal:** Confirm nothing regressed across the suite and Rails versions; document `structured_input` and the interaction removals.
1009
+
1010
+ **Files:**
1011
+ - Modify: `lib/plutonium/interaction/README.md`
1012
+ - Create: `docs/reference/` page (or extend the resource definition / interaction docs) describing `structured_input`.
1013
+ - Modify: any skill doc referencing interaction `nested_input` (search first).
1014
+
1015
+ **Acceptance Criteria:**
1016
+ - [ ] `bundle exec appraisal rails-8.1 rake test` passes; spot-check `rails-7` and `rails-8.0` on the new test files.
1017
+ - [ ] README no longer shows `accepts_nested_attributes_for` + `nested_input` on an interaction; shows `structured_input` instead.
1018
+ - [ ] Docs describe single→hash, `repeat:`→array, JSON-column backing on resources, attribute backing on interactions.
1019
+
1020
+ **Verify:** `bundle exec appraisal rails-8.1 rake test` → 0 failures.
1021
+
1022
+ **Steps:**
1023
+
1024
+ - [ ] **Step 1: Rework the interaction README**
1025
+
1026
+ In `lib/plutonium/interaction/README.md`, replace the `accepts_nested_attributes_for` + `nested_input` example with a `structured_input` example (single + repeat), and add a note that `nested_input`/`accepts_nested_attributes_for` are not available on interactions.
1027
+
1028
+ - [ ] **Step 2: Search for and update other docs/skills**
1029
+
1030
+ ```bash
1031
+ grep -rln "nested_input\|accepts_nested_attributes_for" docs/ .claude/skills/ | grep -v node_modules
1032
+ ```
1033
+ Update any that describe these on interactions; add `structured_input` to the resource/definition and interaction references.
1034
+
1035
+ - [ ] **Step 3: Full suite + cross-version spot check**
1036
+
1037
+ ```bash
1038
+ bundle exec appraisal rails-8.1 rake test
1039
+ bundle exec appraisal rails-8.0 ruby -Itest test/plutonium/definition/structured_inputs_test.rb
1040
+ bundle exec appraisal rails-7 ruby -Itest test/plutonium/structured_inputs/param_cleaner_test.rb
1041
+ ```
1042
+ Expected: 0 failures.
1043
+
1044
+ - [ ] **Step 4: Commit**
1045
+
1046
+ ```bash
1047
+ git add -A
1048
+ git commit -m "docs(structured-inputs): document structured_input; rework interaction nested docs"
1049
+ ```
1050
+
1051
+ ```json:metadata
1052
+ {"files": ["lib/plutonium/interaction/README.md", "docs/"], "verifyCommand": "bundle exec appraisal rails-8.1 rake test", "acceptanceCriteria": ["full suite green", "README/docs document structured_input and the interaction removals"], "requiresUserVerification": false}
1053
+ ```
1054
+
1055
+ ---
1056
+
1057
+ ## Self-Review notes
1058
+
1059
+ - **Spec coverage:** DSL (Task 0), cleaner (Task 1), interaction host + removals (Task 2), rendering (Task 3), param flow both hosts (Task 4), fixtures (Task 5), render + round-trip tests both hosts (Task 6), regression + docs (Task 7). All spec sections mapped.
1060
+ - **Open spec questions resolved here:** clean-step home = `submitted_resource_params` + `submitted_interaction_params` (Task 4); field naming = `as: :<name>` (Task 3, verified by Task 6); JSON column type = `json` on SQLite (Task 5).
1061
+ - **Risk:** the `as: :<name>` extraction contract is verified by Task 6's round-trip; if `extract_input` namespaces differently, Task 3/4/6 are reconciled together (Task 6 is the contract source of truth).