plutonium 0.66.0 → 0.68.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +17 -10
  4. data/.claude/skills/plutonium-ui/SKILL.md +13 -3
  5. data/CHANGELOG.md +35 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +1 -1
  8. data/app/assets/plutonium.js +56 -3
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +32 -32
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/config/brakeman.ignore +1 -1
  13. data/config/locales/en/dashboard.yml +1 -0
  14. data/config/locales/en/js.yml +4 -0
  15. data/config/locales/en/ui.yml +19 -0
  16. data/docs/reference/dashboard/dsl.md +54 -3
  17. data/docs/reference/resource/definition.md +14 -7
  18. data/docs/reference/resource/query.md +11 -1
  19. data/docs/reference/ui/displays.md +19 -2
  20. data/docs/reference/ui/forms.md +77 -4
  21. data/gemfiles/rails_7.gemfile +1 -0
  22. data/gemfiles/rails_7.gemfile.lock +13 -3
  23. data/gemfiles/rails_8.0.gemfile +1 -0
  24. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  27. data/lib/plutonium/auth/rodauth.rb +7 -0
  28. data/lib/plutonium/core/controller.rb +0 -2
  29. data/lib/plutonium/dashboard/base.rb +80 -1
  30. data/lib/plutonium/dashboard/card.rb +12 -13
  31. data/lib/plutonium/dashboard/controller.rb +28 -10
  32. data/lib/plutonium/dashboard/dsl.rb +59 -2
  33. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  34. data/lib/plutonium/dashboard/filter.rb +114 -0
  35. data/lib/plutonium/dashboard/link.rb +35 -0
  36. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  37. data/lib/plutonium/helpers/content_helper.rb +0 -3
  38. data/lib/plutonium/query/filter.rb +2 -1
  39. data/lib/plutonium/query/filters/tags.rb +36 -0
  40. data/lib/plutonium/resource/query_object.rb +6 -0
  41. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  42. data/lib/plutonium/ui/avatar.rb +2 -2
  43. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  44. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  45. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  46. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  47. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  48. data/lib/plutonium/ui/display/base.rb +50 -2
  49. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  50. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  51. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  52. data/lib/plutonium/ui/display/components/code.rb +23 -0
  53. data/lib/plutonium/ui/display/components/color.rb +14 -6
  54. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  55. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  56. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  57. data/lib/plutonium/ui/display/components/list.rb +33 -0
  58. data/lib/plutonium/ui/display/components/number.rb +32 -0
  59. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  60. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  61. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  62. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  63. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  64. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  65. data/lib/plutonium/ui/display/theme.rb +24 -2
  66. data/lib/plutonium/ui/form/base.rb +50 -1
  67. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  68. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  69. data/lib/plutonium/ui/form/components/list.rb +46 -0
  70. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  71. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  72. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  73. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  74. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  75. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  76. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  77. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  78. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  79. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  80. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  81. data/lib/plutonium/ui/form/theme.rb +34 -4
  82. data/lib/plutonium/ui/grid/card.rb +25 -5
  83. data/lib/plutonium/ui/layout/base.rb +10 -1
  84. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  85. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  86. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  87. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  88. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  89. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  90. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  91. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  92. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  93. data/lib/plutonium/version.rb +1 -1
  94. data/package.json +1 -1
  95. data/plutonium.gemspec +1 -1
  96. data/src/css/components.css +43 -0
  97. data/src/css/flatpickr.css +91 -0
  98. data/src/js/controllers/easymde_controller.js +1 -0
  99. data/src/js/controllers/register_controllers.js +2 -0
  100. data/src/js/controllers/slim_select_controller.js +32 -3
  101. data/src/js/controllers/timeago_controller.js +40 -0
  102. metadata +35 -4
@@ -9,7 +9,7 @@ module Plutonium
9
9
  #
10
10
  # {value: 1_240} # just the number
11
11
  # {value: 1_240, previous: 1_100} # change computed: +12.7%
12
- # {value: 1_240, change: 12.7} # change in percentage points
12
+ # {value: 1_240, change: 12.7} # +12.7% (+12.7 pts with change_unit: :points)
13
13
  # {value: 1_240, change: "+140", trend: :up} # change shown verbatim
14
14
  # {value: 98.2, change_label: "vs. last week"} # caption after the change
15
15
  #
@@ -36,13 +36,22 @@ module Plutonium
36
36
  data[:change_label] ||= card.options[:change_label]
37
37
 
38
38
  if !data.key?(:change) && data[:previous].is_a?(Numeric) && data[:value].is_a?(Numeric)
39
- data[:change] = percent_change(data[:value], data[:previous])
39
+ data[:change] = points? ? data[:value] - data[:previous] : percent_change(data[:value], data[:previous])
40
40
  end
41
41
 
