plutonium 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -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
- p(**attributes) do
12
- if value
13
- render Phlex::TablerIcons::Check.new(class: "inline-block w-5 h-5 text-green-600")
14
- else
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 = 10
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: "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
197
+ class: RepeaterFieldStyles::FIELDSET_CLASS
197
198
  ) do
198
- render_nested_fields_fieldset_content(nested, context)
199
- render_nested_fields_delete_button(nested, context.options)
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: "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense") do
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
- subject = action.record_action? ? resource_record! : resource_class
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
 
@@ -19,6 +19,8 @@ module Plutonium
19
19
  :slim_select
20
20
  when :date, :time, :datetime
21
21
  :flatpickr
22
+ when :boolean
23
+ :toggle
22
24
  else
23
25
  inferred_field_component
24
26
  end
@@ -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?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
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,