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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +21 -2
  3. data/.claude/skills/plutonium-ui/SKILL.md +15 -2
  4. data/CHANGELOG.md +31 -0
  5. data/app/assets/plutonium.css +1 -1
  6. data/app/assets/plutonium.js +94 -26
  7. data/app/assets/plutonium.js.map +2 -2
  8. data/app/assets/plutonium.min.js +9 -9
  9. data/app/assets/plutonium.min.js.map +3 -3
  10. data/config/initializers/rabl.rb +16 -0
  11. data/docs/.vitepress/config.ts +1 -0
  12. data/docs/public/templates/lite.rb +10 -0
  13. data/docs/reference/generators/lite.md +65 -0
  14. data/docs/reference/resource/definition.md +18 -2
  15. data/docs/reference/ui/assets.md +14 -0
  16. data/docs/reference/ui/displays.md +27 -1
  17. data/docs/reference/ui/forms.md +2 -1
  18. data/docs/reference/ui/layouts.md +33 -0
  19. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  20. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  21. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  22. data/gemfiles/rails_7.gemfile.lock +1 -1
  23. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  24. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  25. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  26. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  27. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  28. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  29. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  30. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  31. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  32. data/lib/plutonium/models/has_cents.rb +10 -0
  33. data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
  34. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  35. data/lib/plutonium/ui/display/base.rb +9 -0
  36. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  37. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  38. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  39. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  40. data/lib/plutonium/ui/display/theme.rb +5 -0
  41. data/lib/plutonium/ui/form/base.rb +5 -0
  42. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  43. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
  44. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  45. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
  46. data/lib/plutonium/ui/form/interaction.rb +7 -2
  47. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  48. data/lib/plutonium/ui/form/resource.rb +1 -0
  49. data/lib/plutonium/ui/form/theme.rb +12 -0
  50. data/lib/plutonium/ui/grid/card.rb +58 -21
  51. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  52. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  53. data/lib/plutonium/version.rb +1 -1
  54. data/package.json +1 -1
  55. data/plutonium.gemspec +5 -4
  56. data/src/css/components.css +126 -0
  57. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  58. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  59. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  60. metadata +19 -6
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class TuneGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ desc "Tune config/database.yml with performance pragmas for SQLite"
11
+
12
+ RAILS_8_1 = ::Gem::Version.new("8.1.0")
13
+ DATABASE_YML = "config/database.yml"
14
+
15
+ def start
16
+ path = File.expand_path(DATABASE_YML, destination_root)
17
+ unless File.exist?(path)
18
+ log :skip, "#{DATABASE_YML} not found"
19
+ return
20
+ end
21
+
22
+ content = File.read(path)
23
+ if content.include?("wal_autocheckpoint")
24
+ log :skip, "pragmas already tuned in #{DATABASE_YML}"
25
+ return
26
+ end
27
+
28
+ new_content = apply_pragmas(content, rails_version)
29
+ if new_content == content
30
+ log :skip, "no `default: &default` block in #{DATABASE_YML}"
31
+ return
32
+ end
33
+
34
+ create_file DATABASE_YML, new_content, force: true
35
+ say_status :tune, "added SQLite pragmas to #{DATABASE_YML}"
36
+ rescue => e
37
+ exception "#{self.class} failed:", e
38
+ end
39
+
40
+ private
41
+
42
+ # Pure: returns content with pragmas inserted into the `default: &default`
43
+ # block. Merges into an existing default-level `pragmas:` mapping (2-space
44
+ # indent) if present, otherwise inserts a fresh pragmas block. Returns the
45
+ # content unchanged when there is no default anchor. Scoped to the default
46
+ # block so a `pragmas:` nested under another env (e.g. production.primary)
47
+ # is never touched.
48
+ def apply_pragmas(content, version)
49
+ anchor = content.match(/^default: &default\n/)
50
+ return content unless anchor
51
+
52
+ body_start = anchor.end(0)
53
+ rest = content[body_start..]
54
+ # the default block runs until the next top-level (column-0) line
55
+ next_top = rest =~ /^\S/
56
+ default_body = next_top ? rest[0...next_top] : rest
57
+ tail = next_top ? rest[next_top..] : ""
58
+
59
+ if default_body.match?(/^ pragmas:\s*$/)
60
+ new_body = default_body.sub(/^( pragmas:[ \t]*\n)/) { $1 + pragma_keys(version) }
61
+ content[0...body_start] + new_body + tail
62
+ else
63
+ content.sub(/^default: &default\n/, "default: &default\n" + pragma_block(version))
64
+ end
65
+ end
66
+
67
+ def pragma_block(version)
68
+ comment = <<~COMMENT.gsub(/^/, " ")
69
+ # Plutonium-tuned SQLite pragmas (pu:lite:tune).
70
+ # Rails 8.1+ already sets WAL, synchronous=NORMAL, foreign_keys,
71
+ # mmap=128MB and journal_size_limit by default; only deltas are added
72
+ # there. We intentionally do NOT set SQLite's internal busy pragma —
73
+ # Rails routes the `timeout:` key to the sqlite3 gem's constant-poll
74
+ # busy_handler_timeout, which has better tail-latency than SQLite's
75
+ # backoff.
76
+ pragmas:
77
+ COMMENT
78
+ comment + pragma_keys(version)
79
+ end
80
+
81
+ def pragma_keys(version)
82
+ keys = +""
83
+ if version < RAILS_8_1
84
+ keys << <<~YAML.gsub(/^/, " ")
85
+ journal_mode: WAL
86
+ synchronous: NORMAL
87
+ foreign_keys: true
88
+ journal_size_limit: 67108864 # 64 MB
89
+ YAML
90
+ end
91
+ keys << <<~YAML.gsub(/^/, " ")
92
+ cache_size: -64000 # 64 MB page cache (default ~2 MB is too small)
93
+ temp_store: 2 # MEMORY — sorts/temp indexes stay off disk
94
+ mmap_size: 536870912 # 512 MB (override the 128 MB default)
95
+ wal_autocheckpoint: 10000 # checkpoint every ~40 MB of WAL, fewer pauses
96
+ YAML
97
+ keys
98
+ end
99
+
100
+ def rails_version
101
+ @rails_version ||= ::Gem::Version.new(Rails::VERSION::STRING).release
102
+ end
103
+ end
104
+ end
105
+ end
@@ -166,6 +166,16 @@ module Plutonium
166
166
  def has_cents_attribute?(attribute)
