plutonium 0.69.0 → 0.70.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-resource/SKILL.md +13 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +11 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.png +0 -0
- data/config/locales/en/ui.yml +3 -0
- data/docs/blog/introducing-plutonium-dashboards.md +4 -4
- data/docs/blog/the-slowest-query-is-the-count.md +109 -0
- data/docs/guides/performance.md +14 -0
- data/docs/public/favicon-16x16.png +0 -0
- data/docs/public/favicon-32x32.png +0 -0
- data/docs/public/images/blog/i18n-es-admin.png +0 -0
- data/docs/public/images/blog/pagination-capped.png +0 -0
- data/docs/public/images/blog/pagination-countless.png +0 -0
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/dashboard-card-error.png +0 -0
- data/docs/public/images/guides/dashboard-loading.png +0 -0
- data/docs/public/images/guides/dashboard-overview.png +0 -0
- data/docs/public/images/guides/dashboard-sidebar.png +0 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/public/og-image.png +0 -0
- data/docs/public/plutonium.png +0 -0
- data/docs/public/tutorial/plutonium-association-panel.png +0 -0
- data/docs/public/tutorial/plutonium-dashboard.png +0 -0
- data/docs/public/tutorial/plutonium-login-page.png +0 -0
- data/docs/public/tutorial/plutonium-nested-form.png +0 -0
- data/docs/public/tutorial/plutonium-posts-dashboard-customized.png +0 -0
- data/docs/public/tutorial/plutonium-posts-dashboard.png +0 -0
- data/docs/public/tutorial/plutonium-posts-detail-customized.png +0 -0
- data/docs/public/tutorial/plutonium-posts-detail.png +0 -0
- data/docs/public/tutorial/plutonium-publish-post.png +0 -0
- data/docs/reference/configuration.md +1 -0
- data/docs/reference/i18n.md +1 -1
- data/docs/reference/resource/definition.md +35 -0
- data/docs/reference/ui/layouts.md +13 -0
- data/gemfiles/rails_7.gemfile.lock +3 -3
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/plutonium/configuration.rb +17 -0
- data/lib/plutonium/definition/base.rb +22 -0
- data/lib/plutonium/pagination.rb +35 -0
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +27 -1
- data/lib/plutonium/ui/grid/card.rb +11 -2
- data/lib/plutonium/ui/grid/resource.rb +2 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +42 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +8 -2
- data/lib/plutonium/ui/table/components/pagy_info.rb +30 -10
- data/lib/plutonium/ui/table/resource.rb +2 -2
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +3 -1
- data/src/css/components.css +3 -1
- metadata +7 -7
- data/docs/public/images/blog/dashboards-card-error.png +0 -0
- data/docs/public/images/blog/dashboards-loading.png +0 -0
- data/docs/public/images/blog/dashboards-overview.png +0 -0
- data/docs/public/images/blog/dashboards-sidebar.png +0 -0
data/app/assets/plutonium.png
CHANGED
|
Binary file
|
data/config/locales/en/ui.yml
CHANGED
|
@@ -66,6 +66,9 @@ en:
|
|
|
66
66
|
result_count:
|
|
67
67
|
one: "%{count} result"
|
|
68
68
|
other: "%{count} results"
|
|
69
|
+
result_count_capped: "%{count}+ results"
|
|
70
|
+
capped_count: "%{count}+"
|
|
71
|
+
page_range: "Displaying %{item_name} %{from}-%{to}"
|
|
69
72
|
selected_count: "%{count} selected"
|
|
70
73
|
clear_selection: "Clear selection"
|
|
71
74
|
no_bulk_actions: "No bulk actions available"
|
|
@@ -13,7 +13,7 @@ tags: [announcement, dashboards, charts]
|
|
|
13
13
|
|
|
14
14
|
Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. Plutonium 0.65 ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
|
|
15
15
|
|
|
16
|
-

|
|
17
17
|
|
|
18
18
|
## What's in the box
|
|
19
19
|
|
|
@@ -99,7 +99,7 @@ The page response contains no query results. Each card renders as a lazy turbo f
|
|
|
99
99
|
|
|
100
100
|
The browser fetches each frame as it scrolls into view, and the card's block runs in that request. The page paints at once, the cheap cards fill in first, and one expensive aggregate delays itself and nothing else.
|
|
101
101
|
|
|
102
|
-

