plutonium 0.55.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-resource/SKILL.md +21 -2
- data/.claude/skills/plutonium-ui/SKILL.md +15 -2
- data/CHANGELOG.md +31 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +94 -26
- data/app/assets/plutonium.js.map +2 -2
- data/app/assets/plutonium.min.js +9 -9
- data/app/assets/plutonium.min.js.map +3 -3
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +18 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controllers/interactive_actions.rb +19 -2
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +14 -25
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -38
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +1 -0
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +126 -0
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- metadata +19 -6
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|