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
@@ -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
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # An array value edited as chips: a multi-select (slim-select) whose
8
+ # options are the current values plus any `suggestions:`.
9
+ #
10
+ # addable: false only suggestions can be picked (default: values
11
+ # can be typed in)
12
+ # limit: 5 caps the number of chips
13
+ class List < Phlexi::Form::Components::Select
14
+ protected
15
+
16
+ def build_attributes
17
+ @suggestions = Array(attributes.delete(:suggestions))
18
+ @limit = attributes.delete(:limit)
19
+ @addable = attributes.delete(:addable) != false
20
+
21
+ attributes[:multiple] = true
22
+ attributes[:choices] ||= (current_values + @suggestions.map(&:to_s)).uniq
23
+ attributes[:data_slim_select_addable_value] = "true" if @addable
24
+ attributes[:data_slim_select_max_selected_value] = @limit if @limit
25
+ super
26
+ end
27
+
28
+ def current_values = Array(field.value).map(&:to_s)
29
+
30
+ def selected?(option) = current_values.include?(option.to_s)
31
+
32
+ # Selecting nothing submits the hidden "" alone, which clears the list.
33
+ def normalize_input(input_value)
34
+ super&.reject(&:blank?)
35
+ end
36
+
37
+ # Typed-in values aren't among the rendered choices, so only a
38
+ # non-addable list restricts the submission to them.
39
+ def normalize_simple_input(input_value)
40
+ @addable ? input_value&.to_s : super
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A star picker: one radio per star, no JS. The radios run from `max:`
8
+ # (default 5) down to 1 in a row-reversed flex box, so `.pu-rating`'s
9
+ # `:checked ~ label` and `:hover ~ label` rules fill the stars up to
10
+ # the chosen or hovered one. An optional rating ends with a "Clear"
11
+ # radio (blank value); it comes last so no fill rule reaches it.
12
+ class Rating < Phlexi::Form::Components::Base
13
+ include Phlexi::Form::Components::Concerns::HandlesInput
14
+
15
+ def view_template
16
+ fieldset(id: attributes[:id], class: tokens("pu-rating", attributes[:class]), role: :radiogroup) do
17
+ @max.downto(1) do |score|
18
+ id = "#{attributes[:id]}_#{score}"
19
+ input(type: :radio, id:, name: attributes[:name], value: score, checked: field.value.to_i == score,
20
+ required: attributes[:required], disabled: attributes[:disabled], class: "sr-only")
21
+ label(for: id, title: star_label(score)) do
22
+ render Phlex::TablerIcons::Star.new
23
+ span(class: "sr-only") { star_label(score) }
24
+ end
25
+ end
26
+ render_clear unless attributes[:required]
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ def build_attributes
33
+ super
34
+ @max = attributes.delete(:max) || 5
35
+ end
36
+
37
+ private
38
+
39
+ def render_clear
40
+ id = "#{attributes[:id]}_clear"
41
+ input(type: :radio, id:, name: attributes[:name], value: "", checked: field.value.blank?,
42
+ disabled: attributes[:disabled], class: "sr-only")
43
+ label(for: id, class: "pu-rating-clear") { Plutonium::Translation.t("plutonium.ui.form.rating.clear") }
44
+ end
45
+
46
+ def star_label(score) = Plutonium::Translation.t("plutonium.ui.display.rating.label", value: score, max: @max)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A `has_rich_text` field, rendered through Action Text's own helper:
8
+ # Trix by default, Lexxy once the app adds the lexxy gem (which takes
9
+ # over the helper). The editor's JS comes from the app's bundle.
10
+ class RichText < Phlexi::Form::Components::Base
11
+ include Phlexi::Form::Components::Concerns::HandlesInput
12
+
13
+ def view_template
14
+ raw(safe(helpers.rich_text_area_tag(attributes[:name], field.value, id: attributes[:id], required: attributes[:required])))
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Chip input for a no_fly_list `has_tags` context. Suggests the tags
8
+ # that already exist in the record's tag scope (its tenant), follows
9
+ # the context's `restrict_to_existing` and `limit`, and submits to the
10
+ # `<context>_list` writer.
11
+ class Tags < List
12
+ SUGGESTION_LIMIT = 200
13
+
14
+ protected
15
+
16
+ def build_attributes
17
+ context = Plutonium::UI::Options::TagsField.context(field.object, field.key)
18
+ attributes[:input_param] ||= :"#{field.key}_list"
19
+ attributes[:addable] = !context[:restrict_to_existing] unless attributes.key?(:addable)
20
+ attributes[:limit] ||= context[:limit]
21
+ attributes[:suggestions] ||= existing_tags(context)
22
+ super
23
+ end
24
+
25
+ # The proxy, not the association: on a failed save it still holds
26
+ # what was submitted.
27
+ def current_values
28
+ field.object.public_send(:"#{field.key}_list").to_a
29
+ end
30
+
31
+ private
32
+
33
+ def existing_tags(context)
34
+ tags = context[:tag_class_name].constantize.order(:name).limit(SUGGESTION_LIMIT)
35
+ if (scope = context[:scope])
36
+ column = field.object.class.reflect_on_association(scope)&.foreign_key || scope
37
+ tags = tags.where(column => field.object[column])
38
+ end
39
+ tags.pluck(:name)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # The {Plutonium::Query::Filters::Tags} input. Offers only the tags on
8
+ # records the user can see (the policy scope), so a tenant never sees
9
+ # another tenant's vocabulary and every option matches something.
10
+ class TagsFilter < List
11
+ include Plutonium::UI::Component::Methods
12
+
13
+ protected
14
+
15
+ def build_attributes
16
+ @context = attributes.delete(:context)
17
+ attributes[:addable] = false
18
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
19
+ attributes[:class] = ""
20
+ super
21
+ end
22
+
23
+ # Built at render time: the policy scope needs the view context,
24
+ # which doesn't exist yet when attributes are built.
25
+ def choices
26
+ @choices ||= build_choice_mapper(current_values | visible_tags)
27
+ end
28
+
29
+ private
30
+
31
+ def visible_tags
32
+ tag_class = Plutonium::UI::Options::TagsField.context(resource_class.new, @context)[:tag_class_name].constantize
33
+ name = tag_class.arel_table[:name]
34
+ authorized_resource_scope(resource_class).unscope(:order, :select).joins(@context).distinct.order(name).pluck(name)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -155,7 +155,7 @@ module Plutonium
155
155
  class: "w-full py-2 px-4 text-sm text-danger-600 dark:text-danger-400 bg-[var(--pu-surface)] hover:bg-danger-50 dark:hover:bg-danger-900/50 rounded-b-[var(--pu-radius-md)] transition-colors duration-200 flex items-center justify-center gap-2 border-t border-[var(--pu-border)]",
