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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +17 -10
  4. data/.claude/skills/plutonium-ui/SKILL.md +13 -3
  5. data/CHANGELOG.md +35 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +1 -1
  8. data/app/assets/plutonium.js +56 -3
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +32 -32
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/config/brakeman.ignore +1 -1
  13. data/config/locales/en/dashboard.yml +1 -0
  14. data/config/locales/en/js.yml +4 -0
  15. data/config/locales/en/ui.yml +19 -0
  16. data/docs/reference/dashboard/dsl.md +54 -3
  17. data/docs/reference/resource/definition.md +14 -7
  18. data/docs/reference/resource/query.md +11 -1
  19. data/docs/reference/ui/displays.md +19 -2
  20. data/docs/reference/ui/forms.md +77 -4
  21. data/gemfiles/rails_7.gemfile +1 -0
  22. data/gemfiles/rails_7.gemfile.lock +13 -3
  23. data/gemfiles/rails_8.0.gemfile +1 -0
  24. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  27. data/lib/plutonium/auth/rodauth.rb +7 -0
  28. data/lib/plutonium/core/controller.rb +0 -2
  29. data/lib/plutonium/dashboard/base.rb +80 -1
  30. data/lib/plutonium/dashboard/card.rb +12 -13
  31. data/lib/plutonium/dashboard/controller.rb +28 -10
  32. data/lib/plutonium/dashboard/dsl.rb +59 -2
  33. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  34. data/lib/plutonium/dashboard/filter.rb +114 -0
  35. data/lib/plutonium/dashboard/link.rb +35 -0
  36. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  37. data/lib/plutonium/helpers/content_helper.rb +0 -3
  38. data/lib/plutonium/query/filter.rb +2 -1
  39. data/lib/plutonium/query/filters/tags.rb +36 -0
  40. data/lib/plutonium/resource/query_object.rb +6 -0
  41. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  42. data/lib/plutonium/ui/avatar.rb +2 -2
  43. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  44. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  45. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  46. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  47. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  48. data/lib/plutonium/ui/display/base.rb +50 -2
  49. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  50. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  51. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  52. data/lib/plutonium/ui/display/components/code.rb +23 -0
  53. data/lib/plutonium/ui/display/components/color.rb +14 -6
  54. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  55. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  56. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  57. data/lib/plutonium/ui/display/components/list.rb +33 -0
  58. data/lib/plutonium/ui/display/components/number.rb +32 -0
  59. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  60. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  61. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  62. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  63. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  64. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  65. data/lib/plutonium/ui/display/theme.rb +24 -2
  66. data/lib/plutonium/ui/form/base.rb +50 -1
  67. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  68. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  69. data/lib/plutonium/ui/form/components/list.rb +46 -0
  70. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  71. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  72. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  73. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  74. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  75. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  76. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  77. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  78. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  79. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  80. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  81. data/lib/plutonium/ui/form/theme.rb +34 -4
  82. data/lib/plutonium/ui/grid/card.rb +25 -5
  83. data/lib/plutonium/ui/layout/base.rb +10 -1
  84. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  85. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  86. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  87. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  88. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  89. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  90. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  91. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  92. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  93. data/lib/plutonium/version.rb +1 -1
  94. data/package.json +1 -1
  95. data/plutonium.gemspec +1 -1
  96. data/src/css/components.css +43 -0
  97. data/src/css/flatpickr.css +91 -0
  98. data/src/js/controllers/easymde_controller.js +1 -0
  99. data/src/js/controllers/register_controllers.js +2 -0
  100. data/src/js/controllers/slim_select_controller.js +32 -3
  101. data/src/js/controllers/timeago_controller.js +40 -0
  102. 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
- return true if condition.nil?
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. Resolved by the route's
82
- # `dashboard_class` default, so `at:` / `as:` and a scope prefix are honoured.
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 = {card: card.key}
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
- @dashboard_card_route_helper ||= begin
91
- name = Plutonium::Dashboard::RouteResolution.route_name(
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
- :"#{name}_path"
97
- end
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`, `refresh`
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 = route_set.routes.find do |r|
20
+ route(route_set, dashboard_class, action:)&.name&.to_sym
21
+ end
22
+
23
+ # @return [ActionDispatch::Journey::Route, nil] the named route itself
24
+ def route(route_set, dashboard_class, action:)
25
+ route_set.routes.find do |r|
21
26
  d = r.defaults
22
27
  r.name.present? &&
23
28
  d[:action].to_s == action.to_s &&
24
29
  d[:dashboard_class].to_s == dashboard_class.name
25
30
  end
26
- route&.name&.to_sym
27
31
  end
28
32
  end
29
33
  end
@@ -10,9 +10,6 @@ module Plutonium
10
10
  title: content,
11
11
  data: {
12
12
  controller: "timeago",
13
- timeago_refresh_interval_value: 1000,
14
- timeago_include_seconds_value: true,
15
- timeago_add_suffix_value: true,
16
13
  timeago_datetime_value: date.iso8601
17
14
  }
18
15
  )
@@ -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
- class_name = "Plutonium::Query::Filters::#{type.to_s.classify}"
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
@@ -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:) }
@@ -15,7 +15,7 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def view_template
18
- turbo_frame_tag(@card.frame_id, class: "block") do
18
+ turbo_frame_tag(@card.frame_id, target: "_top", class: "block") do
19
19
  render Card.for(dashboard: @dashboard, card: @card)
20
20
  end
21
21
  end