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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
- data/.claude/skills/plutonium-resource/SKILL.md +3 -2
- data/.claude/skills/plutonium-ui/SKILL.md +2 -0
- data/CHANGELOG.md +13 -0
- data/SECURITY.md +1 -1
- data/config/locales/en/dashboard.yml +1 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/reference/dashboard/dsl.md +54 -3
- data/docs/reference/resource/definition.md +2 -0
- data/docs/reference/ui/forms.md +23 -0
- 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/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/dashboard/board.rb +4 -1
- data/lib/plutonium/ui/dashboard/frame.rb +1 -1
- data/lib/plutonium/ui/dashboard/metric.rb +18 -5
- data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
- data/lib/plutonium/ui/form/base.rb +20 -0
- data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
- data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
- data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
- data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
- data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
- data/lib/plutonium/ui/form/theme.rb +15 -1
- data/lib/plutonium/ui/grid/card.rb +12 -4
- data/lib/plutonium/ui/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/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +1 -1
- metadata +12 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a01423b0e39c2e985ec24ea8663cf53377817ff24814c95ce89270806f51b878
|
|
4
|
+
data.tar.gz: cbe95fbf1cd307e3b07cccf24cda7db7293dc15676c0ee288fe076d37102cb15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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 `
|
|
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.
|
|
15
|
+
| Latest release (`0.68.x`) | :white_check_mark: |
|
|
16
16
|
| Older releases | :x: |
|
|
17
17
|
|
|
18
18
|
## Reporting a Vulnerability
|
data/config/locales/en/ui.yml
CHANGED
|
@@ -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`
|
|
74
|
-
| `change` |
|
|
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
|
data/docs/reference/ui/forms.md
CHANGED
|
@@ -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
|
-
|
|
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
|