156
156
  data: {action: "click->attachment-preview#remove"}
157
157
  ) do
158
- span(class: "bi bi-trash")
158
+ render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
159
159
  plain Plutonium::Translation.t("plutonium.ui.form.attachment.delete")
160
160
  end
161
161
  end
@@ -64,6 +64,11 @@ module Plutonium
64
64
  return nil unless name
65
65
 
66
66
  route_key = resource_class.model_name.route_key
67
+ # Path-scoped portals prefix every route name with the entity
68
+ # param key (`organization_scoped_`) and take the entity as the
69
+ # first positional arg, the same as Controller#resource_url_for.
70
+ path_scoped = current_engine.scoped_to_entity? && current_engine.scoped_entity_strategy == :path
71
+ route_key = "#{current_engine.scoped_entity_param_key}_#{route_key}" if path_scoped
67
72
  helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
68
73
 
69
74
  # Engine route helpers are the source of truth for routes
@@ -74,7 +79,8 @@ module Plutonium
74
79
  # uses its eager list.
75
80
  url_helpers = current_engine.routes.url_helpers
76
81
  return nil unless url_helpers.respond_to?(helper)
77
- url_helpers.public_send(helper, name: name)
82
+ helper_args = path_scoped ? [view_context.current_scoped_entity.to_param] : []
83
+ url_helpers.public_send(helper, *helper_args, name: name)
78
84
  end
79
85
  end
80
86
  end
