plutonium 0.54.0 → 0.56.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 (79) 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 +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -27,9 +27,16 @@ export default class extends Controller {
27
27
 
28
28
  init() {
29
29
  if (this.triggerTarget && this.menuTarget && !this.initialized) {
30
+ // Capture a direct reference to the menu node. While open we teleport
31
+ // it to <body> (see show/hide), which moves it out of this controller's
32
+ // scope — so `this.menuTarget` would throw. Remember its home so we can
33
+ // put it back on hide/disconnect.
34
+ this.menu = this.menuTarget
35
+ this.menuHome = { parent: this.menu.parentNode, next: this.menu.nextSibling }
36
+
30
37
  // Initialize popper instance
31
38
  // Use 'fixed' strategy to escape overflow containers (e.g., table rows)
32
- this.popperInstance = createPopper(this.triggerTarget, this.menuTarget, {
39
+ this.popperInstance = createPopper(this.triggerTarget, this.menu, {
33
40
  strategy: 'fixed',
34
41
  placement: this.options.placement,
35
42
  modifiers: [
@@ -72,20 +79,46 @@ export default class extends Controller {
72
79
  // Remove hover event listeners
73
80
  if (this.options.triggerType === 'hover') {
74
81
  this.triggerTarget.removeEventListener('mouseenter', this.hoverShowTriggerHandler)
75
- this.menuTarget.removeEventListener('mouseenter', this.hoverShowMenuHandler)
82
+ this.menu.removeEventListener('mouseenter', this.hoverShowMenuHandler)
76
83
  this.triggerTarget.removeEventListener('mouseleave', this.hoverHideHandler)
77
- this.menuTarget.removeEventListener('mouseleave', this.hoverHideHandler)
84
+ this.menu.removeEventListener('mouseleave', this.hoverHideHandler)
78
85
  }
79
86
 
80
87
  // Remove click outside listener
81
88
  this.removeClickOutsideListener()
82
89
 
90
+ // Put the menu back where it belongs before we tear down, so a teleported
91
+ // menu isn't orphaned in <body> after the controller goes away. If its
92
+ // home is gone (e.g. turbo swapped the surrounding content), drop it.
93
+ this.restoreMenu()
94
+ if (this.menu.parentNode === document.body) {
95
+ this.menu.remove()
96
+ }
97
+
83
98
  // Destroy popper instance
84
99
  this.popperInstance.destroy()
85
100
  this.initialized = false
86
101
  }
87
102
  }
88
103
 
104
+ // Move the menu to <body> so no ancestor's overflow + containing-block
105
+ // (transform/filter/will-change) can clip it. popper's 'fixed' strategy
106
+ // positions relative to the viewport, but painting is still clipped by a
107
+ // transformed, overflow-hidden ancestor (e.g. grid cards) — teleporting
108
+ // sidesteps that entirely.
109
+ teleportMenu() {
110
+ if (this.menu.parentNode !== document.body) {
111
+ document.body.appendChild(this.menu)
112
+ }
113
+ }
114
+
115
+ restoreMenu() {
116
+ const home = this.menuHome
117
+ if (home && home.parent && home.parent.isConnected && this.menu.parentNode !== home.parent) {
118
+ home.parent.insertBefore(this.menu, home.next)
119
+ }
120
+ }
121
+
89
122
  setupEventListeners() {
90
123
  // Bind handlers to preserve context
91
124
  this.clickHandler = this.toggle.bind(this)
@@ -103,7 +136,7 @@ export default class extends Controller {
103
136
  }
104
137
  this.hoverHideHandler = () => {
105
138
  setTimeout(() => {
106
- if (!this.menuTarget.matches(':hover')) {
139
+ if (!this.menu.matches(':hover')) {
107
140
  this.hide()
108
141
  }
109
142
  }, this.options.delay)
@@ -114,9 +147,9 @@ export default class extends Controller {
114
147
  this.triggerTarget.addEventListener('click', this.clickHandler)
115
148
  } else if (this.options.triggerType === 'hover') {
116
149
  this.triggerTarget.addEventListener('mouseenter', this.hoverShowTriggerHandler)
117
- this.menuTarget.addEventListener('mouseenter', this.hoverShowMenuHandler)
150
+ this.menu.addEventListener('mouseenter', this.hoverShowMenuHandler)
118
151
  this.triggerTarget.addEventListener('mouseleave', this.hoverHideHandler)
119
- this.menuTarget.addEventListener('mouseleave', this.hoverHideHandler)
152
+ this.menu.addEventListener('mouseleave', this.hoverHideHandler)
120
153
  }
121
154
  }
122
155
 
@@ -140,8 +173,8 @@ export default class extends Controller {
140
173
  const isFloatingUI = clickedEl.closest('.flatpickr-calendar, .ss-main, .ss-content')
141
174
 
142
175
  if (
143
- clickedEl !== this.menuTarget &&
144
- !this.menuTarget.contains(clickedEl) &&
176
+ clickedEl !== this.menu &&
177
+ !this.menu.contains(clickedEl) &&
145
178
  !this.triggerTarget.contains(clickedEl) &&
146
179
  !isIgnored &&
147
180
  !isFloatingUI &&
@@ -169,9 +202,10 @@ export default class extends Controller {
169
202
  }
170
203
 
171
204
  show() {
172
- this.menuTarget.classList.remove('hidden')
173
- this.menuTarget.classList.add('block')
174
- this.menuTarget.removeAttribute('aria-hidden')
205
+ this.teleportMenu()
206
+ this.menu.classList.remove('hidden')
207
+ this.menu.classList.add('block')
208
+ this.menu.removeAttribute('aria-hidden')
175
209
 
176
210
  // Enable popper event listeners
177
211
  this.popperInstance.setOptions((options) => ({
@@ -188,9 +222,9 @@ export default class extends Controller {
188
222
  }
189
223
 
190
224
  hide() {
191
- this.menuTarget.classList.remove('block')
192
- this.menuTarget.classList.add('hidden')
193
- this.menuTarget.setAttribute('aria-hidden', 'true')
225
+ this.menu.classList.remove('block')
226
+ this.menu.classList.add('hidden')
227
+ this.menu.setAttribute('aria-hidden', 'true')
194
228
 
195
229
  // Disable popper event listeners
196
230
  this.popperInstance.setOptions((options) => ({
@@ -202,6 +236,7 @@ export default class extends Controller {
202
236
  }))
203
237
 
204
238
  this.removeClickOutsideListener()
239
+ this.restoreMenu()
205
240
  this.visible = false
206
241
  }
207
242
  }
@@ -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.56.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-05 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
@@ -635,6 +637,7 @@ files:
635
637
  - docs/reference/behavior/interactions.md
636
638
  - docs/reference/behavior/policies.md
637
639
  - docs/reference/configuration.md
640
+ - docs/reference/generators/lite.md
638
641
  - docs/reference/index.md
639
642
  - docs/reference/resource/actions.md
640
643
  - docs/reference/resource/definition.md
@@ -663,6 +666,10 @@ files:
663
666
  - docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json
664
667
  - docs/superpowers/plans/2026-05-15-public-pages-overhaul.md
665
668
  - docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json
669
+ - docs/superpowers/plans/2026-06-02-structured-inputs.md
670
+ - docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json
671
+ - docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md
672
+ - docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json
666
673
  - docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
667
674
  - docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
668
675
  - docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md
@@ -671,7 +678,8 @@ files:
671
678
  - docs/superpowers/specs/2026-05-13-docs-restructure-design.md
672
679
  - docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md
673
680
  - docs/superpowers/specs/2026-05-29-avatar-component-design.md
674
- - docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md
681
+ - docs/superpowers/specs/2026-06-01-structured-inputs-design.md
682
+ - docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md
675
683
  - esbuild.config.js
676
684
  - exe/pug
677
685
  - gemfiles/rails_7.gemfile
@@ -774,6 +782,7 @@ files:
774
782
  - lib/generators/pu/lib/plutonium_generators/cli.rb
775
783
  - lib/generators/pu/lib/plutonium_generators/concerns/actions.rb
776
784
  - lib/generators/pu/lib/plutonium_generators/concerns/config.rb
785
+ - lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb
777
786
  - lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb
778
787
  - lib/generators/pu/lib/plutonium_generators/concerns/logger.rb
779
788
  - lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb
@@ -786,6 +795,8 @@ files:
786
795
  - lib/generators/pu/lib/plutonium_generators/model_generator_base.rb
787
796
  - lib/generators/pu/lib/plutonium_generators/non_interactive_prompt.rb
788
797
  - lib/generators/pu/lite/litestream/litestream_generator.rb
798
+ - lib/generators/pu/lite/maintenance/maintenance_generator.rb
799
+ - lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt
789
800
  - lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb
790
801
  - lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt
791
802
  - lib/generators/pu/lite/setup/setup_generator.rb
@@ -793,6 +804,7 @@ files:
793
804
  - lib/generators/pu/lite/solid_cache/solid_cache_generator.rb
794
805
  - lib/generators/pu/lite/solid_errors/solid_errors_generator.rb
795
806
  - lib/generators/pu/lite/solid_queue/solid_queue_generator.rb
807
+ - lib/generators/pu/lite/tune/tune_generator.rb
796
808
  - lib/generators/pu/pkg/package/package_generator.rb
797
809
  - lib/generators/pu/pkg/package/templates/.keep
798
810
  - lib/generators/pu/pkg/package/templates/app/controllers/resource_controller.rb.tt
@@ -974,6 +986,7 @@ files:
974
986
  - lib/plutonium/definition/scoping.rb
975
987
  - lib/plutonium/definition/search.rb
976
988
  - lib/plutonium/definition/sorting.rb
989
+ - lib/plutonium/definition/structured_inputs.rb
977
990
  - lib/plutonium/engine.rb
978
991
  - lib/plutonium/engine/validator.rb
979
992
  - lib/plutonium/helpers.rb
@@ -987,7 +1000,6 @@ files:
987
1000
  - lib/plutonium/interaction/base.rb
988
1001
  - lib/plutonium/interaction/concerns/scoping.rb
989
1002
  - lib/plutonium/interaction/concerns/workflow_dsl.rb
990
- - lib/plutonium/interaction/nested_attributes.rb
991
1003
  - lib/plutonium/interaction/outcome.rb
992
1004
  - lib/plutonium/interaction/response/base.rb
993
1005
  - lib/plutonium/interaction/response/failure.rb
@@ -1051,6 +1063,8 @@ files:
1051
1063
  - lib/plutonium/routing/mapper_extensions.rb
1052
1064
  - lib/plutonium/routing/resource_registration.rb
1053
1065
  - lib/plutonium/routing/route_set_extensions.rb
1066
+ - lib/plutonium/structured_inputs/param_cleaner.rb
1067
+ - lib/plutonium/structured_inputs/params_concern.rb
1054
1068
  - lib/plutonium/support/parameters.rb
1055
1069
  - lib/plutonium/testing.rb
1056
1070
  - lib/plutonium/testing/auth_helpers.rb
@@ -1078,8 +1092,10 @@ files:
1078
1092
  - lib/plutonium/ui/display/base.rb
1079
1093
  - lib/plutonium/ui/display/components/association.rb
1080
1094
  - lib/plutonium/ui/display/components/attachment.rb
1095
+ - lib/plutonium/ui/display/components/badge.rb
1081
1096
  - lib/plutonium/ui/display/components/boolean.rb
1082
1097
  - lib/plutonium/ui/display/components/color.rb
1098
+ - lib/plutonium/ui/display/components/currency.rb
1083
1099
  - lib/plutonium/ui/display/components/markdown.rb
1084
1100
  - lib/plutonium/ui/display/components/phlexi_render.rb
1085
1101
  - lib/plutonium/ui/display/options/inferred_types.rb
@@ -1099,8 +1115,12 @@ files:
1099
1115
  - lib/plutonium/ui/form/components/secure_association.rb
1100
1116
  - lib/plutonium/ui/form/components/secure_polymorphic_association.rb
1101
1117
  - lib/plutonium/ui/form/components/sticky_footer.rb
1118
+ - lib/plutonium/ui/form/components/toggle.rb
1102
1119
  - lib/plutonium/ui/form/components/uppy.rb
1103
1120
  - lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb
1121
+ - lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb
1122
+ - lib/plutonium/ui/form/concerns/renders_structured_inputs.rb
1123
+ - lib/plutonium/ui/form/concerns/repeater_field_styles.rb
1104
1124
  - lib/plutonium/ui/form/concerns/typeahead_attributes.rb
1105
1125
  - lib/plutonium/ui/form/interaction.rb
1106
1126
  - lib/plutonium/ui/form/options/inferred_types.rb
@@ -1206,6 +1226,7 @@ files:
1206
1226
  - src/js/controllers/select_navigator.js
1207
1227
  - src/js/controllers/sidebar_controller.js
1208
1228
  - src/js/controllers/slim_select_controller.js
1229
+ - src/js/controllers/structured_input_row_controller.js
1209
1230
  - src/js/controllers/table_column_menu_controller.js
1210
1231
  - src/js/controllers/table_header_controller.js
1211
1232
  - src/js/controllers/textarea_autogrow_controller.js
@@ -1230,17 +1251,18 @@ metadata:
1230
1251
  homepage_uri: https://radioactive-labs.github.io/plutonium-core/
1231
1252
  source_code_uri: https://github.com/radioactive-labs/plutonium-core
1232
1253
  post_install_message: |
1233
- ⚠️ Plutonium 0.54.0 — breaking change
1254
+ ℹ️ Plutonium — breaking change introduced in 0.49.0
1234
1255
 
1235
- Entity-scoped URL helpers and path params have been renamed from
1256
+ Entity-scoped URL helpers and path params were renamed in 0.49.0 from
1236
1257
  `<entity>_scope_*` to `<entity>_scoped_*`.
1237
1258
 
1238
1259
  Examples:
1239
1260
  organization_scope_widgets_path → organization_scoped_widgets_path
1240
1261
  params[:organization_scope] → params[:organization_scoped]
1241
1262
 
1242
- If you reference these helpers or params directly (e.g. in tests, custom
1243
- redirects, or hand-written links), update them to the new names.
1263
+ If you are upgrading from 0.48.0 or earlier and reference these helpers or
1264
+ params directly (e.g. in tests, custom redirects, or hand-written links),
1265
+ update them to the new names.
1244
1266
 
1245
1267
  Apps that only use `resource_url_for` are unaffected.
1246
1268
  rdoc_options: []
@@ -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.
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Plutonium
4
- module Interaction
5
- module NestedAttributes
6
- extend ActiveSupport::Concern
7
-
8
- class_methods do
9
- # Dynamically defines writer and reader methods for handling nested
10
- # attributes in form objects or interaction classes, mimicking the
11
- # behavior of ActiveRecord's `accepts_nested_attributes_for`.
12
- #
13
- # This method allows you to pass in nested data (e.g. from a form) and
14
- # automatically build or destroy associated records based on that input.
15
- #
16
- # === Example 1: Basic usage with default naming
17
- # # If `Contact` is the associated model inferred from the
18
- # `:contacts` association:
19
- #
20
- # `accepts_nested_attributes_for :contacts`
21
- #
22
- # === Example 2: When association name and model name differ
23
- # Suppose the `User` model has a `has_many :contacts` association
24
- # pointing to a `UserContactInfo` model. You need to specify the
25
- # model name.
26
- #
27
- # `accepts_nested_attributes_for :contacts, class_name: "UserAddress"`
28
- #
29
- # This macro defines:
30
- # - `contacts_attributes=` — used to assign nested attributes,
31
- # including support for `_destroy`
32
- # - `contacts_attributes` — returns the current attributes of
33
- # associated records
34
- #
35
- # @param association [Symbol] The association name. (e.g., `:contacts`).
36
- # @param class_name [String, nil] Required if association reflection
37
- # is needed to determine the associated model class (e.g. when the
38
- # association name doesn't match the class name).
39
- # @param reject_if [Proc, Symbol, nil] Used to skip building association
40
- # records when the condition returns true.
41
- def accepts_nested_attributes_for(
42
- association,
43
- class_name: nil,
44
- reject_if: nil
45
- )
46
- destroy_values = [1, "1", "true", true]
47
-
48
- should_destroy = ->(value) { destroy_values.include?(value) }
49
-
50
- should_reject =
51
- lambda do |attrs|
52
- case reject_if
53
- when Symbol
54
- send(reject_if, attrs)
55
- when Proc
56
- reject_if.call(attrs)
57
- else
58
- false
59
- end
60
- end
61
-
62
- assoc_class =
63
- class_name&.constantize || association.to_s.classify.constantize
64
-
65
- define_method(:"#{association}_attributes=") do |attributes|
66
- result =
67
- case attributes
68
- when Hash
69
- attrs = attributes.except(:_destroy)
70
- unless should_destroy.call(attributes[:_destroy]) ||
71
- should_reject.call(attrs)
72
- assoc_class.new(attrs)
73
- end
74
- when Array
75
- attributes.filter_map do |attrs|
76
- unless should_destroy.call(attrs[:_destroy]) ||
77
- should_reject.call(attrs)
78
- assoc_class.new(attrs.except(:_destroy))
79
- end
80
- end
81
- end
82
-
83
- send(:"#{association}=", result)
84
- end
85
-
86
- define_method(:"#{association}_attributes") do
87
- Array(send(association)).map(&:attributes)
88
- end
89
- end
90
- end
91
- end
92
- end
93
- end