plutonium 0.67.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 (41) 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 +3 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +2 -0
  5. data/CHANGELOG.md +13 -0
  6. data/SECURITY.md +1 -1
  7. data/config/locales/en/dashboard.yml +1 -0
  8. data/config/locales/en/ui.yml +3 -0
  9. data/docs/reference/dashboard/dsl.md +54 -3
  10. data/docs/reference/resource/definition.md +2 -0
  11. data/docs/reference/ui/forms.md +23 -0
  12. data/lib/plutonium/dashboard/base.rb +80 -1
  13. data/lib/plutonium/dashboard/card.rb +12 -13
  14. data/lib/plutonium/dashboard/controller.rb +28 -10
  15. data/lib/plutonium/dashboard/dsl.rb +59 -2
  16. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  17. data/lib/plutonium/dashboard/filter.rb +114 -0
  18. data/lib/plutonium/dashboard/link.rb +35 -0
  19. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  20. data/lib/plutonium/resource/query_object.rb +6 -0
  21. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  22. data/lib/plutonium/ui/avatar.rb +2 -2
  23. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  24. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  25. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  26. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  27. data/lib/plutonium/ui/form/base.rb +20 -0
  28. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  29. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  30. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  31. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  32. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  33. data/lib/plutonium/ui/form/theme.rb +15 -1
  34. data/lib/plutonium/ui/grid/card.rb +12 -4
  35. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  36. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  37. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  38. data/lib/plutonium/version.rb +1 -1
  39. data/package.json +1 -1
  40. data/plutonium.gemspec +1 -1
  41. metadata +12 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7285167839fdb5e60e89da076028dc08b719ec60125cb61faaeb26cd151b9140
4
- data.tar.gz: 87fe956c9ab3cdcc92e7083a3c5c965c144661f530e4ce0cb609d3b0a8adfb06
3
+ metadata.gz: a01423b0e39c2e985ec24ea8663cf53377817ff24814c95ce89270806f51b878
4
+ data.tar.gz: cbe95fbf1cd307e3b07cccf24cda7db7293dc15676c0ee288fe076d37102cb15
5
5
  SHA512:
6
- metadata.gz: 3cdc13417311bd847b8b721ae46da0b59b12ecb1fafe95be087e556d71ac4d053d236169f2d48fa08edf831f9d49cdfdce27c3e28229b1bf5155dafdfb30cad5
7
- data.tar.gz: 539932b5d8700edf4878fde1eb3c33ed0cd518d6ebafbbcf83df92de5ff098061dabcc1c1ca1a859cdfcf1e68a3f3e6ceaa55c44499b4132f132192db5cfcd18
6
+ metadata.gz: a2034d1af622ec4bfbc4c6ac00c46ed63f4a7ea6ed878ebc78b9666b109cf572dff20f60da0bc9f5e5adafdb992e76deb292c4ec08146684e3828732daf2ced7
7
+ data.tar.gz: '09f8b0ef419d7adf5df3057a271ed4659140e4308c4b7ea5ea5965554256e2c609314ecb6cd33893629773b9fab97f735b1e16872f08bb8784445cc80049a214'
@@ -77,9 +77,24 @@ end
77
77
 
78
78
  ## metric
79
79
 
80
- Return a value, or a hash: `value` (required), `previous` (computes % change), `change` (numeric percentage points or a verbatim string), `trend` (`:up`/`:down`/`:flat`, inferred), `change_label`.
80
+ Return a value, or a hash: `value` (required), `previous` (computes the change), `change` (a number or a verbatim string), `trend` (`:up`/`:down`/`:flat`, inferred), `change_label`.
81
81
 
