plutonium 0.55.0 → 0.56.1
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/SKILL.md +1 -1
- data/.claude/skills/plutonium-app/SKILL.md +1 -1
- data/.claude/skills/plutonium-auth/SKILL.md +1 -1
- data/.claude/skills/plutonium-resource/SKILL.md +56 -3
- data/.claude/skills/plutonium-ui/SKILL.md +15 -2
- data/CHANGELOG.md +44 -0
- data/CONTRIBUTING.md +1 -1
- data/README.md +38 -16
- 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/getting-started/installation.md +2 -2
- data/docs/getting-started/tutorial/02-first-resource.md +1 -1
- data/docs/getting-started/tutorial/03-authentication.md +3 -3
- data/docs/guides/adding-resources.md +1 -1
- data/docs/guides/authentication.md +1 -1
- data/docs/guides/creating-packages.md +1 -1
- data/docs/guides/multi-tenancy.md +1 -1
- data/docs/guides/nested-resources.md +1 -1
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/user-profile.md +1 -1
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/app/generators.md +3 -3
- data/docs/reference/app/index.md +1 -1
- data/docs/reference/app/portals.md +1 -1
- data/docs/reference/auth/profile.md +1 -1
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/actions.md +55 -0
- data/docs/reference/resource/definition.md +18 -2
- data/docs/reference/resource/index.md +1 -1
- data/docs/reference/tenancy/invites.md +1 -1
- 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/typespec/typespec_generator.rb +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/generators/pu/saas/welcome_generator.rb +1 -1
- data/lib/plutonium/action/base.rb +19 -2
- data/lib/plutonium/action/condition_context.rb +33 -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 +61 -23
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/page/index.rb +1 -1
- data/lib/plutonium/ui/page/show.rb +1 -1
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/ui/table/resource.rb +2 -2
- 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 +20 -6
|
@@ -115,45 +115,12 @@ module Plutonium
|
|
|
115
115
|
div(class: FIELD_GRID_CLASS) do
|
|
116
116
|
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
117
117
|
end
|
|
118
|
-
|
|
119
|
-
end
|
|
120
|
-
render_structured_removed_bar
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def render_structured_remove_button
|
|
125
|
-
div(class: "flex items-center justify-end") do
|
|
126
|
-
button(
|
|
127
|
-
type: :button,
|
|
128
|
-
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
129
|
-
"text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
|
|
130
|
-
"focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900",
|
|
131
|
-
data_action: "structured-input-row#remove"
|
|
132
|
-
) do
|
|
133
|
-
render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
|
|
134
|
-
span { "Remove" }
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Compact bar shown in place of the row once it's marked for removal.
|
|
140
|
-
def render_structured_removed_bar
|
|
141
|
-
div(
|
|
142
|
-
data_structured_input_row_target: "removed",
|
|
143
|
-
hidden: true,
|
|
144
|
-
class: "flex items-center justify-between gap-3 text-sm text-[var(--pu-text-muted)]"
|
|
145
|
-
) do
|
|
146
|
-
span(class: "italic") { "Removed" }
|
|
147
|
-
button(
|
|
148
|
-
type: :button,
|
|
149
|
-
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
150
|
-
"text-secondary-700 hover:bg-secondary-50 dark:text-secondary-300 dark:hover:bg-secondary-900/30 " \
|
|
151
|
-
"focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900",
|
|
152
|
-
data_action: "structured-input-row#restore"
|
|
153
|
-
) do
|
|
154
|
-
render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
|
|
155
|
-
span { "Restore" }
|
|
118
|
+
render_repeater_remove_button(action: "structured-input-row#remove")
|
|
156
119
|
end
|
|
120
|
+
render_repeater_removed_bar(
|
|
121
|
+
restore_action: "structured-input-row#restore",
|
|
122
|
+
data_structured_input_row_target: "removed"
|
|
123
|
+
)
|
|
157
124
|
end
|
|
158
125
|
end
|
|
159
126
|
|
|
@@ -27,8 +27,13 @@ module Plutonium
|
|
|
27
27
|
)
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
# Use existing infrastructure to build the URL
|
|
31
|
-
|
|
30
|
+
# Use existing infrastructure to build the URL.
|
|
31
|
+
# Record-level actions (shown on the show page AND/OR on collection rows)
|
|
32
|
+
# operate on a single record, so the commit URL must target the record.
|
|
33
|
+
# `record_action?` alone is wrong: a collection-row action can be
|
|
34
|
+
# surfaced with `record_action: false` while still being record-scoped.
|
|
35
|
+
record_scoped = action.record_action? || action.collection_record_action?
|
|
36
|
+
subject = record_scoped ? resource_record! : resource_class
|
|
32
37
|
route_options_to_url(commit_route_options, subject)
|
|
33
38
|
end
|
|
34
39
|
|
|
@@ -4,6 +4,7 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Form
|
|
6
6
|
class Resource < Base
|
|
7
|
+
include Plutonium::UI::Form::Concerns::RendersRepeaterRowControls
|
|
7
8
|
include Plutonium::UI::Form::Concerns::RendersNestedResourceFields
|
|
8
9
|
include Plutonium::UI::Form::Concerns::RendersStructuredInputs
|
|
9
10
|
|
|
@@ -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
|
# ---------------------------------------------------------------
|
|
@@ -195,12 +232,13 @@ module Plutonium
|
|
|
195
232
|
|
|
196
233
|
def row_actions
|
|
197
234
|
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
198
|
-
a.collection_record_action? && a.permitted_by?(record_policy)
|
|
235
|
+
a.collection_record_action? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
|
|
199
236
|
}
|
|
200
237
|
end
|
|
201
238
|
|
|
202
239
|
def can_show?
|
|
203
|
-
resource_definition.defined_actions[:show]
|
|
240
|
+
action = resource_definition.defined_actions[:show]
|
|
241
|
+
action&.permitted_by?(record_policy) && action.condition_met?(view_context, record:)
|
|
204
242
|
end
|
|
205
243
|
|
|
206
244
|
def record_policy
|
|
@@ -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
|
|
@@ -33,7 +33,7 @@ module Plutonium
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def page_actions
|
|
36
|
-
super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) }
|
|
36
|
+
super || current_definition.defined_actions.values.select { |a| a.resource_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def render_default_content
|
|
@@ -15,7 +15,7 @@ module Plutonium
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def page_actions
|
|
18
|
-
super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) }
|
|
18
|
+
super || current_definition.defined_actions.values.select { |a| a.record_action? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def render_default_content
|
|
@@ -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",
|
|
@@ -134,7 +134,7 @@ module Plutonium
|
|
|
134
134
|
policy = policy_for(record:)
|
|
135
135
|
|
|
136
136
|
actions = resource_definition.defined_actions
|
|
137
|
-
.select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") }
|
|
137
|
+
.select { |k, a| a.collection_record_action? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
|
|
138
138
|
.values
|
|
139
139
|
|
|
140
140
|
primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
|
|
@@ -161,7 +161,7 @@ module Plutonium
|
|
|
161
161
|
|
|
162
162
|
def bulk_actions
|
|
163
163
|
@bulk_actions ||= resource_definition.defined_actions
|
|
164
|
-
.select { |k, a| a.bulk_action? }
|
|
164
|
+
.select { |k, a| a.bulk_action? && a.condition_met?(view_context) }
|
|
165
165
|
.values
|
|
166
166
|
end
|
|
167
167
|
|
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() {
|