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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +76 -2
- data/.claude/skills/plutonium-ui/SKILL.md +17 -3
- data/CHANGELOG.md +45 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +112 -26
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +128 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -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/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- 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/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +5 -1
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/modal/slideover.rb +9 -3
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +136 -5
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +30 -8
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
- 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.
|
|
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.
|
|
82
|
+
this.menu.removeEventListener('mouseenter', this.hoverShowMenuHandler)
|
|
76
83
|
this.triggerTarget.removeEventListener('mouseleave', this.hoverHideHandler)
|
|
77
|
-
this.
|
|
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.
|
|
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.
|
|
150
|
+
this.menu.addEventListener('mouseenter', this.hoverShowMenuHandler)
|
|
118
151
|
this.triggerTarget.addEventListener('mouseleave', this.hoverHideHandler)
|
|
119
|
-
this.
|
|
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.
|
|
144
|
-
!this.
|
|
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.
|
|
173
|
-
this.
|
|
174
|
-
this.
|
|
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.
|
|
192
|
-
this.
|
|
193
|
-
this.
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
-
|
|
1254
|
+
ℹ️ Plutonium — breaking change introduced in 0.49.0
|
|
1234
1255
|
|
|
1235
|
-
Entity-scoped URL helpers and path params
|
|
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
|
|
1243
|
-
redirects, or hand-written links),
|
|
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
|