plutonium 0.67.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 +3 -2
- data/.claude/skills/plutonium-ui/SKILL.md +2 -0
- data/CHANGELOG.md +13 -0
- data/SECURITY.md +1 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- 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/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/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/form/base.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/theme.rb +15 -1
- data/lib/plutonium/ui/grid/card.rb +12 -4
- 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/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- metadata +12 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A page-level control every card reads, declared with `filter`. Its value
|
|
6
|
+
# comes from the request's query string under the filter's key, and a value
|
|
7
|
+
# that is not one of the choices reads as the default, so a hand-edited URL
|
|
8
|
+
# can never reach a card block.
|
|
9
|
+
#
|
|
10
|
+
# The choices are either given (`choices:`) or are a resource's scopes
|
|
11
|
+
# (`scopes: Post`): "All" plus every scope the resource's definition
|
|
12
|
+
# declares, labelled and gated the way the index's scope pills are.
|
|
13
|
+
class Filter
|
|
14
|
+
# Keys a filter cannot take: the card route's own params and the options
|
|
15
|
+
# `url_for` reads, all of which the filter values are merged into when
|
|
16
|
+
# building each card's frame URL. A key that is a path segment of the
|
|
17
|
+
# mount (an entity scope, a `(:locale)` scope) is refused per request,
|
|
18
|
+
# since only the route knows it.
|
|
19
|
+
RESERVED_KEYS = %i[
|
|
20
|
+
card dashboard_class controller action format anchor params path_params
|
|
21
|
+
host protocol port subdomain domain tld_length user password
|
|
22
|
+
script_name original_script_name only_path trailing_slash
|
|
23
|
+
use_route _recall
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# The value of a scope filter's "All" choice.
|
|
27
|
+
ALL = "all"
|
|
28
|
+
|
|
29
|
+
attr_reader :key, :resource_class
|
|
30
|
+
|
|
31
|
+
# @param choices [Hash{#to_s => String, Proc}, nil] value => label, in display order
|
|
32
|
+
# @param scopes [Class, nil] a resource whose definition's scopes are the choices
|
|
33
|
+
# @param default [#to_s, nil] one of the choices; the first (or, for
|
|
34
|
+
# scopes, the definition's `default_scope`, else "All") when nil
|
|
35
|
+
def initialize(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
36
|
+
@key = key.to_sym
|
|
37
|
+
if RESERVED_KEYS.include?(@key)
|
|
38
|
+
raise ArgumentError, "filter #{@key.inspect}: the name is reserved by the card route, pick another"
|
|
39
|
+
end
|
|
40
|
+
unless choices.nil? ^ scopes.nil?
|
|
41
|
+
raise ArgumentError, "filter #{@key.inspect}: pass exactly one of choices: or scopes:"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@label = label
|
|
45
|
+
@default = default&.to_s
|
|
46
|
+
|
|
47
|
+
if scopes
|
|
48
|
+
raise ArgumentError, "filter #{@key.inspect}: scopes: takes a resource class, got #{scopes.inspect}" unless scopes.is_a?(Class)
|
|
49
|
+
|
|
50
|
+
@resource_class = scopes
|
|
51
|
+
else
|
|
52
|
+
@choices = choices.to_h.transform_keys(&:to_s).freeze
|
|
53
|
+
raise ArgumentError, "filter #{@key.inspect}: choices must not be empty" if @choices.empty?
|
|
54
|
+
|
|
55
|
+
@default ||= @choices.keys.first
|
|
56
|
+
validate_default!
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scopes? = !@resource_class.nil?
|
|
61
|
+
|
|
62
|
+
# The control's accessible name.
|
|
63
|
+
def label
|
|
64
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# value => label for this request. Labels may be lazy `t` procs. Read it
|
|
68
|
+
# through {Base#filter_choices}, which builds it once per request.
|
|
69
|
+
def choices_for(dashboard)
|
|
70
|
+
scopes? ? scope_choices(dashboard) : @choices
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An explicit `default:` must be one of the definition's scopes. A
|
|
74
|
+
# default (explicit or the definition's `default_scope`) whose
|
|
75
|
+
# `condition:` hides it from this user reads as "All", as does a
|
|
76
|
+
# `default_scope` the definition never declares as a scope.
|
|
77
|
+
def default_for(dashboard)
|
|
78
|
+
return @default unless scopes?
|
|
79
|
+
|
|
80
|
+
if @default && !dashboard.scope_query_for(resource_class).scope_definitions.key?(@default)
|
|
81
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not a scope of #{resource_class}'s definition"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
default = @default || dashboard.resource_definition_for(resource_class).default_scope&.to_s
|
|
85
|
+
dashboard.filter_choices(self).key?(default) ? default : ALL
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The chosen value for this request.
|
|
89
|
+
def value_from(dashboard)
|
|
90
|
+
value = dashboard.params[key].to_s
|
|
91
|
+
dashboard.filter_choices(self).key?(value) ? value : default_for(dashboard)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def scope_choices(dashboard)
|
|
97
|
+
query = dashboard.scope_query_for(resource_class)
|
|
98
|
+
if query.scope_definitions.key?(ALL)
|
|
99
|
+
raise ArgumentError, "filter #{key.inspect}: #{resource_class}'s definition declares a scope named :#{ALL}, which is the filter's \"All\" choice"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
visible = query.scope_definitions.keys.select { |name| query.scope_visible?(name, dashboard.view_context) }
|
|
103
|
+
{ALL => -> { Plutonium::Translation.t("plutonium.ui.table.all") }}
|
|
104
|
+
.merge(visible.to_h { |name| [name, query.scope_label(name)] })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_default!
|
|
108
|
+
return if @choices.key?(@default)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not one of #{@choices.keys.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A link in the dashboard's toolbar, declared with `link`: the way to a
|
|
6
|
+
# related page such as the dashboard's settings or a full report.
|
|
7
|
+
class Link
|
|
8
|
+
include Evaluation
|
|
9
|
+
|
|
10
|
+
attr_reader :key, :icon
|
|
11
|
+
|
|
12
|
+
def initialize(key, href:, label: nil, icon: nil, condition: nil)
|
|
13
|
+
@key = key.to_sym
|
|
14
|
+
@href = href
|
|
15
|
+
@label = label
|
|
16
|
+
@icon = icon
|
|
17
|
+
@condition = condition
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def label
|
|
21
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Evaluated like a card's `href:`.
|
|
25
|
+
def href_for(dashboard)
|
|
26
|
+
@href.is_a?(Proc) ? evaluate_on(dashboard, @href) : @href
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Evaluated like a card's `condition:`.
|
|
30
|
+
def visible?(dashboard)
|
|
31
|
+
condition_passes?(dashboard, @condition)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -17,13 +17,17 @@ module Plutonium
|
|
|
17
17
|
# @param action [String, Symbol] "show" or "card"
|
|
18
18
|
# @return [Symbol, nil] the route name (e.g. :sales_dashboard_card)
|
|
19
19
|
def route_name(route_set, dashboard_class, action:)
|
|
20
|
-
route
|
|
20
|
+
route(route_set, dashboard_class, action:)&.name&.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [ActionDispatch::Journey::Route, nil] the named route itself
|
|
24
|
+
def route(route_set, dashboard_class, action:)
|
|
25
|
+
route_set.routes.find do |r|
|
|
21
26
|
d = r.defaults
|
|
22
27
|
r.name.present? &&
|
|
23
28
|
d[:action].to_s == action.to_s &&
|
|
24
29
|
d[:dashboard_class].to_s == dashboard_class.name
|
|
25
30
|
end
|
|
26
|
-
route&.name&.to_sym
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -36,6 +36,12 @@ module Plutonium
|
|
|
36
36
|
Plutonium::Translation.label_for(:filter, resource_class, name) || name.to_s.humanize
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Display label for a scope: the plutonium.scopes.<model>.<name>
|
|
40
|
+
# convention, else the humanized name.
|
|
41
|
+
def scope_label(name)
|
|
42
|
+
Plutonium::Translation.label_for(:scope, resource_class, name) || name.to_s.humanize
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
def initialize(resource_class, params, request_path, &)
|
|
40
46
|
@resource_class = resource_class
|
|
41
47
|
@params = params
|
|
@@ -114,6 +114,11 @@ module Plutonium
|
|
|
114
114
|
# the portal's again, last, so bare helpers (`root_path` in the
|
|
115
115
|
# sidebar) resolve against the engine like a source-defined controller.
|
|
116
116
|
klass.include(namespace.railtie_routes_url_helpers) if namespace != Object
|
|
117
|
+
# The same anonymous `inherited` hook boots the controller before it
|
|
118
|
+
# has a name, so it never prepends the portal's views and a portal's
|
|
119
|
+
# own partials (an ejected sidebar) lose to the gem's. Boot it again
|
|
120
|
+
# now that it is named.
|
|
121
|
+
klass.boot
|
|
117
122
|
|
|
118
123
|
if concern_name && (concern = concern_name.safe_constantize)
|
|
119
124
|
klass.include concern
|
data/lib/plutonium/ui/avatar.rb
CHANGED
|
@@ -56,7 +56,7 @@ module Plutonium
|
|
|
56
56
|
value.attached? ? value.url : nil
|
|
57
57
|
elsif value.respond_to?(:url) # bare Shrine::UploadedFile, CarrierWave, ...
|
|
58
58
|
value.url
|
|
59
|
-
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
59
|
+
elsif value.is_a?(String) && value.start_with?("http", "/", "data:image/")
|
|
60
60
|
value
|
|
61
61
|
end
|
|
62
62
|
rescue ArgumentError, URI::InvalidURIError
|
|
@@ -72,7 +72,7 @@ module Plutonium
|
|
|
72
72
|
# A URL-shaped positional subject is really an image, not an identity:
|
|
73
73
|
# route it to src so Avatar("https://…/p.png") shows the image rather
|
|
74
74
|
# than hashing the URL into a seed.
|
|
75
|
-
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/")
|
|
75
|
+
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/", "data:image/")
|
|
76
76
|
src = subject
|
|
77
77
|
subject = nil
|
|
78
78
|
end
|
|
@@ -6,7 +6,9 @@ module Plutonium
|
|
|
6
6
|
# The card grid. Every lazy card is a `<turbo-frame src loading="lazy">`
|
|
7
7
|
# holding a skeleton of its own shape, so the page paints at once and
|
|
8
8
|
# each card's queries run in a separate request as it scrolls into
|
|
9
|
-
# view. A card declared `lazy: false` renders inline.
|
|
9
|
+
# view. A card declared `lazy: false` renders inline. Frames carry
|
|
10
|
+
# `target="_top"` so a link inside a card navigates the page, not the
|
|
11
|
+
# card's frame.
|
|
10
12
|
class Board < Plutonium::UI::Component::Base
|
|
11
13
|
include Phlex::Rails::Helpers::TurboFrameTag
|
|
12
14
|
|
|
@@ -54,6 +56,7 @@ module Plutonium
|
|
|
54
56
|
src: dashboard_card_path(card),
|
|
55
57
|
loading: "lazy",
|
|
56
58
|
refresh: "morph",
|
|
59
|
+
target: "_top",
|
|
57
60
|
class: tokens("block", span_classes(card)),
|
|
58
61
|
**refresh_attributes(card)
|
|
59
62
|
) { render Skeleton.new(card:) }
|
|
@@ -9,7 +9,7 @@ module Plutonium
|
|
|
9
9
|
#
|
|
10
10
|
# {value: 1_240} # just the number
|
|
11
11
|
# {value: 1_240, previous: 1_100} # change computed: +12.7%
|
|
12
|
-
# {value: 1_240, change: 12.7} #
|
|
12
|
+
# {value: 1_240, change: 12.7} # +12.7% (+12.7 pts with change_unit: :points)
|
|
13
13
|
# {value: 1_240, change: "+140", trend: :up} # change shown verbatim
|
|
14
14
|
# {value: 98.2, change_label: "vs. last week"} # caption after the change
|
|
15
15
|
#
|
|
@@ -36,13 +36,22 @@ module Plutonium
|
|
|
36
36
|
data[:change_label] ||= card.options[:change_label]
|
|
37
37
|
|
|
38
38
|
if !data.key?(:change) && data[:previous].is_a?(Numeric) && data[:value].is_a?(Numeric)
|
|
39
|
-
data[:change] = percent_change(data[:value], data[:previous])
|
|
39
|
+
data[:change] = points? ? data[:value] - data[:previous] : percent_change(data[:value], data[:previous])
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Round to the precision the change is shown at, so float noise
|
|
43
|
+
# (0.3 against 0.1 + 0.2) reads as no change rather than "-0" heading
|
|
44
|
+
# down. `+ 0.0` turns a rounded -0.0 into 0.0.
|
|
45
|
+
data[:change] = data[:change].round(1) + 0.0 if data[:change].is_a?(Numeric)
|
|
46
|
+
|
|
47
|
+
# A `previous` of zero leaves a percent `change` nil but still sets
|
|
48
|
+
# the trend key, so a captioned row shows a neutral dash.
|
|
42
49
|
data[:trend] ||= infer_trend(data[:change]) if data.key?(:change)
|
|
43
50
|
data
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
def points? = card.options[:change_unit] == :points
|
|
54
|
+
|
|
46
55
|
def percent_change(value, previous)
|
|
47
56
|
return nil if previous.zero?
|
|
48
57
|
|
|
@@ -65,9 +74,12 @@ module Plutonium
|
|
|
65
74
|
end
|
|
66
75
|
|
|
67
76
|
def render_change(data)
|
|
77
|
+
# A caption with no change or trend (a count of ratings, say) is
|
|
78
|
+
# not a trend: no icon, neutral tone.
|
|
79
|
+
trending = data.key?(:trend)
|
|
68
80
|
trend = data[:trend] || :flat
|
|
69
|
-
div(class: tokens("pu-metric-change", change_tone_class(trend))) do
|
|
70
|
-
render_trend_icon(trend)
|
|
81
|
+
div(class: tokens("pu-metric-change", change_tone_class(trending ? trend : :flat))) do
|
|
82
|
+
render_trend_icon(trend) if trending
|
|
71
83
|
span(class: "font-medium") { format_change(data[:change]) } if data[:change]
|
|
72
84
|
span(class: "pu-metric-change-label") { data[:change_label] } if data[:change_label].present?
|
|
73
85
|
end
|
|
@@ -93,7 +105,8 @@ module Plutonium
|
|
|
93
105
|
case change
|
|
94
106
|
when Numeric
|
|
95
107
|
sign = change.positive? ? "+" : ""
|
|
96
|
-
|
|
108
|
+
rounded = number_to_rounded(change, precision: 1, strip_insignificant_zeros: true)
|
|
109
|
+
points? ? t("plutonium.dashboard.metric.points", change: "#{sign}#{rounded}") : "#{sign}#{rounded}%"
|
|
97
110
|
else change.to_s
|
|
98
111
|
end
|
|
99
112
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Dashboard
|
|
6
|
+
# The row above the card grid: each filter as a segmented control of
|
|
7
|
+
# links, so the choice is a plain URL that survives a reload and can be
|
|
8
|
+
# shared, and the dashboard's links on the right.
|
|
9
|
+
class Toolbar < Plutonium::UI::Component::Base
|
|
10
|
+
def initialize(dashboard:)
|
|
11
|
+
@dashboard = dashboard
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render?
|
|
15
|
+
dashboard.class.filters.any? || links.any?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def view_template
|
|
19
|
+
div(class: "pu-dashboard-toolbar mb-4 flex flex-wrap items-center justify-between gap-3") do
|
|
20
|
+
div(class: "flex flex-wrap items-center gap-3") do
|
|
21
|
+
dashboard.class.filters.each { |filter| render_filter(filter) }
|
|
22
|
+
end
|
|
23
|
+
div(class: "flex flex-wrap items-center gap-2") do
|
|
24
|
+
links.each { |link, href| render_link(link, href) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :dashboard
|
|
32
|
+
|
|
33
|
+
# Visible links paired with their href. A link whose `href:` resolves
|
|
34
|
+
# to nil (a proc that returns nothing for this user) is left out
|
|
35
|
+
# rather than rendered as a dead button.
|
|
36
|
+
def links
|
|
37
|
+
@links ||= dashboard.visible_links.filter_map do |link|
|
|
38
|
+
href = link.href_for(dashboard)
|
|
39
|
+
[link, href] if href.present?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_filter(filter)
|
|
44
|
+
current = dashboard.filter_value(filter.key)
|
|
45
|
+
nav(class: "pu-dashboard-filter inline-flex rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] p-0.5",
|
|
46
|
+
aria: {label: filter.label}, data: {dashboard_filter: filter.key}) do
|
|
47
|
+
dashboard.filter_choices(filter).each do |value, label|
|
|
48
|
+
selected = value == current
|
|
49
|
+
a(
|
|
50
|
+
href: filter_url(filter, value),
|
|
51
|
+
class: tokens(
|
|
52
|
+
"rounded-[var(--pu-radius-sm)] px-3 py-1 text-sm transition-colors",
|
|
53
|
+
selected ? "bg-[var(--pu-surface-alt)] font-medium text-[var(--pu-text)]" : "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)]"
|
|
54
|
+
),
|
|
55
|
+
aria: {current: selected ? "true" : nil}
|
|
56
|
+
) { Plutonium::Translation.resolve(label) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def render_link(link, href)
|
|
62
|
+
a(href:, class: "pu-btn pu-btn-sm pu-btn-outline", data: {dashboard_link: link.key}) do
|
|
63
|
+
render link.icon.new(class: "size-4") if link.icon
|
|
64
|
+
span { link.label }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The page with this filter set and the rest of the query string kept.
|
|
69
|
+
def filter_url(filter, value)
|
|
70
|
+
query = dashboard.request.query_parameters.merge(filter.key.to_s => value)
|
|
71
|
+
"#{dashboard.request.path}?#{query.to_query}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -147,6 +147,26 @@ module Plutonium
|
|
|
147
147
|
# has to be re-pointed explicitly or habtm skips the policy scope.
|
|
148
148
|
alias_method :has_and_belongs_to_many_tag, :secure_association_tag
|
|
149
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
|
|
169
|
+
|
|
150
170
|
def secure_polymorphic_association_tag(**attributes, &)
|
|
151
171
|
attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select") # TODO: put this behind a config
|
|
152
172
|
create_component(Components::SecurePolymorphicAssociation, :polymorphic_association, **attributes, &)
|
|
@@ -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
|