|
|
103
103
|
|
|
104
104
|
The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload, and in development and test an exception in its block fails the whole page rather than one frame.
|
|
105
105
|
|
|
@@ -131,7 +131,7 @@ end
|
|
|
131
131
|
|
|
132
132
|
In development and test a card that raises, raises, and you fix the query. In production the card shows a short notice in place of its body and the rest of the dashboard is untouched.
|
|
133
133
|
|
|
134
|
-

|
|
135
135
|
|
|
136
136
|
The failure is written to the Rails log with the dashboard class, the card key and the top of the backtrace, and it is reported through `Rails.error`:
|
|
137
137
|
|
|
@@ -156,7 +156,7 @@ Series colours come from the `--pu-chart-1` to `--pu-chart-8` design tokens and
|
|
|
156
156
|
|
|
157
157
|
Registered dashboards are grouped under a Dashboards item in the portal sidebar, with a link per dashboard. A dashboard mounted at the root is what the Home link opens, so it stays out of the group.
|
|
158
158
|
|
|
159
|
-

|
|
160
160
|
|
|
161
161
|
If your portal has an ejected `_resource_sidebar.html.erb` from an earlier release, the [guide](/guides/dashboards#sidebar) has the block to add.
|
|
162
162
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Plutonium pagination: when the slowest query in your Rails admin is the count"
|
|
3
|
+
titleTemplate: "Plutonium Blog"
|
|
4
|
+
date: 2026-10-26
|
|
5
|
+
description: An index page over fourteen million rows runs two queries, and an index only fixes one of them. Plutonium 0.70 lets a resource cap its COUNT(*) or skip it entirely.
|
|
6
|
+
author: Stefan Froelich
|
|
7
|
+
tags: [pagination, performance, postgres]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Plutonium pagination: when the slowest query in your Rails admin is the count
|
|
11
|
+
|
|
12
|
+
<BlogMeta />
|
|
13
|
+
|
|
14
|
+
An admin index page in a production Plutonium app started returning 500s. The table behind it had about fourteen million rows, Postgres was cancelling the request at its 15 second statement timeout, and the page had never been slow before the table got big.
|
|
15
|
+
|
|
16
|
+
The obvious culprit was real. The page sorts by `created_at` and the column had no index, so every load was a parallel sequential scan followed by a sort. Add the index and that query drops to milliseconds, because Postgres can walk the index backwards and stop after twenty rows.
|
|
17
|
+
|
|
18
|
+
But an index page runs two queries, and the index only fixes one of them.
|
|
19
|
+
|
|
20
|
+
## The second query
|
|
21
|
+
|
|
22
|
+
Before Plutonium fetches the page of records, it counts them. The count feeds three things on the page: the footer ("Displaying Transactions 1-20 of 13742118 in total"), the result count above the table, and the numbered page links, which need to know where the last page is.
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
SELECT COUNT(*) FROM "transactions"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Rails strips the `ORDER BY` from a count, so the new index on `created_at` is no help here. And `COUNT(*)` on Postgres cannot read a stored total: rows carry their own visibility information, so the only way to know how many rows this transaction can see is to look at all of them. The best case is an index-only scan over the smallest index, which is still one entry per row. Fourteen million rows means seconds of work, on every page load, every filter change, and every click on "next".
|
|
29
|
+
|
|
30
|
+
The total is also the least useful number on the page. Nobody reads "13742118" and does anything differently than they would with "more than ten thousand".
|
|
31
|
+
|
|
32
|
+
## Cap it
|
|
33
|
+
|
|
34
|
+
Plutonium 0.70 adds a `pagination` setting to the definition:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class TransactionDefinition < ResourceDefinition
|
|
38
|
+
pagination count: 10_000
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
With a cap, the count runs over a subquery with a `LIMIT`:
|
|
43
|
+
|
|
44
|
+
```sql
|
|
45
|
+
SELECT COUNT(*) FROM (SELECT 1 AS one FROM "transactions" LIMIT 10001) AS subquery_for_count
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Postgres stops after 10,001 rows instead of fourteen million. The extra row is how Plutonium knows the total went past the cap: below it, the page shows the exact number; past it, the total reads "10000+".
|
|
49
|
+
|
|
50
|
+

|
|
51
|
+
|
|
52
|
+
The pager links pages up to the one holding row 10,000, and stops there. That is deliberate. A deep `OFFSET` has the same problem as the count, since the database reads and discards every skipped row, so page 500,000 of a transaction log was never a page anyone should be able to request.
|
|
53
|
+
|
|
54
|
+
## Or skip it
|
|
55
|
+
|
|
56
|
+
Some lists do not need a total at all. An audit log is read from the top, and the useful navigation is "older":
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class AuditLogDefinition < ResourceDefinition
|
|
60
|
+
pagination count: false
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
No count query runs. The page fetches one more row than it shows, and that extra row decides whether there is a next page. The footer drops the "of N", and the pager offers previous, next, and the pages visited so far.
|
|
65
|
+
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
A list that fits on one page still shows its total, because the rows on the page are the whole set.
|
|
69
|
+
|
|
70
|
+
## What a cap does not fix
|
|
71
|
+
|
|
72
|
+
A capped count is cheap when lots of rows match, because the subquery fills its 10,001 slots quickly. It is no cheaper when few rows match.
|
|
73
|
+
|
|
74
|
+
Say a "Pending" tab matches 400 rows and `status` has no index. The capped subquery still scans the whole table looking for a 10,001st row that does not exist, so it costs about what the full count did. The page query has the same problem: walking the `created_at` index backwards for twenty pending rows means stepping over every non-pending row on the way.
|
|
75
|
+
|
|
76
|
+
The fix for that tab is an index on the filtered column, a partial one if a single value is the hot path. The cap removes the cost of counting rows you were never going to look at. It cannot remove the cost of finding the ones you are.
|
|
77
|
+
|
|
78
|
+
## Deciding per request
|
|
79
|
+
|
|
80
|
+
The definition sets a resource's default. When the right answer depends on the request, override `pagination_count` in the controller. It returns the same values the definition takes:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class TransactionsController < ResourceController
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def pagination_count
|
|
87
|
+
current_query_object.active_filter_descriptions.empty? ? false : 10_000
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Here the unfiltered list skips the count entirely, and a filtered one gets a capped total.
|
|
93
|
+
|
|
94
|
+
## Defaults
|
|
95
|
+
|
|
96
|
+
Nothing changes unless you ask. The global default is an exact count, set in the initializer alongside the rest of Plutonium's configuration:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
Plutonium.configure do |config|
|
|
100
|
+
config.pagination = {count: :exact}
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The "N+" wording and the countless range sentence are translation keys, so a localized app can override them like any other string.
|
|
105
|
+
|
|
106
|
+
## Where to go next
|
|
107
|
+
|
|
108
|
+
- [Definition › Pagination count](/reference/resource/definition#pagination-count): every value, the SQL each one runs, and the controller override.
|
|
109
|
+
- [Performance](/guides/performance#counting-large-tables): counting large tables, alongside eager loading and the search fallback.
|
data/docs/guides/performance.md
CHANGED
|
@@ -91,6 +91,20 @@ It works without per-model configuration. Before adding it:
|
|
|
91
91
|
|
|
92
92
|
[`ar_lazy_preload`](https://github.com/DmitryTsepelev/ar_lazy_preload) takes the same idea from the other end, preloading lazily on first access, with `ArLazyPreload.config.auto_preload = true` for automatic behaviour everywhere. Its docs warn that enabling it on an existing app can surface edge cases.
|
|
93
93
|
|
|
94
|
+
## Counting large tables
|
|
95
|
+
|
|
96
|
+
Each index page load counts the matching rows to show the total and link the last page. That `COUNT(*)` reads every matching row, and on a table with millions of rows it can take longer than the page of records it sits next to. An index on the sort column does not help it.
|
|
97
|
+
|
|
98
|
+
Cap the count, or drop it, on the resources that need it:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
class TransactionDefinition < ResourceDefinition
|
|
102
|
+
pagination count: 10_000 # "10000+" past the cap
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A capped count is cheap when the filter matches many rows. A filter that matches few rows still scans for them, so index the columns your scopes and filters use. See [Definition › Pagination count](/reference/resource/definition#pagination-count).
|
|
107
|
+
|
|
94
108
|
## Work that does not belong in a request at all
|
|
95
109
|
|
|
96
110
|
Eager loading fixes a page whose queries are wasteful. It does nothing for one that is simply doing too much work: archiving thousands of records, building a report, waiting on a third party.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/docs/public/og-image.png
CHANGED
|
Binary file
|
data/docs/public/plutonium.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -59,6 +59,7 @@ Passing a version older than the earliest available raises rather than silently
|
|
|
59
59
|
| Option | Default | Description |
|
|
60
60
|
|--------|---------|-------------|
|
|
61
61
|
| `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. The field set comes from the policy, so it is known before the collection loads. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
|
|
62
|
+
| `pagination` | `{count: :exact}` | How index pages count their records. `count: :exact` runs a full `COUNT(*)`, an Integer counts at most that many rows (the total then reads "10000+"), `false` runs no count and shows previous/next only. Keys left out keep their defaults. Override per resource with `pagination`, see [Definition › Pagination count](./resource/definition#pagination-count). |
|
|
62
63
|
| `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is a registered resource. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. Any other value raises `ArgumentError` rather than drawing the wrong route table for a typo. See [Nested resources](./tenancy/nested-resources#declaring-which-associations-get-routes). |
|
|
63
64
|
| `default_currency_unit` | `nil` | Symbol used when rendering a currency value with no unit set on `has_cents` or the display. `nil` falls back to the i18n `number.currency.format.unit` when the locale defines one, otherwise no symbol. Set a literal like `"£"`, or `false` (or `""`) for no symbol application-wide. See [`has_cents`](./resource/model#has-cents) and [Currency fields](./ui/forms#currency-fields). |
|
|
64
65
|
| `default_phone_country` | `nil` | Default country (ISO2, e.g. `"gh"`) for `as: :phone` inputs that set no `initial_country:`. `nil` leaves it to intl-tel-input, with no country preselected. Stored verbatim; `config.normalized_default_phone_country` returns it downcased, so `"GH"` and `"gh"` are interchangeable. See [Phone fields](./ui/forms#phone-fields). |
|
data/docs/reference/i18n.md
CHANGED
|
@@ -23,7 +23,7 @@ en:
|
|
|
23
23
|
"false": "Off"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Pagination is the one surface with its own dictionary. The info sentence, the per-page sentence and the nav aria labels come from [Pagy's](https://ddnexus.github.io/pagy/resources/i18n) locale files, which cover about thirty-five languages. Override a Pagy key by adding a file to `Pagy::I18n.pathnames`, not through Rails I18n.
|
|
26
|
+
Pagination is the one surface with its own dictionary. The info sentence, the per-page sentence and the nav aria labels come from [Pagy's](https://ddnexus.github.io/pagy/resources/i18n) locale files, which cover about thirty-five languages. Override a Pagy key by adding a file to `Pagy::I18n.pathnames`, not through Rails I18n. The capped total (`plutonium.ui.table.capped_count`, "%{count}+") and the countless range sentence (`plutonium.ui.table.page_range`) are Plutonium keys, overridden through Rails I18n like any other.
|
|
27
27
|
|
|
28
28
|
## Switching locale
|
|
29
29
|
|
|
@@ -855,6 +855,41 @@ All three inherit to subclasses, so a portal-specific definition keeps its paren
|
|
|
855
855
|
- **Interactions** (`Plutonium::Interaction::Base`) support the same settings, for interactive actions rendered as standalone pages.
|
|
856
856
|
- **Wizards are configured separately**, via `Plutonium.configuration.wizards.width`. It defaults to `:md` independently of `default_page_width`, so widening resource pages leaves wizard steps where they are. Set both if you want them to match.
|
|
857
857
|
|
|
858
|
+
## Pagination count
|
|
859
|
+
|
|
860
|
+
The index page shows its total ("Displaying Posts 1-20 of 13,742 in total") and links every page, which takes a `COUNT(*)` on each load. On a table with millions of rows that count can take seconds. `pagination` sets how much counting a resource does:
|
|
861
|
+
|
|
862
|
+
```ruby
|
|
863
|
+
# config/initializers/plutonium.rb
|
|
864
|
+
Plutonium.configure { |config| config.pagination = {count: :exact} } # the default
|
|
865
|
+
|
|
866
|
+
class TransactionDefinition < ResourceDefinition
|
|
867
|
+
pagination count: 10_000
|
|
868
|
+
end
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
| `count:` | Query | Total shown | Page links |
|
|
872
|
+
|---|---|---|---|
|
|
873
|
+
| `:exact` | `SELECT COUNT(*) FROM ...` | the exact total | every page |
|
|
874
|
+
| an Integer | `SELECT COUNT(*) FROM (SELECT 1 FROM ... LIMIT 10001)` | exact up to the cap, then "10000+" | up to the page holding row 10,000 |
|
|
875
|
+
| `false` | none: fetches one extra row to tell whether a next page exists | the page's range only, without "of N" | previous/next, plus the pages visited so far |
|
|
876
|
+
|
|
877
|
+
A cap makes the count cheap when many rows match, because the subquery stops after `cap + 1` of them. It does not help a filter that matches few rows: the database still scans until it has found them all, so give that filter's column an index.
|
|
878
|
+
|
|
879
|
+
Any other value raises `ArgumentError` at declaration. The setting inherits to subclasses, so a portal-specific definition keeps its parent's choice unless it sets its own.
|
|
880
|
+
|
|
881
|
+
To choose per request, for example no count only while no filter is applied, override `pagination_count` in the controller. It returns the same values:
|
|
882
|
+
|
|
883
|
+
```ruby
|
|
884
|
+
class TransactionsController < ResourceController
|
|
885
|
+
private
|
|
886
|
+
|
|
887
|
+
def pagination_count
|
|
888
|
+
current_query_object.active_filter_descriptions.empty? ? false : 10_000
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
```
|
|
892
|
+
|
|
858
893
|
## File uploads
|
|
859
894
|
|
|
860
895
|
```ruby
|
|
@@ -109,6 +109,19 @@ The sidebar/icon-rail navigation is built with `Phlexi::Menu::Builder` in the ej
|
|
|
109
109
|
) %>
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
### Links pinned above the footer
|
|
113
|
+
|
|
114
|
+
`footer_menu:` takes a second `Phlexi::Menu::Builder` whose items render between the scrolling nav and the pin button, so they stay in view however long the nav gets. Items render the same way as the main menu's:
|
|
115
|
+
|
|
116
|
+
```erb
|
|
117
|
+
<%= render Plutonium::UI::Layout::IconRail.new(
|
|
118
|
+
menu: main_menu,
|
|
119
|
+
footer_menu: Phlexi::Menu::Builder.new do |m|
|
|
120
|
+
m.item "Settings", url: settings_path, icon: Phlex::TablerIcons::Settings
|
|
121
|
+
end
|
|
122
|
+
) %>
|
|
123
|
+
```
|
|
124
|
+
|
|
112
125
|
### Per-item link attributes
|
|
113
126
|
|
|
114
127
|
Any extra options you pass to `item` are spread straight onto the rendered `<a>`, so a menu entry can opt into `target`, `rel`, `data-*`, `aria-*`, etc. Useful for items that open in their own tab or drive a Stimulus/Turbo behavior:
|
|
@@ -9,7 +9,7 @@ GIT
|
|
|
9
9
|
PATH
|
|
10
10
|
remote: ..
|
|
11
11
|
specs:
|
|
12
|
-
plutonium (0.
|
|
12
|
+
plutonium (0.69.0)
|
|
13
13
|
action_policy (~> 0.7.0)
|
|
14
14
|
csv
|
|
15
15
|
listen (~> 3.8)
|
|
@@ -608,10 +608,10 @@ CHECKSUMS
|
|
|
608
608
|
phlex-tabler_icons (0.3.0) sha256=67d80cc09c619646cff171a620fa805cb49543625c3586a947cd752d44d2858e
|
|
609
609
|
phlexi-display (0.2.0) sha256=73e22d01e7204a3b94dcb21432477982bfa5162926c11bacd7d48930114236de
|
|
610
610
|
phlexi-field (0.2.0) sha256=ecb509cc2c0e0559f52651cac8d5dd2fca4bda3878cb7343ea7b298c760e7bca
|
|
611
|
-
phlexi-form (0.14.4)
|
|
611
|
+
phlexi-form (0.14.4) sha256=f5409274dd8441789612d6bf06c9f905a1bc6e2b802620de794905cf4b9ad59b
|
|
612
612
|
phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
|
|
613
613
|
phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
|
|
614
|
-
plutonium (0.
|
|
614
|
+
plutonium (0.69.0)
|
|
615
615
|
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
|
616
616
|
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
|
617
617
|
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
|
@@ -79,6 +79,22 @@ module Plutonium
|
|
|
79
79
|
# off globally here, or per controller via `auto_eager_load_collections?`.
|
|
80
80
|
attr_accessor :auto_eager_load_collections
|
|
81
81
|
|
|
82
|
+
# @return [Hash] index-page pagination defaults. Definitions override per
|
|
83
|
+
# resource with `pagination count: ...`.
|
|
84
|
+
#
|
|
85
|
+
# `count:` is what the index pays to know its total:
|
|
86
|
+
# - `:exact` (default): a full COUNT(*) on every page load
|
|
87
|
+
# - an Integer: count at most that many rows; past it the total reads
|
|
88
|
+
# "N+" and pages beyond the cap are out of range
|
|
89
|
+
# - `false`: no COUNT query; previous/next navigation only
|
|
90
|
+
#
|
|
91
|
+
# Keys left out keep their defaults.
|
|
92
|
+
attr_reader :pagination
|
|
93
|
+
|
|
94
|
+
def pagination=(value)
|
|
95
|
+
@pagination = Plutonium::Pagination::DEFAULTS.merge(Plutonium::Pagination.validate!(value)).freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
82
98
|
# @return [String, nil] the default country (ISO2 code, e.g. "gh") for phone
|
|
83
99
|
# (`as: :phone`) inputs that don't set their own `initial_country:`. `nil`
|
|
84
100
|
# (default) leaves it to the intl-tel-input library (no country preselected).
|
|
@@ -156,6 +172,7 @@ module Plutonium
|
|
|
156
172
|
@navii_host_url = "https://api.navii.dev"
|
|
157
173
|
@auto_eager_load_collections = true
|
|
158
174
|
@default_page_width = :md
|
|
175
|
+
@pagination = {count: :exact}.freeze
|
|
159
176
|
@nested_association_routes = :detected
|
|
160
177
|
end
|
|
161
178
|
|
|
@@ -179,6 +179,28 @@ module Plutonium
|
|
|
179
179
|
self.show_in_config = value
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# pagination — how the index page counts its records. Overrides
|
|
183
|
+
# `Plutonium.configuration.pagination` key by key, and inherits to
|
|
184
|
+
# subclasses like the other definition settings.
|
|
185
|
+
#
|
|
186
|
+
# pagination count: 10_000 # count at most 10,000 rows, then "10000+"
|
|
187
|
+
# pagination count: false # no COUNT query; previous/next only
|
|
188
|
+
# pagination count: :exact # full COUNT(*) (the global default)
|
|
189
|
+
#
|
|
190
|
+
# See Plutonium::Pagination for the accepted values.
|
|
191
|
+
inheritable_config_attr :pagination
|
|
192
|
+
|
|
193
|
+
def self.pagination(**options)
|
|
194
|
+
return pagination_config if options.empty?
|
|
195
|
+
|
|
196
|
+
self.pagination_config = (pagination_config || {}).merge(Plutonium::Pagination.validate!(options)).freeze
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# The global pagination settings with this definition's overrides applied.
|
|
200
|
+
def resolved_pagination
|
|
201
|
+
Plutonium.configuration.pagination.merge(self.class.pagination_config || {})
|
|
202
|
+
end
|
|
203
|
+
|
|
182
204
|
# The model this definition describes — the inverse of the
|
|
183
205
|
# `"#{resource_class}Definition"` lookup controllers do
|
|
184
206
|
# (Plutonium::Resource::Controller#resource_definition).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
# Index-page pagination options, shared by the global setting
|
|
5
|
+
# (`config.pagination`) and the per-resource definition DSL (`pagination`).
|
|
6
|
+
#
|
|
7
|
+
# `count:` decides what the index pays to know its total:
|
|
8
|
+
#
|
|
9
|
+
# :exact - a full COUNT(*) on every page load (the default)
|
|
10
|
+
# Integer - count at most this many rows, then show "N+"
|
|
11
|
+
# false - no COUNT query at all; prev/next navigation only
|
|
12
|
+
module Pagination
|
|
13
|
+
OPTIONS = %i[count].freeze
|
|
14
|
+
DEFAULTS = {count: :exact}.freeze
|
|
15
|
+
|
|
16
|
+
# Validates a partial options hash and returns it frozen. An unknown key or
|
|
17
|
+
# count value raises at declaration, not on the first page load.
|
|
18
|
+
def self.validate!(options)
|
|
19
|
+
raise ArgumentError, "pagination options must be a Hash, got #{options.inspect}" unless options.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
unknown = options.keys - OPTIONS
|
|
22
|
+
raise ArgumentError, "unknown pagination option(s) #{unknown.inspect}, expected #{OPTIONS.inspect}" if unknown.any?
|
|
23
|
+
|
|
24
|
+
if options.key?(:count)
|
|
25
|
+
count = options[:count]
|
|
26
|
+
valid = count == :exact || count == false || (count.is_a?(Integer) && count.positive?)
|
|
27
|
+
unless valid
|
|
28
|
+
raise ArgumentError, "pagination count must be :exact, false, or a positive Integer, got #{count.inspect}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
options.dup.freeze
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -22,7 +22,33 @@ module Plutonium
|
|
|
22
22
|
# eager-loading at the point of use also leaves an app's own
|
|
23
23
|
# `filtered_resource_collection` override unaffected.
|
|
24
24
|
collection = auto_eager_load(filtered_resource_collection, presentable_attributes_for(action))
|
|
25
|
-
@pagy, @resource_records =
|
|
25
|
+
@pagy, @resource_records = paginate_collection(collection)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# How the index counts its records: `:exact`, an Integer cap, or
|
|
29
|
+
# `false` for no count. Read from the definition (falling back to
|
|
30
|
+
# `Plutonium.configuration.pagination`); override in a controller to
|
|
31
|
+
# decide per request.
|
|
32
|
+
def pagination_count
|
|
33
|
+
current_definition.resolved_pagination[:count]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def paginate_collection(collection)
|
|
37
|
+
case (cap = pagination_count)
|
|
38
|
+
when :exact
|
|
39
|
+
pagy(:offset, collection, request: pagy_request_context)
|
|
40
|
+
when false
|
|
41
|
+
pagy(:countless, collection, request: pagy_request_context)
|
|
42
|
+
else
|
|
43
|
+
# COUNT(*) over a LIMIT subquery stops after cap + 1 matching
|
|
44
|
+
# rows. The extra row is how we know the total went past the cap.
|
|
45
|
+
# Pagy gets the cap itself as the count, so the last page is the
|
|
46
|
+
# one holding row `cap`.
|
|
47
|
+
count = collection.limit(cap + 1).count(:all)
|
|
48
|
+
count = count.size if count.is_a?(Hash) # grouped relation
|
|
49
|
+
capped = count > cap
|
|
50
|
+
pagy(:offset, collection, count: [count, cap].min, count_capped: capped, request: pagy_request_context)
|
|
51
|
+
end
|
|
26
52
|
end
|
|
27
53
|
|
|
28
54
|
# What Pagy builds its page URLs from. Pagy accepts either the real
|