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
@@ -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,29 @@ 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
149
+
150
+ # List-style variants of the association input: one control per
151
+ # authorized record instead of a slim-select. Same policy scoping,
152
+ # SGID validation and inline "+" add button.
153
+ def secure_association_checkboxes_tag(**, &)
154
+ create_component(Components::SecureAssociationCheckboxes, :association_checkboxes, **, &)
155
+ end
156
+ alias_method :association_checkboxes_tag, :secure_association_checkboxes_tag
157
+
158
+ def secure_association_toggles_tag(**, &)
159
+ create_component(Components::SecureAssociationToggles, :association_toggles, **, &)
160
+ end
161
+ alias_method :association_toggles_tag, :secure_association_toggles_tag
162
+ alias_method :association_switches_tag, :secure_association_toggles_tag
163
+
164
+ def secure_association_radio_buttons_tag(**, &)
165
+ create_component(Components::SecureAssociationRadioButtons, :association_radio_buttons, **, &)
166
+ end
167
+ alias_method :association_radio_buttons_tag, :secure_association_radio_buttons_tag
168
+ alias_method :association_radios_tag, :secure_association_radio_buttons_tag
119
169
 
120
170
  def secure_polymorphic_association_tag(**attributes, &)
121
171
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select") # TODO: put this behind a config
@@ -134,7 +184,6 @@ module Plutonium
134
184
  alias_method :datetime_tag, :flatpickr_tag
135
185
  alias_method :date_tag, :flatpickr_tag
136
186
  alias_method :time_tag, :flatpickr_tag
137
- alias_method :rich_text_tag, :markdown_tag
138
187
  alias_method :json_tag, :json_input_tag
139
188
  alias_method :jsonb_tag, :json_input_tag
140
189
  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
 
@@ -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
@@ -87,14 +87,25 @@ module Plutonium
87
87
  uri.to_s
88
88
  end
89
89
 
90
+ # Capped at `choice_limit`, plus the records the field holds now:
91
+ # one past the cap or outside the viewer's scope would otherwise not
92
+ # render, and saving the form would drop it (a select falls back to
93
+ # its blank option, an unrendered checkbox is not submitted).
90
94
  def choices
91
95
  @choices ||= begin
92
96
  collection = @raw_choices || authorized_relation
93
- collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
97
+ if collection.is_a?(ActiveRecord::Relation)
98
+ collection = collection.limit(@choice_limit) if @choice_limit
99
+ collection = (collection.to_a + selected_records).uniq
100
+ end
94
101
  build_choice_mapper(collection)
95
102
  end
96
103
  end
97
104
 
105
+ def selected_records
106
+ @selected_records ||= Array(field.object.public_send(association_reflection.name))
107
+ end
108
+
98
109
  # Builds the authorized association relation. Shared by `choices`
99
110
  # (which then applies `choice_limit`) and `normalize_simple_input`
100
111
  # (which validates against the full set so typeahead picks beyond
@@ -106,8 +117,11 @@ module Plutonium
106
117
  authorized_resource_scope(klass, relation: relation)
107
118
  end
108
119
 
120
+ # AssociationBase#build_attributes already calls
121
+ # build_association_attributes. Calling it here too ran it twice, and
122
+ # the second pass re-read the keys the first had deleted, silently
123
+ # dropping `skip_authorization:` and `add_action:`.
109
124
  def build_attributes
110
- build_association_attributes
111
125
  super
112
126
  # Stash; the URL helper needs view_context which only exists
113
127
  # once we're rendering.
@@ -163,8 +177,16 @@ module Plutonium
163
177
  # not include records reachable via typeahead). For explicit
164
178
  # `@raw_choices`, fall back to membership in the rendered list.
165
179
  def normalize_simple_input(input_value)
180
+ # An explicit blank (the select's blank option, a "None" radio)
181
+ # clears a singular association: "" survives resource_params'
182
+ # `compact` and `<name>_sgid = ""` locates nil. A field that was not
183
+ # submitted arrives as nil and is left alone.
184
+ return "" if input_value == "" && !attributes[:multiple]
185
+
166
186
  sgid = SignedGlobalID.parse(input_value.presence)
167
187
  return nil unless sgid
188
+ # Keeping what the field already holds is not a new grant.
189
+ return sgid if selected_records.map(&:to_global_id).include?(sgid)
168
190
 
169
191
  if @raw_choices
170
192
  @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
@@ -177,13 +199,14 @@ module Plutonium
177
199
  nil
178
200
  end
179
201
 
202
+ # Compared as URI strings: URI objects don't match by value in a Set.
180
203
  def selected?(option)
181
- case association_reflection.macro
182
- when :belongs_to, :has_one
183
- singular_field_value == SignedGlobalID.parse(option)
184
- when :has_many, :has_and_belongs_to_many
185
- collection_field_value.any? { |item| item == SignedGlobalID.parse(option) }
186
- end
204
+ selected_gids.include?(SignedGlobalID.parse(option)&.uri&.to_s)
205
+ end
206
+
207
+ def selected_gids
208
+ @selected_gids ||= Array(attributes[:multiple] ? collection_field_value : singular_field_value)
209
+ .compact.to_set { |sgid| sgid.uri.to_s }
187
210
  end
