plutonium 0.66.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +17 -10
- data/.claude/skills/plutonium-ui/SKILL.md +13 -3
- data/CHANGELOG.md +35 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +56 -3
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +32 -32
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/brakeman.ignore +1 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/js.yml +4 -0
- data/config/locales/en/ui.yml +19 -0
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/resource/definition.md +14 -7
- data/docs/reference/resource/query.md +11 -1
- data/docs/reference/ui/displays.md +19 -2
- data/docs/reference/ui/forms.md +77 -4
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +13 -3
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +10 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +10 -1
- data/lib/plutonium/auth/rodauth.rb +7 -0
- data/lib/plutonium/core/controller.rb +0 -2
- 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/helpers/content_helper.rb +0 -3
- data/lib/plutonium/query/filter.rb +2 -1
- data/lib/plutonium/query/filters/tags.rb +36 -0
- 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/component/resolves_tags.rb +13 -0
- 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/display/base.rb +50 -2
- data/lib/plutonium/ui/display/components/badge.rb +1 -1
- data/lib/plutonium/ui/display/components/binary.rb +20 -0
- data/lib/plutonium/ui/display/components/boolean.rb +1 -1
- data/lib/plutonium/ui/display/components/code.rb +23 -0
- data/lib/plutonium/ui/display/components/color.rb +14 -6
- data/lib/plutonium/ui/display/components/duration.rb +31 -0
- data/lib/plutonium/ui/display/components/file_size.rb +22 -0
- data/lib/plutonium/ui/display/components/key_value.rb +37 -0
- data/lib/plutonium/ui/display/components/list.rb +33 -0
- data/lib/plutonium/ui/display/components/number.rb +32 -0
- data/lib/plutonium/ui/display/components/progress.rb +36 -0
- data/lib/plutonium/ui/display/components/rating.rb +36 -0
- data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
- data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
- data/lib/plutonium/ui/display/components/tags.rb +18 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +24 -2
- data/lib/plutonium/ui/form/base.rb +50 -1
- data/lib/plutonium/ui/form/components/binary.rb +40 -0
- data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
- data/lib/plutonium/ui/form/components/list.rb +46 -0
- data/lib/plutonium/ui/form/components/rating.rb +51 -0
- data/lib/plutonium/ui/form/components/rich_text.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/components/tags.rb +45 -0
- data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
- data/lib/plutonium/ui/form/components/uppy.rb +1 -1
- data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
- data/lib/plutonium/ui/form/theme.rb +34 -4
- data/lib/plutonium/ui/grid/card.rb +25 -5
- data/lib/plutonium/ui/layout/base.rb +10 -1
- data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
- data/lib/plutonium/ui/options/secret_field.rb +32 -0
- data/lib/plutonium/ui/options/tags_field.rb +23 -0
- data/lib/plutonium/ui/page/dashboard.rb +1 -0
- data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
- data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
- data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
- data/lib/plutonium/ui/table/display_theme.rb +30 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- data/src/css/components.css +43 -0
- data/src/css/flatpickr.css +91 -0
- data/src/js/controllers/easymde_controller.js +1 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/slim_select_controller.js +32 -3
- data/src/js/controllers/timeago_controller.js +40 -0
- metadata +35 -4
|
@@ -32,7 +32,7 @@ module Plutonium
|
|
|
32
32
|
delegate :current_user, :current_scoped_entity, :scoped_to_entity?,
|
|
33
33
|
:params, :request, :controller, :current_engine,
|
|
34
34
|
:resource_url_for, :authorized_resource_scope, :allowed_to?, :policy_for,
|
|
35
|
-
:registered_resources, :root_path,
|
|
35
|
+
:registered_resources, :root_path, :current_package,
|
|
36
36
|
to: :view_context
|
|
37
37
|
|
|
38
38
|
class << self
|
|
@@ -92,6 +92,75 @@ module Plutonium
|
|
|
92
92
|
card
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# A filter's chosen value for this request, or its default.
|
|
96
|
+
# @raise [ArgumentError] when the dashboard declares no such filter
|
|
97
|
+
def filter_value(key)
|
|
98
|
+
filter_values.fetch(key.to_sym) { raise ArgumentError, "#{self.class.name} declares no filter #{key.inspect}" }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Every filter's chosen value, keyed by filter: what the page carries
|
|
102
|
+
# onto each card's frame URL. Read once per request.
|
|
103
|
+
def filter_values
|
|
104
|
+
@filter_values ||= self.class.filters.to_h { |filter| [filter.key, filter.value_from(self)] }.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A filter's value => label pairs for this request, built once.
|
|
108
|
+
def filter_choices(filter)
|
|
109
|
+
(@filter_choices ||= {})[filter.key] ||= filter.choices_for(self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The resource's authorized scope with the scope chosen by its
|
|
113
|
+
# `filter ..., scopes:` applied by the index's own query object, so a
|
|
114
|
+
# card's number matches the index list {#filtered_url} opens. Without
|
|
115
|
+
# such a filter, or with "All" chosen, the authorized scope as is.
|
|
116
|
+
#
|
|
117
|
+
# The scope block runs against this dashboard's controller, so it has
|
|
118
|
+
# `current_user` and the portal's helpers, but not a resource
|
|
119
|
+
# controller's (`current_parent`, `current_definition`, ...).
|
|
120
|
+
def filtered(resource_class)
|
|
121
|
+
relation = authorized_resource_scope(resource_class)
|
|
122
|
+
name = chosen_scope(resource_class)
|
|
123
|
+
return relation unless name
|
|
124
|
+
|
|
125
|
+
scope_query_for(resource_class).scope_definitions.fetch(name).apply(relation, context: controller)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The resource's index, on the scope chosen by its `filter ..., scopes:`.
|
|
129
|
+
# "All" is sent explicitly when the definition has a `default_scope`, or
|
|
130
|
+
# the index would apply that instead.
|
|
131
|
+
def filtered_url(resource_class)
|
|
132
|
+
name = chosen_scope(resource_class)
|
|
133
|
+
scope = name || ("" if resource_definition_for(resource_class).default_scope)
|
|
134
|
+
options = scope.nil? ? {} : {q: {scope:}}
|
|
135
|
+
resource_url_for(resource_class, parent: nil, **options)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The resource's definition in this dashboard's portal, falling back to
|
|
139
|
+
# the base one, as a resource controller resolves it.
|
|
140
|
+
def resource_definition_for(resource_class)
|
|
141
|
+
(@resource_definitions ||= {})[resource_class] ||= begin
|
|
142
|
+
portal_definition = current_package && "#{current_package}::#{resource_class}Definition".safe_constantize
|
|
143
|
+
(portal_definition || "#{resource_class}Definition".constantize).new
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The index's query object for the resource, holding its definition's
|
|
148
|
+
# scopes: how each applies, whether it shows, and its label. It reads
|
|
149
|
+
# no params and builds no URLs here.
|
|
150
|
+
def scope_query_for(resource_class)
|
|
151
|
+
(@scope_queries ||= {})[resource_class] ||=
|
|
152
|
+
Plutonium::Resource::QueryObject.new(resource_class, {}, nil) do |query|
|
|
153
|
+
resource_definition_for(resource_class).defined_scopes.each do |name, scope|
|
|
154
|
+
query.define_scope name, scope[:block], **scope[:options]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The toolbar links whose `condition:` passes for this request.
|
|
160
|
+
def visible_links
|
|
161
|
+
self.class.links.select { |link| link.visible?(self) }
|
|
162
|
+
end
|
|
163
|
+
|
|
95
164
|
# The refresh interval for a card: its own, else the dashboard default.
|
|
96
165
|
# A card declared `refresh: false` never refreshes.
|
|
97
166
|
def refresh_for(card)
|
|
@@ -106,6 +175,16 @@ module Plutonium
|
|
|
106
175
|
|
|
107
176
|
private
|
|
108
177
|
|
|
178
|
+
# The scope name a `filter ..., scopes: resource_class` has chosen, or
|
|
179
|
+
# nil for "All" or when the dashboard has no such filter.
|
|
180
|
+
def chosen_scope(resource_class)
|
|
181
|
+
filter = self.class.filters.find { |candidate| candidate.resource_class == resource_class }
|
|
182
|
+
return unless filter
|
|
183
|
+
|
|
184
|
+
value = filter_value(filter.key)
|
|
185
|
+
value unless value == Filter::ALL
|
|
186
|
+
end
|
|
187
|
+
|
|
109
188
|
def t(key, **options)
|
|
110
189
|
Plutonium::Translation.t(key, **options)
|
|
111
190
|
end
|
|
@@ -9,6 +9,8 @@ module Plutonium
|
|
|
9
9
|
# Built by {DSL} at class-load and frozen; everything request-dependent
|
|
10
10
|
# (label translation, `condition:`, `href:`) is resolved per render.
|
|
11
11
|
class Card
|
|
12
|
+
include Evaluation
|
|
13
|
+
|
|
12
14
|
KINDS = %i[metric chart custom].freeze
|
|
13
15
|
|
|
14
16
|
# The dashboard grid is 12 columns wide, so halves, thirds and quarters
|
|
@@ -21,11 +23,14 @@ module Plutonium
|
|
|
21
23
|
|
|
22
24
|
METRIC_FORMATS = %i[number currency percentage human].freeze
|
|
23
25
|
METRIC_POSITIVE = %i[up down].freeze
|
|
26
|
+
# How a metric's change reads: a percent of the previous value, or the
|
|
27
|
+
# difference in points (for a value that is itself a percentage).
|
|
28
|
+
METRIC_CHANGE_UNITS = %i[percent points].freeze
|
|
24
29
|
CHART_TYPES = %i[line area column bar pie donut scatter].freeze
|
|
25
30
|
|
|
26
31
|
# Options every kind accepts.
|
|
27
32
|
COMMON_OPTIONS = %i[label description icon span lazy refresh condition href].freeze
|
|
28
|
-
METRIC_OPTIONS = %i[format precision unit prefix suffix positive change_label].freeze
|
|
33
|
+
METRIC_OPTIONS = %i[format precision unit prefix suffix positive change_label change_unit].freeze
|
|
29
34
|
CHART_OPTIONS = %i[type height].freeze
|
|
30
35
|
|
|
31
36
|
attr_reader :key, :kind, :icon, :span, :refresh, :condition, :href, :block, :options, :dashboard_class
|
|
@@ -87,14 +92,7 @@ module Plutonium
|
|
|
87
92
|
# the page AND the card endpoint, so a hidden card cannot be fetched
|
|
88
93
|
# directly.
|
|
89
94
|
def visible?(dashboard)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
result = case condition
|
|
93
|
-
when Symbol then dashboard.send(condition)
|
|
94
|
-
when Proc then evaluate_on(dashboard, condition)
|
|
95
|
-
else condition
|
|
96
|
-
end
|
|
97
|
-
result ? true : false
|
|
95
|
+
condition_passes?(dashboard, condition)
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
# The card's link target for this request, if any.
|
|
@@ -135,10 +133,6 @@ module Plutonium
|
|
|
135
133
|
|
|
136
134
|
private
|
|
137
135
|
|
|
138
|
-
def evaluate_on(dashboard, callable)
|
|
139
|
-
callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
136
|
def validate_span!(span)
|
|
143
137
|
return GRID_COLUMNS if span == :full
|
|
144
138
|
return span if span.is_a?(Integer) && span.between?(1, GRID_COLUMNS)
|
|
@@ -178,6 +172,11 @@ module Plutonium
|
|
|
178
172
|
raise ArgumentError, "metric #{key.inspect}: positive must be :up or :down, got #{positive.inspect}"
|
|
179
173
|
end
|
|
180
174
|
|
|
175
|
+
change_unit = specific.fetch(:change_unit, :percent)
|
|
176
|
+
unless METRIC_CHANGE_UNITS.include?(change_unit)
|
|
177
|
+
raise ArgumentError, "metric #{key.inspect}: change_unit must be one of #{METRIC_CHANGE_UNITS.inspect}, got #{change_unit.inspect}"
|
|
178
|
+
end
|
|
179
|
+
|
|
181
180
|
specific.merge(positive:)
|
|
182
181
|
end
|
|
183
182
|
|
|
@@ -31,6 +31,7 @@ module Plutonium
|
|
|
31
31
|
# GET the mount — the full page.
|
|
32
32
|
def show
|
|
33
33
|
authorize_dashboard!
|
|
34
|
+
ensure_filters_clear_of_path_params!
|
|
34
35
|
set_page_title(current_dashboard_class.label)
|
|
35
36
|
render Plutonium::UI::Page::Dashboard.new(dashboard: current_dashboard)
|
|
36
37
|
end
|
|
@@ -38,6 +39,7 @@ module Plutonium
|
|
|
38
39
|
# GET <mount>/cards/:card — one card, wrapped in its turbo frame.
|
|
39
40
|
def card
|
|
40
41
|
authorize_dashboard!
|
|
42
|
+
ensure_filters_clear_of_path_params!
|
|
41
43
|
card = current_dashboard.visible_card!(params[:card])
|
|
42
44
|
render Plutonium::UI::Dashboard::Frame.new(dashboard: current_dashboard, card:)
|
|
43
45
|
end
|
|
@@ -77,24 +79,40 @@ module Plutonium
|
|
|
77
79
|
)
|
|
78
80
|
end
|
|
79
81
|
|
|
82
|
+
# A path segment of the mount (the tenant on a path-scoped portal, a
|
|
83
|
+
# `(:locale)` scope) belongs to the route, so it cannot be refused when
|
|
84
|
+
# the filter is declared. A filter under that key would read the
|
|
85
|
+
# segment's value and overwrite it on every card's frame URL. The card
|
|
86
|
+
# route's own segments count even when optional and absent from this
|
|
87
|
+
# request, since its URL helper would fill them.
|
|
88
|
+
def ensure_filters_clear_of_path_params!
|
|
89
|
+
segments = (request.path_parameters.keys.map(&:to_sym) | dashboard_card_route.parts) - %i[controller action]
|
|
90
|
+
collision = current_dashboard_class.filters.map(&:key) & segments
|
|
91
|
+
return if collision.empty?
|
|
92
|
+
|
|
93
|
+
raise ArgumentError,
|
|
94
|
+
"#{current_dashboard_class.name} filter #{collision.first.inspect} collides with a path segment of the dashboard's route, pick another name"
|
|
95
|
+
end
|
|
96
|
+
|
|
80
97
|
# The card endpoint's path, threading the entity scope segment through so
|
|
81
|
-
# the frame stays inside the tenant
|
|
82
|
-
#
|
|
98
|
+
# the frame stays inside the tenant, and the dashboard's filter values
|
|
99
|
+
# through as query parameters so the card reads what the page read.
|
|
100
|
+
# Resolved by the route's `dashboard_class` default, so `at:` / `as:` and
|
|
101
|
+
# a scope prefix are honoured.
|
|
83
102
|
def dashboard_card_path(card)
|
|
84
|
-
options =
|
|
103
|
+
options = current_dashboard.filter_values.merge(card: card.key)
|
|
85
104
|
options[scoped_entity_param_key] = params[scoped_entity_param_key] if scoped_to_entity?
|
|
86
105
|
current_engine.routes.url_helpers.public_send(dashboard_card_route_helper, **options)
|
|
87
106
|
end
|
|
88
107
|
|
|
89
108
|
def dashboard_card_route_helper
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
current_engine.routes, current_dashboard_class, action: "card"
|
|
93
|
-
)
|
|
94
|
-
raise "no register_dashboard route found for #{current_dashboard_class.name}" unless name
|
|
109
|
+
:"#{dashboard_card_route.name}_path"
|
|
110
|
+
end
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
112
|
+
def dashboard_card_route
|
|
113
|
+
@dashboard_card_route ||=
|
|
114
|
+
Plutonium::Dashboard::RouteResolution.route(current_engine.routes, current_dashboard_class, action: "card") ||
|
|
115
|
+
raise("no register_dashboard route found for #{current_dashboard_class.name}")
|
|
98
116
|
end
|
|
99
117
|
end
|
|
100
118
|
end
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Plutonium
|
|
4
4
|
module Dashboard
|
|
5
|
-
# The author-facing class macros: `metric`, `chart`, `card`, `
|
|
6
|
-
# and `width`. Mixed into {Base}.
|
|
5
|
+
# The author-facing class macros: `metric`, `chart`, `card`, `filter`,
|
|
6
|
+
# `link`, `refresh` and `width`. Mixed into {Base}.
|
|
7
7
|
module DSL
|
|
8
8
|
extend ActiveSupport::Concern
|
|
9
9
|
|
|
@@ -47,6 +47,61 @@ module Plutonium
|
|
|
47
47
|
add_card(key, kind: :custom, options:, block:)
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# The declared filters, in declaration order.
|
|
51
|
+
def filters
|
|
52
|
+
@filters ||= []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The declared toolbar links, in declaration order.
|
|
56
|
+
def links
|
|
57
|
+
@links ||= []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A page-level control every card reads, such as a period or a region.
|
|
61
|
+
# The chosen value comes from the query string, is carried onto every
|
|
62
|
+
# lazy card's frame URL so each card sees what the page saw, and is
|
|
63
|
+
# read in a card block with `filter_value(key)`. The page renders the
|
|
64
|
+
# filters as a segmented control above the cards.
|
|
65
|
+
#
|
|
66
|
+
# filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days"}, default: "30"
|
|
67
|
+
# metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
|
|
68
|
+
#
|
|
69
|
+
# `scopes:` takes a resource instead of choices: "All" plus the scopes
|
|
70
|
+
# its definition declares. `filtered(Post)` then counts what the index
|
|
71
|
+
# would list, and `filtered_url(Post)` opens that list.
|
|
72
|
+
#
|
|
73
|
+
# filter :status, scopes: Post
|
|
74
|
+
# metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
|
|
75
|
+
def filter(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
76
|
+
key = key.to_sym
|
|
77
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a filter #{key.inspect}" if find_filter(key)
|
|
78
|
+
if scopes && filters.any? { |filter| filter.resource_class == scopes }
|
|
79
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a scope filter for #{scopes}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
filters << Filter.new(key, choices:, scopes:, default:, label:)
|
|
83
|
+
filters.last
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A link in the toolbar above the cards, such as the dashboard's
|
|
87
|
+
# settings or a full report. `href:` and `condition:` are evaluated on
|
|
88
|
+
# the dashboard instance, like a card's.
|
|
89
|
+
#
|
|
90
|
+
# link :settings, href: -> { resource_url_for(Setting) }, icon: Phlex::TablerIcons::Settings
|
|
91
|
+
def link(key, href:, label: nil, icon: nil, condition: nil)
|
|
92
|
+
key = key.to_sym
|
|
93
|
+
raise ArgumentError, "#{name || "dashboard"} already declares a link #{key.inspect}" if links.any? { |link| link.key == key }
|
|
94
|
+
|
|
95
|
+
links << Link.new(key, href:, label:, icon:, condition:)
|
|
96
|
+
links.last
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @return [Filter, nil]
|
|
100
|
+
def find_filter(key)
|
|
101
|
+
key = key.to_sym
|
|
102
|
+
filters.find { |filter| filter.key == key }
|
|
103
|
+
end
|
|
104
|
+
|
|
50
105
|
# Default refresh interval (seconds) for every lazy card. A card's own
|
|
51
106
|
# `refresh:` overrides it; `refresh: false` on a card opts out.
|
|
52
107
|
def refresh(seconds = UNSET)
|
|
@@ -95,6 +150,8 @@ module Plutonium
|
|
|
95
150
|
def inherited(subclass)
|
|
96
151
|
super
|
|
97
152
|
subclass.instance_variable_set(:@cards, cards.dup)
|
|
153
|
+
subclass.instance_variable_set(:@filters, filters.dup)
|
|
154
|
+
subclass.instance_variable_set(:@links, links.dup)
|
|
98
155
|
subclass.instance_variable_set(:@refresh, @refresh)
|
|
99
156
|
subclass.instance_variable_set(:@width, @width)
|
|
100
157
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# How a card or toolbar link runs its author-supplied procs and
|
|
6
|
+
# `condition:` against the dashboard instance, so both accept the same
|
|
7
|
+
# forms.
|
|
8
|
+
module Evaluation
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# A zero-arity proc runs on the dashboard instance; a proc taking an
|
|
12
|
+
# argument also receives the dashboard.
|
|
13
|
+
def evaluate_on(dashboard, callable)
|
|
14
|
+
callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `condition:` is nil (always), a proc (see {#evaluate_on}), a symbol
|
|
18
|
+
# naming a dashboard method, or a plain value.
|
|
19
|
+
def condition_passes?(dashboard, condition)
|
|
20
|
+
result = case condition
|
|
21
|
+
when nil then true
|
|
22
|
+
when Symbol then dashboard.send(condition)
|
|
23
|
+
when Proc then evaluate_on(dashboard, condition)
|
|
24
|
+
else condition
|
|
25
|
+
end
|
|
26
|
+
result ? true : false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A page-level control every card reads, declared with `filter`. Its value
|
|
6
|
+
# comes from the request's query string under the filter's key, and a value
|
|
7
|
+
# that is not one of the choices reads as the default, so a hand-edited URL
|
|
8
|
+
# can never reach a card block.
|
|
9
|
+
#
|
|
10
|
+
# The choices are either given (`choices:`) or are a resource's scopes
|
|
11
|
+
# (`scopes: Post`): "All" plus every scope the resource's definition
|
|
12
|
+
# declares, labelled and gated the way the index's scope pills are.
|
|
13
|
+
class Filter
|
|
14
|
+
# Keys a filter cannot take: the card route's own params and the options
|
|
15
|
+
# `url_for` reads, all of which the filter values are merged into when
|
|
16
|
+
# building each card's frame URL. A key that is a path segment of the
|
|
17
|
+
# mount (an entity scope, a `(:locale)` scope) is refused per request,
|
|
18
|
+
# since only the route knows it.
|
|
19
|
+
RESERVED_KEYS = %i[
|
|
20
|
+
card dashboard_class controller action format anchor params path_params
|
|
21
|
+
host protocol port subdomain domain tld_length user password
|
|
22
|
+
script_name original_script_name only_path trailing_slash
|
|
23
|
+
use_route _recall
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# The value of a scope filter's "All" choice.
|
|
27
|
+
ALL = "all"
|
|
28
|
+
|
|
29
|
+
attr_reader :key, :resource_class
|
|
30
|
+
|
|
31
|
+
# @param choices [Hash{#to_s => String, Proc}, nil] value => label, in display order
|
|
32
|
+
# @param scopes [Class, nil] a resource whose definition's scopes are the choices
|
|
33
|
+
# @param default [#to_s, nil] one of the choices; the first (or, for
|
|
34
|
+
# scopes, the definition's `default_scope`, else "All") when nil
|
|
35
|
+
def initialize(key, choices: nil, scopes: nil, default: nil, label: nil)
|
|
36
|
+
@key = key.to_sym
|
|
37
|
+
if RESERVED_KEYS.include?(@key)
|
|
38
|
+
raise ArgumentError, "filter #{@key.inspect}: the name is reserved by the card route, pick another"
|
|
39
|
+
end
|
|
40
|
+
unless choices.nil? ^ scopes.nil?
|
|
41
|
+
raise ArgumentError, "filter #{@key.inspect}: pass exactly one of choices: or scopes:"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@label = label
|
|
45
|
+
@default = default&.to_s
|
|
46
|
+
|
|
47
|
+
if scopes
|
|
48
|
+
raise ArgumentError, "filter #{@key.inspect}: scopes: takes a resource class, got #{scopes.inspect}" unless scopes.is_a?(Class)
|
|
49
|
+
|
|
50
|
+
@resource_class = scopes
|
|
51
|
+
else
|
|
52
|
+
@choices = choices.to_h.transform_keys(&:to_s).freeze
|
|
53
|
+
raise ArgumentError, "filter #{@key.inspect}: choices must not be empty" if @choices.empty?
|
|
54
|
+
|
|
55
|
+
@default ||= @choices.keys.first
|
|
56
|
+
validate_default!
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scopes? = !@resource_class.nil?
|
|
61
|
+
|
|
62
|
+
# The control's accessible name.
|
|
63
|
+
def label
|
|
64
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# value => label for this request. Labels may be lazy `t` procs. Read it
|
|
68
|
+
# through {Base#filter_choices}, which builds it once per request.
|
|
69
|
+
def choices_for(dashboard)
|
|
70
|
+
scopes? ? scope_choices(dashboard) : @choices
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An explicit `default:` must be one of the definition's scopes. A
|
|
74
|
+
# default (explicit or the definition's `default_scope`) whose
|
|
75
|
+
# `condition:` hides it from this user reads as "All", as does a
|
|
76
|
+
# `default_scope` the definition never declares as a scope.
|
|
77
|
+
def default_for(dashboard)
|
|
78
|
+
return @default unless scopes?
|
|
79
|
+
|
|
80
|
+
if @default && !dashboard.scope_query_for(resource_class).scope_definitions.key?(@default)
|
|
81
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not a scope of #{resource_class}'s definition"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
default = @default || dashboard.resource_definition_for(resource_class).default_scope&.to_s
|
|
85
|
+
dashboard.filter_choices(self).key?(default) ? default : ALL
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The chosen value for this request.
|
|
89
|
+
def value_from(dashboard)
|
|
90
|
+
value = dashboard.params[key].to_s
|
|
91
|
+
dashboard.filter_choices(self).key?(value) ? value : default_for(dashboard)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def scope_choices(dashboard)
|
|
97
|
+
query = dashboard.scope_query_for(resource_class)
|
|
98
|
+
if query.scope_definitions.key?(ALL)
|
|
99
|
+
raise ArgumentError, "filter #{key.inspect}: #{resource_class}'s definition declares a scope named :#{ALL}, which is the filter's \"All\" choice"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
visible = query.scope_definitions.keys.select { |name| query.scope_visible?(name, dashboard.view_context) }
|
|
103
|
+
{ALL => -> { Plutonium::Translation.t("plutonium.ui.table.all") }}
|
|
104
|
+
.merge(visible.to_h { |name| [name, query.scope_label(name)] })
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_default!
|
|
108
|
+
return if @choices.key?(@default)
|
|
109
|
+
|
|
110
|
+
raise ArgumentError, "filter #{key.inspect}: default #{@default.inspect} is not one of #{@choices.keys.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Dashboard
|
|
5
|
+
# A link in the dashboard's toolbar, declared with `link`: the way to a
|
|
6
|
+
# related page such as the dashboard's settings or a full report.
|
|
7
|
+
class Link
|
|
8
|
+
include Evaluation
|
|
9
|
+
|
|
10
|
+
attr_reader :key, :icon
|
|
11
|
+
|
|
12
|
+
def initialize(key, href:, label: nil, icon: nil, condition: nil)
|
|
13
|
+
@key = key.to_sym
|
|
14
|
+
@href = href
|
|
15
|
+
@label = label
|
|
16
|
+
@icon = icon
|
|
17
|
+
@condition = condition
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def label
|
|
21
|
+
Plutonium::Translation.resolve(@label) || key.to_s.humanize
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Evaluated like a card's `href:`.
|
|
25
|
+
def href_for(dashboard)
|
|
26
|
+
@href.is_a?(Proc) ? evaluate_on(dashboard, @href) : @href
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Evaluated like a card's `condition:`.
|
|
30
|
+
def visible?(dashboard)
|
|
31
|
+
condition_passes?(dashboard, @condition)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -17,13 +17,17 @@ module Plutonium
|
|
|
17
17
|
# @param action [String, Symbol] "show" or "card"
|
|
18
18
|
# @return [Symbol, nil] the route name (e.g. :sales_dashboard_card)
|
|
19
19
|
def route_name(route_set, dashboard_class, action:)
|
|
20
|
-
route
|
|
20
|
+
route(route_set, dashboard_class, action:)&.name&.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [ActionDispatch::Journey::Route, nil] the named route itself
|
|
24
|
+
def route(route_set, dashboard_class, action:)
|
|
25
|
+
route_set.routes.find do |r|
|
|
21
26
|
d = r.defaults
|
|
22
27
|
r.name.present? &&
|
|
23
28
|
d[:action].to_s == action.to_s &&
|
|
24
29
|
d[:dashboard_class].to_s == dashboard_class.name
|
|
25
30
|
end
|
|
26
|
-
route&.name&.to_sym
|
|
27
31
|
end
|
|
28
32
|
end
|
|
29
33
|
end
|
|
@@ -10,7 +10,8 @@ module Plutonium
|
|
|
10
10
|
def lookup(type)
|
|
11
11
|
return type if type.is_a?(Class) && type < Filter
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# camelize, not classify: classify singularizes (`:tags` -> `Tag`).
|
|
14
|
+
class_name = "Plutonium::Query::Filters::#{type.to_s.camelize}"
|
|
14
15
|
class_name.constantize
|
|
15
16
|
rescue NameError
|
|
16
17
|
raise ArgumentError, "Unknown filter type: #{type}. Expected #{class_name} to exist."
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Plutonium
|
|
2
|
+
module Query
|
|
3
|
+
module Filters
|
|
4
|
+
# Filter by a no_fly_list `has_tags` context, through the gem's own
|
|
5
|
+
# query scopes.
|
|
6
|
+
#
|
|
7
|
+
# @example Records with any of the chosen tags
|
|
8
|
+
# filter :labels, with: :tags
|
|
9
|
+
#
|
|
10
|
+
# @example Records with every chosen tag
|
|
11
|
+
# filter :labels, with: :tags, match: :all
|
|
12
|
+
#
|
|
13
|
+
class Tags < Filter
|
|
14
|
+
def initialize(match: :any, **)
|
|
15
|
+
super(**)
|
|
16
|
+
@match = match
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def apply(scope, value:)
|
|
20
|
+
tags = Array(value).compact_blank
|
|
21
|
+
return scope if tags.empty?
|
|
22
|
+
|
|
23
|
+
scope.public_send(:"with_#{@match}_#{key}", tags)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def humanize_value(value)
|
|
27
|
+
Array(value).compact_blank.join(", ")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def customize_inputs
|
|
31
|
+
input :value, as: Plutonium::UI::Form::Components::TagsFilter, context: key
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -36,6 +36,12 @@ module Plutonium
|
|
|
36
36
|
Plutonium::Translation.label_for(:filter, resource_class, name) || name.to_s.humanize
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Display label for a scope: the plutonium.scopes.<model>.<name>
|
|
40
|
+
# convention, else the humanized name.
|
|
41
|
+
def scope_label(name)
|
|
42
|
+
Plutonium::Translation.label_for(:scope, resource_class, name) || name.to_s.humanize
|
|
43
|
+
end
|
|
44
|
+
|
|
39
45
|
def initialize(resource_class, params, request_path, &)
|
|
40
46
|
@resource_class = resource_class
|
|
41
47
|
@params = params
|
|
@@ -114,6 +114,11 @@ module Plutonium
|
|
|
114
114
|
# the portal's again, last, so bare helpers (`root_path` in the
|
|
115
115
|
# sidebar) resolve against the engine like a source-defined controller.
|
|
116
116
|
klass.include(namespace.railtie_routes_url_helpers) if namespace != Object
|
|
117
|
+
# The same anonymous `inherited` hook boots the controller before it
|
|
118
|
+
# has a name, so it never prepends the portal's views and a portal's
|
|
119
|
+
# own partials (an ejected sidebar) lose to the gem's. Boot it again
|
|
120
|
+
# now that it is named.
|
|
121
|
+
klass.boot
|
|
117
122
|
|
|
118
123
|
if concern_name && (concern = concern_name.safe_constantize)
|
|
119
124
|
klass.include concern
|
data/lib/plutonium/ui/avatar.rb
CHANGED
|
@@ -56,7 +56,7 @@ module Plutonium
|
|
|
56
56
|
value.attached? ? value.url : nil
|
|
57
57
|
elsif value.respond_to?(:url) # bare Shrine::UploadedFile, CarrierWave, ...
|
|
58
58
|
value.url
|
|
59
|
-
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
59
|
+
elsif value.is_a?(String) && value.start_with?("http", "/", "data:image/")
|
|
60
60
|
value
|
|
61
61
|
end
|
|
62
62
|
rescue ArgumentError, URI::InvalidURIError
|
|
@@ -72,7 +72,7 @@ module Plutonium
|
|
|
72
72
|
# A URL-shaped positional subject is really an image, not an identity:
|
|
73
73
|
# route it to src so Avatar("https://…/p.png") shows the image rather
|
|
74
74
|
# than hashing the URL into a seed.
|
|
75
|
-
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/")
|
|
75
|
+
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/", "data:image/")
|
|
76
76
|
src = subject
|
|
77
77
|
subject = nil
|
|
78
78
|
end
|
|
@@ -34,9 +34,22 @@ module Plutonium
|
|
|
34
34
|
tag ||= inferred_field_component
|
|
35
35
|
return create_component(tag, component_theme_key(tag), **attributes, &) if tag.is_a?(Class)
|
|
36
36
|
|
|
37
|
+
# `field :x, as:` reaches every surface, but some aliases exist on
|
|
38
|
+
# only one (`:toggle` is form-only, `:badge` display-only). Those
|
|
39
|
+
# surfaces render what they would have inferred instead of raising.
|
|
40
|
+
# A tag NO surface defines is a typo and still raises below.
|
|
41
|
+
tag = inferred_field_component if !respond_to?(:"#{tag}_tag") && ResolvesTags.surface_tag?(tag)
|
|
42
|
+
|
|
37
43
|
send(:"#{tag}_tag", **attributes, &)
|
|
38
44
|
end
|
|
39
45
|
|
|
46
|
+
# Whether any field surface (form or display; the table renders through
|
|
47
|
+
# a display builder) defines `tag`.
|
|
48
|
+
def self.surface_tag?(tag)
|
|
49
|
+
[Plutonium::UI::Form::Base::Builder, Plutonium::UI::Display::Base::Builder]
|
|
50
|
+
.any? { |builder| builder.method_defined?(:"#{tag}_tag") }
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
private
|
|
41
54
|
|
|
42
55
|
# Theme key for a directly-rendered component class:
|
|
@@ -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:) }
|