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,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.
|
|
@@ -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
|
|
265
|
+
### Interactions with Structured Input
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
29
|
-
|
|
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 ||=
|
|
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
|
|
243
|
-
# @return [Hash] The submitted
|
|
243
|
+
# Returns the submitted interaction parameters
|
|
244
|
+
# @return [Hash] The submitted interaction parameters
|
|
244
245
|
def submitted_interaction_params
|
|
245
|
-
@submitted_interaction_params ||=
|
|
246
|
-
.interaction
|
|
247
|
-
|
|
248
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|