plutonium 0.66.0 → 0.67.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +14 -8
  3. data/.claude/skills/plutonium-ui/SKILL.md +11 -3
  4. data/CHANGELOG.md +22 -0
  5. data/SECURITY.md +1 -1
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +56 -3
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +32 -32
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/brakeman.ignore +1 -1
  12. data/config/locales/en/js.yml +4 -0
  13. data/config/locales/en/ui.yml +16 -0
  14. data/docs/reference/resource/definition.md +12 -7
  15. data/docs/reference/resource/query.md +11 -1
  16. data/docs/reference/ui/displays.md +19 -2
  17. data/docs/reference/ui/forms.md +54 -4
  18. data/gemfiles/rails_7.gemfile +1 -0
  19. data/gemfiles/rails_7.gemfile.lock +13 -3
  20. data/gemfiles/rails_8.0.gemfile +1 -0
  21. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  22. data/gemfiles/rails_8.1.gemfile +1 -0
  23. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  24. data/lib/plutonium/auth/rodauth.rb +7 -0
  25. data/lib/plutonium/core/controller.rb +0 -2
  26. data/lib/plutonium/helpers/content_helper.rb +0 -3
  27. data/lib/plutonium/query/filter.rb +2 -1
  28. data/lib/plutonium/query/filters/tags.rb +36 -0
  29. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  30. data/lib/plutonium/ui/display/base.rb +50 -2
  31. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  32. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  33. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  34. data/lib/plutonium/ui/display/components/code.rb +23 -0
  35. data/lib/plutonium/ui/display/components/color.rb +14 -6
  36. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  37. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  38. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  39. data/lib/plutonium/ui/display/components/list.rb +33 -0
  40. data/lib/plutonium/ui/display/components/number.rb +32 -0
  41. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  42. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  43. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  44. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  45. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  46. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  47. data/lib/plutonium/ui/display/theme.rb +24 -2
  48. data/lib/plutonium/ui/form/base.rb +30 -1
  49. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  50. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  51. data/lib/plutonium/ui/form/components/list.rb +46 -0
  52. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  53. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  54. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  55. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  56. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  57. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  58. data/lib/plutonium/ui/form/theme.rb +20 -4
  59. data/lib/plutonium/ui/grid/card.rb +13 -1
  60. data/lib/plutonium/ui/layout/base.rb +10 -1
  61. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  62. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  63. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  64. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  65. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  66. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/src/css/components.css +43 -0
  70. data/src/css/flatpickr.css +91 -0
  71. data/src/js/controllers/easymde_controller.js +1 -0
  72. data/src/js/controllers/register_controllers.js +2 -0
  73. data/src/js/controllers/slim_select_controller.js +32 -3
  74. data/src/js/controllers/timeago_controller.js +40 -0
  75. metadata +25 -2
@@ -7,15 +7,23 @@ module Plutonium
7
7
  class Color < Phlexi::Display::Components::Base
8
8
  include Phlexi::Display::Components::Concerns::DisplaysValue
9
9
 