@@ -6,16 +6,23 @@ 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
+ # A tag context is also a has_many of tag records; claim it first.
17
+ return :tags if tags_field?
18
+ return :rich_text if rich_text_field?
19
+
13
20
  # Password detection lives in the string-type inference, not the
14
21
  # component-type inference (a `password` column infers as :string).
15
22
  # Route every inferred password/secret field to the masking Password
16
23
  # component so the stored value never reaches the DOM. We also widen
17
24
  # the heuristic to secret-bearing names Phlexi misses (`*_secret`,
18
- # `*_key`, `salt`, ...) — see #secret_field_name?.
25
+ # `*_key`, `salt`, ...) — see Options::SecretField.
19
26
  return :password if inferred_string_field_type == :password || secret_field_name?
20
27
 
21
28
  # has_cents decimal accessors render as a currency input (number field
@@ -23,8 +30,14 @@ module Plutonium
23
30
  return :currency if has_cents_field?
24
31
 
25
32
  case inferred_field_type
26
- when :rich_text
27
- return :markdown
33
+ when :binary
34
+ # phlexi-form infers :file, which Plutonium renders as an Uppy
35
+ # attachment; a binary column holds the file's bytes instead.
36
+ return :binary
37
+ when :json, :jsonb
38
+ # phlexi-form infers these as a plain :text textarea, which
39
+ # renders `Hash#to_s` and never parses the submission back.
40
+ return :json
28
41
  end
29
42
 
30
43
  inferred_field_component = super
@@ -39,18 +52,6 @@ module Plutonium
39
52
  inferred_field_component
40
53
  end
41
54
  end
42
-
43
- # Secret-bearing names Phlexi's `is_password_field?` does not catch
44
- # (it only handles `password`, `encrypted_*`, `*_password`, `*_digest`,
45
- # `*_hash`, `*_token`). Mask these too so their value never reaches the
46
- # DOM. Still a name heuristic, not a guarantee — opt in/out per field
47
- # with `as: :password` / `as: :string`.
48
- def secret_field_name?
49
- name = key.to_s.downcase
50
- name == "token" || name == "salt" ||
51
- name.include?("secret") ||
52
- name.end_with?("_key", "_salt")
53
- end
54
55
  end
55
56
  end
56
57
  end
@@ -117,6 +117,22 @@ module Plutonium
117
117
  invalid_json: :invalid_input,
118
118
  neutral_json: :neutral_input,
119
119
 
120
+ # EasyMDE replaces the textarea once its JS boots; until then (or
121
+ # without JS) the textarea should look like every other one.
122
+ easymde: :textarea,
123
+ valid_easymde: :valid_textarea,
124
+ invalid_easymde: :invalid_textarea,
125
+ neutral_easymde: :neutral_textarea,
126
+
127
+ # Action Text editor: Trix / Lexxy style themselves.
128
+ rich_text: "",
129
+
130
+ # Star picker: styled by .pu-rating (components.css).
131
+ rating: "",
132
+
133
+ # Key/value editor: the component lays out its own rows and inputs.
134
+ key_value_store: "",
135
+
120
136
  # Uppy file upload
121
137
  uppy: :file,
122
138
  valid_uppy: :valid_file,
@@ -130,10 +146,10 @@ module Plutonium
130
146
  neutral_association: :neutral_select,
131
147
 
132
148
  # Polymorphic association
133
- polymorpic_association: :association,
134
- valid_polymorpic_association: :valid_association,
135
- invalid_polymorpic_association: :invalid_association,
136
- neutral_polymorpic_association: :neutral_association
149
+ polymorphic_association: :association,
150
+ valid_polymorphic_association: :valid_association,
151
+ invalid_polymorphic_association: :invalid_association,
152
+ neutral_polymorphic_association: :neutral_association
137
153
  })
138
154
  end
139
155
  end
@@ -314,7 +314,19 @@ module Plutonium
314
314
  "#{record.class.name} doesn't respond to it. " \
315
315
  "Define the method on the model or remove the slot."
316
316
  end
