plutonium 0.67.0 → 0.69.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
  3. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  4. data/.claude/skills/plutonium-resource/SKILL.md +3 -2
  5. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  6. data/CHANGELOG.md +33 -0
  7. data/SECURITY.md +1 -1
  8. data/app/assets/plutonium.css +3 -1
  9. data/config/locales/en/async.yml +11 -1
  10. data/config/locales/en/dashboard.yml +1 -0
  11. data/config/locales/en/ui.yml +3 -0
  12. data/docs/public/images/reference/async-progress-page.png +0 -0
  13. data/docs/public/images/reference/async-running-banner.png +0 -0
  14. data/docs/reference/behavior/async-interactions.md +62 -5
  15. data/docs/reference/dashboard/dsl.md +54 -3
  16. data/docs/reference/generators/lite.md +30 -6
  17. data/docs/reference/i18n.md +1 -1
  18. data/docs/reference/resource/definition.md +2 -0
  19. data/docs/reference/ui/forms.md +23 -0
  20. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
  21. data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
  22. data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
  23. data/gemfiles/rails_7.gemfile.lock +5 -5
  24. data/gemfiles/rails_8.0.gemfile.lock +3 -3
  25. data/gemfiles/rails_8.1.gemfile.lock +3 -3
  26. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
  27. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
  28. data/lib/plutonium/dashboard/base.rb +80 -1
  29. data/lib/plutonium/dashboard/card.rb +12 -13
  30. data/lib/plutonium/dashboard/controller.rb +28 -10
  31. data/lib/plutonium/dashboard/dsl.rb +59 -2
  32. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  33. data/lib/plutonium/dashboard/filter.rb +114 -0
  34. data/lib/plutonium/dashboard/link.rb +35 -0
  35. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  36. data/lib/plutonium/definition/presentable.rb +4 -1
  37. data/lib/plutonium/interaction/async/configuration.rb +15 -0
  38. data/lib/plutonium/interaction/async/context.rb +12 -2
  39. data/lib/plutonium/interaction/async/executor.rb +54 -0
  40. data/lib/plutonium/interaction/async/run.rb +128 -14
  41. data/lib/plutonium/interaction/async/run_definition.rb +5 -4
  42. data/lib/plutonium/interaction/async/run_policy.rb +5 -5
  43. data/lib/plutonium/interaction/response/redirect.rb +19 -7
  44. data/lib/plutonium/resource/query_object.rb +6 -0
  45. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  46. data/lib/plutonium/ui/avatar.rb +2 -2
  47. data/lib/plutonium/ui/breadcrumbs.rb +26 -8
  48. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  49. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  50. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  51. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  52. data/lib/plutonium/ui/form/base.rb +20 -0
  53. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  54. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  55. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  56. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  57. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  58. data/lib/plutonium/ui/form/theme.rb +15 -1
  59. data/lib/plutonium/ui/grid/card.rb +12 -4
  60. data/lib/plutonium/ui/interaction/async/run_progress.rb +25 -11
  61. data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
  62. data/lib/plutonium/ui/layout/base.rb +0 -9
  63. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  64. data/lib/plutonium/ui/page/index.rb +23 -4
  65. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  66. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  67. data/lib/plutonium/version.rb +1 -1
  68. data/package.json +1 -1
  69. data/plutonium.gemspec +1 -1
  70. data/src/css/components.css +9 -0
  71. data/src/css/font_awesome.css +2328 -0
  72. data/src/css/plutonium.css +1 -0
  73. metadata +16 -4
@@ -27,6 +27,15 @@ module Plutonium
27
27
  IN_PROGRESS_STATES = %w[pending running].freeze
28
28
  FAILURE_POLICIES = %i[halt continue transactional].freeze
29
29
 
30
+ # Tags the errors_log entries a pre_check rejection writes, which is what
31
+ # #outcome reads "rejected" from. A key in the existing JSON rather than a
32
+ # column: older entries simply lack it.
33
+ PRE_CHECK_STAGE = "pre_check"
34
+
35
+ # Raised by #reject called anywhere but inside pre_check, where nothing
36
+ # would collect the rejection.
37
+ class RejectOutsidePreCheckError < StandardError; end
38
+
30
39
  # Routes, paths and helpers are all derived from model_name, and the
