plutonium 0.65.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 (164) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +158 -141
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +161 -98
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +43 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +56 -3
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +32 -32
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/config/brakeman.ignore +1 -1
  23. data/config/locales/en/js.yml +4 -0
  24. data/config/locales/en/ui.yml +16 -0
  25. data/docs/.vitepress/sync-skills.mjs +6 -3
  26. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  27. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  28. data/docs/getting-started/installation.md +5 -5
  29. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  30. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  31. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  32. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  33. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  34. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  36. data/docs/getting-started/tutorial/index.md +1 -1
  37. data/docs/guides/adding-resources.md +10 -7
  38. data/docs/guides/authentication.md +25 -25
  39. data/docs/guides/authorization.md +24 -24
  40. data/docs/guides/creating-packages.md +17 -17
  41. data/docs/guides/custom-actions.md +32 -32
  42. data/docs/guides/customizing-ui.md +29 -26
  43. data/docs/guides/dashboards.md +1 -1
  44. data/docs/guides/index.md +3 -3
  45. data/docs/guides/kanban.md +55 -55
  46. data/docs/guides/multi-tenancy.md +35 -22
  47. data/docs/guides/nested-resources.md +21 -21
  48. data/docs/guides/performance.md +3 -3
  49. data/docs/guides/search-filtering.md +13 -13
  50. data/docs/guides/testing.md +16 -12
  51. data/docs/guides/theming.md +32 -17
  52. data/docs/guides/troubleshooting.md +2 -2
  53. data/docs/guides/user-invites.md +17 -17
  54. data/docs/guides/user-profile.md +51 -24
  55. data/docs/guides/wizards.md +55 -55
  56. data/docs/reference/app/generators.md +22 -22
  57. data/docs/reference/app/index.md +15 -18
  58. data/docs/reference/app/packages.md +8 -8
  59. data/docs/reference/app/portals.md +75 -29
  60. data/docs/reference/auth/accounts.md +15 -15
  61. data/docs/reference/auth/index.md +12 -12
  62. data/docs/reference/auth/profile.md +67 -29
  63. data/docs/reference/behavior/async-interactions.md +24 -24
  64. data/docs/reference/behavior/controllers.md +28 -28
  65. data/docs/reference/behavior/index.md +5 -5
  66. data/docs/reference/behavior/interactions.md +44 -44
  67. data/docs/reference/behavior/policies.md +48 -28
  68. data/docs/reference/configuration.md +6 -6
  69. data/docs/reference/dashboard/dsl.md +2 -2
  70. data/docs/reference/dashboard/index.md +1 -1
  71. data/docs/reference/generators/lite.md +7 -7
  72. data/docs/reference/i18n.md +23 -0
  73. data/docs/reference/index.md +1 -1
  74. data/docs/reference/kanban/authorization.md +9 -9
  75. data/docs/reference/kanban/dsl.md +32 -32
  76. data/docs/reference/kanban/index.md +1 -1
  77. data/docs/reference/kanban/positioning.md +17 -15
  78. data/docs/reference/resource/actions.md +51 -51
  79. data/docs/reference/resource/definition.md +85 -80
  80. data/docs/reference/resource/export.md +6 -6
  81. data/docs/reference/resource/index.md +16 -16
  82. data/docs/reference/resource/model.md +24 -24
  83. data/docs/reference/resource/positioning.md +78 -76
  84. data/docs/reference/resource/query.md +24 -14
  85. data/docs/reference/tenancy/entity-scoping.md +65 -35
  86. data/docs/reference/tenancy/index.md +11 -11
  87. data/docs/reference/tenancy/invites.md +20 -20
  88. data/docs/reference/tenancy/nested-resources.md +13 -13
  89. data/docs/reference/testing/index.md +116 -22
  90. data/docs/reference/ui/assets.md +57 -25
  91. data/docs/reference/ui/components.md +20 -20
  92. data/docs/reference/ui/displays.md +32 -15
  93. data/docs/reference/ui/forms.md +89 -39
  94. data/docs/reference/ui/index.md +17 -15
  95. data/docs/reference/ui/layouts.md +21 -21
  96. data/docs/reference/ui/pages.md +22 -22
  97. data/docs/reference/ui/tables.md +7 -7
  98. data/docs/reference/wizard/anchoring-resume.md +33 -32
  99. data/docs/reference/wizard/dsl.md +44 -44
  100. data/docs/reference/wizard/index.md +6 -6
  101. data/docs/reference/wizard/one-time.md +18 -18
  102. data/docs/reference/wizard/registration-launch.md +32 -32
  103. data/docs/reference/wizard/storage-config.md +23 -23
  104. data/gemfiles/rails_7.gemfile +1 -0
  105. data/gemfiles/rails_7.gemfile.lock +13 -3
  106. data/gemfiles/rails_8.0.gemfile +1 -0
  107. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  108. data/gemfiles/rails_8.1.gemfile +1 -0
  109. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  110. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  111. data/lib/plutonium/auth/rodauth.rb +7 -0
  112. data/lib/plutonium/core/controller.rb +0 -2
  113. data/lib/plutonium/helpers/content_helper.rb +0 -3
  114. data/lib/plutonium/query/filter.rb +2 -1
  115. data/lib/plutonium/query/filters/tags.rb +36 -0
  116. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  117. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  118. data/lib/plutonium/ui/display/base.rb +50 -2
  119. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  120. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  121. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  122. data/lib/plutonium/ui/display/components/code.rb +23 -0
  123. data/lib/plutonium/ui/display/components/color.rb +14 -6
  124. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  125. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  126. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  127. data/lib/plutonium/ui/display/components/list.rb +33 -0
  128. data/lib/plutonium/ui/display/components/number.rb +32 -0
  129. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  130. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  131. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  132. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  133. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  134. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  135. data/lib/plutonium/ui/display/theme.rb +24 -2
  136. data/lib/plutonium/ui/form/base.rb +30 -1
  137. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  138. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  139. data/lib/plutonium/ui/form/components/list.rb +46 -0
  140. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  141. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  142. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  143. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  144. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  145. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  146. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  147. data/lib/plutonium/ui/form/theme.rb +20 -4
  148. data/lib/plutonium/ui/grid/card.rb +13 -1
  149. data/lib/plutonium/ui/layout/base.rb +10 -1
  150. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  151. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  152. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  153. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  154. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  155. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  156. data/lib/plutonium/version.rb +1 -1
  157. data/package.json +1 -1
  158. data/src/css/components.css +53 -10
  159. data/src/css/flatpickr.css +91 -0
  160. data/src/js/controllers/easymde_controller.js +1 -0
  161. data/src/js/controllers/register_controllers.js +2 -0
  162. data/src/js/controllers/slim_select_controller.js +32 -3
  163. data/src/js/controllers/timeago_controller.js +40 -0
  164. metadata +25 -2
@@ -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
@@ -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