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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +13 -0
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +17 -0
  5. data/CLAUDE.md +11 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +2 -2
  8. data/app/assets/plutonium.png +0 -0
  9. data/config/locales/en/ui.yml +3 -0
  10. data/docs/blog/introducing-plutonium-dashboards.md +4 -4
  11. data/docs/blog/the-slowest-query-is-the-count.md +109 -0
  12. data/docs/guides/performance.md +14 -0
  13. data/docs/public/favicon-16x16.png +0 -0
  14. data/docs/public/favicon-32x32.png +0 -0
  15. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  16. data/docs/public/images/blog/pagination-capped.png +0 -0
  17. data/docs/public/images/blog/pagination-countless.png +0 -0
  18. data/docs/public/images/components/avatar.png +0 -0
  19. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  20. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  21. data/docs/public/images/guides/dashboard-loading.png +0 -0
  22. data/docs/public/images/guides/dashboard-overview.png +0 -0
  23. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  24. data/docs/public/images/guides/kanban-after-move.png +0 -0
  25. data/docs/public/images/guides/kanban-board-light.png +0 -0
  26. data/docs/public/images/guides/kanban-board.png +0 -0
  27. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  28. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  29. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  30. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  31. data/docs/public/images/guides/nested-inputs.png +0 -0
  32. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  33. data/docs/public/images/guides/search-filtering-index.png +0 -0
  34. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  35. data/docs/public/images/guides/theming-after.png +0 -0
  36. data/docs/public/images/guides/theming-before.png +0 -0
  37. data/docs/public/images/guides/user-invites-landing.png +0 -0
  38. data/docs/public/images/guides/user-profile-edit.png +0 -0
  39. data/docs/public/images/guides/user-profile-show.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/public/images/home/tour-actions.png +0 -0
  47. data/docs/public/images/home/tour-async.png +0 -0
  48. data/docs/public/images/home/tour-kanban.png +0 -0
  49. data/docs/public/images/home/tour-tenancy.png +0 -0
  50. data/docs/public/images/home/tour-wizard.png +0 -0
  51. data/docs/public/images/home-index.png +0 -0
  52. data/docs/public/images/home-new.png +0 -0
  53. data/docs/public/images/home-show.png +0 -0
  54. data/docs/public/images/reference/async-progress-page.png +0 -0
  55. data/docs/public/images/reference/async-running-banner.png +0 -0
  56. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  57. data/docs/public/images/reference/structured-inputs.png +0 -0
  58. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  59. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  60. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  61. data/docs/public/images/tutorial/02-new-form.png +0 -0
  62. data/docs/public/images/tutorial/03-create-account.png +0 -0
  63. data/docs/public/images/tutorial/03-login.png +0 -0
  64. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  65. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  66. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  67. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  68. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  69. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  70. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  71. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  72. data/docs/public/og-image.png +0 -0
  73. data/docs/public/plutonium.png +0 -0
  74. data/docs/public/tutorial/plutonium-association-panel.png +0 -0
  75. data/docs/public/tutorial/plutonium-dashboard.png +0 -0
  76. data/docs/public/tutorial/plutonium-login-page.png +0 -0
  77. data/docs/public/tutorial/plutonium-nested-form.png +0 -0
  78. data/docs/public/tutorial/plutonium-posts-dashboard-customized.png +0 -0
  79. data/docs/public/tutorial/plutonium-posts-dashboard.png +0 -0
  80. data/docs/public/tutorial/plutonium-posts-detail-customized.png +0 -0
  81. data/docs/public/tutorial/plutonium-posts-detail.png +0 -0
  82. data/docs/public/tutorial/plutonium-publish-post.png +0 -0
  83. data/docs/reference/configuration.md +1 -0
  84. data/docs/reference/i18n.md +1 -1
  85. data/docs/reference/resource/definition.md +35 -0
  86. data/docs/reference/ui/layouts.md +13 -0
  87. data/gemfiles/rails_7.gemfile.lock +3 -3
  88. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  89. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  90. data/lib/plutonium/configuration.rb +17 -0
  91. data/lib/plutonium/definition/base.rb +22 -0
  92. data/lib/plutonium/pagination.rb +35 -0
  93. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +27 -1
  94. data/lib/plutonium/ui/grid/card.rb +11 -2
  95. data/lib/plutonium/ui/grid/resource.rb +2 -2
  96. data/lib/plutonium/ui/layout/icon_rail.rb +42 -4
  97. data/lib/plutonium/ui/table/components/filter_pills.rb +8 -2
  98. data/lib/plutonium/ui/table/components/pagy_info.rb +30 -10
  99. data/lib/plutonium/ui/table/resource.rb +2 -2
  100. data/lib/plutonium/version.rb +1 -1
  101. data/package.json +1 -1
  102. data/plutonium.gemspec +3 -1
  103. data/src/css/components.css +3 -1
  104. metadata +7 -7
  105. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  106. data/docs/public/images/blog/dashboards-loading.png +0 -0
  107. data/docs/public/images/blog/dashboards-overview.png +0 -0
  108. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
Binary file
@@ -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
- ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/blog/dashboards-overview.png)
16
+ ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/guides/dashboard-overview.png)
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
- ![The same dashboard before its frames have loaded: every card is a grey skeleton except the Conversion metric, which is already showing 12.5%](/images/blog/dashboards-loading.png)
102
+ ![The same dashboard before its frames have loaded: every card is a grey skeleton except the Conversion metric, which is already showing 12.5%](/images/guides/dashboard-loading.png)
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
- ![A dashboard where the Revenue card shows a red "This card could not be loaded." notice while the metrics and the signups chart around it render normally](/images/blog/dashboards-card-error.png)
134
+ ![A dashboard where the Revenue card shows a red "This card could not be loaded." notice while the metrics and the signups chart around it render normally](/images/guides/dashboard-card-error.png)
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
- ![The portal's icon rail with the Dashboards item open, listing Overview and Content](/images/blog/dashboards-sidebar.png)
159
+ ![The portal's icon rail with the Dashboards item open, listing Overview and Content](/images/guides/dashboard-sidebar.png)
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
+ ![A posts index capped at 100: the result count reads "100+ results", the footer reads "Displaying Posts 1-20 of 100+ in total", and the pager links pages 1 through 5](/images/blog/pagination-capped.png)
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
+ ![A countless pager on page 4: the footer reads "Displaying Posts 61-80" with no total, and the pager shows pages 1 through 5](/images/blog/pagination-countless.png)
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.
@@ -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
@@ -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). |
@@ -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.68.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.68.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
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.68.0)
12
+ plutonium (0.69.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -9,7 +9,7 @@ GIT
9
9
  PATH
10
10
  remote: ..
11
11
  specs:
12
- plutonium (0.68.0)
12
+ plutonium (0.69.0)
13
13
  action_policy (~> 0.7.0)
14
14
  csv
15
15
  listen (~> 3.8)
@@ -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 = pagy(:offset, collection, request: pagy_request_context)
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