317
- record.public_send(name)
317
+ value = record.public_send(name)
318
+ # Slots render raw values, so mask secrets here. Blank stays blank so
319
+ # the slot still collapses.
320
+ (value.present? && secret_slot?(name)) ? Plutonium::UI::Options::SecretField::MASK : value
321
+ end
322
+
323
+ # Same rule as the show page: an explicit `as:` (display over field)
324
+ # decides; otherwise the secret-name heuristic does.
325
+ def secret_slot?(name)
326
+ name = name.to_sym
327
+ declared = resource_definition.defined_displays.dig(name, :options, :as) ||
328
+ resource_definition.defined_fields.dig(name, :options, :as)
329
+ declared ? %i[password secret].include?(declared.to_sym) : Plutonium::UI::Options::SecretField.secret_name?(name)
318
330
  end
319
331
 
320
332
  def row_actions
@@ -241,7 +241,16 @@ module Plutonium
241
241
  href:
242
242
  "https://cdn.jsdelivr.net/npm/easymde@2.18.0/dist/easymde.min.css",
243
243
  integrity:
244
- "uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
244
+ "sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
245
+ crossorigin: "anonymous"
246
+ )
247
+ # EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
248
+ link(
249
+ rel: "stylesheet",
250
+ href:
251
+ "https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
252
+ integrity:
253
+ "sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
245
254
  crossorigin: "anonymous"
246
255
  )
247
256
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Detects `has_rich_text` fields by the `rich_text_<name>` association
7
+ # Action Text defines. phlexi-field stopped inferring :rich_text, so
8
+ # without this they fall through to a plain string.
9
+ module RichTextField
10
+ private
11
+
12
+ def rich_text_field?
13
+ object.is_a?(ActiveRecord::Base) && object.class.reflect_on_association(:"rich_text_#{key}").present?
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Shared detector for secret-bearing field names, so every surface masks
7
+ # the same fields: the form (Password input), the show page / table /
8
+ # wizard summary (masked display) and grid / kanban card slots.
9
+ #
10
+ # Covers Phlexi's password names (`password`, `encrypted_*`, `*_password`,
11
+ # `*_digest`, `*_hash`, `*_token`) plus ones it misses (`token`, `salt`,
12
+ # `*_salt`, `*_key`, anything containing `secret`). Still a name
13
+ # heuristic, not a guarantee: opt a field in or out with `as: :password`
14
+ # / `as: :string`.
15
+ module SecretField
16
+ MASK = "••••••••"
17
+
18
+ def self.secret_name?(name)
19
+ name = name.to_s.downcase
20
+ %w[password token salt].include?(name) ||
21
+ name.start_with?("encrypted_") ||
22
+ name.end_with?("_password", "_digest", "_hash", "_token", "_salt", "_key") ||
23
+ name.include?("secret")
24
+ end
25
+
26
+ private
27
+
28
+ def secret_field_name? = SecretField.secret_name?(key)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Options
6
+ # Detects no_fly_list `has_tags` contexts. A context named `labels`
7
+ # defines a `labels` has_many (the tag records) next to the
8
+ # `labels_list` proxy, so without this it infers as an association.
9
+ # no_fly_list is optional: without the gem nothing is a tag field.
10
+ module TagsField
11
+ def self.context(object, key)
12
+ return unless defined?(NoFlyList::TaggableRecord) && object.is_a?(NoFlyList::TaggableRecord)
13
+
14
+ object.class._no_fly_list.tag_contexts[key.to_sym]
15
+ end
16
+
17
+ private
18
+
19
+ def tags_field? = TagsField.context(object, key).present?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -80,7 +80,7 @@ module Plutonium
80
80
  end
81
81
 
82
82
  def render_search
83
- form(method: :get, action: @search_url) do
83
+ form(method: :get, action: @search_url, class: "min-w-0") do
84
84
  div(class: "relative") do
85
85
  div(class: "absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none") do
86
86
  render Phlex::TablerIcons::Search.new(class: "w-4 h-4 text-[var(--pu-text-muted)]")
@@ -91,7 +91,7 @@ module Plutonium
91
91
  name: "#{@search_param}[search]",
92
92
  value: @search_value,
93
93
  placeholder: t("plutonium.ui.table.search_placeholder"),
94
- class: "pu-input pu-input-toolbar pu-input-icon-left w-[220px]",
94
+ class: "pu-input pu-input-toolbar pu-input-icon-left w-[220px] max-w-full",
95
95
  # turbo-permanent + a stable id keep the DOM node