31
40
  # default would spell the gem's own namespace out in every URL
32
41
  # (/admin/plutonium/interaction/runs) and every helper
@@ -65,6 +74,7 @@ module Plutonium
65
74
 
66
75
  scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
67
76
  scope :for_target, ->(klass) { where(target_type: klass.to_s) }
77
+ scope :finished_since, ->(time) { where.not(state: IN_PROGRESS_STATES).where(finished_at: time..) }
68
78
 
69
79
  # In progress with no recorded activity since +before+ — see
70
80
  # Async::ReapJob. +last_activity_at+ is nil until a job actually claims
@@ -130,9 +140,17 @@ module Plutonium
130
140
  #
131
141
  # @return [String]
132
142
  def outcome
143
+ return "rejected" if rejected?
144
+
133
145
  (state == "completed" && errors_log.any?) ? "completed_with_errors" : state
134
146
  end
135
147
 
148
+ # Failed because its pre_check refused the input, as opposed to failing
149
+ # while doing the work. Nothing was processed. state stays "failed", so
150
+ # every reader keyed on state (ReapJob, in_progress, the banner) is
151
+ # unaffected.
152
+ def rejected? = state == "failed" && errors_log.any? { |entry| entry["stage"] == PRE_CHECK_STAGE }
153
+
136
154
  # @return [Integer] recorded target failures, run-level entries included
137
155
  def error_count = errors_log.size
138
156
 
@@ -153,23 +171,42 @@ module Plutonium
153
171
 
154
172
  # Runs have no name or title, so Labeling would fall back to
155
173
  # "Async run #12" — true but silent about the only thing that
156
- # distinguishes one row from the next.
157
- #
158
- # Demodulized: a host's run class is as likely to be
159
- # Billing::ReissueInvoicesRun as a top-level one, and the namespace adds
160
- # nothing to a label that already sits under the run's own breadcrumb.
161
- #
162
- # Except when demodulizing is what throws the name away. A run declared
163
- # by Dispatchable#async is Blogging::ArchivePosts::Run, and every
164
- # one of them would render "Run #12" — the failure mode this method
165
- # exists to avoid, reintroduced for the shape most authors will write.
166
- # For those, the enclosing segment IS the name, so it is folded back in.
174
+ # distinguishes one row from the next. The dispatching interaction's
175
+ # label is that thing: "Import Posts #12".
167
176
  #
168
177
  # @return [String]
169
178
  def to_label
170
- parts = self.class.name.split("::")
171
- name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
172
- I18n.t("plutonium.async.run.label", name: name.titleize, id: to_param)
179
+ I18n.t("plutonium.async.run.label", name: interaction_label, id: to_param)
180
+ end
181
+
182
+ # The interaction's label (its `presents label:`), or a name derived from
183
+ # the run class for a run with no interaction behind it.
184
+ #
185
+ # @return [String]
186
+ def interaction_label
187
+ interaction_class&.label || run_class_label
188
+ end
189
+
190
+ # The interaction this run belongs to. A run declared by
191
+ # Dispatchable#async is <Interaction>::Run, so it is the enclosing class.
192
+ # nil for a standalone run class, which may be shared by several
193
+ # interactions and so belongs to none of them.
194
+ #
195
+ # @return [Class, nil]
196
+ def interaction_class
197
+ parent = self.class.module_parent
198
+ parent if parent < Plutonium::Interaction::Base
199
+ end
200
+
201
+ # The index's progress cell: "1 / 4", or nil for opaque work. Two
202
+ # counter columns showed opaque work as "0" done of nothing, which reads
203
+ # as a run that did nothing rather than one with no count to report.
204
+ #
205
+ # @return [String, nil]
206
+ def progress
207
+ return nil if progress_total.nil?
208
+
209
+ I18n.t("plutonium.async.run.progress", done: progress_done, total: progress_total)
173
210
  end
174
211
 
175
212
  # nil means INDETERMINATE, not zero: opaque work has no denominator, and
@@ -222,6 +259,53 @@ module Plutonium
222
259
  )
223
260
  end
224
261
 
