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,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?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
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-transparent data-[open]:backdrop:bg-black/60 " \
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 " \
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.54.0"
2
+ VERSION = "0.55.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -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.54.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-01 00:00:00.000000000 Z
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-interaction-repeater-inputs-design.md
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.54.0 — breaking change
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.