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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  5. data/CHANGELOG.md +13 -0
  6. data/SECURITY.md +1 -1
  7. data/config/locales/en/dashboard.yml +1 -0
  8. data/config/locales/en/ui.yml +3 -0
  9. data/docs/reference/dashboard/dsl.md +54 -3
  10. data/docs/reference/resource/definition.md +2 -0
  11. data/docs/reference/ui/forms.md +23 -0
  12. data/lib/plutonium/dashboard/base.rb +80 -1
  13. data/lib/plutonium/dashboard/card.rb +12 -13
  14. data/lib/plutonium/dashboard/controller.rb +28 -10
  15. data/lib/plutonium/dashboard/dsl.rb +59 -2
  16. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  17. data/lib/plutonium/dashboard/filter.rb +114 -0
  18. data/lib/plutonium/dashboard/link.rb +35 -0
  19. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  20. data/lib/plutonium/resource/query_object.rb +6 -0
  21. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  22. data/lib/plutonium/ui/avatar.rb +2 -2
  23. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  24. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  25. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  26. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  27. data/lib/plutonium/ui/form/base.rb +20 -0
  28. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  29. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  30. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  31. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  32. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  33. data/lib/plutonium/ui/form/theme.rb +15 -1
  34. data/lib/plutonium/ui/grid/card.rb +12 -4
  35. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  36. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  37. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  38. data/lib/plutonium/version.rb +1 -1
  39. data/package.json +1 -1
  40. data/plutonium.gemspec +1 -1
  41. 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 = route_set.routes.find do |r|
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
@@ -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:) }
@@ -15,7 +15,7 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def view_template
18
- turbo_frame_tag(@card.frame_id, class: "block") do
18
+ turbo_frame_tag(@card.frame_id, target: "_top", class: "block") do
19
19
  render Card.for(dashboard: @dashboard, card: @card)
20
20
  end
21
21
  end
@@ -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} # change in percentage points
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
- "#{sign}#{number_to_rounded(change, precision: 1, strip_insignificant_zeros: true)}%"
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
- collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
97
+ if collection.is_a?(ActiveRecord::Relation)
98
+ collection = collection.limit(@choice_limit) if @choice_limit
99
+ collection = (collection.to_a + selected_records).uniq
100
+ end
94
101
  build_choice_mapper(collection)
95
102
  end
96
103
  end
97
104
 
105
+ def selected_records
106
+ @selected_records ||= Array(field.object.public_send(association_reflection.name))
107
+ end
108
+
98
109
  # Builds the authorized association relation. Shared by `choices`
99
110
  # (which then applies `choice_limit`) and `normalize_simple_input`
100
111
  # (which validates against the full set so typeahead picks beyond
@@ -106,8 +117,11 @@ module Plutonium
106
117
  authorized_resource_scope(klass, relation: relation)
107
118
  end
108
119
 
120
+ # AssociationBase#build_attributes already calls
121
+ # build_association_attributes. Calling it here too ran it twice, and
122
+ # the second pass re-read the keys the first had deleted, silently
123
+ # dropping `skip_authorization:` and `add_action:`.
109
124
  def build_attributes
110
- build_association_attributes
111
125
  super
112
126
  # Stash; the URL helper needs view_context which only exists
113
127
  # once we're rendering.
@@ -163,8 +177,16 @@ module Plutonium
163
177
  # not include records reachable via typeahead). For explicit
164
178
  # `@raw_choices`, fall back to membership in the rendered list.
165
179
  def normalize_simple_input(input_value)
180
+ # An explicit blank (the select's blank option, a "None" radio)
181
+ # clears a singular association: "" survives resource_params'
182
+ # `compact` and `<name>_sgid = ""` locates nil. A field that was not
183
+ # submitted arrives as nil and is left alone.
184
+ return "" if input_value == "" && !attributes[:multiple]
185
+
166
186
  sgid = SignedGlobalID.parse(input_value.presence)
167
187
  return nil unless sgid
188
+ # Keeping what the field already holds is not a new grant.
189
+ return sgid if selected_records.map(&:to_global_id).include?(sgid)
168
190
 
169
191
  if @raw_choices
170
192
  @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
@@ -177,13 +199,14 @@ module Plutonium
177
199
  nil
178
200
  end
179
201
 
202
+ # Compared as URI strings: URI objects don't match by value in a Set.
180
203
  def selected?(option)
181
- case association_reflection.macro
182
- when :belongs_to, :has_one
183
- singular_field_value == SignedGlobalID.parse(option)
184
- when :has_many, :has_and_belongs_to_many
185
- collection_field_value.any? { |item| item == SignedGlobalID.parse(option) }
186
- end
204
+ selected_gids.include?(SignedGlobalID.parse(option)&.uri&.to_s)
205
+ end
206
+
207
+ def selected_gids
208
+ @selected_gids ||= Array(attributes[:multiple] ? collection_field_value : singular_field_value)
209
+ .compact.to_set { |sgid| sgid.uri.to_s }
187
210
  end
188
211
 