42
+ # Round to the precision the change is shown at, so float noise
43
+ # (0.3 against 0.1 + 0.2) reads as no change rather than "-0" heading
44
+ # down. `+ 0.0` turns a rounded -0.0 into 0.0.
45
+ data[:change] = data[:change].round(1) + 0.0 if data[:change].is_a?(Numeric)
46
+
47
+ # A `previous` of zero leaves a percent `change` nil but still sets
48
+ # the trend key, so a captioned row shows a neutral dash.
42
49
  data[:trend] ||= infer_trend(data[:change]) if data.key?(:change)
43
50
  data
44
51
  end
45
52
 
53
+ def points? = card.options[:change_unit] == :points
54
+
46
55
  def percent_change(value, previous)
47
56
  return nil if previous.zero?
48
57
 
@@ -65,9 +74,12 @@ module Plutonium
65
74
  end
66
75
 
67
76
  def render_change(data)
77
+ # A caption with no change or trend (a count of ratings, say) is
78
+ # not a trend: no icon, neutral tone.
79
+ trending = data.key?(:trend)
68
80
  trend = data[:trend] || :flat
69
- div(class: tokens("pu-metric-change", change_tone_class(trend))) do
70
- render_trend_icon(trend)
81
+ div(class: tokens("pu-metric-change", change_tone_class(trending ? trend : :flat))) do
82
+ render_trend_icon(trend) if trending
71
83
  span(class: "font-medium") { format_change(data[:change]) } if data[:change]
72
84
  span(class: "pu-metric-change-label") { data[:change_label] } if data[:change_label].present?
73
85
  end
@@ -93,7 +105,8 @@ module Plutonium
93
105
  case change
94
106
  when Numeric
95
107
  sign = change.positive? ? "+" : ""
96
- "#{sign}#{number_to_rounded(change, precision: 1, strip_insignificant_zeros: true)}%"
108
+ rounded = number_to_rounded(change, precision: 1, strip_insignificant_zeros: true)
109
+ points? ? t("plutonium.dashboard.metric.points", change: "#{sign}#{rounded}") : "#{sign}#{rounded}%"
97
110
  else change.to_s
98
111
  end
99
112
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # The row above the card grid: each filter as a segmented control of
7
+ # links, so the choice is a plain URL that survives a reload and can be
8
+ # shared, and the dashboard's links on the right.
9
+ class Toolbar < Plutonium::UI::Component::Base
10
+ def initialize(dashboard:)
11
+ @dashboard = dashboard
12
+ end
13
+
14
+ def render?
15
+ dashboard.class.filters.any? || links.any?
16
+ end
17
+
18
+ def view_template
19
+ div(class: "pu-dashboard-toolbar mb-4 flex flex-wrap items-center justify-between gap-3") do
20
+ div(class: "flex flex-wrap items-center gap-3") do
21
+ dashboard.class.filters.each { |filter| render_filter(filter) }
22
+ end
23
+ div(class: "flex flex-wrap items-center gap-2") do
24
+ links.each { |link, href| render_link(link, href) }
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :dashboard
32
+
33
+ # Visible links paired with their href. A link whose `href:` resolves
34
+ # to nil (a proc that returns nothing for this user) is left out
35
+ # rather than rendered as a dead button.
36
+ def links
37
+ @links ||= dashboard.visible_links.filter_map do |link|
38
+ href = link.href_for(dashboard)
39
+ [link, href] if href.present?
40
+ end
41
+ end
42
+
43
+ def render_filter(filter)
44
+ current = dashboard.filter_value(filter.key)
45
+ nav(class: "pu-dashboard-filter inline-flex rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] p-0.5",
46
+ aria: {label: filter.label}, data: {dashboard_filter: filter.key}) do
47
+ dashboard.filter_choices(filter).each do |value, label|
48
+ selected = value == current
49
+ a(
50
+ href: filter_url(filter, value),
51
+ class: tokens(
52
+ "rounded-[var(--pu-radius-sm)] px-3 py-1 text-sm transition-colors",
53
+ selected ? "bg-[var(--pu-surface-alt)] font-medium text-[var(--pu-text)]" : "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)]"
54
+ ),
55
+ aria: {current: selected ? "true" : nil}
56
+ ) { Plutonium::Translation.resolve(label) }
57
+ end
58
+ end
59
+ end
60
+
61
+ def render_link(link, href)
62
+ a(href:, class: "pu-btn pu-btn-sm pu-btn-outline", data: {dashboard_link: link.key}) do
63
+ render link.icon.new(class: "size-4") if link.icon
64
+ span { link.label }
65
+ end
66
+ end
67
+
68
+ # The page with this filter set and the rest of the query string kept.
69
+ def filter_url(filter, value)
70
+ query = dashboard.request.query_parameters.merge(filter.key.to_s => value)
71
+ "#{dashboard.request.path}?#{query.to_query}"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -17,7 +17,10 @@ module Plutonium
17
17
  def markdown_tag(**, &)
