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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module UI
|
|
7
|
+
module Display
|
|
8
|
+
module Components
|
|
9
|
+
# Renders a scalar value (typically an enum / status) as a colored pill.
|
|
10
|
+
#
|
|
11
|
+
# display :status, as: :badge
|
|
12
|
+
# display :status, as: :badge, colors: {archived: :neutral, vip: :accent}
|
|
13
|
+
class Badge < Phlexi::Display::Components::Base
|
|
14
|
+
include Phlexi::Display::Components::Concerns::DisplaysValue
|
|
15
|
+
|
|
16
|
+
VARIANTS = %i[neutral primary secondary success danger warning info accent].freeze
|
|
17
|
+
|
|
18
|
+
# Decorative variants used for values with no semantic meaning, chosen
|
|
19
|
+
# deterministically so a given value always gets the same color.
|
|
20
|
+
DECORATIVE = %i[primary secondary info accent].freeze
|
|
21
|
+
|
|
22
|
+
SEMANTIC_VARIANTS = {
|
|
23
|
+
"active" => :success, "approved" => :success, "completed" => :success,
|
|
24
|
+
"complete" => :success, "success" => :success, "succeeded" => :success,
|
|
25
|
+
"paid" => :success, "published" => :success, "enabled" => :success,
|
|
26
|
+
"confirmed" => :success, "verified" => :success, "live" => :success,
|
|
27
|
+
"available" => :success, "fulfilled" => :success, "done" => :success,
|
|
28
|
+
"pending" => :warning, "processing" => :warning, "in_progress" => :warning,
|
|
29
|
+
"draft" => :warning, "review" => :warning, "waiting" => :warning,
|
|
30
|
+
"scheduled" => :warning, "trial" => :warning, "paused" => :warning,
|
|
31
|
+
"on_hold" => :warning, "partial" => :warning,
|
|
32
|
+
"failed" => :danger, "rejected" => :danger, "cancelled" => :danger,
|
|
33
|
+
"canceled" => :danger, "error" => :danger, "inactive" => :danger,
|
|
34
|
+
"disabled" => :danger, "expired" => :danger, "banned" => :danger,
|
|
35
|
+
"blocked" => :danger, "closed" => :danger, "unpaid" => :danger,
|
|
36
|
+
"overdue" => :danger, "refunded" => :danger, "declined" => :danger,
|
|
37
|
+
"new" => :info, "queued" => :info, "open" => :info, "info" => :info
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def self.variant_for(value, colors: nil)
|
|
41
|
+
return :neutral if value.nil?
|
|
42
|
+
|
|
43
|
+
if colors
|
|
44
|
+
override = colors[value] || colors[value.to_s.to_sym] || colors[value.to_s]
|
|
45
|
+
return override if override && VARIANTS.include?(override.to_sym)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
key = value.to_s.downcase
|
|
49
|
+
SEMANTIC_VARIANTS[key] || decorative_variant_for(key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Stable across processes (String#hash is seeded, so we digest instead).
|
|
53
|
+
def self.decorative_variant_for(key)
|
|
54
|
+
index = Digest::SHA256.hexdigest(key)[0, 8].to_i(16) % DECORATIVE.size
|
|
55
|
+
DECORATIVE[index]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.humanize(value)
|
|
59
|
+
value.to_s.humanize
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def render_value(value)
|
|
63
|
+
variant = self.class.variant_for(value, colors: @colors)
|
|
64
|
+
span(**attributes, class: tokens("pu-badge", "pu-badge-#{variant}")) do
|
|
65
|
+
plain self.class.humanize(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
protected
|
|
70
|
+
|
|
71
|
+
def build_attributes
|
|
72
|
+
@colors = attributes.delete(:colors)
|
|
73
|
+
super
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_value(value)
|
|
77
|
+
value
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -4,18 +4,40 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Display
|
|
6
6
|
module Components
|
|
7
|
+
# Renders a boolean as a colored "Yes" / "No" pill with a leading icon.
|
|
7
8
|
class Boolean < Phlexi::Display::Components::Base
|
|
8
9
|
include Phlexi::Display::Components::Concerns::DisplaysValue
|
|
9
10
|
|
|
10
11
|
def render_value(value)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
render Phlex::TablerIcons::X.new(class: "inline-block w-5 h-5 text-red-500")
|
|
16
|
-
end
|
|
12
|
+
if value
|
|
13
|
+
pill(label: true_label, variant: "pu-badge-success", icon: Phlex::TablerIcons::Check)
|
|
14
|
+
else
|
|
15
|
+
pill(label: false_label, variant: "pu-badge-neutral", icon: Phlex::TablerIcons::X)
|
|
17
16
|
end
|
|
18
17
|
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def pill(label:, variant:, icon:)
|
|
22
|
+
span(**attributes, class: tokens("pu-badge", variant), "aria-label": label) do
|
|
23
|
+
render icon.new(class: "w-3.5 h-3.5")
|
|
24
|
+
plain label
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def true_label = @true_label || "Yes"
|
|
29
|
+
|
|
30
|
+
def false_label = @false_label || "No"
|
|
31
|
+
|
|
32
|
+
def build_attributes
|
|
33
|
+
@true_label = attributes.delete(:true_label)
|
|
34
|
+
@false_label = attributes.delete(:false_label)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Keep the real boolean — the default stringifies, turning `false` into
|
|
39
|
+
# the truthy string "false".
|
|
40
|
+
def normalize_value(value) = value
|
|
19
41
|
end
|
|
20
42
|
end
|
|
21
43
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/number_helper"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module UI
|
|
7
|
+
module Display
|
|
8
|
+
module Components
|
|
9
|
+
# Renders a numeric value as currency (delimited, 2 decimals). No symbol
|
|
10
|
+
# by default; pass a literal `unit:` ("£") or a Symbol read off the
|
|
11
|
+
# record (`unit: :currency_symbol`) for per-row currencies.
|
|
12
|
+
#
|
|
13
|
+
# display :price, as: :currency
|
|
14
|
+
# display :price, as: :currency, unit: "£"
|
|
15
|
+
# display :price, as: :currency, unit: :currency_symbol
|
|
16
|
+
class Currency < Phlexi::Display::Components::Base
|
|
17
|
+
include Phlexi::Display::Components::Concerns::DisplaysValue
|
|
18
|
+
|
|
19
|
+
def render_value(value)
|
|
20
|
+
p(**attributes) { format_currency(value) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def build_attributes
|
|
26
|
+
@unit = attributes.delete(:unit)
|
|
27
|
+
@options = attributes.delete(:options) || {}
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_currency(value)
|
|
34
|
+
ActiveSupport::NumberHelper.number_to_currency(value, unit: resolved_unit, **@options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resolved_unit
|
|
38
|
+
case @unit
|
|
39
|
+
when nil then ""
|
|
40
|
+
when Symbol then field.object.public_send(@unit)
|
|
41
|
+
else @unit
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_value(value) = value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -8,13 +8,26 @@ module Plutonium
|
|
|
8
8
|
private
|
|
9
9
|
|
|
10
10
|
def infer_field_component
|
|
11
|
+
# has_cents decimal accessors infer as :float/:decimal; render money.
|
|
12
|
+
return :currency if has_cents_field?
|
|
13
|
+
|
|
11
14
|
case inferred_field_type
|
|
12
15
|
when :attachment
|
|
13
16
|
:attachment
|
|
17
|
+
when :boolean
|
|
18
|
+
# phlexi-display falls back to :string, rendering "true"/"false".
|
|
19
|
+
:boolean
|
|
20
|
+
when :enum
|
|
21
|
+
:badge
|
|
14
22
|
else
|
|
15
23
|
super
|
|
16
24
|
end
|
|
17
25
|
end
|
|
26
|
+
|
|
27
|
+
def has_cents_field?
|
|
28
|
+
klass = object.class
|
|
29
|
+
klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
|
|
30
|
+
end
|
|
18
31
|
end
|
|
19
32
|
end
|
|
20
33
|
end
|
|
@@ -25,6 +25,11 @@ module Plutonium
|
|
|
25
25
|
color: "flex items-center text-lg text-[var(--pu-text)] whitespace-pre-line",
|
|
26
26
|
color_indicator: "w-10 h-10 rounded-lg mr-3 shadow-sm border border-[var(--pu-border)]",
|
|
27
27
|
|
|
28
|
+
# Boolean / badge pills — variant class is applied by the component.
|
|
29
|
+
boolean: "",
|
|
30
|
+
badge: "",
|
|
31
|
+
currency: "text-lg text-[var(--pu-text)] tabular-nums",
|
|
32
|
+
|
|
28
33
|
# Contact info
|
|
29
34
|
email: "flex items-center gap-2 text-lg text-primary-600 dark:text-primary-400 hover:text-primary-500 transition-colors",
|
|
30
35
|
phone: "flex items-center gap-2 text-lg text-primary-600 dark:text-primary-400 hover:text-primary-500 transition-colors",
|
|
@@ -42,6 +42,11 @@ module Plutonium
|
|
|
42
42
|
end
|
|
43
43
|
alias_method :markdown_tag, :easymde_tag
|
|
44
44
|
|
|
45
|
+
def toggle_tag(**, &)
|
|
46
|
+
create_component(Plutonium::UI::Form::Components::Toggle, :toggle, **, &)
|
|
47
|
+
end
|
|
48
|
+
alias_method :switch_tag, :toggle_tag
|
|
49
|
+
|
|
45
50
|
def slim_select_tag(**attributes, &)
|
|
46
51
|
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
|
|
47
52
|
select_tag(**attributes, required: false, class!: "", &)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Switch-styled boolean input (`input :notify, as: :toggle`). Identical
|
|
8
|
+
# behavior to the checkbox; only the `.pu-toggle` styling differs.
|
|
9
|
+
class Toggle < Phlexi::Form::Components::Checkbox
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -10,7 +10,7 @@ module Plutonium
|
|
|
10
10
|
module RendersNestedResourceFields
|
|
11
11
|
extend ActiveSupport::Concern
|
|
12
12
|
|
|
13
|
-
DEFAULT_NESTED_LIMIT =
|
|
13
|
+
DEFAULT_NESTED_LIMIT = RepeaterFieldStyles::DEFAULT_LIMIT
|
|
14
14
|
NESTED_OPTION_KEYS = [:allow_destroy, :update_only, :macro, :class].freeze
|
|
15
15
|
SINGULAR_MACROS = %i[belongs_to has_one].freeze
|
|
16
16
|
|
|
@@ -191,17 +191,29 @@ module Plutonium
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
def render_nested_fields_fieldset(nested, context)
|
|
194
|
+
removable = !nested.object&.persisted? || context.options[:allow_destroy]
|
|
194
195
|
fieldset(
|
|
195
196
|
data_new_record: !nested.object&.persisted?,
|
|
196
|
-
class:
|
|
197
|
+
class: RepeaterFieldStyles::FIELDSET_CLASS
|
|
197
198
|
) do
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
# Content is wrapped so the controller can hide it (and reveal the
|
|
200
|
+
# removed bar) on remove without disturbing the row itself, leaving
|
|
201
|
+
# the hidden _destroy field in place to submit the deletion.
|
|
202
|
+
div(data_nested_content: "") do
|
|
203
|
+
render_nested_fields_fieldset_content(nested, context)
|
|
204
|
+
render_repeater_remove_button(action: "nested-resource-form-fields#remove") if removable
|
|
205
|
+
end
|
|
206
|
+
if removable
|
|
207
|
+
render_repeater_removed_bar(
|
|
208
|
+
restore_action: "nested-resource-form-fields#restore",
|
|
209
|
+
data_nested_removed: ""
|
|
210
|
+
)
|
|
211
|
+
end
|
|
200
212
|
end
|
|
201
213
|
end
|
|
202
214
|
|
|
203
215
|
def render_nested_fields_fieldset_content(nested, context)
|
|
204
|
-
div(class:
|
|
216
|
+
div(class: RepeaterFieldStyles::FIELD_GRID_CLASS) do
|
|
205
217
|
render_nested_fields_hidden_fields(nested, context)
|
|
206
218
|
render_nested_fields_visible_fields(nested, context)
|
|
207
219
|
end
|
|
@@ -220,29 +232,6 @@ module Plutonium
|
|
|
220
232
|
end
|
|
221
233
|
end
|
|
222
234
|
|
|
223
|
-
def render_nested_fields_delete_button(nested, options)
|
|
224
|
-
return unless !nested.object&.persisted? || options[:allow_destroy]
|
|
225
|
-
|
|
226
|
-
render_nested_fields_delete_button_content
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def render_nested_fields_delete_button_content
|
|
230
|
-
div(class: "flex items-center justify-end") do
|
|
231
|
-
label(class: "inline-flex items-center text-md font-medium text-red-900 cursor-pointer") do
|
|
232
|
-
plain "Delete"
|
|
233
|
-
render_nested_fields_delete_checkbox
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def render_nested_fields_delete_checkbox
|
|
239
|
-
input(
|
|
240
|
-
type: :checkbox,
|
|
241
|
-
class: "w-4 h-4 ms-2 text-danger-600 bg-danger-100 border-danger-300 rounded focus:ring-danger-500 dark:focus:ring-danger-600 focus:ring-2 dark:bg-[var(--pu-surface-alt)] dark:border-[var(--pu-border)] cursor-pointer",
|
|
242
|
-
data_action: "nested-resource-form-fields#remove"
|
|
243
|
-
)
|
|
244
|
-
end
|
|
245
|
-
|
|
246
235
|
def render_nested_fields_add_button(context)
|
|
247
236
|
div do
|
|
248
237
|
button(
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Shared chrome for removable repeater rows (structured inputs and
|
|
8
|
+
# nested resource fields): the "Remove" button and the compact
|
|
9
|
+
# "Removed — Restore" accent bar shown in its place. Centralising these
|
|
10
|
+
# keeps the two concerns visually in lockstep.
|
|
11
|
+
# @api private
|
|
12
|
+
module RendersRepeaterRowControls
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
REMOVE_BUTTON_CLASS =
|
|
18
|
+
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
19
|
+
"text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
|
|
20
|
+
"focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900"
|
|
21
|
+
|
|
22
|
+
RESTORE_BUTTON_CLASS =
|
|
23
|
+
"inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
24
|
+
"text-secondary-700 hover:bg-secondary-100 dark:text-secondary-300 dark:hover:bg-secondary-900/40 " \
|
|
25
|
+
"focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900"
|
|
26
|
+
|
|
27
|
+
# Right-aligned "Remove" button that triggers the row's remove action.
|
|
28
|
+
def render_repeater_remove_button(action:)
|
|
29
|
+
div(class: "flex items-center justify-end") do
|
|
30
|
+
button(type: :button, class: REMOVE_BUTTON_CLASS, data_action: action) do
|
|
31
|
+
render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
|
|
32
|
+
span { "Remove" }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Compact accent bar shown in place of a removed row. Negative margin
|
|
38
|
+
# cancels the row's padding so the bar fills the fieldset edge-to-edge;
|
|
39
|
+
# a left danger stripe + struck-through label read as "pending delete".
|
|
40
|
+
#
|
|
41
|
+
# @param restore_action [String] Stimulus action for the Restore button
|
|
42
|
+
# @param label [String] text shown beside the trash icon
|
|
43
|
+
# @param bar_data [Hash] extra data attributes (Stimulus target/marker)
|
|
44
|
+
# the controller uses to find and toggle this bar
|
|
45
|
+
def render_repeater_removed_bar(restore_action:, label: "Removed", **bar_data)
|
|
46
|
+
div(
|
|
47
|
+
hidden: true,
|
|
48
|
+
class: "-m-4 flex items-center justify-between gap-3 px-4 py-2.5 " \
|
|
49
|
+
"rounded-[var(--pu-radius-md)] border-l-4 border-danger-400 dark:border-danger-600 " \
|
|
50
|
+
"bg-danger-50/70 dark:bg-danger-950/20",
|
|
51
|
+
**bar_data
|
|
52
|
+
) do
|
|
53
|
+
span(class: "inline-flex items-center gap-2 text-sm text-[var(--pu-text-muted)]") do
|
|
54
|
+
render Phlex::TablerIcons::Trash.new(class: "w-4 h-4 shrink-0 text-danger-500 dark:text-danger-400")
|
|
55
|
+
span(class: "line-through decoration-danger-400/60") { label }
|
|
56
|
+
end
|
|
57
|
+
button(type: :button, class: RESTORE_BUTTON_CLASS, data_action: restore_action) do
|
|
58
|
+
render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
|
|
59
|
+
span { "Restore" }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Renders classless structured inputs (single → hash via nest_one,
|
|
8
|
+
# repeater → array via nest_many). Field/namespace work is delegated to
|
|
9
|
+
# the form; this concern owns the structural markup.
|
|
10
|
+
# @api private
|
|
11
|
+
module RendersStructuredInputs
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def render_structured_input(name)
|
|
17
|
+
entry = resource_definition.defined_structured_inputs[name]
|
|
18
|
+
options = entry[:options] || {}
|
|
19
|
+
definition = structured_input_fields_definition(entry)
|
|
20
|
+
fields = options[:fields] || definition.defined_inputs.keys
|
|
21
|
+
repeat = options[:repeat]
|
|
22
|
+
|
|
23
|
+
if repeat
|
|
24
|
+
render_structured_repeater(name, definition, fields, repeat_limit(repeat))
|
|
25
|
+
else
|
|
26
|
+
render_structured_single(name, definition, fields)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def structured_input_fields_definition(entry)
|
|
31
|
+
return entry[:options][:using] if entry[:options]&.key?(:using)
|
|
32
|
+
|
|
33
|
+
holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
|
|
34
|
+
entry[:block].call(holder)
|
|
35
|
+
holder
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def repeat_limit(repeat)
|
|
39
|
+
repeat.is_a?(Integer) ? repeat : RepeaterFieldStyles::DEFAULT_LIMIT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- single -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def render_structured_single(name, definition, fields)
|
|
45
|
+
raw = structured_input_value(name)
|
|
46
|
+
value = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
|
|
47
|
+
div(class: "col-span-full space-y-2 my-4") do
|
|
48
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
49
|
+
nest_one(name, as: name, object: value) do |nested|
|
|
50
|
+
render_structured_fieldset(nested, definition, fields, removable: false)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- repeater -----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def render_structured_repeater(name, definition, fields, limit)
|
|
58
|
+
rows = Array(structured_input_value(name)).map { |row| row.is_a?(Hash) ? row.with_indifferent_access : row }
|
|
59
|
+
existing_collection = rows.presence || {NEW_RECORD: {}}
|
|
60
|
+
div(
|
|
61
|
+
class: "col-span-full space-y-2 my-4",
|
|
62
|
+
data: {
|
|
63
|
+
controller: "nested-resource-form-fields",
|
|
64
|
+
nested_resource_form_fields_limit_value: limit
|
|
65
|
+
}
|
|
66
|
+
) do
|
|
67
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
68
|
+
template data_nested_resource_form_fields_target: "template" do
|
|
69
|
+
nest_many(name, as: name, collection: {NEW_RECORD: {}}, default: {NEW_RECORD: {}}, template: true) do |nested|
|
|
70
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
nest_many(name, as: name, collection: existing_collection) do |nested|
|
|
74
|
+
if nested.object.blank?
|
|
75
|
+
vanish { render_structured_fieldset(nested, definition, fields, removable: true) }
|
|
76
|
+
else
|
|
77
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
div(data_nested_resource_form_fields_target: :target, hidden: true)
|
|
81
|
+
render_structured_add_button(name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- helpers ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def structured_input_value(name)
|
|
88
|
+
obj = object
|
|
89
|
+
return nil unless obj.respond_to?(name)
|
|
90
|
+
obj.public_send(name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Single source of truth shared with RendersNestedResourceFields.
|
|
94
|
+
FIELDSET_CLASS = RepeaterFieldStyles::FIELDSET_CLASS
|
|
95
|
+
FIELD_GRID_CLASS = RepeaterFieldStyles::FIELD_GRID_CLASS
|
|
96
|
+
|
|
97
|
+
# @param removable [Boolean] repeater rows are removable; a single
|
|
98
|
+
# structured input is the one-and-only object, so it is not.
|
|
99
|
+
def render_structured_fieldset(nested, definition, fields, removable:)
|
|
100
|
+
unless removable
|
|
101
|
+
return fieldset(class: FIELDSET_CLASS) do
|
|
102
|
+
div(class: FIELD_GRID_CLASS) do
|
|
103
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Removable rows soft-delete by DISABLING the inner fieldset: a
|
|
109
|
+
# disabled <fieldset> is omitted from submission, so the server just
|
|
110
|
+
# receives the payload without this row and rebuilds the JSON column
|
|
111
|
+
# from what it gets (no _destroy marker). The row stays in the DOM,
|
|
112
|
+
# collapsed to a "Removed — Restore" bar, so it can be restored.
|
|
113
|
+
div(data_controller: "structured-input-row", class: FIELDSET_CLASS) do
|
|
114
|
+
fieldset(data_structured_input_row_target: "content", class: "space-y-4 border-0 p-0 m-0") do
|
|
115
|
+
div(class: FIELD_GRID_CLASS) do
|
|
116
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
117
|
+
end
|
|
118
|
+
render_repeater_remove_button(action: "structured-input-row#remove")
|
|
119
|
+
end
|
|
120
|
+
render_repeater_removed_bar(
|
|
121
|
+
restore_action: "structured-input-row#restore",
|
|
122
|
+
data_structured_input_row_target: "removed"
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def render_structured_add_button(name)
|
|
128
|
+
div do
|
|
129
|
+
button(
|
|
130
|
+
type: :button,
|
|
131
|
+
class: "inline-block",
|
|
132
|
+
data: {action: "nested-resource-form-fields#add", nested_resource_form_fields_target: "addButton"}
|
|
133
|
+
) do
|
|
134
|
+
span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
|
|
135
|
+
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
|
|
136
|
+
span { "Add #{name.to_s.singularize.humanize}" }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Shared sizing + chrome for repeater-style field groups. Both
|
|
8
|
+
# RendersNestedResourceFields and RendersStructuredInputs render the
|
|
9
|
+
# same fieldset/grid markup and share a default row limit, so the
|
|
10
|
+
# values live here to keep the two concerns from drifting apart.
|
|
11
|
+
module RepeaterFieldStyles
|
|
12
|
+
# Default maximum number of rows a repeater renders/clones.
|
|
13
|
+
DEFAULT_LIMIT = 10
|
|
14
|
+
|
|
15
|
+
# Outer fieldset chrome for a single repeater row.
|
|
16
|
+
FIELDSET_CLASS = "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
|
|
17
|
+
|
|
18
|
+
# Responsive grid the row's fields are laid out in.
|
|
19
|
+
FIELD_GRID_CLASS = "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -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,7 +4,9 @@ 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
|
|
9
|
+
include Plutonium::UI::Form::Concerns::RendersStructuredInputs
|
|
8
10
|
|
|
9
11
|
attr_reader :resource_fields, :resource_definition, :singular_resource
|
|
10
12
|
|
|
@@ -171,7 +173,9 @@ module Plutonium
|
|
|
171
173
|
|
|
172
174
|
def render_resource_field(name)
|
|
173
175
|
when_permitted(name) do
|
|
174
|
-
if resource_definition.respond_to?(:
|
|
176
|
+
if resource_definition.respond_to?(:defined_structured_inputs) && resource_definition.defined_structured_inputs[name]
|
|
177
|
+
render_structured_input(name)
|
|
178
|
+
elsif resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
|
|
175
179
|
render_nested_resource_field(name)
|
|
176
180
|
else
|
|
177
181
|
render_simple_resource_field(name, resource_definition, self)
|
|
@@ -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,
|