189
212
  def singular_field_value
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A has_many / habtm association rendered as one checkbox per authorized
8
+ # record (`input :tags, as: :association_checkboxes`).
9
+ class SecureAssociationCheckboxes < SecureAssociationChoices
10
+ protected
11
+
12
+ def control_type = :checkbox
13
+
14
+ def control_theme = :association_checkbox
15
+
16
+ def supported_macros = %i[has_many has_and_belongs_to_many]
17
+
18
+ # Unchecked boxes are not submitted, so without this blank value
19
+ # clearing every box would leave the param out entirely and keep the
20
+ # existing records. The blank is dropped by normalize_array_input.
21
+ # Disabled with the list: a disabled list submits no boxes, so a live
22
+ # blank would save as "no records" and wipe the association.
23
+ def render_hidden_input
24
+ input(type: :hidden, name: attributes[:name], value: "", autocomplete: "off", hidden: true, disabled: attributes[:disabled])
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Abstract base for the list-style association inputs: every choice is
8
+ # rendered as its own control (checkbox, toggle or radio button) instead
9
+ # of an option inside a slim-select.
10
+ #
11
+ # Everything that makes the association input "secure" is inherited from
12
+ # {SecureAssociation} unchanged: the choices come from the policy-scoped
13
+ # relation (or `choices:`), each value is a signed global id, submitted
14
+ # ids are validated against the authorized relation, and the inline "+"
15
+ # add button still renders. Only the markup differs.
16
+ #
17
+ # Subclasses declare the control's input type, the macros they accept and
18
+ # the theme key their controls style off.
19
+ class SecureAssociationChoices < SecureAssociation
20
+ def view_template
21
+ div(class: "flex items-start space-x-1") do
22
+ # The wrapper's <label for=> points at this id, but a label can't
23
+ # name a div, so the group carries the field label itself.
24
+ div(**group_attributes) do
25
+ render_hidden_input
26
+ render_blank_choice if include_blank?
27
+ render_choices
28
+ end
29
+ render_add_button
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # The type attribute of each control: "checkbox" or "radio".
36
+ def control_type = raise(NotImplementedError, "#{self.class}#control_type")
37
+
38
+ # Theme key the individual controls style off.
39
+ def control_theme = raise(NotImplementedError, "#{self.class}#control_theme")
40
+
41
+ # Association macros this variant can represent.
42
+ def supported_macros = raise(NotImplementedError, "#{self.class}#supported_macros")
43
+
44
+ def group_role = "group"
45
+
46
+ # Everything the caller passed (data-*, aria-*, ...) lands on the
47
+ # group; what belongs to a form control goes on each choice instead.
48
+ CONTROL_ATTRIBUTES = %i[name required disabled multiple size autofocus form value].freeze
49
+
50
+ def group_attributes
51
+ mix(attributes.except(*CONTROL_ATTRIBUTES), {role: group_role, aria: {label: field.label}})
52
+ end
53
+
54
+ def build_association_attributes
55
+ super
56
+
57
+ unless supported_macros.include?(association_reflection.macro)
58
+ raise ArgumentError,
59
+ "#{self.class.name.demodulize} does not support #{association_reflection.macro} " \
60
+ "associations (#{association_name}). Supported: #{supported_macros.join(", ")}."
61
+ end
62
+
63
+ if association_reflection.polymorphic?
64
+ raise ArgumentError, "#{self.class.name.demodulize} does not support polymorphic associations (#{association_name})."
65
+ end
66
+ end
67
+
68
+ def association_name = "#{field.object.class.name}##{association_reflection.name}"
69
+
70
+ def build_attributes
71
+ super
72
+ if @group_method
73
+ raise ArgumentError, "#{self.class.name.demodulize} does not support group_method: (#{association_name})."
74
+ end
75
+
76
+ # Typeahead drives a slim-select dropdown; a rendered list has no
77
+ # search box to wire it to.
78
+ @typeahead_option = false
79
+ end
80
+
81
+ def render_choices
82
+ if choices.values.empty?
83
+ p(class: "pu-hint") { Plutonium::Translation.t("plutonium.ui.form.association_choices.empty") }
84
+ return
85
+ end
86
+
87
+ choices.each.with_index do |(value, text), index|
88
+ render_choice(value:, text:, id: "#{attributes[:id]}_#{index}", checked: selected?(value), autofocus: index.zero? && attributes[:autofocus])
89
+ end
90
+ end
91
+
92
+ def render_choice(value:, text:, id:, checked:, autofocus: false)
93
+ label(for: id, class: themed(:association_choice_label, field)) do
94
+ input(
95
+ type: control_type,
96
+ id:,
97
+ name: attributes[:name],
98
+ value:,
99
+ checked:,
100
+ required: control_required?,
101
+ disabled: attributes[:disabled],
102
+ form: attributes[:form],
103
+ autofocus:,
104
+ class: themed(control_theme, field)
105
+ )
106
+ span { text }
107
+ end
108
+ end
109
+
110
+ def render_hidden_input
111
+ end
112
+
113
+ def render_blank_choice
114
+ end
115
+
116
+ def include_blank? = false
117
+
118
+ def control_required? = false
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end