188
211
 
189
212
  def singular_field_value
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A has_many / habtm association rendered as one checkbox per authorized
8
+ # record (`input :tags, as: :association_checkboxes`).
9
+ class SecureAssociationCheckboxes < SecureAssociationChoices
10
+ protected
11
+
12
+ def control_type = :checkbox
13
+
14
+ def control_theme = :association_checkbox
15
+
16
+ def supported_macros = %i[has_many has_and_belongs_to_many]
17
+
18
+ # Unchecked boxes are not submitted, so without this blank value
19
+ # clearing every box would leave the param out entirely and keep the
20
+ # existing records. The blank is dropped by normalize_array_input.
21
+ # Disabled with the list: a disabled list submits no boxes, so a live
22
+ # blank would save as "no records" and wipe the association.
23
+ def render_hidden_input
24
+ input(type: :hidden, name: attributes[:name], value: "", autocomplete: "off", hidden: true, disabled: attributes[:disabled])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Abstract base for the list-style association inputs: every choice is
8
+ # rendered as its own control (checkbox, toggle or radio button) instead
9
+ # of an option inside a slim-select.
10
+ #
11
+ # Everything that makes the association input "secure" is inherited from
12
+ # {SecureAssociation} unchanged: the choices come from the policy-scoped
13
+ # relation (or `choices:`), each value is a signed global id, submitted
14
+ # ids are validated against the authorized relation, and the inline "+"
15
+ # add button still renders. Only the markup differs.
16
+ #
17
+ # Subclasses declare the control's input type, the macros they accept and
18
+ # the theme key their controls style off.
19
+ class SecureAssociationChoices < SecureAssociation
20
+ def view_template
21
+ div(class: "flex items-start space-x-1") do
22
+ # The wrapper's <label for=> points at this id, but a label can't
23
+ # name a div, so the group carries the field label itself.
24
+ div(**group_attributes) do
25
+ render_hidden_input
26
+ render_blank_choice if include_blank?
27
+ render_choices
28
+ end
29
+ render_add_button
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # The type attribute of each control: "checkbox" or "radio".
36
+ def control_type = raise(NotImplementedError, "#{self.class}#control_type")
37
+
38
+ # Theme key the individual controls style off.
39
+ def control_theme = raise(NotImplementedError, "#{self.class}#control_theme")
40
+
41
+ # Association macros this variant can represent.
42
+ def supported_macros = raise(NotImplementedError, "#{self.class}#supported_macros")
43
+
44
+ def group_role = "group"
45
+
46
+ # Everything the caller passed (data-*, aria-*, ...) lands on the
47
+ # group; what belongs to a form control goes on each choice instead.
48
+ CONTROL_ATTRIBUTES = %i[name required disabled multiple size autofocus form value].freeze
49
+
50
+ def group_attributes
51
+ mix(attributes.except(*CONTROL_ATTRIBUTES), {role: group_role, aria: {label: field.label}})
52
+ end
53
+
54
+ def build_association_attributes
55
+ super
56
+
57
+ unless supported_macros.include?(association_reflection.macro)
58
+ raise ArgumentError,
59
+ "#{self.class.name.demodulize} does not support #{association_reflection.macro} " \
60
+ "associations (#{association_name}). Supported: #{supported_macros.join(", ")}."
61
+ end
62
+
63
+ if association_reflection.polymorphic?
64
+ raise ArgumentError, "#{self.class.name.demodulize} does not support polymorphic associations (#{association_name})."
65
+ end
66
+ end
67
+
68
+ def association_name = "#{field.object.class.name}##{association_reflection.name}"
69
+
70
+ def build_attributes
71
+ super
72
+ if @group_method
73
+ raise ArgumentError, "#{self.class.name.demodulize} does not support group_method: (#{association_name})."
74
+ end
75
+
76
+ # Typeahead drives a slim-select dropdown; a rendered list has no
77
+ # search box to wire it to.
78
+ @typeahead_option = false
79
+ end
80
+
81
+ def render_choices
82
+ if choices.values.empty?
83
+ p(class: "pu-hint") { Plutonium::Translation.t("plutonium.ui.form.association_choices.empty") }
84
+ return
85
+ end
86
+
87
+ choices.each.with_index do |(value, text), index|
88
+ render_choice(value:, text:, id: "#{attributes[:id]}_#{index}", checked: selected?(value), autofocus: index.zero? && attributes[:autofocus])
89
+ end
90
+ end
91
+
92
+ def render_choice(value:, text:, id:, checked:, autofocus: false)
93
+ label(for: id, class: themed(:association_choice_label, field)) do
94
+ input(
95
+ type: control_type,
96
+ id:,
97
+ name: attributes[:name],
98
+ value:,
99
+ checked:,
100
+ required: control_required?,
101
+ disabled: attributes[:disabled],
102
+ form: attributes[:form],
103
+ autofocus:,
104
+ class: themed(control_theme, field)
105
+ )
106
+ span { text }
107
+ end
108
+ end
109
+
110
+ def render_hidden_input
111
+ end
112
+
113
+ def render_blank_choice
114
+ end
115
+
116
+ def include_blank? = false
117
+
118
+ def control_required? = false
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end