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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-async-interactions/SKILL.md +41 -2
- 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 +33 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +3 -1
- data/config/locales/en/async.yml +11 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/reference/behavior/async-interactions.md +62 -5
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/generators/lite.md +30 -6
- data/docs/reference/i18n.md +1 -1
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md +1056 -0
- data/docs/superpowers/plans/2026-10-15-async-run-pre-check.md.tasks.json +49 -0
- data/docs/superpowers/specs/2026-10-15-async-run-pre-check-design.md +208 -0
- data/gemfiles/rails_7.gemfile.lock +5 -5
- data/gemfiles/rails_8.0.gemfile.lock +3 -3
- data/gemfiles/rails_8.1.gemfile.lock +3 -3
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +20 -1
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +18 -1
- 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/definition/presentable.rb +4 -1
- data/lib/plutonium/interaction/async/configuration.rb +15 -0
- data/lib/plutonium/interaction/async/context.rb +12 -2
- data/lib/plutonium/interaction/async/executor.rb +54 -0
- data/lib/plutonium/interaction/async/run.rb +128 -14
- data/lib/plutonium/interaction/async/run_definition.rb +5 -4
- data/lib/plutonium/interaction/async/run_policy.rb +5 -5
- data/lib/plutonium/interaction/response/redirect.rb +19 -7
- 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/breadcrumbs.rb +26 -8
- 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/interaction/async/run_progress.rb +25 -11
- data/lib/plutonium/ui/interaction/async/running_banner.rb +21 -5
- data/lib/plutonium/ui/layout/base.rb +0 -9
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/page/index.rb +23 -4
- 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
- data/src/css/components.css +9 -0
- data/src/css/font_awesome.css +2328 -0
- data/src/css/plutonium.css +1 -0
- 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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
|
79
|
-
#
|
|
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[
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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:) }
|
|
@@ -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, &)
|