82
- Options: `format:` (`:number` default, `:currency`, `:percentage`, `:human`, or `->(v) { ... }`), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` default, `:down` when falling is good), `change_label:`.
82
+ Options: `format:` (`:number` default, `:currency`, `:percentage`, `:human`, or `->(v) { ... }`), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` default, `:down` when falling is good), `change_label:`, `change_unit:` (`:percent` default; `:points` for a value that is itself a percentage, so 50% to 55.6% reads `+5.6 pts`).
83
+
84
+ ## Filters and toolbar links
85
+
86
+ ```ruby
87
+ filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days"}, default: "30"
88
+ link :settings, icon: Phlex::TablerIcons::Settings, href: -> { resource_url_for(Setting) }
89
+
90
+ metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
91
+ ```
92
+
93
+ - A filter's value comes from the query string; an unknown value reads as the default. Read it with `filter_value(:key)`, never `params[:key]` directly.
94
+ - Don't name a filter after a card-route param or `url_for` option (`card`, `format`, `anchor`, `host`, `use_route`, ...) or a path segment of the dashboard's route (entity param, `locale`); it raises.
95
+ - To filter by a resource's scopes, use `filter :status, scopes: Post` (choices = "All" + the definition's scopes) with `filtered(Post)` in card blocks and `href: -> { filtered_url(Post) }`. Do not hand-map filter values to `where` clauses or `q[scope]` URLs; `filtered` runs the definition's scope, so the count matches the index. One scope filter per resource. The scope block runs on the dashboard's controller, so it cannot call resource-controller helpers like `current_parent`.
96
+ - Every lazy card's frame URL carries the filter values, so lazy cards see the page's choice. Do not hand-roll query forwarding or a custom `DashboardsController` for this.
97
+ - Filters render as segmented links and links as buttons in a toolbar above the cards; neither needs a custom page class.
83
98
 
84
99
  ## chart
85
100
 
@@ -622,8 +622,9 @@ end
622
622
 
623
623
  - `input :tag_ids` uses Rails' collection-ids setter. Permit `:tag_ids`; values are plain ids. Simplest for a scoped checkbox list.
624
624
  - `input :tags, as: :secure_association` is the native picker. It sets input_param `tag_sgids`, scopes via `choices:` (or the target policy), and validates submitted SGIDs by decoded model-id.
625
+ - `input :tags, as: :association_checkboxes` (or `:association_toggles`) is the same picker rendered as one checkbox/switch per record. `input :category, as: :association_radio_buttons` does the same for a belongs_to/has_one. They are capped by `choice_limit:` like the select but have no typeahead, so keep them to short lists.
625
626
 
626
- ⚠️ Do **not** hand-roll `collection_checkboxes_tag(choices: [[label, record.to_sgid.to_s]])`. SGIDs are non-deterministic (`to_sgid` embeds a timestamp), the component re-derives `choices` at submit and intersects the posted value against the freshly-minted SGIDs, so it never matches and the value is dropped. Use stable ids in custom checkboxes, or `secure_association` (which validates by model-id).
627
+ ⚠️ Do **not** hand-roll `collection_checkboxes_tag(choices: [[label, record.to_sgid.to_s]])`. SGIDs are non-deterministic (`to_sgid` embeds a timestamp), the component re-derives `choices` at submit and intersects the posted value against the freshly-minted SGIDs, so it never matches and the value is dropped. Use stable ids in custom checkboxes, or `as: :association_checkboxes` (which validates by model-id).
627
628
 
628
629
  ## Conditional Rendering
629
630
 