262
+ # Fails the run with its pre_check's rejections, in ONE write, for the
263
+ # reason record_target_failures! gives: a crash between two writes must
264
+ # not leave a failed run without its reasons, or reasons on a run still
265
+ # marked running. Entries carry the run-level nil target_id, so progress
266
+ # and handled_target_ids are untouched: nothing was processed.
267
+ #
268
+ # @param messages [Array<String>] the rejections that were stored
269
+ # @param overflow [Integer] how many more were rejected past max_rejections
270
+ def reject_run!(messages, overflow: 0)
271
+ entries = messages.map { |message| pre_check_entry(message) }
272
+ entries << pre_check_entry(I18n.t("plutonium.async.executor.rejections_truncated", count: overflow)) if overflow.positive?
273
+
274
+ now = Time.current
275
+ update!(errors_log: errors_log + entries, state: "failed", finished_at: now, last_activity_at: now)
276
+ end
277
+
278
+ # Runs the block with #reject live, and closes it again afterwards.
279
+ # Called by Async::Executor around pre_check.
280
+ #
281
+ # @return [Array(Array<String>, Integer)] the stored messages, and how
282
+ # many more were rejected past max_rejections
283
+ def collect_rejections
284
+ @rejections = []
285
+ @rejection_count = 0
286
+ yield
287
+ [@rejections, @rejection_count - @rejections.size]
288
+ ensure
289
+ @rejections = @rejection_count = nil
290
+ end
291
+
292
+ # Records one problem with the run's input and returns, so a check can
293
+ # report every bad row in one pass. Only live inside pre_check: a
294
+ # rejection from perform would be collected by nothing.
295
+ #
296
+ # Counts past max_rejections but stores no more than that, so memory
297
+ # stays bounded and the "and N more" count is exact.
298
+ #
299
+ # @param message [String]
300
+ # @return [void]
301
+ def reject(message)
302
+ raise RejectOutsidePreCheckError, I18n.t("plutonium.async.run.reject_outside_pre_check", run_class: self.class) if @rejections.nil?
303
+
304
+ @rejection_count += 1
305
+ @rejections << message.to_s if @rejections.size < Plutonium.configuration.async_interactions.max_rejections
306
+ nil
307
+ end
308
+
225
309
  # Target ids not yet dispositioned — what a resumed run still has to
226
310
  # do. See {#record_target_failures!} and Async::Executor#advance!, which
227
311
  # are what populate handled_target_ids.
@@ -232,6 +316,16 @@ module Plutonium
232
316
  target_ids.reject { |id| handled.include?(id.to_s) }
233
317
  end
234
318
 
319
+ # Whether an earlier pass got as far as applying a target, which it can
320
+ # only have done after its pre_check passed. A handled id with no
321
+ # errors_log entry was applied; failures (unresolved or raised) all carry one.
322
+ #
323
+ # @return [Boolean]
324
+ def any_target_applied?
325
+ failed = errors_log.filter_map { |entry| entry["target_id"]&.to_s }.to_set
326
+ handled_target_ids.any? { |id| !failed.include?(id.to_s) }
327
+ end
328
+
235
329
  # The interaction's validated inputs, with the types dispatch put in.
236
330
  #
237
331
  # The column is JSON, so Dispatchable#dispatch_options writes it through
@@ -377,8 +471,28 @@ module Plutonium
377
471
  "#perform_on(record) for work over targets, or #perform for opaque work"
378
472
  end
379
473
 
474
+ # Checks the run's input before any work, and refuses it with #reject.
475
+ # Optional: the default checks nothing. Override it as
476
+ # +pre_check(records)+ in a run with +perform_on+ (it receives the
477
+ # resolved, authorized records), or +pre_check+ for opaque work.
478
+ # See Async::Executor#pre_check_passed?.
479
+ def pre_check(*) = nil
480
+
380
481
  private
381
482
 
483
+ def pre_check_entry(message) = {"target_id" => nil, "message" => message, "stage" => PRE_CHECK_STAGE}
484
+
485
+ # For a standalone run class, passed as `async Billing::ReissueInvoicesRun`,
486
+ # which has no interaction around it to take a label from. Demodulized,
487
+ # since the namespace adds nothing under the run's own breadcrumb. A
488
+ # <Something>::Run that is not inside an interaction keeps the enclosing
489
+ # segment, or every one of them would read "Run".
490
+ def run_class_label
491
+ parts = self.class.name.split("::")
492
+ name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
493
+ name.titleize
494
+ end
495
+
382
496
  # Not swallowed: after_commit runs synchronously, so re-raising here
