concerns_on_rails 1.28.0 → 1.28.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f9aef0d5f95ffc23ef6213d5d95ea872a6146763d488a287a334db8be68b922
4
- data.tar.gz: b364f4276149a31b1ce1f6efaf170435900f34188eb358035c10020567e8b51d
3
+ metadata.gz: 00337457f7ea35fd2e02bb74bcb4031208109536946b448706104a9e031bd9ea
4
+ data.tar.gz: 3bfc8749303d212e51d98d8dd7fa4323888e3d984444c768695d5c290df311a3
5
5
  SHA512:
6
- metadata.gz: 025306bb013a418c8051db996ddd0fe6f2bf7ac7f657d22335639b5bcceecff8416e85c66ebde92a357656eeaba90f51a2993fab23232267af105e823cf28934
7
- data.tar.gz: b0d98d45a2b189b43985e5d5548c3b905a161f2aa0df9022d251a2788b62524360bc002d2981e7e9801a10e4b0f7aa4a7b1d6b0ee5adb4298edeef89a07a8467
6
+ metadata.gz: 50ea50c59f6b15978fe3f1cc86f03266998249bd4b17dc84b36ff6a717e9a57d0efba37f2e5648c58009761aeb2198f14368b2c20715c0bc40c246c1e7b5ee25
7
+ data.tar.gz: f6b8c7784e26ac3f5c8f14217e07cbe6d846ecab216fad6c61fb192bf06a18aa4484d80efb32fd82d625c683d917d9fb8c9b88724adcf2a11878fb0ea6a1f7e9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.1 (2026-09-07)
4
+
5
+ One additive Paginatable option (#89): `pagination_meta` can now publish the
6
+ page window a pagination bar needs — first, last and N pages either side of the
7
+ current page — instead of leaving every client to compute it from
8
+ `total_pages`. Opt-in, controller-only, no extra query, and existing responses
9
+ are byte-for-byte unchanged. 1314 examples, 0 failures.
10
+
11
+ ### Added
12
+ - **Controllers::Paginatable**: `paginate_by window: 3` adds a `pages:` key to
13
+ `pagination_meta` — the first page, the last page, and N pages either side of
14
+ the current one, with the Symbol `:gap` standing in for each run left out
15
+ (`[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]`) — enough to render a
16
+ `1 … 44 45 46 [47] 48 49 50 … 100` bar straight from the meta Hash. Opt-in:
17
+ without `window:` the key is absent entirely (not `nil`), so no existing
18
+ meta Hash or serialized body changes shape, and `meta.key?(:pages)` is a
19
+ clean probe. The window is arithmetic over the `total` already counted, so it
20
+ costs no extra query on either the memoized `paginated` path or the fresh
21
+ `pagination_meta` one. A jump of exactly two pages is filled with the page it
22
+ would have hidden (`1 2 3`, never the wider `1 … 3`), and a `?page=` past the
23
+ last page windows around the last page as `rel="prev"` already does.
24
+ `window:` is validated at declaration — a non-negative Integer, or
25
+ `nil`/`false` to disable; `0` yields first, current and last only. Headers
26
+ and `Link` rels are unchanged: `pages:` is body-only by design. (#89)
27
+
28
+ ### Internal
29
+ - `set_pagination_headers` no longer splats the memoized meta Hash, which
30
+ raised `unknown keyword: :pages` once that Hash could carry the window; the
31
+ four header values are passed explicitly.
32
+ - README's advertised example counts were stale (1,160 and 1,303) and now read
33
+ 1,314.
34
+
3
35
  ## 1.28.0 (2026-09-06)
4
36
 
5
37
  Pagination is the theme: the four-PR Paginatable stack (#40, #45, #62, #83)
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early — with a ready-to-paste `rails generate migration` hint when it doesn't
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,160 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,314 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -158,7 +158,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
158
158
  Add to your application's `Gemfile`:
159
159
 
160
160
  ```ruby
161
- gem "concerns_on_rails", "~> 1.26"
161
+ gem "concerns_on_rails", "~> 1.28"
162
162
  ```
163
163
 
164
164
  Or pull the latest from GitHub:
@@ -1494,6 +1494,41 @@ paginate_by style: :jsonapi # ?page[numbe
1494
1494
  paginate_by page_param: %i[paging page], per_page_param: %i[paging per] # any nested path
1495
1495
  ```
1496
1496
 
1497
+ **Page window for a pagination bar**
1498
+
1499
+ `window:` adds a `pages:` key to `pagination_meta` — the first page, the last page, and N pages
1500
+ either side of the current one, with `:gap` standing in for the runs left out. It is opt-in:
1501
+ without `window:` the key is absent entirely. No extra query either way — it is arithmetic over
1502
+ the total already counted.
1503
+
1504
+ ```ruby
1505
+ paginate_by per_page: 10, window: 3
1506
+
1507
+ pagination_meta
1508
+ # => { total: 1000, page: 47, per_page: 10, total_pages: 100,
1509
+ # pages: [1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100] }
1510
+ ```
1511
+
1512
+ Render it straight into a `1 … 44 45 46 [47] 48 49 50 … 100` bar:
1513
+
1514
+ ```erb
1515
+ <% pagination_meta[:pages].each do |page| %>
1516
+ <%= page == :gap ? "…" : link_to(page, url_for(page: page)) %>
1517
+ <% end %>
1518
+ ```
1519
+
1520
+ | Situation | `pages:` |
1521
+ |-----------|----------|
1522
+ | `?page=47` of 100 | `[1, :gap, 44, 45, 46, 47, 48, 49, 50, :gap, 100]` |
1523
+ | `?page=2` of 100 | `[1, 2, 3, 4, 5, :gap, 100]` — no leading gap once the window reaches page 1 |
1524
+ | `?page=6` of 100 | `[1, 2, 3, 4, 5, 6, 7, 8, 9, :gap, 100]` — a one-page gap is filled, never `1 … 3` |
1525
+ | 5 pages total | `[1, 2, 3, 4, 5]` — the window spans everything |
1526
+ | empty collection | key absent |
1527
+ | `window: 0` | `[1, :gap, 47, :gap, 100]` — first, current and last only |
1528
+
1529
+ `?page=` past the last page windows around the last page (as `rel="prev"` already does), and
1530
+ `window:` must be a non-negative Integer or `nil`/`false` — validated at declaration.
1531
+
1497
1532
  **Response headers**: `X-Total-Count`, `X-Page`, `X-Per-Page`, `X-Total-Pages`, and an RFC 8288 `Link`
1498
1533
  header with `first` / `prev` / `next` / `last` URLs rebuilt from the current request (other query params
1499
1534
  preserved; `prev`/`next` only when such a page exists; nothing for an empty collection) — the GitHub
@@ -2122,9 +2157,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2122
2157
 
2123
2158
  ```sh
2124
2159
  bundle install # install dev dependencies
2125
- bundle exec rspec # run the test suite (1,303 examples)
2160
+ bundle exec rspec # run the test suite (1,314 examples)
2126
2161
  gem build concerns_on_rails.gemspec # build the gem
2127
- gem install ./concerns_on_rails-1.28.0.gem # install locally
2162
+ gem install ./concerns_on_rails-1.28.1.gem # install locally
2128
2163
 
2129
2164
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2130
2165
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -41,6 +41,9 @@ module ConcernsOnRails
41
41
  class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
42
42
  class_attribute :paginatable_max_per_page, default: DEFAULT_MAX_PER_PAGE
43
43
  class_attribute :paginatable_link_header, default: true
44
+ # Half-width of the page window in `pagination_meta[:pages]`; nil = no
45
+ # window, and no `pages:` key at all.
46
+ class_attribute :paginatable_window, default: nil
44
47
  # Where page / per_page are read from — a path of param names (`["page"]`,
45
48
  # or `["page", "number"]` for JSON:API's page[number]).
46
49
  class_attribute :paginatable_page_param, default: %w[page].freeze
@@ -55,10 +58,11 @@ module ConcernsOnRails
55
58
  # Example:
56
59
  # paginate_by per_page: 50, max_per_page: 500, link_header: false
57
60
  def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
58
- page_param: nil, per_page_param: nil, style: :flat)
61
+ page_param: nil, per_page_param: nil, style: :flat, window: nil)
59
62
  self.paginatable_per_page = per_page.to_i
60
63
  self.paginatable_max_per_page = max_per_page.to_i
61
64
  self.paginatable_link_header = link_header ? true : false
65
+ self.paginatable_window = paginatable_window!(window)
62
66
  defaults = paginatable_style_params!(style)
63
67
  self.paginatable_page_param = paginatable_param_path!(:page_param, page_param || defaults[0])
64
68
  self.paginatable_per_page_param = paginatable_param_path!(:per_page_param, per_page_param || defaults[1])
@@ -75,6 +79,15 @@ module ConcernsOnRails
75
79
  end
76
80
  end
77
81
 
82
+ # nil / false disable the window (no `pages:` key). `0` is meaningful:
83
+ # first, current and last only.
84
+ def paginatable_window!(value)
85
+ return nil if value.nil? || value == false
86
+ return value if value.is_a?(Integer) && !value.negative?
87
+
88
+ raise ArgumentError, "#{LABEL}: window: must be a non-negative Integer or nil (got #{value.inspect})"
89
+ end
90
+
78
91
  # A name or a non-empty path of names, normalized to Strings.
79
92
  def paginatable_param_path!(option, value)
80
93
  path = Array(value)
@@ -117,8 +130,9 @@ module ConcernsOnRails
117
130
  source.limit(per_page).offset(offset)
118
131
  end
119
132
 
120
- @paginatable_meta = { total: total, page: page, per_page: per_page, total_pages: total_pages }
121
- set_pagination_headers(**@paginatable_meta)
133
+ @paginatable_meta =
134
+ paginatable_windowed(total: total, page: page, per_page: per_page, total_pages: total_pages)
135
+ set_pagination_headers(total: total, page: page, per_page: per_page, total_pages: total_pages)
122
136
  set_pagination_links(page: page, total_pages: total_pages)
123
137
  records
124
138
  end
@@ -134,12 +148,12 @@ module ConcernsOnRails
134
148
 
135
149
  total = paginatable_meta_total(collection, total)
136
150
  per_page = pagination_per_page
137
- {
151
+ paginatable_windowed(
138
152
  total: total,
139
153
  page: pagination_page,
140
154
  per_page: per_page,
141
155
  total_pages: per_page.positive? ? (total.to_f / per_page).ceil : 0
142
- }
156
+ )
143
157
  end
144
158
 
145
159
  private
@@ -228,6 +242,39 @@ module ConcernsOnRails
228
242
  { path.first.to_sym => nested }
229
243
  end
230
244
 
245
+ # Adds `pages:` to a meta Hash when `window:` is declared, and leaves the
246
+ # Hash untouched otherwise — the key is absent, never nil, so
247
+ # `meta.key?(:pages)` is a clean opt-in probe.
248
+ def paginatable_windowed(meta)
249
+ pages = paginatable_page_window(meta[:page], meta[:total_pages])
250
+ pages ? meta.merge(pages: pages) : meta
251
+ end
252
+
253
+ # First, last, and `window` pages either side of the current page, with
254
+ # :gap standing in for the runs left out — [1, :gap, 46, 47, 48, :gap, 100].
255
+ # Pure arithmetic over the total already counted: no extra query. A page
256
+ # past the last one windows around the last page, as `prev` already does.
257
+ def paginatable_page_window(page, total_pages)
258
+ window = self.class.paginatable_window
259
+ return nil if window.nil? || total_pages < 1
260
+
261
+ current = page.clamp(1, total_pages)
262
+ from = [current - window, 1].max
263
+ to = [current + window, total_pages].min
264
+ paginatable_insert_gaps(([1, total_pages] + (from..to).to_a).uniq.sort)
265
+ end
266
+
267
+ # A jump of exactly two pages is filled with the page it would have
268
+ # hidden — "1 2 3", never the wider "1 … 3"; anything longer collapses
269
+ # into one :gap.
270
+ def paginatable_insert_gaps(numbers)
271
+ numbers.each_cons(2).with_object([numbers.first]) do |(previous, current), result|
272
+ result << (previous + 1) if current - previous == 2
273
+ result << :gap if current - previous > 2
274
+ result << current
275
+ end
276
+ end
277
+
231
278
  def set_pagination_headers(total:, page:, per_page:, total_pages:)
232
279
  return unless respond_to?(:response) && response
233
280
 
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.0".freeze
2
+ VERSION = "1.28.1".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.28.0
4
+ version: 1.28.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-06 00:00:00.000000000 Z
11
+ date: 2026-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack