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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +17 -10
- data/.claude/skills/plutonium-ui/SKILL.md +13 -3
- data/CHANGELOG.md +35 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +56 -3
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +32 -32
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/brakeman.ignore +1 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/js.yml +4 -0
- data/config/locales/en/ui.yml +19 -0
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/resource/definition.md +14 -7
- data/docs/reference/resource/query.md +11 -1
- data/docs/reference/ui/displays.md +19 -2
- data/docs/reference/ui/forms.md +77 -4
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +13 -3
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +10 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +10 -1
- data/lib/plutonium/auth/rodauth.rb +7 -0
- data/lib/plutonium/core/controller.rb +0 -2
- data/lib/plutonium/dashboard/base.rb +80 -1
- data/lib/plutonium/dashboard/card.rb +12 -13
- data/lib/plutonium/dashboard/controller.rb +28 -10
- data/lib/plutonium/dashboard/dsl.rb +59 -2
- data/lib/plutonium/dashboard/evaluation.rb +30 -0
- data/lib/plutonium/dashboard/filter.rb +114 -0
- data/lib/plutonium/dashboard/link.rb +35 -0
- data/lib/plutonium/dashboard/route_resolution.rb +6 -2
- data/lib/plutonium/helpers/content_helper.rb +0 -3
- data/lib/plutonium/query/filter.rb +2 -1
- data/lib/plutonium/query/filters/tags.rb +36 -0
- data/lib/plutonium/resource/query_object.rb +6 -0
- data/lib/plutonium/routing/dashboard_registration.rb +5 -0
- data/lib/plutonium/ui/avatar.rb +2 -2
- data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
- data/lib/plutonium/ui/dashboard/board.rb +4 -1
- data/lib/plutonium/ui/dashboard/frame.rb +1 -1
- data/lib/plutonium/ui/dashboard/metric.rb +18 -5
- data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
- data/lib/plutonium/ui/display/base.rb +50 -2
- data/lib/plutonium/ui/display/components/badge.rb +1 -1
- data/lib/plutonium/ui/display/components/binary.rb +20 -0
- data/lib/plutonium/ui/display/components/boolean.rb +1 -1
- data/lib/plutonium/ui/display/components/code.rb +23 -0
- data/lib/plutonium/ui/display/components/color.rb +14 -6
- data/lib/plutonium/ui/display/components/duration.rb +31 -0
- data/lib/plutonium/ui/display/components/file_size.rb +22 -0
- data/lib/plutonium/ui/display/components/key_value.rb +37 -0
- data/lib/plutonium/ui/display/components/list.rb +33 -0
- data/lib/plutonium/ui/display/components/number.rb +32 -0
- data/lib/plutonium/ui/display/components/progress.rb +36 -0
- data/lib/plutonium/ui/display/components/rating.rb +36 -0
- data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
- data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
- data/lib/plutonium/ui/display/components/tags.rb +18 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +24 -2
- data/lib/plutonium/ui/form/base.rb +50 -1
- data/lib/plutonium/ui/form/components/binary.rb +40 -0
- data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
- data/lib/plutonium/ui/form/components/list.rb +46 -0
- data/lib/plutonium/ui/form/components/rating.rb +51 -0
- data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
- data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
- data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
- data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
- data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
- data/lib/plutonium/ui/form/components/tags.rb +45 -0
- data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
- data/lib/plutonium/ui/form/components/uppy.rb +1 -1
- data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
- data/lib/plutonium/ui/form/theme.rb +34 -4
- data/lib/plutonium/ui/grid/card.rb +25 -5
- data/lib/plutonium/ui/layout/base.rb +10 -1
- data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
- data/lib/plutonium/ui/options/secret_field.rb +32 -0
- data/lib/plutonium/ui/options/tags_field.rb +23 -0
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
- data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
- data/lib/plutonium/ui/table/display_theme.rb +30 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- data/src/css/components.css +43 -0
- data/src/css/flatpickr.css +91 -0
- data/src/js/controllers/easymde_controller.js +1 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/slim_select_controller.js +32 -3
- data/src/js/controllers/timeago_controller.js +40 -0
- 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
|
|
45
|
-
color_indicator: "w-
|
|
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
|
|
63
|
+
class: "key-value-pair flex items-center gap-2",
|
|
79
64
|
data_key_value_store_target: "pair"
|
|
80
65
|
) do
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|