383
497
  # still surfaces to the dispatching request rather than leaving a
384
498
  # permanently invisible, un-enqueued ghost.
@@ -25,19 +25,20 @@ module Plutonium
25
25
  # neither a type to infer nor a badge to infer one from (`state` is a
26
26
  # plain string column with an inclusion validation, not a Rails enum).
27
27
  #
28
- # `running` and `completed_with_errors` are coloured explicitly: neither
29
- # is in the badge component's semantic table, and its fallback is a
28
+ # `running`, `completed_with_errors` and `rejected` are coloured explicitly:
29
+ # none is in the badge component's semantic table, and its fallback is a
30
30
  # stable-but-meaningless colour.
31
31
  field :outcome, as: :string
32
32
  display :outcome, as: :badge,
33
- colors: {running: :info, completed_with_errors: :warning}
33
+ colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
34
34
  # Declared for the table too: the index is where an operator scans for the
35
35
  # run that did not do what it was asked, and a bare
36
36
  # "completed_with_errors" string is the one place that reads worse than
37
37
  # the badge.
38
38
  column :outcome, as: :badge,
39
- colors: {running: :info, completed_with_errors: :warning}
39
+ colors: {running: :info, completed_with_errors: :warning, rejected: :warning}
40
40
 
41
+ field :interaction_label, label: t("plutonium.async.run.interaction")
41
42
  field :target_label, label: t("plutonium.async.run.target_type")
42
43
 
43
44
  # The details tab, with the live panel above the fields.
@@ -63,22 +63,22 @@ module Plutonium
63
63
  # unreadably.
64
64
  def permitted_attributes_for_read
65
65
  %i[
66
- type outcome progress_done progress_total
66
+ interaction_label outcome progress
67
67
  target_label initiator started_at finished_at created_at
68
68
  ]
69
69
  end
70
70
 
71
- # The show page drops the progress COUNTERS, for the reason errors_log
71
+ # The show page drops the progress cell, for the reason errors_log
72
72
  # is absent above: the panel already renders them, as "5 of 5 targets
73
73
  # (100%)" over a bar. Two renderings of one number is the same report
74
74
  # twice — and they disagree, because only the panel is inside the polled
75
75
  # frame. A page that says "Completed" and "0 done" at once is worse than
76
76
  # either alone.
77
77
  #
78
- # The INDEX keeps them. There is no progress bar in a table row, so the
79
- # counters are the only progress it can show.
78
+ # The INDEX keeps it. There is no progress bar in a table row, so the
79
+ # cell is the only progress it can show.
80
80
  def permitted_attributes_for_show
81
- permitted_attributes_for_read - %i[progress_done progress_total]
81
+ permitted_attributes_for_read - %i[progress]
82
82
  end
83
83
  end
84
84
  end
@@ -24,10 +24,13 @@ module Plutonium
24
24
  # appending the options hash raises ArgumentError rather than
25
25
  # producing a formatted URL. A caller handing over a complete URL has
26
26
  # already decided what it points at.
27
+ #
28
+ # Also skipped for a turbo-stream request: it is answered below with a
29
+ # visit, and a visit wants the page, not a .turbo_stream URL.
27
30
  fully_formed_url = redirect_args.size == 1 && redirect_args.first.is_a?(String)
28
31
 
29
32
  url_options = redirect_args.last.is_a?(Hash) ? redirect_args.last : {}
30
- if !fully_formed_url && !url_options.key?(:format) && request.format.symbol != :html
33
+ if !fully_formed_url && !url_options.key?(:format) && !request.format.symbol.in?(%i[html turbo_stream])
31
34
  url_options = url_options.merge(format: request.format.symbol)
32
35
  redirect_args = [*redirect_args[0...-1], url_options] if redirect_args.last.is_a?(Hash)
33
36
  redirect_args = [*redirect_args, url_options] unless redirect_args.last.is_a?(Hash)
