plutonium 0.66.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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-resource/SKILL.md +14 -8
- data/.claude/skills/plutonium-ui/SKILL.md +11 -3
- data/CHANGELOG.md +22 -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/js.yml +4 -0
- data/config/locales/en/ui.yml +16 -0
- data/docs/reference/resource/definition.md +12 -7
- data/docs/reference/resource/query.md +11 -1
- data/docs/reference/ui/displays.md +19 -2
- data/docs/reference/ui/forms.md +54 -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/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/ui/component/resolves_tags.rb +13 -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 +30 -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/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 +20 -4
- data/lib/plutonium/ui/grid/card.rb +13 -1
- 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/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/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 +25 -2
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
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 :
|
|
27
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
})
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -1247,3 +1247,46 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1247
1247
|
opacity: 0.6;
|
|
1248
1248
|
}
|
|
1249
1249
|
}
|
|
1250
|
+
|
|
1251
|
+
/* Star rating input (Form::Components::Rating). Radios run max..1 in a
|
|
1252
|
+
row-reversed box, so `~ label` reaches the stars below the checked or
|
|
1253
|
+
hovered one. */
|
|
1254
|
+
.pu-rating {
|
|
1255
|
+
@apply inline-flex flex-row-reverse justify-end gap-0.5 border-0 p-0 m-0;
|
|
1256
|
+
}
|
|
1257
|
+
.pu-rating label {
|
|
1258
|
+
@apply cursor-pointer text-[var(--pu-border)] transition-colors;
|
|
1259
|
+
}
|
|
1260
|
+
.pu-rating label svg {
|
|
1261
|
+
@apply w-6 h-6;
|
|
1262
|
+
}
|
|
1263
|
+
.pu-rating input:checked ~ label,
|
|
1264
|
+
.pu-rating label:hover,
|
|
1265
|
+
.pu-rating label:hover ~ label {
|
|
1266
|
+
@apply text-warning-500;
|
|
1267
|
+
}
|
|
1268
|
+
.pu-rating input:checked ~ label svg,
|
|
1269
|
+
.pu-rating label:hover svg,
|
|
1270
|
+
.pu-rating label:hover ~ label svg {
|
|
1271
|
+
fill: currentColor;
|
|
1272
|
+
}
|
|
1273
|
+
/* "Clear" comes last in the DOM; order: -1 puts it after the stars
|
|
1274
|
+
(row-reverse lays out from the right). `input ~` matches the star-fill
|
|
1275
|
+
rules' specificity, and coming later lets this win. */
|
|
1276
|
+
.pu-rating input ~ label.pu-rating-clear {
|
|
1277
|
+
@apply order-[-1] ml-2 self-center text-sm text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:underline;
|
|
1278
|
+
}
|
|
1279
|
+
.pu-rating input:focus-visible + label {
|
|
1280
|
+
@apply rounded-sm outline-2 outline-offset-2 outline-primary-500;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/* Tailwind's preflight strips list markers. Restore them inside the rich
|
|
1284
|
+
text editors (Trix, Lexxy) so lists look like lists while editing. */
|
|
1285
|
+
trix-editor ul,
|
|
1286
|
+
.lexxy-content ul {
|
|
1287
|
+
@apply list-disc ps-6;
|
|
1288
|
+
}
|
|
1289
|
+
trix-editor ol,
|
|
1290
|
+
.lexxy-content ol {
|
|
1291
|
+
@apply list-decimal ps-6;
|
|
1292
|
+
}
|