@@ -1116,7 +1117,7 @@ Only declare `index_views` explicitly if you want to **disable** one (e.g. `inde
1116
1117
  | `grid_layout :media` | `:compact` (image left) or `:media` (image on top). |
1117
1118
  | `grid_columns 3` | Override responsive column count. |
1118
1119
 
1119
- All grid slots are optional; slots pointing at unpermitted fields collapse silently.
1120
+ All grid slots are optional; slots pointing at unpermitted fields collapse silently. In the `:media` layout a record whose image does not resolve keeps the cover's space, with a neutral icon, so cards in a row stay one height. An image may be an attachment, an `http` or root-relative URL, or a `data:image/` URI.
1120
1121
 
1121
1122
  ---
1122
1123
 
@@ -324,6 +324,8 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
324
324
  | `uppy_tag` / `file_tag` | Uppy file upload |
325
325
  | `secure_association_tag` | Association with policy-checked options |
326
326
  | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
327
+ | `association_checkboxes_tag` / `association_toggles_tag` | has_many / habtm as checkboxes or switches (`as: :association_checkboxes` / `:association_toggles`); same policy scoping and SGID validation as the select, capped by `choice_limit:` like the select (selected records always render), no typeahead |
328
+ | `association_radio_buttons_tag` | belongs_to / has_one as a radio group (`as: :association_radio_buttons`); optional associations get a "None" choice |
327
329
  | `key_value_store_tag` | Key/value pairs editor |
328
330
  | `currency_tag` | Money input (number field + unit prefix) |
329
331
  | `rich_text_tag` | Action Text editor (Trix, or Lexxy once the app installs it); default for `has_rich_text` |
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.68.0] - 2026-10-14
6
+
7
+ ### Bug Fixes
8
+
9
+ - Send card links to the whole page ([#183](https://github.com/radioactive-labs/plutonium-core/issues/183))
10
+ - Keep the grid card cover's space when a record has no image ([#184](https://github.com/radioactive-labs/plutonium-core/issues/184))
11
+
12
+ ### Features
13
+
14
+ - Filters carried to card frames, toolbar links, point changes ([#182](https://github.com/radioactive-labs/plutonium-core/issues/182))
15
+ - Checkbox, toggle and radio variants of the association input ([#185](https://github.com/radioactive-labs/plutonium-core/issues/185))
16
+ - Scope filters, plus fixes from the #182 review ([#186](https://github.com/radioactive-labs/plutonium-core/issues/186))
17
+
5
18
  ## [0.67.0] - 2026-10-09
6
19
 
7
20
  ### Bug Fixes
data/SECURITY.md CHANGED
@@ -12,7 +12,7 @@ please upgrade before reporting an issue to confirm it still reproduces.
12
12
 
13
13
  | Version | Supported |
14
14
  | ------- | ------------------ |
15
- | Latest release (`0.67.x`) | :white_check_mark: |
15
+ | Latest release (`0.68.x`) | :white_check_mark: |
16
16
  | Older releases | :x: |
17
17
 
18
18
  ## Reporting a Vulnerability
@@ -23,3 +23,4 @@ en:
23
23
  increase: "Up %{change}"
24
24
  decrease: "Down %{change}"
25
25
  unchanged: "No change"
26
+ points: "%{change} pts"
@@ -94,6 +94,9 @@ en:
94
94
  delete: "Delete"
95
95
  rating:
96
96
  clear: "Clear"
97
+ association_choices:
98
+ none: "None"
99
+ empty: "No options available"
97
100
  binary:
98
101
  replace_hint: "Stored: %{size}. Choose a file to replace it."
99
102
  display:
@@ -30,6 +30,49 @@ Default reload interval for every lazy card. `nil` (the default) disables it.
30
30
 
31
31
  Page width, one of `:sm`, `:md`, `:lg`, `:xl`, `:full`. Default `:full`.
32
32
 
33
+ ### filter(key, choices: nil, scopes: nil, default: nil, label: nil)
34
+
35
+ A page-level control every card reads, such as a period or a region.
36
+
37
+ ```ruby
38
+ filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days", "90" => "Last 90 days"}, default: "30"
39
+
40
+ metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
41
+ ```
42
+
43
+ - `choices:` maps each value to its label (a string or a lazy `t` proc), in display order. `default:` must be one of the values and is the first when omitted.
44
+ - The key cannot be one the card route or `url_for` uses (`card`, `format`, `anchor`, `host`, `use_route`, ...), which raises when declared, or a path segment of the dashboard's route (the entity param on a path-scoped portal, a `(:locale)` scope), which raises on the request.
45
+ - The value comes from the query string under the filter's key. A value that is not one of the choices reads as the default.
46
+ - Every lazy card's frame URL carries every filter's value, so a card's own request reads what the page read.
47
+ - The page renders each filter as a segmented row of links above the cards, so a choice is a URL that survives a reload and can be shared. Other query parameters on the page are kept.
48
+ - A card's `href:` is not rewritten with the filter values. Use a scope filter (below) to link a card to the matching list.
49
+
50
+ #### Scope filters
51
+
52
+ `scopes:` takes a resource instead of `choices:`. The choices are "All" plus the scopes the resource's definition declares, labelled and gated (`condition:`) the way the index's scope pills are. The default is `default:`, else the definition's `default_scope`, else "All". A default whose `condition:` hides it from the current user reads as "All" for that user.
53
+
54
+ ```ruby
55
+ filter :status, scopes: Post
56
+
57
+ metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
58
+ chart(:by_author) { filtered(Post).group(:author_name).count }
59
+ ```
60
+
61
+ - `filtered(Post)` is `authorized_resource_scope(Post)` with the chosen scope applied through the definition's own scope, the same code the index runs, so the card's number matches the list.
62
+ - `filtered_url(Post)` is the Post index on that scope (`q[scope]=...`). With "All" chosen and a `default_scope` on the definition, it sends an explicit blank scope so the index shows everything.
63
+ - Without a scope filter for the resource, `filtered` is the authorized scope as is and `filtered_url` the plain index.
64
+ - A scope's block runs against the dashboard's controller. `current_user`, `current_scoped_entity` and the portal's helpers work there; resource-controller helpers such as `current_parent` do not.
65
+ - One scope filter per resource; a second raises when declared. A `default:` that is not one of the definition's scopes, or a definition scope named `:all` (the "All" choice's value), raises on the request.
66
+
67
+ ### link(key, href:, label: nil, icon: nil, condition: nil)
68
+
69
+ A button in the toolbar above the cards, such as the dashboard's settings or a full report. `href:` and `condition:` take the same forms as a card's and are evaluated on the dashboard instance. A link whose `href:` resolves to nil is left out.
70
+
71
+ ```ruby
72
+ link :settings, icon: Phlex::TablerIcons::Settings, href: -> { resource_url_for(Setting) },
73
+ condition: -> { allowed_to?(:update?, Setting) }
74
+ ```
75
+
33
76
  ## Cards
34
77
 
35
78
  ```ruby
@@ -70,8 +113,8 @@ The block returns a value or a hash.
70
113
  | Hash key | Meaning |
71
114
  |---|---|
72
115
  | `value` | The number or string. `nil` renders `plutonium.dashboard.metric.empty` |
73
- | `previous` | Computes `change` as a percentage from this value; a zero previous yields no percentage |
74
- | `change` | Numeric percentage points (`2.5` → `+2.5%`) or a string shown as-is |
116
+ | `previous` | Computes `change` from this value: a percentage of it, or the difference with `change_unit: :points`. A zero previous yields no percentage (a neutral dash when there is a caption); with `:points` the difference still shows. The change is rounded to one decimal before its trend is read |
117
+ | `change` | A number (`2.5` → `+2.5%`, or `+2.5 pts` with `change_unit: :points`) or a string shown as-is |
75
118
  | `trend` | `:up`, `:down`, `:flat`; inferred from the sign of `change` |
76
119
  | `change_label` | Caption after the change |
77
120
 
@@ -83,6 +126,7 @@ The block returns a value or a hash.
83
126
  | `prefix:` / `suffix:` | String | Wrapped around the formatted value |
84
127
  | `positive:` | `:up` (default), `:down` | Which direction is good; drives the change colour |
85
128
  | `change_label:` | String | Same as the hash key |
129
+ | `change_unit:` | `:percent` (default), `:points` | How the change reads. Use `:points` for a value that is itself a percentage: 50% to 55.6% reads `+5.6 pts`, not `+11.2%` |
86
130
 
87
131
  ### chart
88
132
 
@@ -106,6 +150,11 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
106
150
  |---|---|
107
151
  | `authorize?` | Override to gate the page and every card. Default `true` |
108
152
  | `visible_cards` | Cards whose `condition:` passes |
153
+ | `filter_value(key)` | The filter's value for this request, or its default |
154
+ | `filter_values` | `{key => value}` for every filter; what each card's frame URL carries |
155
+ | `filtered(resource_class)` | The authorized scope with the resource's scope filter applied |
156
+ | `filtered_url(resource_class)` | The resource's index on the scope that filter has chosen |
157
+ | `visible_links` | Toolbar links whose `condition:` passes |
109
158
  | `visible_card!(key)` | The card, or `Plutonium::Dashboard::UnknownCardError` (404) |
110
159
  | `refresh_for(card)` | The card's interval, else the dashboard's; `nil` for a card declared `refresh: false` |
111
160
  | `view_context` / `helpers` | The Rails view context |
@@ -117,7 +166,9 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
117
166
  |---|---|
118
167
  | `cards` | All declared cards, in order |
119
168
  | `find_card(key)` / `find_card!(key)` | Lookup by key |
169
+ | `filters` / `find_filter(key)` | Declared filters |
170
+ | `links` | Declared toolbar links |
120
171
  | `label`, `description`, `icon` | Resolved presentation |
121
172
  | `i18n_key`, `route_name` | `admin_portal/sales`, `sales` |
122
173
 
123
- Subclasses inherit the parent's cards and options by copy, so a portal-specific subclass can add cards without touching the parent.
174
+ Subclasses inherit the parent's cards, filters, links and options by copy, so a portal-specific subclass can add cards without touching the parent.
@@ -1011,6 +1011,8 @@ end
1011
1011
 
1012
1012
  Grid slots, `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`, are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
1013
1013
 
1014
+ In the `:media` layout a record whose image does not resolve keeps the cover's space, with a neutral icon, so cards in a row stay one height. An image may be an attachment, an `http` or root-relative URL, or a `data:image/` URI.
1015
+
1014
1016
  Only declare `index_views` explicitly to **disable** one (e.g. `index_views :grid` to drop the table view).
1015
1017
 
1016
1018
  ## Custom page classes
@@ -171,6 +171,8 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
171
171
  | `rating_tag` | Star picker (radio buttons, no JS); an optional rating gets a Clear option |
172
172
  | `secure_association_tag` | Association with policy-checked options (inline `+` add, typeahead) |
173
173
  | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
174
+ | `association_checkboxes_tag` / `association_toggles_tag` | has_many / habtm as a list of checkboxes or switches (see [Association inputs](#association-inputs)) |
175
+ | `association_radio_buttons_tag` | belongs_to / has_one as a radio group |
174
176
  | `key_value_store_tag` | Key/value pairs editor |
175
177
 
176
178
  ```ruby
@@ -333,6 +335,27 @@ input :author, label_method: :email
333
335
  For large target tables, write an explicit `search` block on the target resource definition; the fallback's leading-wildcard `LIKE` can't use a b-tree index.
334
336
  :::
335
337
 
338
+ ### List variants: checkboxes, toggles, radio buttons
339
+
340
+ For a short list of options, render one control per record instead of a dropdown:
341
+
342
+ ```ruby
343
+ input :tags, as: :association_checkboxes # has_many / habtm
344
+ input :tags, as: :association_toggles # same, styled as switches
345
+ input :category, as: :association_radio_buttons # belongs_to / has_one
346
+ ```
347
+
348
+ The variants share the select's security: options come from the target's policy scope (or `choices:`), values are signed global ids, and submitted ids are checked against the authorized relation. Like the select, they always render the records the field holds now, even past `choice_limit:` or outside the viewer's scope, and accept them back on submit, so saving a form never drops a value the viewer did not touch. The inline `+` add button renders next to the list, and `add_action:`, `skip_authorization:`, `choices:` and `label_method:` work the same way.
349
+
350
+ How they differ from the select:
351
+
352
+ - **The list is capped like the select.** `choice_limit:` defaults to the select's cap. There is no typeahead, so a record past the cap can't be picked: use them for short lists, or raise `choice_limit:`.
353
+ - **Unchecking everything clears the association.** The checkbox list submits a blank value, so an empty selection saves as no records.
354
+ - **Optional radio groups get a "None" choice.** A radio group can't be cleared otherwise. Picking it clears the association. On a has_one, Active Record detaches the old record (or destroys it, with `dependent: :destroy`) as soon as the value is assigned, before the form validates, the same as picking a different record. It is left out when the field is required (a checked "None" would satisfy the browser's `required`), and `include_blank: false` drops it on an optional one. Its label is the field's `placeholder:` if set.
355
+ - **The macro has to fit.** Checkboxes and toggles take a has_many or habtm; radio buttons take a belongs_to or has_one. A mismatch raises `ArgumentError` when the input is built, on render and on submit, as do a polymorphic association and `group_method:`.
356
+
357
+ The list and its controls are themed by `association_checkboxes`, `association_toggles`, `association_radio_buttons` (the list), `association_checkbox`, `association_toggle`, `association_radio_button` (each control) and `association_choice_label`.
358
+
336
359
  ## Submit buttons
337
360
 
338
361
  Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
@@ -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