167
167
  has_cents_attributes.key?(attribute.to_sym)
168
168
  end
169
+
170
+ # Checks if a given attribute is the decimal accessor of a has_cents pair
171
+ # (e.g. :amount for `has_cents :amount_cents`).
172
+ #
173
+ # @param attribute [Symbol] The attribute to check
174
+ # @return [Boolean]
175
+ def has_cents_decimal_attribute?(attribute)
176
+ attribute = attribute.to_sym
177
+ has_cents_attributes.any? { |_, opts| opts[:name] == attribute }
178
+ end
169
179
  end
170
180
  end
171
181
  end
@@ -244,9 +244,26 @@ module Plutonium
244
244
  # @return [Hash] The submitted interaction parameters
245
245
  def submitted_interaction_params
246
246
  @submitted_interaction_params ||= begin
247
- interaction = current_interactive_action.interaction
247
+ action = current_interactive_action
248
+ interaction = action.interaction
249
+ instance = interaction.new(view_context:)
250
+ # Bind the action's subject before the form is rendered for param
251
+ # extraction. extract_input renders the form when it hasn't been
252
+ # rendered yet, which eagerly materializes input choices — any
253
+ # `choices:` proc (or other render-time config) that reads the
254
+ # subject would otherwise run against a nil resource/resources and
255
+ # raise a deep-stack NoMethodError before the interaction ever runs.
256
+ # This mirrors the subject the real instance is given in
257
+ # build_interactive_*_action_interaction. interaction_params still
258
+ # strips :resource/:resources from the extracted params, so
259
+ # mass-assignment safety is unaffected.
260
+ if action.record_action? || action.collection_record_action?
261
+ instance.resource = resource_record!
262
+ elsif action.bulk_action?
263
+ instance.resources = interactive_bulk
264
+ end
248
265
  extracted = interaction
249
- .build_form(interaction.new(view_context:))
266
+ .build_form(instance)
250
267
  .extract_input(params, view_context:)[:interaction]
251
268
  clean_structured_inputs(interaction, extracted)
252
269
  end
@@ -99,8 +99,13 @@ module Plutonium
99
99
  next unless base_config
100
100
 
101
101
  # Register with association-based key: "parent_plural/association_name"
102
+ # Force route_type: :resources — has_many associations always nest as a
103
+ # plural (member-with-id) route, even when the child resource is registered
104
+ # `singular: true` at the top level (which would otherwise leak :resource
105
+ # into base_config and make member URL helpers resolve to the wrong name).
102
106
  nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
103
107
  nested_config = base_config.merge(
108
+ route_type: :resources,
104
109
  association_name: assoc_info[:name],
105
110
  resource_class: assoc_info[:klass]
106
111
  )
@@ -34,6 +34,15 @@ module Plutonium
34
34
  create_component(Plutonium::UI::Display::Components::Color, :color, **, &)
35
35
  end
36
36
 
37
+ def badge_tag(**, &)
38
+ create_component(Plutonium::UI::Display::Components::Badge, :badge, **, &)
39
+ end
40
+ alias_method :enum_tag, :badge_tag
41
+
42
+ def currency_tag(**, &)
43
+ create_component(Plutonium::UI::Display::Components::Currency, :currency, **, &)
44
+ end
45
+
37
46
  # Type aliases for common column types
38
47
  alias_method :float_tag, :number_tag
39
48
  alias_method :decimal_tag, :number_tag
@@ -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
@@ -191,12 +191,24 @@ 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
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
 
@@ -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
@@ -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
- render_structured_remove_button
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
- 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,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