96
96
  # across Turbo morphs so focus / caret / IME state
97
97
  # survive the search-as-you-type submit cycle.
@@ -35,7 +35,7 @@ module Plutonium
35
35
  div(
36
36
  role: "tablist",
37
37
  aria: {label: t("plutonium.ui.table.view")},
38
- class: "inline-flex h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden",
38
+ class: "inline-flex shrink-0 h-8 rounded-md border border-[var(--pu-border)] bg-[var(--pu-surface)] overflow-hidden",
39
39
  data: {
40
40
  controller: "view-switcher",
41
41
  view_switcher_cookie_name_value: @cookie_name,
@@ -76,7 +76,9 @@ module Plutonium
76
76
  }
77
77
  ) do
78
78
  render icon.new(class: "w-4 h-4 shrink-0")
79
- span { label }
79
+ # Icon-only below sm so the switcher fits narrow toolbars; the
80
+ # label stays in the accessibility tree via sr-only.
81
+ span(class: "sr-only sm:not-sr-only") { label }
80
82
  end
81
83
  end
82
84
  end
@@ -9,10 +9,38 @@ module Plutonium
9
9
  value_wrapper: "max-h-[150px] overflow-y-auto",
10
10
  prefixed_icon: "w-4 h-4 mr-1 text-[var(--pu-text-muted)]",
11
11
  link: "text-primary-600 dark:text-primary-400 hover:text-primary-500 dark:hover:text-primary-300 transition-colors",
12
- color: "flex items-center",
13
- color_indicator: "w-8 h-8 rounded-md mr-2 shadow-sm border border-[var(--pu-border)]",
12
+ color: "flex items-center gap-2",
13
+ color_indicator: "w-6 h-6 rounded border border-[var(--pu-border)]",
14
+ color_label: "text-sm text-[var(--pu-text-muted)]",
14
15
  email: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
15
16
  phone: "flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:text-primary-500 whitespace-nowrap transition-colors",
17
+ markdown: "format format-sm dark:format-invert format-primary max-w-none",
18
+ rich_text: "format format-sm dark:format-invert format-primary max-w-none",
19
+ currency: "tabular-nums",
20
+ # Boolean / badge pills: the component applies the variant class.
21
+ boolean: "",
22
+ badge: "",
23
+ key_value: "grid grid-cols-[auto_1fr] w-max gap-x-3 gap-y-0.5 text-sm",
24
+ key_value_key: "font-medium text-[var(--pu-text-muted)] break-words",
25
+ key_value_value: "break-words",
26
+ key_value_nested: "font-mono text-xs",
27
+ list: "flex flex-wrap gap-1",
28
+ list_item: "pu-badge pu-badge-neutral",
29
+ relative_time: "text-sm text-[var(--pu-text)]",
30
+ code: "flex items-center gap-2",
31
+ 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",
32
+ code_copy: "pu-btn pu-btn-xs pu-btn-ghost",
33
+ duration: "text-sm text-[var(--pu-text)] tabular-nums",
34
+ file_size: "text-sm text-[var(--pu-text)] tabular-nums",
35
+ progress: "flex items-center gap-2",
36
+ progress_track: "h-2 w-full max-w-xs rounded-full bg-[var(--pu-surface-alt)] overflow-hidden",
37
+ progress_bar: "h-full rounded-full bg-primary-500",
38
+ progress_label: "text-sm text-[var(--pu-text-muted)] tabular-nums",
39
+ rating: "flex items-center gap-0.5",
40
+ rating_filled: "w-4 h-4 text-warning-500 fill-current",
41
+ rating_empty: "w-4 h-4 text-[var(--pu-border)]",
42
+ binary: "text-sm text-[var(--pu-text-muted)]",
43
+ phlexi_render: :string,
16
44
  json: "whitespace-pre font-mono text-xs bg-[var(--pu-surface-alt)] border border-[var(--pu-border-muted)] rounded-[var(--pu-radius-sm)] p-2 overflow-x-auto",
17
45
  attachment_value_wrapper: "flex flex-wrap gap-1"
18
46
  })