plutonium 0.55.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-resource/SKILL.md +21 -2
- data/.claude/skills/plutonium-ui/SKILL.md +15 -2
- data/CHANGELOG.md +31 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +94 -26
- data/app/assets/plutonium.js.map +2 -2
- data/app/assets/plutonium.min.js +9 -9
- data/app/assets/plutonium.min.js.map +3 -3
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +18 -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-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-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/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
- data/lib/plutonium/routing/mapper_extensions.rb +5 -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 +14 -25
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
- 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 +1 -0
- 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/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 +126 -0
- 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/resource_drop_down_controller.js +49 -14
- metadata +19 -6
|
@@ -39,6 +39,11 @@ module Plutonium
|
|
|
39
39
|
valid_boolean: "pu-checkbox",
|
|
40
40
|
invalid_boolean: "pu-checkbox pu-input-invalid",
|
|
41
41
|
|
|
42
|
+
# Toggle switch (opt-in boolean input: `as: :toggle`)
|
|
43
|
+
toggle: "pu-toggle",
|
|
44
|
+
valid_toggle: "pu-toggle",
|
|
45
|
+
invalid_toggle: "pu-toggle pu-input-invalid",
|
|
46
|
+
|
|
42
47
|
# Radio buttons
|
|
43
48
|
radio_button: "pu-radio",
|
|
44
49
|
collection_radio_buttons: "flex flex-col gap-2",
|
|
@@ -81,6 +86,13 @@ module Plutonium
|
|
|
81
86
|
invalid_int_tel_input: :invalid_input,
|
|
82
87
|
neutral_int_tel_input: :neutral_input,
|
|
83
88
|
|
|
89
|
+
# JSON / JSONB textarea — without this the component falls back to
|
|
90
|
+
# the neutral (classless) theme and loses pu-input's dark styling.
|
|
91
|
+
json: :input,
|
|
92
|
+
valid_json: :valid_input,
|
|
93
|
+
invalid_json: :invalid_input,
|
|
94
|
+
neutral_json: :neutral_input,
|
|
95
|
+
|
|
84
96
|
# Uppy file upload
|
|
85
97
|
uppy: :file,
|
|
86
98
|
valid_uppy: :valid_file,
|
|
@@ -100,46 +100,83 @@ module Plutonium
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def render_subheader_slot
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
p(class: "text-xs text-[var(--pu-text-muted)] truncate")
|
|
103
|
+
name = slots[:subheader]
|
|
104
|
+
value = field_value(name)
|
|
105
|
+
p(class: "text-xs text-[var(--pu-text-muted)] truncate") do
|
|
106
|
+
value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
|
|
107
|
+
end
|
|
106
108
|
end
|
|
107
109
|
|
|
108
110
|
def render_body_slot
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
p(class: "text-sm text-[var(--pu-text)] line-clamp-3")
|
|
111
|
+
name = slots[:body]
|
|
112
|
+
value = field_value(name)
|
|
113
|
+
p(class: "text-sm text-[var(--pu-text)] line-clamp-3") do
|
|
114
|
+
value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
|
|
115
|
+
end
|
|
112
116
|
end
|
|
113
117
|
|
|
114
118
|
def render_meta_slot
|
|
115
119
|
fields = Array(slots[:meta])
|
|
116
120
|
values = fields.map { |f| field_value(f) }.reject(&:blank?)
|
|
117
|
-
return if values.empty?
|
|
118
121
|
|
|
119
122
|
div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
|
|
120
|
-
values.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
123
|
+
if values.empty?
|
|
124
|
+
render_blank_placeholder
|
|
125
|
+
else
|
|
126
|
+
values.each { |v| render_meta_badge(v) }
|
|
125
127
|
end
|
|
126
128
|
end
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
def render_footer_slot
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
name = footer_field
|
|
133
|
+
value = field_value(name)
|
|
132
134
|
p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Emits a slot value formatted by type, reusing the display layer's
|
|
140
|
+
# logic without its show-page label/wrapper chrome:
|
|
141
|
+
# - dates/times → timeago markup (same path as the footer used)
|
|
142
|
+
# - has_cents columns → currency (matches the Currency component)
|
|
143
|
+
# - everything else → display_name_of
|
|
144
|
+
def render_formatted_value(name, value)
|
|
145
|
+
if value.respond_to?(:strftime)
|
|
146
|
+
# display_datetime_value returns HTML-safe <time> markup
|
|
147
|
+
# rendered by the timeago Stimulus controller.
|
|
148
|
+
raw safe(helpers.display_datetime_value(value))
|
|
149
|
+
elsif currency_field?(name)
|
|
150
|
+
plain helpers.number_to_currency(value, unit: "")
|
|
151
|
+
else
|
|
152
|
+
plain helpers.display_name_of(value)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Renders a meta value as a colored pill, borrowing the Badge
|
|
157
|
+
# display component's semantic color + humanize logic. Status-like
|
|
158
|
+
# values (published, pending, failed…) get meaningful colors;
|
|
159
|
+
# free-form values get a deterministic decorative color.
|
|
160
|
+
def render_meta_badge(value)
|
|
161
|
+
badge = Plutonium::UI::Display::Components::Badge
|
|
162
|
+
variant = badge.variant_for(value)
|
|
163
|
+
span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
|
|
164
|
+
plain badge.humanize(value)
|
|
140
165
|
end
|
|
141
166
|
end
|
|
142
167
|
|
|
168
|
+
def currency_field?(name)
|
|
169
|
+
klass = record.class
|
|
170
|
+
klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A declared slot with no value renders a muted em-dash rather than
|
|
174
|
+
# collapsing, so cards in a grid keep an even height instead of
|
|
175
|
+
# ragged rows when some records lack the field.
|
|
176
|
+
def render_blank_placeholder
|
|
177
|
+
span(class: "text-[var(--pu-text-subtle)]") { plain "—" }
|
|
178
|
+
end
|
|
179
|
+
|
|
143
180
|
# ---------------------------------------------------------------
|
|
144
181
|
# Card chrome — selection, actions, show
|
|
145
182
|
# ---------------------------------------------------------------
|
|
@@ -113,8 +113,7 @@ module Plutonium
|
|
|
113
113
|
a(
|
|
114
114
|
href: item.url,
|
|
115
115
|
title: item.label,
|
|
116
|
-
|
|
117
|
-
class: "icon-rail-leaf #{leaf_classes(item, depth)}"
|
|
116
|
+
**item_link_attributes(item, "icon-rail-leaf #{leaf_classes(item, depth)}", base_aria: {label: item.label})
|
|
118
117
|
) do
|
|
119
118
|
render_item_icon(item)
|
|
120
119
|
span(class: "icon-rail-label hidden") { item.label }
|
|
@@ -137,12 +136,12 @@ module Plutonium
|
|
|
137
136
|
a(
|
|
138
137
|
href: item.url || "#",
|
|
139
138
|
title: item.label,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"icon-rail-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
**item_link_attributes(
|
|
140
|
+
item,
|
|
141
|
+
"icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}",
|
|
142
|
+
base_aria: {label: item.label, haspopup: "menu", expanded: "false"},
|
|
143
|
+
base_data: {"icon-rail-flyout-target": "trigger", action: "click->icon-rail-flyout#toggle"}
|
|
144
|
+
)
|
|
146
145
|
) do
|
|
147
146
|
render_item_icon(item)
|
|
148
147
|
span(class: "icon-rail-label") { item.label }
|
|
@@ -159,7 +158,11 @@ module Plutonium
|
|
|
159
158
|
div(class: "icon-rail-flyout-inner") do
|
|
160
159
|
div(class: "icon-rail-flyout-label") { item.label }
|
|
161
160
|
item.items.each do |child|
|
|
162
|
-
a(
|
|
161
|
+
a(
|
|
162
|
+
href: child.url,
|
|
163
|
+
role: "menuitem",
|
|
164
|
+
**item_link_attributes(child, "icon-rail-flyout-item")
|
|
165
|
+
) { child.label }
|
|
163
166
|
end
|
|
164
167
|
end
|
|
165
168
|
end
|
|
@@ -206,6 +209,23 @@ module Plutonium
|
|
|
206
209
|
def active?(item)
|
|
207
210
|
item.active?(self)
|
|
208
211
|
end
|
|
212
|
+
|
|
213
|
+
# Anchor attributes a menu item opts into via its Phlexi::Menu options
|
|
214
|
+
# (target:, rel:, data:, aria:, …), merged with the anchor's own
|
|
215
|
+
# framework attributes and spread onto the <a>. The framework's
|
|
216
|
+
# class / data / aria (base styling, flyout wiring, popup semantics)
|
|
217
|
+
# take precedence so a menu item can *extend* the link without breaking
|
|
218
|
+
# navigation behavior. Phlexi keeps its own :active key in options,
|
|
219
|
+
# which must never become an attribute.
|
|
220
|
+
def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
|
|
221
|
+
opts = (item.options || {}).except(:active)
|
|
222
|
+
data = (opts[:data] || {}).merge(base_data)
|
|
223
|
+
aria = (opts[:aria] || {}).merge(base_aria)
|
|
224
|
+
opts[:class] = [base_class, opts[:class]].compact.join(" ")
|
|
225
|
+
opts[:data] = data unless data.empty?
|
|
226
|
+
opts[:aria] = aria unless aria.empty?
|
|
227
|
+
opts
|
|
228
|
+
end
|
|
209
229
|
end
|
|
210
230
|
end
|
|
211
231
|
end
|
|
@@ -89,6 +89,35 @@ module Plutonium
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Spread any per-item HTML attributes (target:, rel:, data:, …) the item
|
|
93
|
+
# opts into via its Phlexi::Menu options — e.g. a menu item that opens a
|
|
94
|
+
# full-screen SPA in its own tab. The base Phlexi implementation
|
|
95
|
+
# hardcodes the anchor and drops these, so we re-render the leaf.
|
|
96
|
+
def render_item_link(item, depth)
|
|
97
|
+
link_class = themed(:item_link, depth)
|
|
98
|
+
active = active_class(item, depth)
|
|
99
|
+
classes = active ? "#{link_class} #{active}" : link_class
|
|
100
|
+
|
|
101
|
+
a(href: item.url, **item_link_attributes(item, classes)) do
|
|
102
|
+
render_item_interior(item, depth)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Anchor attributes opted into via Phlexi::Menu item options (target:,
|
|
107
|
+
# rel:, data:, aria:, …), minus Phlexi's own :active key (which must not
|
|
108
|
+
# leak onto the <a>). A user-supplied :class merges with the themed base
|
|
109
|
+
# classes; base_data / base_aria (none on the plain leaf today) always
|
|
110
|
+
# win so options extend rather than replace framework wiring.
|
|
111
|
+
def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
|
|
112
|
+
opts = (item.options || {}).except(:active)
|
|
113
|
+
data = (opts[:data] || {}).merge(base_data)
|
|
114
|
+
aria = (opts[:aria] || {}).merge(base_aria)
|
|
115
|
+
opts[:class] = [base_class, opts[:class]].compact.join(" ")
|
|
116
|
+
opts[:data] = data unless data.empty?
|
|
117
|
+
opts[:aria] = aria unless aria.empty?
|
|
118
|
+
opts
|
|
119
|
+
end
|
|
120
|
+
|
|
92
121
|
def render_collapsible_button(item, depth)
|
|
93
122
|
button(
|
|
94
123
|
type: "button",
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/plutonium.gemspec
CHANGED
|
@@ -17,17 +17,18 @@ Gem::Specification.new do |spec|
|
|
|
17
17
|
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
18
18
|
|
|
19
19
|
spec.post_install_message = <<~MSG
|
|
20
|
-
|
|
20
|
+
ℹ️ Plutonium — breaking change introduced in 0.49.0
|
|
21
21
|
|
|
22
|
-
Entity-scoped URL helpers and path params
|
|
22
|
+
Entity-scoped URL helpers and path params were renamed in 0.49.0 from
|
|
23
23
|
`<entity>_scope_*` to `<entity>_scoped_*`.
|
|
24
24
|
|
|
25
25
|
Examples:
|
|
26
26
|
organization_scope_widgets_path → organization_scoped_widgets_path
|
|
27
27
|
params[:organization_scope] → params[:organization_scoped]
|
|
28
28
|
|
|
29
|
-
If you
|
|
30
|
-
redirects, or hand-written links),
|
|
29
|
+
If you are upgrading from 0.48.0 or earlier and reference these helpers or
|
|
30
|
+
params directly (e.g. in tests, custom redirects, or hand-written links),
|
|
31
|
+
update them to the new names.
|
|
31
32
|
|
|
32
33
|
Apps that only use `resource_url_for` are unaffected.
|
|
33
34
|
MSG
|
data/src/css/components.css
CHANGED
|
@@ -178,6 +178,116 @@
|
|
|
178
178
|
@apply bg-accent-950/50 text-accent-300 hover:bg-accent-900/60 active:bg-accent-900/80;
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/* ===================
|
|
182
|
+
BADGES - Status pills
|
|
183
|
+
=================== */
|
|
184
|
+
|
|
185
|
+
.pu-badge {
|
|
186
|
+
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap align-middle;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.pu-badge-neutral {
|
|
190
|
+
@apply bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] border border-[var(--pu-border-muted)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.pu-badge-primary {
|
|
194
|
+
@apply bg-primary-50 text-primary-700;
|
|
195
|
+
}
|
|
196
|
+
.dark .pu-badge-primary {
|
|
197
|
+
@apply bg-primary-950/50 text-primary-300;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.pu-badge-secondary {
|
|
201
|
+
@apply bg-secondary-50 text-secondary-700;
|
|
202
|
+
}
|
|
203
|
+
.dark .pu-badge-secondary {
|
|
204
|
+
@apply bg-secondary-950/50 text-secondary-300;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.pu-badge-success {
|
|
208
|
+
@apply bg-success-50 text-success-700;
|
|
209
|
+
}
|
|
210
|
+
.dark .pu-badge-success {
|
|
211
|
+
@apply bg-success-950/50 text-success-300;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.pu-badge-danger {
|
|
215
|
+
@apply bg-danger-50 text-danger-700;
|
|
216
|
+
}
|
|
217
|
+
.dark .pu-badge-danger {
|
|
218
|
+
@apply bg-danger-950/50 text-danger-300;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.pu-badge-warning {
|
|
222
|
+
@apply bg-warning-50 text-warning-700;
|
|
223
|
+
}
|
|
224
|
+
.dark .pu-badge-warning {
|
|
225
|
+
@apply bg-warning-950/50 text-warning-300;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.pu-badge-info {
|
|
229
|
+
@apply bg-info-50 text-info-700;
|
|
230
|
+
}
|
|
231
|
+
.dark .pu-badge-info {
|
|
232
|
+
@apply bg-info-950/50 text-info-300;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.pu-badge-accent {
|
|
236
|
+
@apply bg-accent-50 text-accent-700;
|
|
237
|
+
}
|
|
238
|
+
.dark .pu-badge-accent {
|
|
239
|
+
@apply bg-accent-950/50 text-accent-300;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ===================
|
|
243
|
+
TOGGLE - Switch-styled checkbox
|
|
244
|
+
=================== */
|
|
245
|
+
|
|
246
|
+
.pu-toggle {
|
|
247
|
+
appearance: none;
|
|
248
|
+
-webkit-appearance: none;
|
|
249
|
+
box-sizing: border-box;
|
|
250
|
+
position: relative;
|
|
251
|
+
display: inline-block;
|
|
252
|
+
flex-shrink: 0;
|
|
253
|
+
width: 2.75rem; /* 44px */
|
|
254
|
+
height: 1.5rem; /* 24px */
|
|
255
|
+
padding: 0;
|
|
256
|
+
border: 0;
|
|
257
|
+
border-radius: 9999px;
|
|
258
|
+
background-color: var(--pu-border);
|
|
259
|
+
background-image: none; /* override @tailwindcss/forms checkmark */
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
vertical-align: middle;
|
|
262
|
+
transition: background-color var(--pu-transition-normal, 0.2s);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.pu-toggle:checked {
|
|
266
|
+
background-image: none; /* override @tailwindcss/forms checkmark */
|
|
267
|
+
@apply bg-primary-600;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.pu-toggle::before {
|
|
271
|
+
content: "";
|
|
272
|
+
position: absolute;
|
|
273
|
+
top: 3px;
|
|
274
|
+
left: 3px;
|
|
275
|
+
width: 18px;
|
|
276
|
+
height: 18px;
|
|
277
|
+
border-radius: 9999px;
|
|
278
|
+
background-color: #fff;
|
|
279
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
|
280
|
+
transition: transform var(--pu-transition-normal, 0.2s);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.pu-toggle:checked::before {
|
|
284
|
+
transform: translateX(20px); /* 44 - 18 - 3 - 3 */
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.pu-toggle:focus-visible {
|
|
288
|
+
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
|
289
|
+
}
|
|
290
|
+
|
|
181
291
|
/* ===================
|
|
182
292
|
CARDS - Clean, elevated
|
|
183
293
|
=================== */
|
|
@@ -206,6 +316,22 @@
|
|
|
206
316
|
@apply w-full px-3 h-9 text-sm focus:outline-none;
|
|
207
317
|
}
|
|
208
318
|
|
|
319
|
+
/* Native multi-selects (and sized list boxes) must grow — pu-input's fixed
|
|
320
|
+
h-9 collapses them to a single clipped row. */
|
|
321
|
+
select.pu-input[multiple],
|
|
322
|
+
select.pu-input[size]:not([size="1"]) {
|
|
323
|
+
height: auto;
|
|
324
|
+
min-height: 7.5rem;
|
|
325
|
+
@apply py-1.5;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
select.pu-input[multiple] option {
|
|
329
|
+
border-radius: var(--pu-radius-md);
|
|
330
|
+
margin-block: 4px;
|
|
331
|
+
padding: 0.4rem 0.625rem;
|
|
332
|
+
line-height: 1.4;
|
|
333
|
+
}
|
|
334
|
+
|
|
209
335
|
.pu-input-toolbar {
|
|
210
336
|
@apply h-8 text-sm;
|
|
211
337
|
}
|
|
@@ -5,6 +5,27 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
5
5
|
// Self-disables when not inside a <dialog>, so it's safe to attach to
|
|
6
6
|
// every form unconditionally.
|
|
7
7
|
//
|
|
8
|
+
// Dirtiness is a diff against a baseline captured at the user's *first*
|
|
9
|
+
// real interaction — not at connect. Field widgets (intl-tel-input,
|
|
10
|
+
// flatpickr, slim-select, easymde) mutate the form *after* connect —
|
|
11
|
+
// injecting hidden inputs, reformatting values, replacing the native
|
|
12
|
+
// control — via silent `input.value = …` writes (no event) or synthetic
|
|
13
|
+
// events. Snapshotting at connect counted that settling as edits and
|
|
14
|
+
// prompted on a pristine modal.
|
|
15
|
+
//
|
|
16
|
+
// Instead:
|
|
17
|
+
// • On the first *trusted* pointer/key action inside the form, serialize
|
|
18
|
+
// the form into `baseline`. The user can't interact before the form has
|
|
19
|
+
// rendered, so widgets have already settled — their hidden inputs and
|
|
20
|
+
// reformatted values are part of the baseline, not a phantom diff. The
|
|
21
|
+
// key/pointer event fires *before* the value changes, so the baseline is
|
|
22
|
+
// pre-edit.
|
|
23
|
+
// • On close, the form is dirty if its serialization differs from
|
|
24
|
+
// `baseline`. This is independent of which events a widget dispatches
|
|
25
|
+
// (or whether it dispatches any), catches widget-mediated edits the same
|
|
26
|
+
// as native typing, and — being a diff — treats an edit reverted to its
|
|
27
|
+
// original value as clean. No interaction → no baseline → never dirty.
|
|
28
|
+
//
|
|
8
29
|
// Esc is intercepted at the document's capture phase: relying on the
|
|
9
30
|
// dialog's `cancel` event alone proved flaky under rapid/held Esc when
|
|
10
31
|
// the parent dialog uses `closedby="any"`. The cancel listener stays
|
|
@@ -12,24 +33,38 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
12
33
|
export default class extends Controller {
|
|
13
34
|
static targets = ["confirmDialog"];
|
|
14
35
|
|
|
15
|
-
// Set by controllers, not the user —
|
|
16
|
-
//
|
|
36
|
+
// Set by controllers, not the user — they're already present (or absent)
|
|
37
|
+
// when the baseline is taken, so they never contribute to the diff; listed
|
|
38
|
+
// for safety against a controller writing them mid-edit.
|
|
17
39
|
static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
|
|
18
40
|
|
|
41
|
+
// Keys that move focus or dismiss the dialog rather than edit it — they
|
|
42
|
+
// must not, on their own, baseline the form.
|
|
43
|
+
static NON_EDITING_KEYS = new Set([
|
|
44
|
+
"Tab", "Escape", "Shift", "Control", "Alt", "Meta",
|
|
45
|
+
]);
|
|
46
|
+
|
|
19
47
|
connect() {
|
|
20
48
|
this.dialog = this.element.closest("dialog");
|
|
21
49
|
if (!this.dialog) return;
|
|
22
50
|
|
|
23
|
-
this.
|
|
51
|
+
this.baseline = null;
|
|
24
52
|
this.forceClose = false;
|
|
25
53
|
this.submitting = false;
|
|
26
54
|
|
|
55
|
+
this.onFirstIntent = this.#onFirstIntent.bind(this);
|
|
27
56
|
this.onCancel = this.#onCancel.bind(this);
|
|
28
57
|
this.onSubmit = this.#onSubmit.bind(this);
|
|
29
58
|
this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
|
|
30
59
|
this.onConfirmCancel = this.#onConfirmCancel.bind(this);
|
|
31
60
|
this.onKeydown = this.#onKeydown.bind(this);
|
|
32
61
|
|
|
62
|
+
// A trusted pointer/key action inside the form is the user starting to
|
|
63
|
+
// edit — capture the (settled, pre-edit) baseline then. Capture phase so
|
|
64
|
+
// a widget that stops propagation can't hide it from us.
|
|
65
|
+
this.element.addEventListener("pointerdown", this.onFirstIntent, true);
|
|
66
|
+
this.element.addEventListener("keydown", this.onFirstIntent, true);
|
|
67
|
+
|
|
33
68
|
document.addEventListener("keydown", this.onKeydown, true);
|
|
34
69
|
// Capture phase so this runs before remote-modal's cancel handler
|
|
35
70
|
// — that way `defaultPrevented` is visible there if we intervene.
|
|
@@ -47,6 +82,8 @@ export default class extends Controller {
|
|
|
47
82
|
|
|
48
83
|
disconnect() {
|
|
49
84
|
if (!this.dialog) return;
|
|
85
|
+
this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
|
|
86
|
+
this.element.removeEventListener("keydown", this.onFirstIntent, true);
|
|
50
87
|
document.removeEventListener("keydown", this.onKeydown, true);
|
|
51
88
|
this.dialog.removeEventListener("cancel", this.onCancel, true);
|
|
52
89
|
this.element.removeEventListener("submit", this.onSubmit);
|
|
@@ -84,6 +121,17 @@ export default class extends Controller {
|
|
|
84
121
|
return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
|
|
85
122
|
}
|
|
86
123
|
|
|
124
|
+
// Capture the baseline the first time the user really touches the form —
|
|
125
|
+
// a trusted pointer or editing keystroke. The form has rendered (so widgets
|
|
126
|
+
// have settled) and the event fires before the value changes, so this is
|
|
127
|
+
// the settled, pre-edit state. Runs once; later interactions are no-ops.
|
|
128
|
+
#onFirstIntent(event) {
|
|
129
|
+
if (this.baseline != null) return;
|
|
130
|
+
if (!event.isTrusted) return;
|
|
131
|
+
if (event.type === "keydown" && this.constructor.NON_EDITING_KEYS.has(event.key)) return;
|
|
132
|
+
this.baseline = this.#serialize();
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
#serialize() {
|
|
88
136
|
const data = new FormData(this.element);
|
|
89
137
|
const enc = encodeURIComponent;
|
|
@@ -97,8 +145,11 @@ export default class extends Controller {
|
|
|
97
145
|
.join("&");
|
|
98
146
|
}
|
|
99
147
|
|
|
148
|
+
// No interaction → no baseline → never dirty. Otherwise dirty iff the form
|
|
149
|
+
// now serializes differently than it did at first touch (so an edit reverted
|
|
150
|
+
// to its original value reads as clean).
|
|
100
151
|
#isDirty() {
|
|
101
|
-
return this.#serialize() !== this.
|
|
152
|
+
return this.baseline != null && this.#serialize() !== this.baseline;
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
#onSubmit() {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="nested-resource-form-fields"
|
|
4
|
-
//
|
|
4
|
+
// Adapted from https://github.com/stimulus-components/stimulus-rails-nested-form
|
|
5
|
+
//
|
|
6
|
+
// Persisted rows soft-delete: the row collapses to a "Removed — Restore" bar,
|
|
7
|
+
// its `_destroy` flag flips to "1", and it drops out of the row count so the
|
|
8
|
+
// add button can come back. Restoring reverses all three. Unpersisted rows are
|
|
9
|
+
// removed from the DOM outright — there is nothing to restore server-side.
|
|
5
10
|
export default class extends Controller {
|
|
6
11
|
static targets = ["target", "template", "addButton"]
|
|
7
12
|
|
|
@@ -23,9 +28,7 @@ export default class extends Controller {
|
|
|
23
28
|
const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime().toString())
|
|
24
29
|
this.targetTarget.insertAdjacentHTML("beforebegin", content)
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
this.element.dispatchEvent(event)
|
|
28
|
-
|
|
31
|
+
this.dispatch("add")
|
|
29
32
|
this.updateState()
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -36,19 +39,37 @@ export default class extends Controller {
|
|
|
36
39
|
if (wrapper.dataset.newRecord !== undefined) {
|
|
37
40
|
wrapper.remove()
|
|
38
41
|
} else {
|
|
39
|
-
wrapper
|
|
40
|
-
wrapper.classList.remove(...wrapper.classList)
|
|
41
|
-
|
|
42
|
-
const input = wrapper.querySelector("input[name*='_destroy']")
|
|
43
|
-
input.value = "1"
|
|
42
|
+
this.toggleRemoved(wrapper, true)
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
this.
|
|
45
|
+
this.dispatch("remove")
|
|
46
|
+
this.updateState()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
restore(e) {
|
|
50
|
+
e.preventDefault()
|
|
51
|
+
|
|
52
|
+
const wrapper = e.target.closest(this.wrapperSelectorValue)
|
|
53
|
+
this.toggleRemoved(wrapper, false)
|
|
48
54
|
|
|
55
|
+
this.dispatch("restore")
|
|
49
56
|
this.updateState()
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
// Collapse a persisted row to its "Removed" bar (or expand it back), keeping
|
|
60
|
+
// the `_destroy` flag and the removed-state marker in sync.
|
|
61
|
+
toggleRemoved(wrapper, removed) {
|
|
62
|
+
wrapper.toggleAttribute("data-removed", removed)
|
|
63
|
+
|
|
64
|
+
const content = wrapper.querySelector(":scope > [data-nested-content]")
|
|
65
|
+
const removedBar = wrapper.querySelector(":scope > [data-nested-removed]")
|
|
66
|
+
if (content) content.hidden = removed
|
|
67
|
+
if (removedBar) removedBar.hidden = !removed
|
|
68
|
+
|
|
69
|
+
const destroyInput = wrapper.querySelector("input[name*='_destroy']")
|
|
70
|
+
if (destroyInput) destroyInput.value = removed ? "1" : "0"
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
updateState() {
|
|
53
74
|
if (!this.hasAddButtonTarget || this.limitValue == 0) return
|
|
54
75
|
|
|
@@ -58,7 +79,9 @@ export default class extends Controller {
|
|
|
58
79
|
this.addButtonTarget.style.display = "initial"
|
|
59
80
|
}
|
|
60
81
|
|
|
82
|
+
// Removed rows keep their wrapper (so they can be restored) but are excluded
|
|
83
|
+
// from the count so the limit reflects rows that will actually be saved.
|
|
61
84
|
get childCount() {
|
|
62
|
-
return this.element.querySelectorAll(this.wrapperSelectorValue).length
|
|
85
|
+
return this.element.querySelectorAll(`${this.wrapperSelectorValue}:not([data-removed])`).length
|
|
63
86
|
}
|
|
64
87
|
}
|