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,60 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-06-02-structured-inputs.md",
3
+ "tasks": [
4
+ {
5
+ "id": 0,
6
+ "subject": "Task 0: Shared structured_input DSL + registry",
7
+ "status": "completed",
8
+ "description": "Shared concern: structured_input DSL, defined_structured_inputs registry (inherited), FieldsDefinition holder; included in resource Definition::Base.\n\n```json:metadata\n{\"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}\n```"
9
+ },
10
+ {
11
+ "id": 1,
12
+ "subject": "Task 1: Shared param cleaner",
13
+ "status": "completed",
14
+ "description": "Pure ParamCleaner: single hash (strip _destroy) / repeater array (normalize array|index-hash, drop all-blank, strip _destroy).\n\n```json:metadata\n{\"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 to {} or []\"], \"requiresUserVerification\": false}\n```"
15
+ },
16
+ {
17
+ "id": 2,
18
+ "subject": "Task 2: Interaction host — attribute + remove nested surface",
19
+ "status": "completed",
20
+ "blockedBy": [0],
21
+ "description": "On interactions structured_input declares the backing attribute ({} single / [] repeat); remove nested_input + accepts_nested_attributes_for; delete nested_attributes.rb.\n\n```json:metadata\n{\"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}\n```"
22
+ },
23
+ {
24
+ "id": 3,
25
+ "subject": "Task 3: Rendering — RendersStructuredInputs + form branch",
26
+ "status": "completed",
27
+ "blockedBy": [0],
28
+ "description": "RendersStructuredInputs concern: single via nest_one (one fieldset), repeater via nest_many (repeater chrome), classless, as: name. Branch render_resource_field in resource.rb. Full HTML assertions in Task 6.\n\n```json:metadata\n{\"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}\n```"
29
+ },
30
+ {
31
+ "id": 4,
32
+ "subject": "Task 4: Param flow — apply cleaner on both hosts",
33
+ "status": "completed",
34
+ "blockedBy": [0, 1],
35
+ "description": "ParamsConcern#clean_structured_inputs(definition, params) keyed off defined_structured_inputs; applied in submitted_resource_params and submitted_interaction_params.\n\n```json:metadata\n{\"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}\n```"
36
+ },
37
+ {
38
+ "id": 5,
39
+ "subject": "Task 5: Dummy fixtures (generators)",
40
+ "status": "completed",
41
+ "blockedBy": [0, 2, 3],
42
+ "description": "Via pu:* generators: a catalog resource with json columns + structured_input (single + repeat) + policy permit + admin route; an interaction with single + repeat structured_input wired as an action.\n\n```json:metadata\n{\"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}\n```"
43
+ },
44
+ {
45
+ "id": 6,
46
+ "subject": "Task 6: Integration tests — render + round-trip (both hosts)",
47
+ "status": "completed",
48
+ "blockedBy": [3, 4, 5],
49
+ "description": "Render + persistence round-trip for single + repeater on resources (JSON column) and interactions (attribute to execute). Contract source of truth for the as: naming.\n\n```json:metadata\n{\"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}\n```"
50
+ },
51
+ {
52
+ "id": 7,
53
+ "subject": "Task 7: Full regression + docs",
54
+ "status": "completed",
55
+ "blockedBy": [6],
56
+ "description": "Full suite + cross-version spot checks; rework interaction README to structured_input; document structured_input; update skill docs referencing interaction nested_input.\n\n```json:metadata\n{\"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}\n```"
57
+ }
58
+ ],
59
+ "lastUpdated": "2026-06-02"
60
+ }
@@ -0,0 +1,191 @@
1
+ # Structured Inputs
2
+
3
+ **Date:** 2026-06-01 (updated 2026-06-02)
4
+ **Status:** Approved — DSL name `structured_input` final; ready for implementation plan
5
+
6
+ ## Problem
7
+
8
+ There is no first-class way to collect a **structured input** — a group of
9
+ fields gathered into one object, or a variable-length list of such objects —
10
+ without an ActiveRecord association behind it:
11
+
12
+ - **Interactions** declare scalar `attribute`s only. They cannot collect a
13
+ structured object (`address => {street:, city:}`) or a list of them. The
14
+ included `nested_input` is model-backed and silently renders nothing for the
15
+ classless case (characterized in
16
+ `test/plutonium/ui/form/interaction_nested_input_test.rb`).
17
+ - **Resources** can edit a JSON/jsonb column only as a raw JSON textarea or a
18
+ flat `key_value_store` — no structured UI for `{a, b}` or `[{a, b}, …]` stored
19
+ in a JSON column. Association-backed repetition is `nested_input`'s job; JSON
20
+ columns have nothing.
21
+
22
+ ## Goal
23
+
24
+ One DSL — **`structured_input`** — for a *classless* group of fields, collected
25
+ as:
26
+
27
+ - a **hash** (single object) by default, or
28
+ - an **array of hashes** when `repeat:` is given (the repeater),
29
+
30
+ rendered with a fields group, and feeding either an **interaction attribute** or
31
+ a **resource JSON column**. The single object is the base; the repeater is the
32
+ same group rendered N times.
33
+
34
+ ### Non-goals
35
+
36
+ - Type coercion of values. Rows/objects are plain hashes; the host validates.
37
+ - Replacing model-backed `nested_input` (stays the resource association feature).
38
+
39
+ ## Feasibility anchors
40
+
41
+ This design follows the grain of phlexi-form rather than fighting it:
42
+
43
+ 1. **`nest_one` → hash, `nest_many` → array.** The library's own
44
+ `extract_input` already produces exactly these shapes, and `nest_many` is
45
+ literally `nest_one` repeated:
46
+
47
+ ```ruby
48
+ # Namespace#extract_input (nest_one): { key => { field => value, … } }
49
+ # NamespaceCollection#extract_input (nest_many):
50
+ params = params[key]
51
+ params = params.values if params.is_a?(Hash) # index-hash → array, free
52
+ { key => Array(params).map { |p| namespace.extract_input([p]) } }
53
+ ```
54
+
55
+ 2. **Hash-backed rendering.** `Phlexi::Field::Support::Value.from` reads
56
+ `object[key]` for a `Hash`, so a row/object is a plain hash and the blank
57
+ template is `{}`. No synthesized classes.
58
+
59
+ 3. **Form-driven param extraction.** Plutonium extracts submitted resource
60
+ params via `build_form(record).extract_input(params)`
61
+ (`controller.rb#submitted_resource_params`). Nesting with `as: :<name>` makes
62
+ the extracted hash/array land under `:<name>`, which assigns **directly** to
63
+ the JSON column / interaction attribute — no `_attributes=` setter, no manual
64
+ strong-params.
65
+
66
+ ## Design
67
+
68
+ ### 1. The DSL — `structured_input`
69
+
70
+ ```ruby
71
+ # single → hash
72
+ structured_input :address do |f|
73
+ f.input :street
74
+ f.input :city
75
+ end
76
+ # value: address => { street:, city: }
77
+
78
+ # repeater → array of hashes (max 10 rows)
79
+ structured_input :contacts, repeat: 10 do |f|
80
+ f.input :label
81
+ f.input :phone_number
82
+ end
83
+ # value: contacts => [ { label:, phone_number: }, … ]
84
+ ```
85
+
86
+ - `repeat:` semantics: **`repeat: <int>` = maximum rows**; `repeat: true` =
87
+ repeater with the default cap; **absent = single** (hash). Presence of
88
+ `repeat:` always means an array — `repeat: 1` is "array, max one row", *not*
89
+ the single form.
90
+ - Fields come from a block or `using:`/`fields:` (the existing lightweight
91
+ `NestedInputsDefinition` holder, `defineable_props :field, :input`).
92
+ - Registers config in a `defined_structured_inputs` registry, via a shared
93
+ module included by both resource definitions (`Plutonium::Definition::Base`)
94
+ and interactions (`Plutonium::Interaction::Base`) — both already mix in
95
+ `DefineableProps`.
96
+
97
+ ### 2. Rendering (shared, classless)
98
+
99
+ The unit is the **fields group** — the declared inputs rendered over a nested
100
+ builder. Single and repeater differ only in `nest_one` vs `nest_many` and the
101
+ surrounding chrome:
102
+
103
+ - **Single:** `nest_one(:address, as: :address)` over the current value (or `{}`)
104
+ → one fieldset of inputs. No add/remove chrome, no `<template>`, no hidden
105
+ `id`/`_destroy`.
106
+ - **Repeater:** `nest_many(:contacts, as: :contacts)` → the existing repeater
107
+ chrome (the `nested-resource-form-fields` Stimulus controller, `limit` = the
108
+ `repeat` cap, a `<template>` holding a blank `{}` row, existing rows from the
109
+ array value, an add button, a per-row delete control). No hidden `id`/`_destroy`.
110
+
111
+ This reuses the same Stimulus controller and visual structure as the model-backed
112
+ repeater, but is a **separate classless code path**. Model-backed `nested_input`
113
+ rendering is untouched (still covered by
114
+ `test/integration/admin_portal/nested_form_rendering_test.rb`).
115
+
116
+ ### 3. Param flow (shared)
117
+
118
+ - Nesting with `as: :<name>` → `extract_input` yields `{name => {…}}` (single) or
119
+ `{name => [{…}, …]}` (repeater, index-hash already normalized to array).
120
+ - The value assigns **directly** to the JSON column / interaction attribute.
121
+ - A shared **clean step**, keyed off `defined_structured_inputs`, runs
122
+ post-extract: for repeaters, drop all-blank rows and strip `_destroy`; single
123
+ passes through. Rows are positional — **no ids**.
124
+
125
+ ### 4. Host — interactions
126
+
127
+ `structured_input :name` declares `attribute :name` defaulting to `{}` (single)
128
+ or `[]` (repeater). The extracted+cleaned value flows into the attribute;
129
+ `execute` sees a hash or an array.
130
+
131
+ ### 5. Host — resources (JSON, no model macro)
132
+
133
+ Because it is classless, the resource side needs **no `accepts_nested_attributes_for`-style
134
+ model declaration** — just:
135
+
136
+ - **Model:** a `json`/`jsonb` column named `name`.
137
+ - **Definition:** `structured_input :name …`.
138
+ - **Policy:** permit `:name` in `permitted_attributes_for_*` so the form renders
139
+ it.
140
+
141
+ Params then flow via the form's `extract_input` → clean → `record.name = hash/array`
142
+ → persisted in the JSON column. The `nested_input` model/definition split
143
+ dissolves here; classless JSON needs only the column + the definition DSL.
144
+
145
+ ### 6. Remove the broken nested surface from interactions
146
+
147
+ - `nested_input` is **removed** from interactions (`Interaction::Base` stops
148
+ mixing in `Plutonium::Definition::NestedInputs`) → `NoMethodError` at
149
+ class-load. `defined_nested_inputs` is likewise undefined; the inherited form
150
+ branch is guarded by `respond_to?` and simply skips.
151
+ - `accepts_nested_attributes_for` is **removed** from interactions
152
+ (`Interaction::Base` stops mixing in `Plutonium::Interaction::NestedAttributes`).
153
+ Build models explicitly in `execute` if ever needed.
154
+
155
+ Resources keep both model-backed `nested_input` and AR `accepts_nested_attributes_for`
156
+ unchanged, and additionally gain `structured_input`.
157
+
158
+ ## Testing
159
+
160
+ - **Clean step** (unit): array and index-keyed-hash → array; all-blank rows
161
+ dropped; `_destroy` stripped; single passes through; no ids.
162
+ - **Single** (interaction + resource): renders one fieldset; round-trip → a hash
163
+ value on the attribute / JSON column.
164
+ - **Repeater** (interaction + resource): renders the repeater chrome with
165
+ `host[contacts][NEW_RECORD][label]` naming, add/delete, no hidden id/`_destroy`;
166
+ round-trip → an array value; blank rows rejected; edit repopulates.
167
+ - **Guard** (unit): interactions do not respond to `nested_input` /
168
+ `accepts_nested_attributes_for` / `defined_nested_inputs` (replaces the current
169
+ `interaction_nested_input_test.rb`).
170
+ - **Regression**: resource `nested_input` unchanged — existing characterization
171
+ tests stay green.
172
+ - Fixtures via the `pu:*` generators; the resource fixture uses a JSON column.
173
+
174
+ ## Open questions (resolve in plan)
175
+
176
+ - **Clean-step placement:** one shared function, two call sites (the resource
177
+ controller param path; the interaction's param assignment). Confirm the exact
178
+ hooks.
179
+ - **Field naming:** `as: :<name>` (direct assignment, recommended) vs a
180
+ `_attributes` suffix. Verify no collision with form/extract internals.
181
+ - **JSON column type in the dummy fixture:** SQLite `json`/serialized text for
182
+ the test; document `jsonb` as the production recommendation.
183
+
184
+ ## Risk / breaking change
185
+
186
+ - Removing `nested_input` + `accepts_nested_attributes_for` from interactions is
187
+ a deliberate breaking change; the only previously-working case (an interaction
188
+ mirroring a real resource association) is redundant with resource forms.
189
+ Offenders get a `NoMethodError` naming the method; the README is reworked to
190
+ `structured_input`.
191
+ - `structured_input` is additive on resources and new on interactions.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.53.1)
4
+ plutonium (0.54.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -33,6 +33,7 @@ module Plutonium
33
33
  include Scoping
34
34
  include Search
35
35
  include NestedInputs
36
+ include StructuredInputs
36
37
  include IndexViews
37
38
  include Metadata
38
39
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Classless structured inputs: a group of fields collected into a hash
6
+ # (single) or an array of hashes (when `repeat:` is given). Mixed into both
7
+ # resource definitions and interactions.
8
+ #
9
+ # @example
10
+ # structured_input :address do |f|
11
+ # f.input :street
12
+ # f.input :city
13
+ # end
14
+ #
15
+ # structured_input :contacts, repeat: 10 do |f|
16
+ # f.input :label
17
+ # f.input :phone_number
18
+ # end
19
+ module StructuredInputs
20
+ extend ActiveSupport::Concern
21
+
22
+ # Holder built per render from a structured_input block. Reuses the same
23
+ # field/input DSL as the rest of Plutonium definitions.
24
+ class FieldsDefinition
25
+ include Plutonium::Definition::DefineableProps
26
+
27
+ defineable_props :field, :input
28
+ end
29
+
30
+ class_methods do
31
+ # @option options [Integer, true] :repeat presence => array; Integer => max rows
32
+ # @option options [Class] :using a FieldsDefinition-like class instead of a block
33
+ # @option options [Array<Symbol>] :fields restrict rendered fields
34
+ def structured_input(name, **options, &block)
35
+ unless block || options[:using]
36
+ raise ArgumentError,
37
+ "`structured_input :#{name}` needs a block or `using:` — e.g. " \
38
+ "`structured_input :#{name} do |f| f.input :field end` or " \
39
+ "`structured_input :#{name}, using: #{name.to_s.classify}Fields`"
40
+ end
41
+
42
+ defined_structured_inputs[name] = {options:, block:}.compact
43
+ end
44
+
45
+ def defined_structured_inputs
46
+ @defined_structured_inputs ||= {}
47
+ end
48
+
49
+ def inherited(subclass)
50
+ super
51
+ subclass.instance_variable_set(
52
+ :@defined_structured_inputs,
53
+ defined_structured_inputs.deep_dup
54
+ )
55
+ end
56
+ end
57
+
58
+ # Instance access mirrors the defineable_prop convention (where
59
+ # `defined_<plural>` is available on instances). The form's render path and
60
+ # the param cleaner both hold a definition instance, so they read the
61
+ # registry through here.
62
+ def defined_structured_inputs
63
+ self.class.defined_structured_inputs
64
+ end
65
+ end
66
+ end
67
+ end
@@ -262,71 +262,21 @@ class MyInteraction < Plutonium::Interaction::Base
262
262
  end
263
263
  ```
264
264
 
265
- ### Interactions with Nested Attributes
265
+ ### Interactions with Structured Input
266
266
 
267
- This example demonstrates how to handle nested attributes—specifically,
268
- a `User` with multiple `Contact` and `UserAddress` records using
269
- a Plutonium `Interaction`.
267
+ > **Note:** `nested_input` and `accepts_nested_attributes_for` are **not**
268
+ > available on interactions. They are resource-only features that work with
269
+ > model-backed `has_many`/`has_one` associations. For collecting structured or
270
+ > repeating input inside an interaction, use `structured_input` instead.
270
271
 
271
- #### Key Highlights
272
+ `structured_input` declares an attribute on the interaction and renders an
273
+ inline fieldset in the auto-generated form. It comes in two forms:
272
274
 
273
- The model definitions are included here for completeness, but the primary focus
274
- remains on demonstrating how to build interactions that handle nested
275
- attributes.
276
-
277
- - Core user attributes (`first_name`, `last_name`, `email`) are declared and
278
- validated at the top level of the interaction.
279
-
280
- - Nested associations (`contacts`, `addresses`) are managed via
281
- `accepts_nested_attributes_for`. The optional `reject_if` condition is used
282
- to discard entries that lack required fields—helping ensure data integrity at
283
- the input level.
284
-
285
- - The `nested_input` DSL provides a declarative way to structure nested inputs,
286
- specifying accepted fields and mapping them to their respective definition
287
- classes (`ContactDefinition` and `UserAddressDefinition`).
288
-
289
- - During execution, a `User` instance is initialized with both top-level and
290
- nested attributes, then persisted with all applicable validations.
291
-
292
- **Note:** The `class_name` option is explicitly defined in the interaction's
293
- `accepts_nested_attributes_for` macro because the `addresses` association does
294
- not directly map to its underlying model name. Simply provide the class name,
295
- for example, `class_name: "UserAddress"`, to ensure the correct model is used.
296
-
297
- **This is essential only when the association name differs from the actual
298
- class name.**
299
-
300
- This approach enables seamless handling of complex nested input from forms or
301
- API requests, while keeping validation logic clean, maintainable, and modular.
275
+ - **Single** the attribute arrives in `execute` as a plain `Hash`.
276
+ - **Repeat** the attribute arrives as an `Array` of hashes (capped at the
277
+ given number of rows; `repeat: true` defaults to 10).
302
278
 
303
279
  ```ruby
304
- # app/models/user.rb
305
- class User < ApplicationRecord
306
- include Plutonium::Resource::Record
307
-
308
- has_many :contacts
309
- has_many :addresses, class_name: "UserAddress"
310
-
311
- accepts_nested_attributes_for :contacts, :addresses
312
- end
313
-
314
- # app/models/contact.rb
315
- class Contact < ApplicationRecord
316
- include Plutonium::Resource::Record
317
-
318
- belongs_to :user
319
- validates :label, :phone_number, presence: true
320
- end
321
-
322
- # app/models/user_address.rb
323
- class UserAddress < ApplicationRecord
324
- include Plutonium::Resource::Record
325
-
326
- belongs_to :user
327
- validates :label, :map_url, presence: true
328
- end
329
-
330
280
  # app/interactions/users/interactions/create_user_interaction.rb
331
281
  module Users
332
282
  module Interactions
@@ -338,37 +288,33 @@ module Users
338
288
  attribute :first_name, :string
339
289
  attribute :last_name, :string
340
290
  attribute :email, :string
341
- attribute :contacts
342
- attribute :addresses
343
291
 
344
292
  validates :first_name, :last_name, presence: true
345
293
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
346
294
 
347
- accepts_nested_attributes_for :contacts,
348
- reject_if: proc { |attributes| attributes[:label].blank? }
349
-
350
- accepts_nested_attributes_for :addresses, class_name: "UserAddress",
351
- reject_if: proc { |attributes| attributes[:label].blank? }
352
-
353
- nested_input :contacts,
354
- using: ContactDefinition,
355
- fields: %i[label phone_number],
356
- description: "Add one or more contacts for this user."
295
+ # single → a hash
296
+ structured_input :address do |f|
297
+ f.input :street
298
+ f.input :city
299
+ end
357
300
 
358
- nested_input :addresses,
359
- using: UserAddressDefinition,
360
- fields: %i[label map_url],
361
- description: "Add one or more addresses for this user."
301
+ # repeater → an array of hashes (max 5 rows)
302
+ structured_input :contacts, repeat: 5 do |f|
303
+ f.input :label
304
+ f.input :phone_number
305
+ end
362
306
 
363
307
  private
364
308
 
365
309
  def execute
366
- user = User.new(self.attributes)
310
+ # address => { "street" => "...", "city" => "..." }
311
+ # contacts => [ { "label" => "...", "phone_number" => "..." }, ... ]
312
+ user = User.new(first_name: first_name, last_name: last_name, email: email)
367
313
 
368
314
  if user.save
369
315
  success(user).with_message("User created successfully")
370
316
  else
371
- failed(user.errors)
317
+ failure(user.errors)
372
318
  end
373
319
  end
374
320
  end
@@ -25,8 +25,16 @@ module Plutonium
25
25
  include Plutonium::Definition::DefineableProps
26
26
  include Plutonium::Definition::ConfigAttr
27
27
  include Plutonium::Definition::Presentable
28
- include Plutonium::Definition::NestedInputs
29
- include Plutonium::Interaction::NestedAttributes
28
+ include Plutonium::Definition::StructuredInputs
29
+
30
+ # On interactions, declaring a structured input also declares the backing
31
+ # ActiveModel attribute so the value survives `attributes=` and appears in
32
+ # `attribute_names` (which drives the interaction form's field list).
33
+ def self.structured_input(name, **options, &block)
34
+ super
35
+ default = options[:repeat] ? -> { [] } : -> { {} }
36
+ attribute name, default: default
37
+ end
30
38
 
31
39
  # include Plutonium::Interaction::Concerns::WorkflowDSL
32
40
 
@@ -16,6 +16,7 @@ module Plutonium
16
16
  include Plutonium::Resource::Controllers::CrudActions
17
17
  include Plutonium::Resource::Controllers::InteractiveActions
18
18
  include Plutonium::Resource::Controllers::Typeahead
19
+ include Plutonium::StructuredInputs::ParamsConcern
19
20
 
20
21
  included do
21
22
  after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
@@ -144,7 +145,11 @@ module Plutonium
144
145
  # Use existing record (cloned) for context during param extraction, or new instance for create
145
146
  # Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
146
147
  extraction_record = resource_record?&.dup || resource_class.new
147
- @submitted_resource_params ||= build_form(extraction_record, form_action: false).extract_input(params, view_context:)[resource_param_key.to_sym].compact
148
+ @submitted_resource_params ||= begin
149
+ extracted = build_form(extraction_record, form_action: false)
150
+ .extract_input(params, view_context:)[resource_param_key.to_sym].compact
151
+ clean_structured_inputs(current_definition, extracted)
152
+ end
148
153
  end
149
154
 
150
155
  # Returns the resource parameters, including scoped and parent parameters
@@ -3,6 +3,7 @@ module Plutonium
3
3
  module Controllers
4
4
  module InteractiveActions
5
5
  extend ActiveSupport::Concern
6
+ include Plutonium::StructuredInputs::ParamsConcern
6
7
 
7
8
  included do
8
9
  helper_method :current_interactive_action
@@ -239,13 +240,16 @@ module Plutonium
239
240
  @interaction
240
241
  end
241
242
 
242
- # Returns the submitted resource parameters
243
- # @return [Hash] The submitted resource parameters
243
+ # Returns the submitted interaction parameters
244
+ # @return [Hash] The submitted interaction parameters
244
245
  def submitted_interaction_params
245
- @submitted_interaction_params ||= current_interactive_action
246
- .interaction
247
- .build_form(current_interactive_action.interaction.new(view_context:))
248
- .extract_input(params, view_context:)[:interaction]
246
+ @submitted_interaction_params ||= begin
247
+ interaction = current_interactive_action.interaction
248
+ extracted = interaction
249
+ .build_form(interaction.new(view_context:))
250
+ .extract_input(params, view_context:)[:interaction]
251
+ clean_structured_inputs(interaction, extracted)
252
+ end
249
253
  end
250
254
 
251
255
  def redirect_url_after_action_on(resource_record_or_resource_class)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module StructuredInputs
5
+ # Normalises an extracted structured-input value before it is stored.
6
+ #
7
+ # The form's `extract_input` already yields a `Hash` (single) or an
8
+ # `Array<Hash>` (repeater), so the only work left is to symbolise keys and,
9
+ # for repeaters, drop rows the user left entirely blank.
10
+ module ParamCleaner
11
+ module_function
12
+
13
+ # @param value [Hash, Array, nil] the extracted param for this input
14
+ # @param repeat [Boolean, Integer] truthy => array (repeater), else hash
15
+ # @return [Hash, Array<Hash>]
16
+ def call(value, repeat:)
17
+ repeat ? clean_collection(value) : clean_one(value)
18
+ end
19
+
20
+ def clean_one(value)
21
+ value.is_a?(Hash) ? symbolize(value) : {}
22
+ end
23
+
24
+ def clean_collection(value)
25
+ Array(value)
26
+ .select { |row| row.is_a?(Hash) }
27
+ .map { |row| symbolize(row) }
28
+ .reject { |row| row.values.all? { |v| v.to_s.strip.empty? } }
29
+ end
30
+
31
+ def symbolize(row)
32
+ row.to_h.transform_keys(&:to_sym)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module StructuredInputs
5
+ # Rewrites structured-input params in place through ParamCleaner. Shared by
6
+ # the resource controller and the interactive-actions controller.
7
+ module ParamsConcern
8
+ # @param definition a definition instance (resource) or class (interaction)
9
+ # exposing `defined_structured_inputs`
10
+ # @param params [Hash] extracted form params (mutable copy)
11
+ # @return [Hash]
12
+ def clean_structured_inputs(definition, params)
13
+ registry = structured_inputs_registry(definition)
14
+ return params unless registry
15
+
16
+ registry.each do |name, entry|
17
+ next unless params.key?(name)
18
+
19
+ repeat = entry[:options]&.fetch(:repeat, false)
20
+ params[name] = Plutonium::StructuredInputs::ParamCleaner.call(params[name], repeat:)
21
+ end
22
+ params
23
+ end
24
+
25
+ private
26
+
27
+ def structured_inputs_registry(definition)
28
+ if definition.respond_to?(:defined_structured_inputs)
29
+ definition.defined_structured_inputs
30
+ elsif definition.class.respond_to?(:defined_structured_inputs)
31
+ definition.class.defined_structured_inputs
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -10,7 +10,7 @@ module Plutonium
10
10
  module RendersNestedResourceFields
11
11
  extend ActiveSupport::Concern
12
12
 
13
- DEFAULT_NESTED_LIMIT = 10
13
+ DEFAULT_NESTED_LIMIT = RepeaterFieldStyles::DEFAULT_LIMIT
14
14
  NESTED_OPTION_KEYS = [:allow_destroy, :update_only, :macro, :class].freeze
15
15
  SINGULAR_MACROS = %i[belongs_to has_one].freeze
16
16
 
@@ -193,7 +193,7 @@ module Plutonium
193
193
  def render_nested_fields_fieldset(nested, context)
194
194
  fieldset(
195
195
  data_new_record: !nested.object&.persisted?,
196
- class: "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
196
+ class: RepeaterFieldStyles::FIELDSET_CLASS
197
197
  ) do
198
198
  render_nested_fields_fieldset_content(nested, context)
199
199
  render_nested_fields_delete_button(nested, context.options)
@@ -201,7 +201,7 @@ module Plutonium
201
201
  end
202
202
 
203
203
  def render_nested_fields_fieldset_content(nested, context)
204
- div(class: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense") do
204
+ div(class: RepeaterFieldStyles::FIELD_GRID_CLASS) do
205
205
  render_nested_fields_hidden_fields(nested, context)
206
206
  render_nested_fields_visible_fields(nested, context)
207
207
  end