@@ -36,14 +39,23 @@ module Plutonium
36
39
  url = fully_formed_url ? redirect_args.first : url_for(*redirect_args)
37
40
 
38
41
  respond_to do |format|
42
+ # Answered with a stream action wherever the form lives, the way
43
+ # every other Plutonium success path answers a turbo-stream post.
44
+ # A plain redirect_to here is followed by fetch and its page
45
+ # rendered into whatever frame submitted the form.
46
+ #
47
+ # turbo_stream_redirect is a client-side visit, so it skips the
48
+ # open-redirect check redirect_to applies. It is applied here the
49
+ # same way, with redirect_to's own default for allow_other_host:
50
+ # resolved up front from raise_on_open_redirects before Rails 8.1,
51
+ # left nil from 8.1 on, where the check resolves it and nil also
52
+ # drives action_on_open_redirect (:log / :notify).
39
53
  format.turbo_stream do
40
- if helpers.current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
41
- render turbo_stream: [
42
- helpers.turbo_stream_redirect(url)
43
- ]
44
- else
45
- redirect_to(url, **redirect_options)
54
+ allow_other_host = redirect_options.fetch(:allow_other_host) do
55
+ _allow_other_host if ActionPack.gem_version < Gem::Version.new("8.1")
46
56
  end
57
+ _enforce_open_redirect_protection(url, allow_other_host:)
58
+ render turbo_stream: helpers.turbo_stream_redirect(url)
47
59
  end
48
60
 
49
61
  format.any { redirect_to(url, **redirect_options) }
@@ -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
@@ -48,10 +48,24 @@ module Plutonium
48
48
  def breadcrumb_items
49
49
  @breadcrumb_items ||= [].tap do |items|
50
50
  collect_parent_breadcrumbs(items) if current_parent.present?
51
- collect_resource_breadcrumbs(items) if resource_record?
51
+ if resource_record?
52
+ collect_resource_breadcrumbs(items)
53
+ elsif collection_page?
54
+ collect_resource_index_breadcrumb(items)
55
+ end
52
56
  end
53
57
  end
54
58
 
59
+ # A page that hangs off the resource list without being about one record:
60
+ # a resource action (import), a wizard launched from the index, or a
61
+ # custom collection route (calendar). It has no record to name, but the
62
+ # list it came from is still a level up. The index itself is excluded
63
+ # because its page title already says where you are, and a singular
64
+ # resource has no index to point back to.
65
+ def collection_page?
66
+ action_name != "index" && !singular_resource_context?
67
+ end
68
+
55
69
  # The segments eligible to be folded into the overflow menu. The last one
56
70
  # is never foldable — it is the deepest thing the trail knows about, which
57
71
  # is the record on new/edit pages but the resource index on show pages,
@@ -151,21 +165,25 @@ module Plutonium
151
165
  end
152
166
 
153
167
  def collect_plural_resource_breadcrumbs(items)
168
+ collect_resource_index_breadcrumb(items)
169
+
170
+ # Record itself (for non-singular routes only)
171
+ return unless resource_record!.persisted? && action_name != "show"
172
+
173
+ items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
174
+ end
175
+
176
+ def collect_resource_index_breadcrumb(items)
154
177
  # No `parent:` here on purpose: Resource::Controller#resource_url_args_for
155
178
  # already defaults it to `current_parent` (and supplies
156
- # `current_nested_association` with it), so both links nest themselves
179
+ # `current_nested_association` with it), so the link nests itself
157
180
  # when the request arrived through a nesting. Passing it explicitly reads
158
- # like it changes something; it doesn't. The parent segments above pass
181
+ # like it changes something; it doesn't. The parent segments pass
159
182
  # `parent: nil` precisely to opt OUT of that default.
160
183
  items << segment(
161
184
  nestable_resource_name_plural(resource_class),
162
185
  resource_url_for(resource_class)
163
186
  )
164
-
165
- # Record itself (for non-singular routes only)
166
- return unless resource_record!.persisted? && action_name != "show"
167
-
168
- items << segment(display_name_of(resource_record!), resource_url_for(resource_record!))
169
187
  end
170
188
 
171
189
  # Stands in for whatever the controller folded away, the way GitHub
@@ -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, &)