18
18
  create_component(Plutonium::UI::Display::Components::Markdown, :markdown, **, &)
19
19
  end
20
- alias_method :rich_text_tag, :markdown_tag
20
+
21
+ def rich_text_tag(**, &)
22
+ create_component(Plutonium::UI::Display::Components::RichText, :rich_text, **, &)
23
+ end
21
24
 
22
25
  def attachment_tag(**, &)
23
26
  create_component(Plutonium::UI::Display::Components::Attachment, :attachment, **, &)
@@ -51,12 +54,57 @@ module Plutonium
51
54
  create_component(Plutonium::UI::Display::Components::Currency, :currency, **, &)
52
55
  end
53
56
 
57
+ def number_tag(**, &)
58
+ create_component(Plutonium::UI::Display::Components::Number, :number, **, &)
59
+ end
60
+
61
+ def key_value_tag(**, &)
62
+ create_component(Plutonium::UI::Display::Components::KeyValue, :key_value, **, &)
63
+ end
64
+ alias_method :hstore_tag, :key_value_tag
65
+
66
+ def list_tag(**, &)
67
+ create_component(Plutonium::UI::Display::Components::List, :list, **, &)
68
+ end
69
+
70
+ def tags_tag(**, &)
71
+ create_component(Plutonium::UI::Display::Components::Tags, :list, **, &)
72
+ end
73
+
74
+ def relative_time_tag(**, &)
75
+ create_component(Plutonium::UI::Display::Components::RelativeTime, :relative_time, **, &)
76
+ end
77
+
78
+ def code_tag(**, &)
79
+ create_component(Plutonium::UI::Display::Components::Code, :code, **, &)
80
+ end
81
+
82
+ def duration_tag(**, &)
83
+ create_component(Plutonium::UI::Display::Components::Duration, :duration, **, &)
84
+ end
85
+
86
+ def file_size_tag(**, &)
87
+ create_component(Plutonium::UI::Display::Components::FileSize, :file_size, **, &)
88
+ end
89
+
90
+ def progress_tag(**, &)
91
+ create_component(Plutonium::UI::Display::Components::Progress, :progress, **, &)
92
+ end
93
+
94
+ def rating_tag(**, &)
95
+ create_component(Plutonium::UI::Display::Components::Rating, :rating, **, &)
96
+ end
97
+
98
+ def binary_tag(**, &)
99
+ create_component(Plutonium::UI::Display::Components::Binary, :binary, **, &)
100
+ end
101
+
54
102
  # Type aliases for common column types
55
103
  alias_method :float_tag, :number_tag
56
104
  alias_method :decimal_tag, :number_tag
57
105
  alias_method :jsonb_tag, :json_tag
58
- alias_method :key_value_tag, :hstore_tag
59
106
  alias_method :phlexi_tag, :phlexi_render_tag
107
+ alias_method :secret_tag, :password_tag
60
108
  end
61
109
 
62
110
  private
@@ -61,7 +61,7 @@ module Plutonium
61
61
 
62
62
  def render_value(value)
63
63
  variant = self.class.variant_for(value, colors: @colors)
64
- span(**attributes, class: tokens("pu-badge", "pu-badge-#{variant}")) do
64
+ span(**mix(attributes, {class: tokens("pu-badge", "pu-badge-#{variant}")})) do
65
65
  plain value_label(value)
66
66
  end
67
67
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Display
6
+ module Components
7
+ # A `binary` column's size, never its bytes.
8
+ class Binary < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ def render_value(value)
12
+ span(**attributes) do
13
+ Plutonium::Translation.t("plutonium.ui.display.binary", size: ActiveSupport::NumberHelper.number_to_human_size(value.bytesize))
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -19,7 +19,7 @@ module Plutonium
19
19
  private
20
20
 
21
21
  def pill(label:, variant:, icon:)
22
- span(**attributes, class: tokens("pu-badge", variant), "aria-label": label) do
22
+ span(**mix(attributes, {class: tokens("pu-badge", variant)}), "aria-label": label) do
23
23
  render icon.new(class: "w-3.5 h-3.5")
24
24
  plain label
25
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 value in monospace with a copy button: IDs, keys, references.
8
+ class Code < Phlexi::Display::Components::Base
9
+ include Phlexi::Display::Components::Concerns::DisplaysValue
10
+
11
+ def render_value(value)
12
+ div(**attributes, data: {controller: "clipboard"}) do
13
+ code(class: themed(:code_value), data: {clipboard_target: "source"}) { value }
14
+ button(type: :button, class: themed(:code_copy), data: {action: "clipboard#copy"}) do
15
+ Plutonium::Translation.t("plutonium.ui.display.code.copy")
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -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