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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +55 -0
- data/.claude/skills/plutonium-ui/SKILL.md +2 -1
- data/CHANGELOG.md +14 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +18 -0
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +30 -30
- data/app/assets/plutonium.min.js.map +4 -4
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/reference/resource/definition.md +110 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
- data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/structured_inputs.rb +67 -0
- data/lib/plutonium/interaction/README.md +24 -78
- data/lib/plutonium/interaction/base.rb +10 -2
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +10 -6
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +3 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +178 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- data/lib/plutonium/ui/form/resource.rb +4 -1
- data/lib/plutonium/ui/modal/slideover.rb +9 -3
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -5
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +14 -5
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
- 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).
|