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
|
@@ -87,14 +87,25 @@ module Plutonium
|
|
|
87
87
|
uri.to_s
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# Capped at `choice_limit`, plus the records the field holds now:
|
|
91
|
+
# one past the cap or outside the viewer's scope would otherwise not
|
|
92
|
+
# render, and saving the form would drop it (a select falls back to
|
|
93
|
+
# its blank option, an unrendered checkbox is not submitted).
|
|
90
94
|
def choices
|
|
91
95
|
@choices ||= begin
|
|
92
96
|
collection = @raw_choices || authorized_relation
|
|
93
|
-
|
|
97
|
+
if collection.is_a?(ActiveRecord::Relation)
|
|
98
|
+
collection = collection.limit(@choice_limit) if @choice_limit
|
|
99
|
+
collection = (collection.to_a + selected_records).uniq
|
|
100
|
+
end
|
|
94
101
|
build_choice_mapper(collection)
|
|
95
102
|
end
|
|
96
103
|
end
|
|
97
104
|
|
|
105
|
+
def selected_records
|
|
106
|
+
@selected_records ||= Array(field.object.public_send(association_reflection.name))
|
|
107
|
+
end
|
|
108
|
+
|
|
98
109
|
# Builds the authorized association relation. Shared by `choices`
|
|
99
110
|
# (which then applies `choice_limit`) and `normalize_simple_input`
|
|
100
111
|
# (which validates against the full set so typeahead picks beyond
|
|
@@ -106,8 +117,11 @@ module Plutonium
|
|
|
106
117
|
authorized_resource_scope(klass, relation: relation)
|
|
107
118
|
end
|
|
108
119
|
|
|
120
|
+
# AssociationBase#build_attributes already calls
|
|
121
|
+
# build_association_attributes. Calling it here too ran it twice, and
|
|
122
|
+
# the second pass re-read the keys the first had deleted, silently
|
|
123
|
+
# dropping `skip_authorization:` and `add_action:`.
|
|
109
124
|
def build_attributes
|
|
110
|
-
build_association_attributes
|
|
111
125
|
super
|
|
112
126
|
# Stash; the URL helper needs view_context which only exists
|
|
113
127
|
# once we're rendering.
|
|
@@ -163,8 +177,16 @@ module Plutonium
|
|
|
163
177
|
# not include records reachable via typeahead). For explicit
|
|
164
178
|
# `@raw_choices`, fall back to membership in the rendered list.
|
|
165
179
|
def normalize_simple_input(input_value)
|
|
180
|
+
# An explicit blank (the select's blank option, a "None" radio)
|
|
181
|
+
# clears a singular association: "" survives resource_params'
|
|
182
|
+
# `compact` and `<name>_sgid = ""` locates nil. A field that was not
|
|
183
|
+
# submitted arrives as nil and is left alone.
|
|
184
|
+
return "" if input_value == "" && !attributes[:multiple]
|
|
185
|
+
|
|
166
186
|
sgid = SignedGlobalID.parse(input_value.presence)
|
|
167
187
|
return nil unless sgid
|
|
188
|
+
# Keeping what the field already holds is not a new grant.
|
|
189
|
+
return sgid if selected_records.map(&:to_global_id).include?(sgid)
|
|
168
190
|
|
|
169
191
|
if @raw_choices
|
|
170
192
|
@signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
|
|
@@ -177,13 +199,14 @@ module Plutonium
|
|
|
177
199
|
nil
|
|
178
200
|
end
|
|
179
201
|
|
|
202
|
+
# Compared as URI strings: URI objects don't match by value in a Set.
|
|
180
203
|
def selected?(option)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
204
|
+
selected_gids.include?(SignedGlobalID.parse(option)&.uri&.to_s)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def selected_gids
|
|
208
|
+
@selected_gids ||= Array(attributes[:multiple] ? collection_field_value : singular_field_value)
|
|
209
|
+
.compact.to_set { |sgid| sgid.uri.to_s }
|
|
187
210
|
end
|
|
188
211
|
|
|
189
212
|
def singular_field_value
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# A has_many / habtm association rendered as one checkbox per authorized
|
|
8
|
+
# record (`input :tags, as: :association_checkboxes`).
|
|
9
|
+
class SecureAssociationCheckboxes < SecureAssociationChoices
|
|
10
|
+
protected
|
|
11
|
+
|
|
12
|
+
def control_type = :checkbox
|
|
13
|
+
|
|
14
|
+
def control_theme = :association_checkbox
|
|
15
|
+
|
|
16
|
+
def supported_macros = %i[has_many has_and_belongs_to_many]
|
|
17
|
+
|
|
18
|
+
# Unchecked boxes are not submitted, so without this blank value
|
|
19
|
+
# clearing every box would leave the param out entirely and keep the
|
|
20
|
+
# existing records. The blank is dropped by normalize_array_input.
|
|
21
|
+
# Disabled with the list: a disabled list submits no boxes, so a live
|
|
22
|
+
# blank would save as "no records" and wipe the association.
|
|
23
|
+
def render_hidden_input
|
|
24
|
+
input(type: :hidden, name: attributes[:name], value: "", autocomplete: "off", hidden: true, disabled: attributes[:disabled])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Abstract base for the list-style association inputs: every choice is
|
|
8
|
+
# rendered as its own control (checkbox, toggle or radio button) instead
|
|
9
|
+
# of an option inside a slim-select.
|
|
10
|
+
#
|
|
11
|
+
# Everything that makes the association input "secure" is inherited from
|
|
12
|
+
# {SecureAssociation} unchanged: the choices come from the policy-scoped
|
|
13
|
+
# relation (or `choices:`), each value is a signed global id, submitted
|
|
14
|
+
# ids are validated against the authorized relation, and the inline "+"
|
|
15
|
+
# add button still renders. Only the markup differs.
|
|
16
|
+
#
|
|
17
|
+
# Subclasses declare the control's input type, the macros they accept and
|
|
18
|
+
# the theme key their controls style off.
|
|
19
|
+
class SecureAssociationChoices < SecureAssociation
|
|
20
|
+
def view_template
|
|
21
|
+
div(class: "flex items-start space-x-1") do
|
|
22
|
+
# The wrapper's <label for=> points at this id, but a label can't
|
|
23
|
+
# name a div, so the group carries the field label itself.
|
|
24
|
+
div(**group_attributes) do
|
|
25
|
+
render_hidden_input
|
|
26
|
+
render_blank_choice if include_blank?
|
|
27
|
+
render_choices
|
|
28
|
+
end
|
|
29
|
+
render_add_button
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
# The type attribute of each control: "checkbox" or "radio".
|
|
36
|
+
def control_type = raise(NotImplementedError, "#{self.class}#control_type")
|
|
37
|
+
|
|
38
|
+
# Theme key the individual controls style off.
|
|
39
|
+
def control_theme = raise(NotImplementedError, "#{self.class}#control_theme")
|
|
40
|
+
|
|
41
|
+
# Association macros this variant can represent.
|
|
42
|
+
def supported_macros = raise(NotImplementedError, "#{self.class}#supported_macros")
|
|
43
|
+
|
|
44
|
+
def group_role = "group"
|
|
45
|
+
|
|
46
|
+
# Everything the caller passed (data-*, aria-*, ...) lands on the
|
|
47
|
+
# group; what belongs to a form control goes on each choice instead.
|
|
48
|
+
CONTROL_ATTRIBUTES = %i[name required disabled multiple size autofocus form value].freeze
|
|
49
|
+
|
|
50
|
+
def group_attributes
|
|
51
|
+
mix(attributes.except(*CONTROL_ATTRIBUTES), {role: group_role, aria: {label: field.label}})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_association_attributes
|
|
55
|
+
super
|
|
56
|
+
|
|
57
|
+
unless supported_macros.include?(association_reflection.macro)
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"#{self.class.name.demodulize} does not support #{association_reflection.macro} " \
|
|
60
|
+
"associations (#{association_name}). Supported: #{supported_macros.join(", ")}."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if association_reflection.polymorphic?
|
|
64
|
+
raise ArgumentError, "#{self.class.name.demodulize} does not support polymorphic associations (#{association_name})."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def association_name = "#{field.object.class.name}##{association_reflection.name}"
|
|
69
|
+
|
|
70
|
+
def build_attributes
|
|
71
|
+
super
|
|
72
|
+
if @group_method
|
|
73
|
+
raise ArgumentError, "#{self.class.name.demodulize} does not support group_method: (#{association_name})."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Typeahead drives a slim-select dropdown; a rendered list has no
|
|
77
|
+
# search box to wire it to.
|
|
78
|
+
@typeahead_option = false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def render_choices
|
|
82
|
+
if choices.values.empty?
|
|
83
|
+
p(class: "pu-hint") { Plutonium::Translation.t("plutonium.ui.form.association_choices.empty") }
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
choices.each.with_index do |(value, text), index|
|
|
88
|
+
render_choice(value:, text:, id: "#{attributes[:id]}_#{index}", checked: selected?(value), autofocus: index.zero? && attributes[:autofocus])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_choice(value:, text:, id:, checked:, autofocus: false)
|
|
93
|
+
label(for: id, class: themed(:association_choice_label, field)) do
|
|
94
|
+
input(
|
|
95
|
+
type: control_type,
|
|
96
|
+
id:,
|
|
97
|
+
name: attributes[:name],
|
|
98
|
+
value:,
|
|
99
|
+
checked:,
|
|
100
|
+
required: control_required?,
|
|
101
|
+
disabled: attributes[:disabled],
|
|
102
|
+
form: attributes[:form],
|
|
103
|
+
autofocus:,
|
|
104
|
+
class: themed(control_theme, field)
|
|
105
|
+
)
|
|
106
|
+
span { text }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def render_hidden_input
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render_blank_choice
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def include_blank? = false
|
|
117
|
+
|
|
118
|
+
def control_required? = false
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# A belongs_to / has_one association rendered as one radio button per
|
|
8
|
+
# authorized record (`input :category, as: :association_radio_buttons`).
|
|
9
|
+
#
|
|
10
|
+
# An optional association also gets a blank "None" choice, since a
|
|
11
|
+
# radio group can't otherwise be cleared once something is picked.
|
|
12
|
+
# `include_blank: false` drops it. A required association never gets
|
|
13
|
+
# one: a checked "None" would satisfy the group's `required`.
|
|
14
|
+
class SecureAssociationRadioButtons < SecureAssociationChoices
|
|
15
|
+
protected
|
|
16
|
+
|
|
17
|
+
def control_type = :radio
|
|
18
|
+
|
|
19
|
+
def control_theme = :association_radio_button
|
|
20
|
+
|
|
21
|
+
def supported_macros = %i[belongs_to has_one]
|
|
22
|
+
|
|
23
|
+
def group_role = "radiogroup"
|
|
24
|
+
|
|
25
|
+
def include_blank?
|
|
26
|
+
return false if attributes[:required]
|
|
27
|
+
|
|
28
|
+
@include_blank != false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `required` on any radio in a group makes picking one of them required.
|
|
32
|
+
def control_required? = attributes[:required]
|
|
33
|
+
|
|
34
|
+
def render_blank_choice
|
|
35
|
+
render_choice(value: "", text: blank_choice_text, id: "#{attributes[:id]}_blank", checked: singular_field_value.nil?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def blank_choice_text
|
|
39
|
+
field.placeholder.presence || Plutonium::Translation.t("plutonium.ui.form.association_choices.none")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Switch-styled {SecureAssociationCheckboxes}
|
|
8
|
+
# (`input :tags, as: :association_toggles`). Same behavior; only the
|
|
9
|
+
# `.pu-toggle` styling differs.
|
|
10
|
+
class SecureAssociationToggles < SecureAssociationCheckboxes
|
|
11
|
+
protected
|
|
12
|
+
|
|
13
|
+
def control_theme = :association_toggle
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -149,7 +149,21 @@ module Plutonium
|
|
|
149
149
|
polymorphic_association: :association,
|
|
150
150
|
valid_polymorphic_association: :valid_association,
|
|
151
151
|
invalid_polymorphic_association: :invalid_association,
|
|
152
|
-
neutral_polymorphic_association: :neutral_association
|
|
152
|
+
neutral_polymorphic_association: :neutral_association,
|
|
153
|
+
|
|
154
|
+
# List-style association variants (`as: :association_checkboxes`,
|
|
155
|
+
# `:association_toggles`, `:association_radio_buttons`). The bare key
|
|
156
|
+
# styles the list; `association_<control>` styles each control.
|
|
157
|
+
association_checkboxes: "flex flex-col gap-2",
|
|
158
|
+
association_toggles: "flex flex-col gap-2",
|
|
159
|
+
association_radio_buttons: "flex flex-col gap-2",
|
|
160
|
+
association_choice_label: "inline-flex items-center gap-2 text-sm text-[var(--pu-text)] cursor-pointer",
|
|
161
|
+
association_checkbox: :checkbox,
|
|
162
|
+
invalid_association_checkbox: :invalid_boolean,
|
|
163
|
+
association_toggle: :toggle,
|
|
164
|
+
invalid_association_toggle: :invalid_toggle,
|
|
165
|
+
association_radio_button: :radio_button,
|
|
166
|
+
invalid_association_radio_button: "pu-radio pu-input-invalid"
|
|
153
167
|
})
|
|
154
168
|
end
|
|
155
169
|
end
|
|
@@ -105,13 +105,21 @@ module Plutonium
|
|
|
105
105
|
value = field_value(slots[:image])
|
|
106
106
|
|
|
107
107
|
if size == :cover
|
|
108
|
-
# Cover is a full-width banner, not an avatar
|
|
109
|
-
#
|
|
108
|
+
# Cover is a full-width banner, not an avatar, so a record with no
|
|
109
|
+
# image gets no generated stand-in. It still gets the banner's
|
|
110
|
+
# space, as an empty slot elsewhere gets its em-dash: without it the
|
|
111
|
+
# card is a banner shorter than its neighbours, and the row stretches
|
|
112
|
+
# it into a block of blank card under its text.
|
|
110
113
|
src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
|
|
111
|
-
return unless src
|
|
112
114
|
|
|
113
115
|
div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
114
|
-
|
|
116
|
+
if src
|
|
117
|
+
img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
|
|
118
|
+
else
|
|
119
|
+
div(class: "w-full h-full flex items-center justify-center text-[var(--pu-text-subtle)]", aria_hidden: "true") do
|
|
120
|
+
render Phlex::TablerIcons::Photo.new(class: "w-8 h-8")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
115
123
|
end
|
|
116
124
|
else
|
|
117
125
|
# Small avatar slot: render the resolved image, or Avatar's generic
|
|
@@ -43,6 +43,8 @@ module Plutonium
|
|
|
43
43
|
"running" => "info",
|
|
44
44
|
"completed" => "success",
|
|
45
45
|
"completed_with_errors" => "warning",
|
|
46
|
+
# The input was refused, nothing broke: see Run#rejected?.
|
|
47
|
+
"rejected" => "warning",
|
|
46
48
|
"failed" => "danger"
|
|
47
49
|
}.freeze
|
|
48
50
|
|
|
@@ -83,7 +85,9 @@ module Plutonium
|
|
|
83
85
|
div(class: "pu-card pu-run-progress", **poll_attributes) do
|
|
84
86
|
div(class: "pu-card-body space-y-4") do
|
|
85
87
|
render_status
|
|
86
|
-
|
|
88
|
+
# A rejected run never started: an empty progress line beside
|
|
89
|
+
# "Nothing was processed" would say the same thing twice.
|
|
90
|
+
run.rejected? ? render_rejected : render_progress
|
|
87
91
|
render_failures if run.errors_log.any?
|
|
88
92
|
end
|
|
89
93
|
end
|
|
@@ -123,15 +127,24 @@ module Plutonium
|
|
|
123
127
|
# The count rides alongside the badge rather than only in the list
|
|
124
128
|
# below: a partially-applied :continue run ends as "completed", and
|
|
125
129
|
# the one thing a reader must not be able to miss is that it did not
|
|
126
|
-
# do everything it was asked to.
|
|
130
|
+
# do everything it was asked to. Muted, because the alert below
|
|
131
|
+
# carries the colour (danger or warning) for what the problems mean.
|
|
127
132
|
if run.error_count.positive?
|
|
128
|
-
span(class: "text-xs font-medium text-[var(--pu-text-
|
|
133
|
+
span(class: "text-xs font-medium text-[var(--pu-text-muted)]") do
|
|
129
134
|
plain t("plutonium.async.progress.error_count", count: run.error_count)
|
|
130
135
|
end
|
|
131
136
|
end
|
|
132
137
|
end
|
|
133
138
|
end
|
|
134
139
|
|
|
140
|
+
# In place of the progress line: "0 of 50 targets" reads as a run that
|
|
141
|
+
# died at its first target. A rejected run never started, and says so.
|
|
142
|
+
def render_rejected
|
|
143
|
+
div(class: "text-xs text-[var(--pu-text-muted)]") do
|
|
144
|
+
plain t("plutonium.async.progress.rejected")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
135
148
|
# NEVER rendered without {#render_status} above it. After a refused
|
|
136
149
|
# partial batch (:halt / :transactional), progress_done counts targets
|
|
137
150
|
# DISPOSITIONED, not applied — so a run that performed nothing at all can
|
|
@@ -201,19 +214,20 @@ module Plutonium
|
|
|
201
214
|
((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
|
|
202
215
|
end
|
|
203
216
|
|
|
217
|
+
# The framework's inline alert, so the problems read like every other
|
|
218
|
+
# message Plutonium shows rather than as a panel of their own.
|
|
204
219
|
def render_failures
|
|
205
|
-
div(class: "
|
|
206
|
-
|
|
207
|
-
plain
|
|
208
|
-
end
|
|
209
|
-
ul(class: "pu-run-progress-failures text-xs space-y-1") do
|
|
210
|
-
run.errors_log.each do |entry|
|
|
211
|
-
li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
|
|
212
|
-
end
|
|
220
|
+
div(class: "pu-alert pu-alert-#{failures_variant} pu-run-progress-failures", role: "alert") do
|
|
221
|
+
ul(class: "pu-alert-message") do
|
|
222
|
+
run.errors_log.each { |entry| li { plain failure_text(entry) } }
|
|
213
223
|
end
|
|
214
224
|
end
|
|
215
225
|
end
|
|
216
226
|
|
|
227
|
+
# Danger only for a run that broke. Refused input (rejected) and partial
|
|
228
|
+
# application (completed_with_errors) are warnings, matching the badge.
|
|
229
|
+
def failures_variant = (run.outcome == "failed") ? "danger" : "warning"
|
|
230
|
+
|
|
217
231
|
# A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
|
|
218
232
|
# not a target whose id happens to be blank.
|
|
219
233
|
def failure_text(entry)
|
|
@@ -4,7 +4,9 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Interaction
|
|
6
6
|
module Async
|
|
7
|
-
# Lists the runs currently working on THIS resource, above its collection
|
|
7
|
+
# Lists the runs currently working on THIS resource, above its collection,
|
|
8
|
+
# followed by the viewer's own runs that finished within
|
|
9
|
+
# +recent_runs_window+ (see Page::Index#render_running_banner).
|
|
8
10
|
#
|
|
9
11
|
# == Why it exists
|
|
10
12
|
#
|
|
@@ -16,8 +18,9 @@ module Plutonium
|
|
|
16
18
|
#
|
|
17
19
|
# +target_type+ is a real column rather than a key inside the +options+
|
|
18
20
|
# JSON precisely so this predicate can be indexed — see the migration's own
|
|
19
|
-
# comment. An opaque run
|
|
20
|
-
#
|
|
21
|
+
# comment. An opaque run dispatched from a resource still records that
|
|
22
|
+
# resource as its +target_type+, so it shows on that resource's index; one
|
|
23
|
+
# dispatched from nowhere has none and appears on no resource index.
|
|
21
24
|
#
|
|
22
25
|
# == Why it can render nothing
|
|
23
26
|
#
|
|
@@ -51,13 +54,26 @@ module Plutonium
|
|
|
51
54
|
# own page renders the pair.
|
|
52
55
|
span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
|
|
53
56
|
plain " "
|
|
54
|
-
|
|
57
|
+
run.in_progress? ? render_state(run) : render_outcome(run)
|
|
55
58
|
end
|
|
56
59
|
a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
|
|
57
|
-
plain t("plutonium.async.banner.view_progress")
|
|
60
|
+
plain t(run.in_progress? ? "plutonium.async.banner.view_progress" : "plutonium.async.banner.view_run")
|
|
58
61
|
end
|
|
59
62
|
end
|
|
60
63
|
end
|
|
64
|
+
|
|
65
|
+
def render_state(run)
|
|
66
|
+
span(class: "text-[var(--pu-text-muted)]") { plain(Plutonium::Translation.value_label(run.class, :state, run.state) || run.state.humanize) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A finished run is listed for what it ended as, so outcome rather than
|
|
70
|
+
# state ("completed" hides a partial :continue run; "failed" hides a
|
|
71
|
+
# rejection), in the progress panel's colours.
|
|
72
|
+
def render_outcome(run)
|
|
73
|
+
span(class: "pu-badge pu-badge-#{RunProgress::BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
|
|
74
|
+
plain(Plutonium::Translation.value_label(run.class, :outcome, run.outcome) || run.outcome.humanize)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
61
77
|
end
|
|
62
78
|
end
|
|
63
79
|
end
|
|
@@ -244,15 +244,6 @@ module Plutonium
|
|
|
244
244
|
"sha384-uqD/OYCNfagd1EgXMgl5QedTD5K+B3e9b8GYo/41t7+Serf7CBxvl+tU1gHd+qd1",
|
|
245
245
|
crossorigin: "anonymous"
|
|
246
246
|
)
|
|
247
|
-
# EasyMDE's toolbar uses Font Awesome 4 class names (renamed in 5+).
|
|
248
|
-
link(
|
|
249
|
-
rel: "stylesheet",
|
|
250
|
-
href:
|
|
251
|
-
"https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css",
|
|
252
|
-
integrity:
|
|
253
|
-
"sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN",
|
|
254
|
-
crossorigin: "anonymous"
|
|
255
|
-
)
|
|
256
247
|
end
|
|
257
248
|
|
|
258
249
|
def render_scripts
|
|
@@ -4,6 +4,11 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Page
|
|
6
6
|
class Index < Base
|
|
7
|
+
# How many recently finished runs the running banner lists at most. It
|
|
8
|
+
# sits above the collection; a burst of dispatches must not push the
|
|
9
|
+
# collection off the screen. The run index has the rest.
|
|
10
|
+
RECENT_RUNS_LIMIT = 5
|
|
11
|
+
|
|
7
12
|
# Cookie name carrying a per-resource view preference. Single
|
|
8
13
|
# source of truth — Table::Resource, Grid::Resource, and the
|
|
9
14
|
# Stimulus view-switcher controller all read from here. Underscored
|
|
@@ -101,12 +106,26 @@ module Plutonium
|
|
|
101
106
|
# a URL for a route that doesn't exist here.
|
|
102
107
|
return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
relation:
|
|
109
|
+
run_class = Plutonium::Interaction::Async::Run
|
|
110
|
+
in_progress = authorized_resource_scope(
|
|
111
|
+
run_class, relation: run_class.for_target(resource_class).in_progress
|
|
112
|
+
).to_a
|
|
113
|
+
|
|
114
|
+
# The current user's own runs that settled moments ago. A dispatch
|
|
115
|
+
# sends them back here, and a short run (or one its pre_check rejected)
|
|
116
|
+
# finishes before this page renders, so "in progress" alone would show
|
|
117
|
+
# them nothing. Only their own: someone else's finished run is not news.
|
|
118
|
+
recent = authorized_resource_scope(
|
|
119
|
+
run_class,
|
|
120
|
+
relation: run_class.for_target(resource_class)
|
|
121
|
+
.where(initiator: current_user)
|
|
122
|
+
.finished_since(Plutonium.configuration.async_interactions.recent_runs_window.ago)
|
|
123
|
+
.order(finished_at: :desc)
|
|
124
|
+
.limit(RECENT_RUNS_LIMIT)
|
|
107
125
|
).to_a
|
|
108
126
|
|
|
109
|
-
|
|
127
|
+
# uniq: a run that finishes between the two queries is in both.
|
|
128
|
+
render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: (in_progress + recent).uniq)
|
|
110
129
|
end
|
|
111
130
|
|
|
112
131
|
def selected_view
|
|
@@ -32,7 +32,7 @@ module Plutonium
|
|
|
32
32
|
|
|
33
33
|
def render_pill(key)
|
|
34
34
|
active = current_query_object.selected_scope.to_s == key.to_s
|
|
35
|
-
label =
|
|
35
|
+
label = current_query_object.scope_label(key)
|
|
36
36
|
|
|
37
37
|
a(
|
|
38
38
|
id: "#{key}-scope",
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/plutonium.gemspec
CHANGED
|
@@ -60,7 +60,7 @@ Gem::Specification.new do |spec|
|
|
|
60
60
|
spec.add_dependency "phlex-rails"
|
|
61
61
|
spec.add_dependency "phlex-tabler_icons"
|
|
62
62
|
spec.add_dependency "phlexi-field", ">= 0.2.0"
|
|
63
|
-
spec.add_dependency "phlexi-form", ">= 0.14.
|
|
63
|
+
spec.add_dependency "phlexi-form", ">= 0.14.4"
|
|
64
64
|
spec.add_dependency "phlexi-table", ">= 0.2.0"
|
|
65
65
|
spec.add_dependency "phlexi-display", ">= 0.2.0"
|
|
66
66
|
spec.add_dependency "phlexi-menu", ">= 0.4.1"
|
data/src/css/components.css
CHANGED
|
@@ -1222,6 +1222,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
1222
1222
|
margin-left: var(--pu-drag-marker-gap, 4px);
|
|
1223
1223
|
}
|
|
1224
1224
|
|
|
1225
|
+
/* The failures on an async run's progress panel: an inline alert holding a
|
|
1226
|
+
list, so it sits flush in the card and the list keeps its bullets. */
|
|
1227
|
+
.pu-run-progress-failures {
|
|
1228
|
+
@apply mb-0 items-start;
|
|
1229
|
+
}
|
|
1230
|
+
.pu-run-progress-failures .pu-alert-message {
|
|
1231
|
+
@apply ms-0 ps-4 list-disc space-y-1;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1225
1234
|
/* Indeterminate progress for an async run with no denominator (opaque work).
|
|
1226
1235
|
A short segment sweeping the track: the motion is what says "alive", which a
|
|
1227
1236
|
static bar at any width cannot, and a full-width bar would misread as done. */
|