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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Renders classless structured inputs (single → hash via nest_one,
|
|
8
|
+
# repeater → array via nest_many). Field/namespace work is delegated to
|
|
9
|
+
# the form; this concern owns the structural markup.
|
|
10
|
+
# @api private
|
|
11
|
+
module RendersStructuredInputs
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def render_structured_input(name)
|
|
17
|
+
entry = resource_definition.defined_structured_inputs[name]
|
|
18
|
+
options = entry[:options] || {}
|
|
19
|
+
definition = structured_input_fields_definition(entry)
|
|
20
|
+
fields = options[:fields] || definition.defined_inputs.keys
|
|
21
|
+
repeat = options[:repeat]
|
|
22
|
+
|
|
23
|
+
if repeat
|
|
24
|
+
render_structured_repeater(name, definition, fields, repeat_limit(repeat))
|
|
25
|
+
else
|
|
26
|
+
render_structured_single(name, definition, fields)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def structured_input_fields_definition(entry)
|
|
31
|
+
return entry[:options][:using] if entry[:options]&.key?(:using)
|
|
32
|
+
|
|
33
|
+
holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
|
|
34
|
+
entry[:block].call(holder)
|
|
35
|
+
holder
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def repeat_limit(repeat)
|
|
39
|
+
repeat.is_a?(Integer) ? repeat : RepeaterFieldStyles::DEFAULT_LIMIT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- single -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def render_structured_single(name, definition, fields)
|
|
45
|
+
raw = structured_input_value(name)
|
|
46
|
+
value = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
|
|
47
|
+
div(class: "col-span-full space-y-2 my-4") do
|
|
48
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
49
|
+
nest_one(name, as: name, object: value) do |nested|
|
|
50
|
+
render_structured_fieldset(nested, definition, fields, removable: false)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- repeater -----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def render_structured_repeater(name, definition, fields, limit)
|
|
58
|
+
rows = Array(structured_input_value(name)).map { |row| row.is_a?(Hash) ? row.with_indifferent_access : row }
|
|
59
|
+
existing_collection = rows.presence || {NEW_RECORD: {}}
|
|
60
|
+
div(
|
|
61
|
+
class: "col-span-full space-y-2 my-4",
|
|
62
|
+
data: {
|
|
63
|
+
controller: "nested-resource-form-fields",
|
|
64
|
+
nested_resource_form_fields_limit_value: limit
|
|
65
|
+
}
|
|
66
|
+
) do
|
|
67
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
68
|
+
template data_nested_resource_form_fields_target: "template" do
|
|
69
|
+
nest_many(name, as: name, collection: {NEW_RECORD: {}}, default: {NEW_RECORD: {}}, template: true) do |nested|
|
|
70
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
nest_many(name, as: name, collection: existing_collection) do |nested|
|
|
74
|
+
if nested.object.blank?
|
|
75
|
+
vanish { render_structured_fieldset(nested, definition, fields, removable: true) }
|
|
76
|
+
else
|
|
77
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
div(data_nested_resource_form_fields_target: :target, hidden: true)
|
|
81
|
+
render_structured_add_button(name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- helpers ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def structured_input_value(name)
|
|
88
|
+
obj = object
|
|
89
|
+
return nil unless obj.respond_to?(name)
|
|
90
|
+
obj.public_send(name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Single source of truth shared with RendersNestedResourceFields.
|
|
94
|
+
FIELDSET_CLASS = RepeaterFieldStyles::FIELDSET_CLASS
|
|
95
|
+
FIELD_GRID_CLASS = RepeaterFieldStyles::FIELD_GRID_CLASS
|
|
96
|
+
|
|
97
|
+
# @param removable [Boolean] repeater rows are removable; a single
|
|
98
|
+
# structured input is the one-and-only object, so it is not.
|
|
99
|
+
def render_structured_fieldset(nested, definition, fields, removable:)
|
|
100
|
+
unless removable
|
|
101
|
+
return fieldset(class: FIELDSET_CLASS) do
|
|
102
|
+
div(class: FIELD_GRID_CLASS) do
|
|
103
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Removable rows soft-delete by DISABLING the inner fieldset: a
|
|
109
|
+
# disabled <fieldset> is omitted from submission, so the server just
|
|
110
|
+
# receives the payload without this row and rebuilds the JSON column
|
|
111
|
+
# from what it gets (no _destroy marker). The row stays in the DOM,
|
|
112
|
+
# collapsed to a "Removed — Restore" bar, so it can be restored.
|
|
113
|
+
div(data_controller: "structured-input-row", class: FIELDSET_CLASS) do
|
|
114
|
+
fieldset(data_structured_input_row_target: "content", class: "space-y-4 border-0 p-0 m-0") do
|
|
115
|
+
div(class: FIELD_GRID_CLASS) do
|
|
116
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
117
|
+
end
|
|
118
|
+
render_structured_remove_button
|
|
119
|
+
end
|
|
120
|
+
render_structured_removed_bar
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_structured_remove_button
|
|
125
|
+
div(class: "flex items-center justify-end") do
|
|
126
|
+
button(
|
|
127
|
+
type: :button,
|
|
128
|
+
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
129
|
+
"text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
|
|
130
|
+
"focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900",
|
|
131
|
+
data_action: "structured-input-row#remove"
|
|
132
|
+
) do
|
|
133
|
+
render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
|
|
134
|
+
span { "Remove" }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Compact bar shown in place of the row once it's marked for removal.
|
|
140
|
+
def render_structured_removed_bar
|
|
141
|
+
div(
|
|
142
|
+
data_structured_input_row_target: "removed",
|
|
143
|
+
hidden: true,
|
|
144
|
+
class: "flex items-center justify-between gap-3 text-sm text-[var(--pu-text-muted)]"
|
|
145
|
+
) do
|
|
146
|
+
span(class: "italic") { "Removed" }
|
|
147
|
+
button(
|
|
148
|
+
type: :button,
|
|
149
|
+
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
150
|
+
"text-secondary-700 hover:bg-secondary-50 dark:text-secondary-300 dark:hover:bg-secondary-900/30 " \
|
|
151
|
+
"focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900",
|
|
152
|
+
data_action: "structured-input-row#restore"
|
|
153
|
+
) do
|
|
154
|
+
render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
|
|
155
|
+
span { "Restore" }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_structured_add_button(name)
|
|
161
|
+
div do
|
|
162
|
+
button(
|
|
163
|
+
type: :button,
|
|
164
|
+
class: "inline-block",
|
|
165
|
+
data: {action: "nested-resource-form-fields#add", nested_resource_form_fields_target: "addButton"}
|
|
166
|
+
) do
|
|
167
|
+
span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
|
|
168
|
+
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
|
|
169
|
+
span { "Add #{name.to_s.singularize.humanize}" }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Shared sizing + chrome for repeater-style field groups. Both
|
|
8
|
+
# RendersNestedResourceFields and RendersStructuredInputs render the
|
|
9
|
+
# same fieldset/grid markup and share a default row limit, so the
|
|
10
|
+
# values live here to keep the two concerns from drifting apart.
|
|
11
|
+
module RepeaterFieldStyles
|
|
12
|
+
# Default maximum number of rows a repeater renders/clones.
|
|
13
|
+
DEFAULT_LIMIT = 10
|
|
14
|
+
|
|
15
|
+
# Outer fieldset chrome for a single repeater row.
|
|
16
|
+
FIELDSET_CLASS = "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
|
|
17
|
+
|
|
18
|
+
# Responsive grid the row's fields are laid out in.
|
|
19
|
+
FIELD_GRID_CLASS = "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -5,6 +5,7 @@ module Plutonium
|
|
|
5
5
|
module Form
|
|
6
6
|
class Resource < Base
|
|
7
7
|
include Plutonium::UI::Form::Concerns::RendersNestedResourceFields
|
|
8
|
+
include Plutonium::UI::Form::Concerns::RendersStructuredInputs
|
|
8
9
|
|
|
9
10
|
attr_reader :resource_fields, :resource_definition, :singular_resource
|
|
10
11
|
|
|
@@ -171,7 +172,9 @@ module Plutonium
|
|
|
171
172
|
|
|
172
173
|
def render_resource_field(name)
|
|
173
174
|
when_permitted(name) do
|
|
174
|
-
if resource_definition.respond_to?(:
|
|
175
|
+
if resource_definition.respond_to?(:defined_structured_inputs) && resource_definition.defined_structured_inputs[name]
|
|
176
|
+
render_structured_input(name)
|
|
177
|
+
elsif resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
|
|
175
178
|
render_nested_resource_field(name)
|
|
176
179
|
else
|
|
177
180
|
render_simple_resource_field(name, resource_definition, self)
|
|
@@ -25,11 +25,17 @@ module Plutonium
|
|
|
25
25
|
# controller on the frame after showModal(). Mirrors the filter
|
|
26
26
|
# slideover's pattern — see Centered for the same rationale.
|
|
27
27
|
def base_dialog_classes
|
|
28
|
+
# The backdrop dim+blur is static (no [data-open] gating, no
|
|
29
|
+
# transition): a ::backdrop that fades its bg-color while carrying
|
|
30
|
+
# backdrop-filter re-rasterises the blur every frame and stutters
|
|
31
|
+
# the panel slide. Keeping it static lets only the panel animate
|
|
32
|
+
# (transform), composited smoothly. The backdrop snaps in at
|
|
33
|
+
# showModal() and is dropped when the dialog leaves the top layer
|
|
34
|
+
# on close(), so it still covers the panel's slide-out. Mirrors
|
|
35
|
+
# the .pu-dialog::backdrop rule in components.css.
|
|
28
36
|
"fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
|
|
29
37
|
"bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
|
|
30
|
-
"backdrop:bg-
|
|
31
|
-
"data-[open]:backdrop:backdrop-blur-sm " \
|
|
32
|
-
"backdrop:transition-[background-color] backdrop:duration-300 backdrop:ease-out " \
|
|
38
|
+
"backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
|
|
33
39
|
"rounded-none p-0 " \
|
|
34
40
|
"open:flex flex-col " \
|
|
35
41
|
"translate-x-full data-[open]:translate-x-0 " \
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -513,12 +513,17 @@
|
|
|
513
513
|
border-radius: var(--pu-radius-lg);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/* Dim + frosted blur for the whole time the dialog is in the top layer.
|
|
517
|
+
Deliberately NOT transitioned and NOT gated on [data-open]: a ::backdrop
|
|
518
|
+
that repaints every frame (a bg-color fade) while carrying a
|
|
519
|
+
backdrop-filter forces the browser to re-rasterise the blur per frame,
|
|
520
|
+
which stutters the panel's open/close animation — and doubly so when a
|
|
521
|
+
nested confirm animates over a parent modal whose blur is still live.
|
|
522
|
+
Keeping it static lets the GPU rasterise the blur once; only the panel
|
|
523
|
+
animates (opacity/scale/translate, cheaply composited). The backdrop
|
|
524
|
+
snaps in at showModal() and is removed when the dialog leaves the top
|
|
525
|
+
layer on close(), so the dim still covers the panel's exit. */
|
|
516
526
|
.pu-dialog::backdrop {
|
|
517
|
-
background-color: transparent;
|
|
518
|
-
transition: background-color 200ms ease-out;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
.pu-dialog[data-open]::backdrop {
|
|
522
527
|
background-color: rgb(0 0 0 / 0.6);
|
|
523
528
|
backdrop-filter: blur(4px);
|
|
524
529
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Import controllers here
|
|
2
2
|
import ResourceHeaderController from "./resource_header_controller.js"
|
|
3
3
|
import NestedResourceFormFieldsController from "./nested_resource_form_fields_controller.js"
|
|
4
|
+
import StructuredInputRowController from "./structured_input_row_controller.js"
|
|
4
5
|
import FormController from "./form_controller.js"
|
|
5
6
|
import ResourceDropDownController from "./resource_drop_down_controller.js"
|
|
6
7
|
import ResourceCollapseController from "./resource_collapse_controller.js"
|
|
@@ -40,6 +41,7 @@ export default function (application) {
|
|
|
40
41
|
application.register("sidebar", SidebarController)
|
|
41
42
|
application.register("resource-header", ResourceHeaderController)
|
|
42
43
|
application.register("nested-resource-form-fields", NestedResourceFormFieldsController)
|
|
44
|
+
application.register("structured-input-row", StructuredInputRowController)
|
|
43
45
|
application.register("form", FormController)
|
|
44
46
|
application.register("resource-drop-down", ResourceDropDownController)
|
|
45
47
|
application.register("resource-collapse", ResourceCollapseController)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="structured-input-row"
|
|
4
|
+
//
|
|
5
|
+
// Soft-removes a classless structured-input row by DISABLING its fields — a
|
|
6
|
+
// disabled <fieldset> is omitted from form submission, so the server simply
|
|
7
|
+
// receives the payload without that row and rebuilds the JSON column from what
|
|
8
|
+
// it gets (no _destroy marker needed). The row stays in the DOM, collapsed to a
|
|
9
|
+
// "Removed — Restore" bar, so it can be restored (re-enabled) before saving.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static targets = ["content", "removed"]
|
|
12
|
+
|
|
13
|
+
remove(e) {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
this.contentTarget.disabled = true
|
|
16
|
+
this.contentTarget.hidden = true
|
|
17
|
+
this.removedTarget.hidden = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
restore(e) {
|
|
21
|
+
e.preventDefault()
|
|
22
|
+
this.contentTarget.disabled = false
|
|
23
|
+
this.contentTarget.hidden = false
|
|
24
|
+
this.removedTarget.hidden = true
|
|
25
|
+
}
|
|
26
|
+
}
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: plutonium
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.55.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stefan Froelich
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-06-
|
|
10
|
+
date: 2026-06-03 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: zeitwerk
|
|
@@ -593,6 +593,8 @@ files:
|
|
|
593
593
|
- docs/public/images/home-index.png
|
|
594
594
|
- docs/public/images/home-new.png
|
|
595
595
|
- docs/public/images/home-show.png
|
|
596
|
+
- docs/public/images/reference/structured-inputs-removed.png
|
|
597
|
+
- docs/public/images/reference/structured-inputs.png
|
|
596
598
|
- docs/public/images/tutorial/02-empty-index.png
|
|
597
599
|
- docs/public/images/tutorial/02-index-with-posts.png
|
|
598
600
|
- docs/public/images/tutorial/02-new-form-modal.png
|
|
@@ -663,6 +665,8 @@ files:
|
|
|
663
665
|
- docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json
|
|
664
666
|
- docs/superpowers/plans/2026-05-15-public-pages-overhaul.md
|
|
665
667
|
- docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json
|
|
668
|
+
- docs/superpowers/plans/2026-06-02-structured-inputs.md
|
|
669
|
+
- docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json
|
|
666
670
|
- docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
|
|
667
671
|
- docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
|
|
668
672
|
- docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md
|
|
@@ -671,7 +675,7 @@ files:
|
|
|
671
675
|
- docs/superpowers/specs/2026-05-13-docs-restructure-design.md
|
|
672
676
|
- docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md
|
|
673
677
|
- docs/superpowers/specs/2026-05-29-avatar-component-design.md
|
|
674
|
-
- docs/superpowers/specs/2026-06-01-
|
|
678
|
+
- docs/superpowers/specs/2026-06-01-structured-inputs-design.md
|
|
675
679
|
- esbuild.config.js
|
|
676
680
|
- exe/pug
|
|
677
681
|
- gemfiles/rails_7.gemfile
|
|
@@ -974,6 +978,7 @@ files:
|
|
|
974
978
|
- lib/plutonium/definition/scoping.rb
|
|
975
979
|
- lib/plutonium/definition/search.rb
|
|
976
980
|
- lib/plutonium/definition/sorting.rb
|
|
981
|
+
- lib/plutonium/definition/structured_inputs.rb
|
|
977
982
|
- lib/plutonium/engine.rb
|
|
978
983
|
- lib/plutonium/engine/validator.rb
|
|
979
984
|
- lib/plutonium/helpers.rb
|
|
@@ -987,7 +992,6 @@ files:
|
|
|
987
992
|
- lib/plutonium/interaction/base.rb
|
|
988
993
|
- lib/plutonium/interaction/concerns/scoping.rb
|
|
989
994
|
- lib/plutonium/interaction/concerns/workflow_dsl.rb
|
|
990
|
-
- lib/plutonium/interaction/nested_attributes.rb
|
|
991
995
|
- lib/plutonium/interaction/outcome.rb
|
|
992
996
|
- lib/plutonium/interaction/response/base.rb
|
|
993
997
|
- lib/plutonium/interaction/response/failure.rb
|
|
@@ -1051,6 +1055,8 @@ files:
|
|
|
1051
1055
|
- lib/plutonium/routing/mapper_extensions.rb
|
|
1052
1056
|
- lib/plutonium/routing/resource_registration.rb
|
|
1053
1057
|
- lib/plutonium/routing/route_set_extensions.rb
|
|
1058
|
+
- lib/plutonium/structured_inputs/param_cleaner.rb
|
|
1059
|
+
- lib/plutonium/structured_inputs/params_concern.rb
|
|
1054
1060
|
- lib/plutonium/support/parameters.rb
|
|
1055
1061
|
- lib/plutonium/testing.rb
|
|
1056
1062
|
- lib/plutonium/testing/auth_helpers.rb
|
|
@@ -1101,6 +1107,8 @@ files:
|
|
|
1101
1107
|
- lib/plutonium/ui/form/components/sticky_footer.rb
|
|
1102
1108
|
- lib/plutonium/ui/form/components/uppy.rb
|
|
1103
1109
|
- lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb
|
|
1110
|
+
- lib/plutonium/ui/form/concerns/renders_structured_inputs.rb
|
|
1111
|
+
- lib/plutonium/ui/form/concerns/repeater_field_styles.rb
|
|
1104
1112
|
- lib/plutonium/ui/form/concerns/typeahead_attributes.rb
|
|
1105
1113
|
- lib/plutonium/ui/form/interaction.rb
|
|
1106
1114
|
- lib/plutonium/ui/form/options/inferred_types.rb
|
|
@@ -1206,6 +1214,7 @@ files:
|
|
|
1206
1214
|
- src/js/controllers/select_navigator.js
|
|
1207
1215
|
- src/js/controllers/sidebar_controller.js
|
|
1208
1216
|
- src/js/controllers/slim_select_controller.js
|
|
1217
|
+
- src/js/controllers/structured_input_row_controller.js
|
|
1209
1218
|
- src/js/controllers/table_column_menu_controller.js
|
|
1210
1219
|
- src/js/controllers/table_header_controller.js
|
|
1211
1220
|
- src/js/controllers/textarea_autogrow_controller.js
|
|
@@ -1230,7 +1239,7 @@ metadata:
|
|
|
1230
1239
|
homepage_uri: https://radioactive-labs.github.io/plutonium-core/
|
|
1231
1240
|
source_code_uri: https://github.com/radioactive-labs/plutonium-core
|
|
1232
1241
|
post_install_message: |
|
|
1233
|
-
⚠️ Plutonium 0.
|
|
1242
|
+
⚠️ Plutonium 0.55.0 — breaking change
|
|
1234
1243
|
|
|
1235
1244
|
Entity-scoped URL helpers and path params have been renamed from
|
|
1236
1245
|
`<entity>_scope_*` to `<entity>_scoped_*`.
|
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
# Interaction Repeater Inputs
|
|
2
|
-
|
|
3
|
-
**Date:** 2026-06-01
|
|
4
|
-
**Status:** Design — pending review
|
|
5
|
-
|
|
6
|
-
## Problem
|
|
7
|
-
|
|
8
|
-
Interactions (`Plutonium::Resource::Interaction`) collect scalar inputs via
|
|
9
|
-
`attribute`, but have no first-class way to collect a **repeating group of
|
|
10
|
-
fields** — e.g. a variable-length list of `{label, phone_number}` contacts —
|
|
11
|
-
as structured input the interaction can validate and use in `execute`.
|
|
12
|
-
|
|
13
|
-
The repeater UX already exists for resource forms (the
|
|
14
|
-
`nested-resource-form-fields` Stimulus controller: add/remove rows, `<template>`
|
|
15
|
-
cloning). But it is **model-backed**: the renderer
|
|
16
|
-
(`Plutonium::UI::Form::Concerns::RendersNestedResourceFields` →
|
|
17
|
-
`NestedFieldContext`) sources its row metadata (`:class`, `:macro`, `:limit`,
|
|
18
|
-
multiplicity) from `resource_class.all_nested_attributes_options`, which only
|
|
19
|
-
reflects ActiveRecord associations on the acted-on resource.
|
|
20
|
-
|
|
21
|
-
For an interaction collecting a classless list, there is no association and no
|
|
22
|
-
class. The current behaviour (characterized in
|
|
23
|
-
`test/plutonium/ui/form/interaction_nested_input_test.rb`):
|
|
24
|
-
|
|
25
|
-
- `nested_input` is registered on interactions and param coercion works, **but**
|
|
26
|
-
- `nested_attribute_options` resolves to `{}`, so `blank_object` is `nil` →
|
|
27
|
-
`nest_one`/`nest_many` have no template object → the nested UI renders nothing.
|
|
28
|
-
- The one case that *does* work is when the interaction's nested attribute
|
|
29
|
-
happens to mirror a real association on the acted-on resource (the README's
|
|
30
|
-
`CreateUserInteraction` building a `User` with `has_many :contacts`).
|
|
31
|
-
|
|
32
|
-
So interactions have the *param* half of nested inputs but no working *rendering*
|
|
33
|
-
half for the classless case, and the failure is silent.
|
|
34
|
-
|
|
35
|
-
## Goal
|
|
36
|
-
|
|
37
|
-
Let an interaction declare a **classless repeating field group** that:
|
|
38
|
-
|
|
39
|
-
- renders with the existing repeater UX (add/remove rows, template cloning,
|
|
40
|
-
delete checkbox),
|
|
41
|
-
- collects into the interaction as an **array of plain hashes**
|
|
42
|
-
(`contacts => [{label:, phone_number:}, …]`),
|
|
43
|
-
- needs **no backing class** — just a fields definition.
|
|
44
|
-
|
|
45
|
-
Non-goals (explicitly out of scope):
|
|
46
|
-
|
|
47
|
-
- A single (non-repeater) variant. Repeater only; value is always an array.
|
|
48
|
-
- Type coercion of row values. Rows are plain hashes; the interaction validates
|
|
49
|
-
them itself.
|
|
50
|
-
- Reusing/extending model-backed `nested_input` semantics for the classless case.
|
|
51
|
-
|
|
52
|
-
## Feasibility anchor
|
|
53
|
-
|
|
54
|
-
Phlexi already supports hash-backed rendering. `Phlexi::Field::Support::Value.from`:
|
|
55
|
-
|
|
56
|
-
```ruby
|
|
57
|
-
return object[key] if object.is_a?(Hash)
|
|
58
|
-
object.public_send(key) if object.respond_to?(key)
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
So a row can be a plain `Hash`, and the blank/template row can be `{}` (every
|
|
62
|
-
field reads `nil` → empty). No synthesized classes are required.
|
|
63
|
-
|
|
64
|
-
## Design
|
|
65
|
-
|
|
66
|
-
### 1. DSL — `repeater`
|
|
67
|
-
|
|
68
|
-
A new, self-contained DSL on the interaction base. One call declares everything:
|
|
69
|
-
|
|
70
|
-
```ruby
|
|
71
|
-
class CreateUserInteraction < Plutonium::Resource::Interaction
|
|
72
|
-
attribute :first_name, :string
|
|
73
|
-
|
|
74
|
-
repeater :contacts do |f|
|
|
75
|
-
f.input :label
|
|
76
|
-
f.input :phone_number
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# or, reusing an existing fields definition:
|
|
80
|
-
# repeater :addresses, using: AddressFields, fields: %i[label map_url]
|
|
81
|
-
|
|
82
|
-
# options: limit: (default 10)
|
|
83
|
-
end
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`repeater :name` will:
|
|
87
|
-
|
|
88
|
-
1. declare `attribute :name` defaulting to `[]`,
|
|
89
|
-
2. register a **classless** `name_attributes=` collector (see §2),
|
|
90
|
-
3. register render config (the fields definition, `multiple: true`, `limit`).
|
|
91
|
-
|
|
92
|
-
A distinct name (not `nested_input`) is intentional: it signals different
|
|
93
|
-
semantics (classless, collects hashes) and keeps a clean, conditional-free
|
|
94
|
-
implementation path separate from model-backed `nested_input`.
|
|
95
|
-
|
|
96
|
-
### 2. Param handling
|
|
97
|
-
|
|
98
|
-
`name_attributes=` accepts what the form submits — either an `Array` of row
|
|
99
|
-
hashes or a `Hash` keyed by index (`{"0" => {...}, "1" => {...}}`, Rails' nested
|
|
100
|
-
form shape). For each row:
|
|
101
|
-
|
|
102
|
-
- skip if `_destroy` is truthy (`1`/`"1"`/`true`/`"true"`),
|
|
103
|
-
- skip if every value is blank (the empty trailing/blank rows),
|
|
104
|
-
- otherwise keep the row as a symbolized hash with `_destroy` removed.
|
|
105
|
-
|
|
106
|
-
Store the result as `name` = `Array<Hash>`. The `name` reader returns that array
|
|
107
|
-
(used both by `execute` and by the renderer to repopulate on re-render).
|
|
108
|
-
|
|
109
|
-
### 3. Rendering
|
|
110
|
-
|
|
111
|
-
Reuse the existing repeater chrome. Add a **classless render path** that does not
|
|
112
|
-
touch `all_nested_attributes_options`:
|
|
113
|
-
|
|
114
|
-
- config (fields, `multiple: true`, `limit`) comes from the `repeater`
|
|
115
|
-
declaration,
|
|
116
|
-
- the template/blank row object is `{}`,
|
|
117
|
-
- existing rows come from the array of hashes already on the attribute (so a
|
|
118
|
-
validation-failed re-render repopulates),
|
|
119
|
-
- field naming via `nest_many(:contacts, as: :contacts_attributes, …)` →
|
|
120
|
-
`interaction[contacts_attributes][N][label]`.
|
|
121
|
-
|
|
122
|
-
The resource (model-backed) render path is left untouched and stays covered by
|
|
123
|
-
the existing characterization tests
|
|
124
|
-
(`test/integration/admin_portal/nested_form_rendering_test.rb`).
|
|
125
|
-
|
|
126
|
-
> Implementation detail deferred to the plan: whether the classless path is a
|
|
127
|
-
> sibling context to `NestedFieldContext` or a generalization of it, and how the
|
|
128
|
-
> `repeater` fields block maps onto the existing `NestedInputsDefinition`. The
|
|
129
|
-
> design constraint is only that the resource path does not change behaviour.
|
|
130
|
-
|
|
131
|
-
### 4. `nested_input` raises when no class is resolvable
|
|
132
|
-
|
|
133
|
-
Convert the silent classless failure into a guiding error. When the nested-field
|
|
134
|
-
renderer cannot resolve a class to build rows (no `object_class`, and no `:class`
|
|
135
|
-
from association metadata), raise:
|
|
136
|
-
|
|
137
|
-
```
|
|
138
|
-
`nested_input :contacts` could not resolve a class to build its rows.
|
|
139
|
-
If this is an interaction collecting plain inputs, use `repeater :contacts` instead.
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
This keeps the legitimate model-backed `nested_input` working (class present →
|
|
143
|
-
renders) and fails loudly only where it is actually broken. It also guards
|
|
144
|
-
genuinely misconfigured resource nested inputs.
|
|
145
|
-
|
|
146
|
-
### 5. `accepts_nested_attributes_for` is unchanged
|
|
147
|
-
|
|
148
|
-
It stays available on interactions for the deliberate case of building real
|
|
149
|
-
model instances in `execute`. `repeater` does not use or replace it.
|
|
150
|
-
|
|
151
|
-
### 6. Documentation
|
|
152
|
-
|
|
153
|
-
Update the interaction README: document `repeater` for classless repeating
|
|
154
|
-
input; note that `nested_input` is model-backed and now errors without a
|
|
155
|
-
resolvable class.
|
|
156
|
-
|
|
157
|
-
## Testing
|
|
158
|
-
|
|
159
|
-
- **Param round-trip** (unit): `contacts_attributes=` with an `Array` and with a
|
|
160
|
-
Rails index-keyed `Hash` → `contacts` is an array of symbolized hashes;
|
|
161
|
-
`_destroy` and all-blank rows dropped.
|
|
162
|
-
- **Render** (integration): a dummy interaction with a `repeater`, wired to an
|
|
163
|
-
interactive action, GET its form → assert the repeater HTML — controller
|
|
164
|
-
container + limit, `<template>`, fieldset, `interaction[contacts_attributes]
|
|
165
|
-
[NEW_RECORD][label]` naming, add button, delete checkbox. Fixture built via
|
|
166
|
-
the `pu:*` generators per project convention.
|
|
167
|
-
- **Guard** (unit/integration): a classless `nested_input` on an interaction
|
|
168
|
-
raises the guiding error. Update the existing characterization test
|
|
169
|
-
(`interaction_nested_input_test.rb`) from "blank_object is nil" to "raises".
|
|
170
|
-
- **Regression**: resource nested fields unchanged — existing characterization
|
|
171
|
-
tests stay green.
|
|
172
|
-
|
|
173
|
-
## Risk / breaking change
|
|
174
|
-
|
|
175
|
-
Making `nested_input` raise without a resolvable class is a (small) breaking
|
|
176
|
-
change for any interaction relying on the silently-broken classless path — but
|
|
177
|
-
that path renders nothing today, so there is no working behaviour to break. The
|
|
178
|
-
model-backed path is preserved.
|