10
+ # Hex, a named color, or a plain rgb()/hsl() call. The value is
11
+ # interpolated into `style:`, so anything else (a `;` or `url(`)
12
+ # would let a stored string inject arbitrary CSS.
13
+ CSS_COLOR = /\A(?:#\h{3,8}|[a-z]+|(?:rgb|hsl)a?\([\d\s.,%\/]+\))\z/i
14
+
10
15
  def render_value(value)
11
- div(**attributes, class: "flex items-center gap-2") do
12
- div(
13
- class: "w-6 h-6 rounded border border-[var(--pu-border)]",
14
- style: "background-color: #{value};"
15
- )
16
- span(class: "text-sm text-[var(--pu-text-muted)]") { value }
16
+ div(**attributes) do
17
+ div(class: themed(:color_indicator), style: swatch_style(value))
18
+ span(class: themed(:color_label)) { value }
17
19
  end
18
20
  end
21
+
22
+ private
23
+
24
+ def swatch_style(value)
25
+ "background-color: #{value};" if CSS_COLOR.match?(value)
26
+ end
19
27
  end
20
28
  end
21
29
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A number of seconds (or an ActiveSupport::Duration) as "2h 15m".
8
+ class Duration < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ UNITS = {days: 86_400, hours: 3600, minutes: 60, seconds: 1}.freeze
12
+
13
+ def render_value(value)
14
+ span(**attributes) { value }
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_value(value)
20
+ remaining = value.to_i
21
+ parts = UNITS.filter_map do |unit, size|
22
+ count, remaining = remaining.divmod(size)
23
+ Plutonium::Translation.t("plutonium.ui.display.duration.#{unit}", count:) if count.positive?
24
+ end
25
+ parts.empty? ? Plutonium::Translation.t("plutonium.ui.display.duration.seconds", count: 0) : parts.join(" ")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A byte count as "12 KB".
8
+ class FileSize < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ def render_value(value)
12
+ span(**attributes) { value }
13
+ end
14
+
15
+ private
16
+
17
+ def normalize_value(value) = ActiveSupport::NumberHelper.number_to_human_size(value)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A Hash as a two-column list of keys and values: hstore columns, or
8
+ # any hash-valued field declared `as: :key_value`. Nested hashes and
9
+ # arrays stay on one line as compact JSON.
10
+ class KeyValue < Phlexi::Display::Components::Base
11
+ include Phlexi::Display::Components::Concerns::DisplaysValue
12
+
13
+ def render_value(value)
14
+ dl(**attributes) do
15
+ value.each do |key, item|
16
+ dt(class: themed(:key_value_key)) { key.to_s }
17
+ dd(class: themed(:key_value_value)) { format_item(item) }
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def normalize_value(value) = value.to_h
25
+
26
+ def format_item(item)
27
+ case item
28
+ when nil then "—"
29
+ when Hash, Array then code(class: themed(:key_value_nested)) { item.to_json }
30
+ else item.to_s
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # An array value as a row of chips.
8
+ class List < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ # DisplaysValue renders a multi-valued field once per item; a list
12
+ # is one component for the whole array.
13
+ def view_template
14
+ items = normalize_value(field.value)
15
+ return render(field.placeholder_tag(**@placeholder_attributes)) if items.empty?
16
+
17
+ wrapped { render_value(items) }
18
+ end
19
+
20
+ def render_value(value)
21
+ ul(**attributes) do
22
+ value.each { |item| li(class: themed(:list_item)) { item.to_s } }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_value(value) = Array(value).compact_blank
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # phlexi's Number coerces through Float(), which drops a decimal
8
+ # column's scale (10.50 → "10.5") and digits past ~15 significant
9
+ # figures. Decimals stay BigDecimal and render at the column's scale.
10
+ # Explicit `options:` still go straight to number_to_delimited.
11
+ class Number < Phlexi::Display::Components::Number
12
+ private
13
+
14
+ def format_number(value)
15
+ return super unless value.is_a?(BigDecimal) && @options.empty?
16
+
17
+ digits = scale ? ActiveSupport::NumberHelper.number_to_rounded(value, precision: scale) : value.to_s("F")
18
+ ActiveSupport::NumberHelper.number_to_delimited(digits)
19
+ end
20
+
21
+ def normalize_value(value)
22
+ value.is_a?(BigDecimal) ? value : super
23
+ end
24
+
25
+ def scale
26
+ field.object.class.try(:type_for_attribute, field.key)&.scale
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A value out of `max:` (default 100) as a bar and a percentage.
8
+ #
9
+ # display :completion, as: :progress # 0..100
10
+ # display :ratio, as: :progress, max: 1 # 0..1
11
+ class Progress < Phlexi::Display::Components::Base
12
+ include Phlexi::Display::Components::Concerns::DisplaysValue
13
+
14
+ def render_value(value)
15
+ percent = (value.to_f / @max * 100).clamp(0, 100).round
16
+ div(**attributes, role: :progressbar, aria_valuenow: value, aria_valuemin: 0, aria_valuemax: @max) do
17
+ div(class: themed(:progress_track)) do
18
+ div(class: themed(:progress_bar), style: "width: #{percent}%")
19
+ end
20
+ span(class: themed(:progress_label)) { "#{percent}%" }
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_attributes
27
+ super
28
+ @max = attributes.delete(:max) || 100
29
+ end
30
+
31
+ def normalize_value(value) = value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A score as stars out of `max:` (default 5).
8
+ class Rating < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ def render_value(value)
12
+ filled = value.to_f.round.clamp(0, @max)
13
+ label = Plutonium::Translation.t("plutonium.ui.display.rating.label", value: filled, max: @max)
14
+ div(**attributes, role: :img, aria_label: label) do
15
+ @max.times do |index|
16
+ state = (index < filled) ? "filled" : "empty"
17
+ span(data: {rating_star: state}) do
18
+ render Phlex::TablerIcons::Star.new(class: themed(:"rating_#{state}"))
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def build_attributes
27
+ super
28
+ @max = attributes.delete(:max) || 5
29
+ end
30
+
31
+ def normalize_value(value) = value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A timestamp as "3 minutes ago", kept live by the timeago controller.
8
+ # The absolute time is the fallback text and the tooltip.
9
+ class RelativeTime < Phlexi::Display::Components::Base
10
+ include Phlexi::Display::Components::Concerns::DisplaysValue
11
+
12
+ def render_value(value)
13
+ absolute = I18n.l(value, format: :long)
14
+ time(**attributes, datetime: value.iso8601, title: absolute,
15
+ data: {controller: "timeago", timeago_datetime_value: value.iso8601}) { absolute }
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_value(value) = value
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A `has_rich_text` field. Action Text renders and sanitizes the HTML
8
+ # (including attachments) through its content layout.
9
+ class RichText < Phlexi::Display::Components::Base
10
+ include Phlexi::Display::Components::Concerns::DisplaysValue
11
+
12
+ def render_value(value)
13
+ div(**attributes) { raw(safe(value.to_s)) }
14
+ end
15
+
16
+ private
17
+
18
+ def normalize_value(value) = value
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A no_fly_list `has_tags` context as chips. Reads the tag records
8
+ # through the context's association, so a table can preload it
9
+ # (`includes(:labels)`) to avoid a query per row.
10
+ class Tags < List
11
+ private
12
+
13
+ def normalize_value(value) = value.map(&:name)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -6,16 +6,29 @@ module Plutonium
6
6
  module Options
7
7
  module InferredTypes
8
8
  include Plutonium::UI::Options::HasCentsField
9
+ include Plutonium::UI::Options::SecretField
10
+ include Plutonium::UI::Options::TagsField
11
+ include Plutonium::UI::Options::RichTextField
9
12
 
10
13
  private
11
14
 
12
15
  def infer_field_component
16
+ return :tags if tags_field?
17
+ return :rich_text if rich_text_field?
18
+
19
+ # Mask the same secret-bearing names the form does, so the show
20
+ # page and table never print what the form refuses to echo.
21
+ return :password if secret_field_name?
22
+
13
23
  # has_cents decimal accessors infer as :float/:decimal; render money.
14
24
  return :currency if has_cents_field?
15
25
 
16
26
  case inferred_field_type
17
27
  when :attachment
18
28
  :attachment
29
+ when :binary
30
+ # phlexi-display falls back to :string, printing the raw bytes.
31
+ :binary
19
32
  when :boolean
20
33
  # phlexi-display falls back to :string, rendering "true"/"false".
21
34
  :boolean
@@ -41,8 +41,9 @@ module Plutonium
41
41
  link: "text-lg text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 whitespace-pre-line transition-colors",
42
42
 
43
43
  # Color display
44
- color: "flex items-center text-lg text-[var(--pu-text)] whitespace-pre-line",
45
- color_indicator: "w-10 h-10 rounded-lg mr-3 shadow-sm border border-[var(--pu-border)]",
44
+ color: "flex items-center gap-2",
45
+ color_indicator: "w-6 h-6 rounded border border-[var(--pu-border)]",
46
+ color_label: "text-sm text-[var(--pu-text-muted)]",
46
47
 
47
48
  # Boolean / badge pills — variant class is applied by the component.
48
49
  boolean: "",
@@ -57,6 +58,27 @@ module Plutonium
57
58
  json: "text-sm text-[var(--pu-text)] whitespace-pre font-mono bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-md)] p-4 overflow-x-auto",
58
59
  prefixed_icon: "w-6 h-6 mr-2 text-[var(--pu-text-muted)]",
59
60
  markdown: "format dark:format-invert format-primary max-w-none",
61
+ rich_text: "format dark:format-invert format-primary max-w-none",
62
+ key_value: "grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-[var(--pu-text)]",
63
+ key_value_key: "font-medium text-[var(--pu-text-muted)] break-words",
64
+ key_value_value: "break-words",
65
+ key_value_nested: "font-mono text-sm",
66
+ list: "flex flex-wrap gap-1.5",
67
+ list_item: "pu-badge pu-badge-neutral",
68
+ relative_time: "text-lg text-[var(--pu-text)]",
69
+ code: "flex items-center gap-2",
70
+ code_value: "font-mono text-sm text-[var(--pu-text)] bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-sm)] px-2 py-0.5 break-all",
71
+ code_copy: "pu-btn pu-btn-xs pu-btn-ghost",
72
+ duration: "text-lg text-[var(--pu-text)] tabular-nums",
73
+ file_size: "text-lg text-[var(--pu-text)] tabular-nums",
74
+ progress: "flex items-center gap-3",
75
+ progress_track: "h-2 w-full max-w-xs rounded-full bg-[var(--pu-surface-alt)] overflow-hidden",
76
+ progress_bar: "h-full rounded-full bg-primary-500",
77
+ progress_label: "text-sm text-[var(--pu-text-muted)] tabular-nums",
78
+ rating: "flex items-center gap-0.5",
79
+ rating_filled: "w-5 h-5 text-warning-500 fill-current",
80
+ rating_empty: "w-5 h-5 text-[var(--pu-border)]",
81
+ binary: "text-sm text-[var(--pu-text-muted)]",
60
82
 
61
83
  # Attachments
62
84
  attachment_value_wrapper: "grid grid-cols-[repeat(auto-fill,minmax(0,200px))] gap-4",
@@ -43,6 +43,11 @@ module Plutonium
43
43
  end
44
44
  alias_method :markdown_tag, :easymde_tag
45
45
 
46
+ # Action Text editor (Trix, or Lexxy when installed). See {Components::RichText}.
47
+ def rich_text_tag(**, &)
48
+ create_component(Components::RichText, :rich_text, **, &)
49
+ end
50
+
46
51
  def toggle_tag(**, &)
47
52
  create_component(Plutonium::UI::Form::Components::Toggle, :toggle, **, &)
48
53
  end
@@ -54,12 +59,30 @@ module Plutonium
54
59
  def password_tag(**, &)
55
60
  create_component(Components::Password, :password, **, &)
56
61
  end
62
+ alias_method :secret_tag, :password_tag
57
63
 
58
64
  def slim_select_tag(**attributes, &)
59
65
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
60
66
  select_tag(**attributes, required: false, class!: "", &)
61
67
  end
62
68
 
69
+ # Chip input for array values. See {Components::List}.
70
+ def list_tag(**attributes, &)
71
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
72
+ create_component(Components::List, :select, required: false, class!: "", **attributes, &)
73
+ end
74
+
75
+ # Chip input for a no_fly_list `has_tags` context. See {Components::Tags}.
76
+ def tags_tag(**attributes, &)
77
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
78
+ create_component(Components::Tags, :select, required: false, class!: "", **attributes, &)
79
+ end
80
+
81
+ # Star picker. See {Components::Rating}.
82
+ def rating_tag(**, &)
83
+ create_component(Components::Rating, :rating, **, &)
84
+ end
85
+
63
86
  def flatpickr_tag(**, &)
64
87
  create_component(Components::Flatpickr, :flatpickr, **, &)
65
88
  end
@@ -86,6 +109,10 @@ module Plutonium
86
109
  alias_method :"#{name}_tag", :uppy_tag
87
110
  end
88
111
 
112
+ def binary_tag(**, &)
113
+ create_component(Components::Binary, :file, **, &)
114
+ end
115
+
89
116
  def key_value_store_tag(**, &)
90
117
  create_component(Components::KeyValueStore, :key_value_store, **, &)
91
118
  end
@@ -116,6 +143,9 @@ module Plutonium
116
143
  alias_method :belongs_to_tag, :secure_association_tag
117
144
  alias_method :has_many_tag, :secure_association_tag
118
145
  alias_method :has_one_tag, :secure_association_tag
146
+ # phlexi bound this to ITS has_many_tag at definition time, so it
147
+ # has to be re-pointed explicitly or habtm skips the policy scope.
148
+ alias_method :has_and_belongs_to_many_tag, :secure_association_tag
119
149
 
120
150
  def secure_polymorphic_association_tag(**attributes, &)
121
151
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select") # TODO: put this behind a config
@@ -134,7 +164,6 @@ module Plutonium
134
164
  alias_method :datetime_tag, :flatpickr_tag
135
165
  alias_method :date_tag, :flatpickr_tag
136
166
  alias_method :time_tag, :flatpickr_tag
137
- alias_method :rich_text_tag, :markdown_tag
138
167
  alias_method :json_tag, :json_input_tag
139
168
  alias_method :jsonb_tag, :json_input_tag
140
169
  alias_method :hstore_tag, :key_value_store_tag
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # File input for a `binary` column: the uploaded file's bytes become
8
+ # the value. Choosing no file extracts nil, which the resource
9
+ # controller compacts away, so an edit that doesn't replace the file
10
+ # keeps the stored bytes. There is no way to clear the column here.
11
+ class Binary < Phlexi::Form::Components::FileInput
12
+ protected
13
+
14
+ def build_input_attributes
15
+ super
16
+ apply_default_hint
17
+ end
18
+
19
+ def normalize_input(input_value)
20
+ input_value.read if input_value.is_a?(ActionDispatch::Http::UploadedFile)
21
+ end
22
+
23
+ private
24
+
25
+ # Says what a blank submit keeps: the bytes in the database, not a
26
+ # rejected upload still assigned to the record on a failed re-render.
27
+ def apply_default_hint
28
+ return if field.has_hint?
29
+
30
+ stored = field.object.attribute_in_database(field.key) if field.object.persisted?
31
+ return if stored.blank?
32
+
33
+ size = ActiveSupport::NumberHelper.number_to_human_size(stored.bytesize)
34
+ field.hint(Plutonium::Translation.t("plutonium.ui.form.binary.replace_hint", size:))
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -44,21 +44,6 @@ module Plutonium
44
44
  attributes[:data][:key_value_store_limit_value] = limit
45
45
  end
46
46
 
47
- def render_header
48
- div(class: "key-value-store-header") do
49
- if attributes[:label]
50
- h3(class: "text-lg font-semibold text-[var(--pu-text)]") do
51
- plain attributes[:label]
52
- end
53
- end
54
- if attributes[:description]
55
- p(class: "text-sm text-[var(--pu-text-muted)]") do
56
- plain attributes[:description]
57
- end
58
- end
59
- end
60
- end
61
-
62
47
  def render_key_value_pairs
63
48
  # Hidden sentinel input ensures the field is always present in params when the
64
49
  # component is rendered. Without this, removing all pairs would submit nothing,
@@ -75,42 +60,37 @@ module Plutonium
75
60
 
76
61
  def render_key_value_pair(key, value, index)
77
62
  div(
78
- class: "key-value-pair flex items-center gap-2 p-2 border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)]",
63
+ class: "key-value-pair flex items-center gap-2",
79
64
  data_key_value_store_target: "pair"
80
65
  ) do
81
- # Key input
82
- input(
83
- type: :text,
84
- placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.key_placeholder"),
85
- value: key,
86
- name: "#{field_name}[#{index}][key]",
87
- id: "#{field.dom.id}_#{index}_key",
88
- class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
89
- data_key_value_store_target: "keyInput"
90
- )
91
-
92
- # Value input
93
- input(
94
- type: :text,
95
- placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.value_placeholder"),
96
- value: value,
97
- name: "#{field_name}[#{index}][value]",
98
- id: "#{field.dom.id}_#{index}_value",
99
- class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
100
- data_key_value_store_target: "valueInput"
101
- )
102
-
103
- # Remove button
66
+ render_pair_input(:key, key, index)
67
+ render_pair_input(:value, value, index)
68
+
104
69
  button(
105
70
  type: :button,
106
- class: "px-2 py-1 text-danger-600 hover:text-danger-800 focus:outline-none transition-colors",
71
+ class: "pu-btn pu-btn-sm pu-btn-ghost text-danger-600 dark:text-danger-400",
72
+ aria_label: Plutonium::Translation.t("plutonium.ui.form.key_value_store.remove_pair"),
107
73
  data_action: "key-value-store#removePair"
108
74
  ) do
109
- plain "×"
75
+ render Phlex::TablerIcons::X.new(class: "w-4 h-4")
110
76
  end
111
77
  end
112
78
  end
113
79
 
80
+ # `part` is :key or :value. The template row passes "__INDEX__", which
81
+ # the controller swaps for the real index when it clones the row.
82
+ def render_pair_input(part, value, index)
83
+ input(
84
+ type: :text,
85
+ placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.#{part}_placeholder"),
86
+ value:,
87
+ name: "#{field_name}[#{index}][#{part}]",
88
+ id: "#{field.dom.id}_#{index}_#{part}",
89
+ class: "pu-input flex-1 min-w-0",
90
+ data_key_value_store_target: "#{part}Input"
91
+ )
92
+ end
93
+
114
94
  def render_add_button
115
95
  div(class: "key-value-store-actions mt-2") do
116
96
  button(
@@ -129,36 +109,7 @@ module Plutonium
129
109
 
130
110
  def render_template
131
111
  template(data_key_value_store_target: "template") do
132
- div(
133
- class: "key-value-pair flex items-center gap-2 p-2 border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)]",
134
- data_key_value_store_target: "pair"
135
- ) do
136
- input(
137
- type: :text,
138
- placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.key_placeholder"),
139
- name: "#{field_name}[__INDEX__][key]",
140
- id: "#{field.dom.id}___INDEX___key",
141
- class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
142
- data_key_value_store_target: "keyInput"
143
- )
144
-
145
- input(
146
- type: :text,
147
- placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.value_placeholder"),
148
- name: "#{field_name}[__INDEX__][value]",
149
- id: "#{field.dom.id}___INDEX___value",
150
- class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
151
- data_key_value_store_target: "valueInput"
152
- )
153
-
154
- button(
155
- type: :button,
156
- class: "px-2 py-1 text-danger-600 hover:text-danger-800 focus:outline-none transition-colors",
157
- data_action: "key-value-store#removePair"
158
- ) do
159
- plain "×"
160
- end
161
- end
112
+ render_key_value_pair(nil, nil, "__INDEX__")
162
113
  end
163
114
  end
164
115